feat: 改版阶段 4:挑战列表页、教程按小节存代码、出题页合并、用户管理、整站暗色
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:
xuyueandClaude Opus 5.5 committed 2026-10-27 08:17:43 +08:00
1 parent df3f842dac
commit 8653d411a6
40 files changed
+1208 -851

No files matched your search

+2
View File
@@ -14,6 +14,8 @@
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "6.12.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.1",
"@fontsource/jetbrains-mono": "^5.3.0",
"@vueuse/core": "^15.0.0",
+7 -4
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { dateZhCN, zhCN } from "naive-ui"
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
import Login from "./components/Login.vue"
import AppShell from "./components/layout/AppShell.vue"
import { onMounted } from "vue"
import { computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { loadUser } from "./store/session"
import { light, naiveOverrides } from "./theme"
import { dark, light, naiveOverrides } from "./theme"
import { isDark } from "./store/theme"
import hljs from "highlight.js/lib/core"
const route = useRoute()
const themeOverrides = naiveOverrides(light)
const theme = computed(() => (isDark.value ? darkTheme : null))
const themeOverrides = computed(() => naiveOverrides(isDark.value ? dark : light))
onMounted(loadUser)
</script>
@@ -20,6 +22,7 @@ onMounted(loadUser)
:locale="zhCN"
:date-locale="dateZhCN"
:hljs="hljs"
:theme="theme"
:theme-overrides="themeOverrides"
>
<n-modal-provider>
+8 -8
View File
@@ -160,11 +160,11 @@ watch([() => messages.value.length, streamingContent], () => {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #888;
color: var(--wp-muted);
}
.guidance-msg.user .msg-role {
color: #2080f0;
color: var(--wp-css);
}
.guidance-msg.assistant .msg-role {
@@ -172,8 +172,8 @@ watch([() => messages.value.length, streamingContent], () => {
}
.guidance-msg.assistant .msg-content {
background: #f0f7ff;
border-left: 3px solid #2080f0;
background: var(--wp-css-soft);
border-left: 3px solid var(--wp-css);
padding: 8px 12px;
border-radius: 0 6px 6px 0;
font-size: 14px;
@@ -183,7 +183,7 @@ watch([() => messages.value.length, streamingContent], () => {
.guidance-msg.user .msg-content {
font-size: 14px;
line-height: 1.6;
color: #333;
color: var(--wp-ink);
}
.msg-content :deep(p) {
@@ -198,9 +198,9 @@ watch([() => messages.value.length, streamingContent], () => {
font-size: 13px;
font-weight: 500;
padding: 6px 10px;
background: #f0fff4;
background: var(--wp-brand-soft);
border-radius: 6px;
border: 1px solid #b8e8cc;
border: 1px solid var(--wp-brand-soft);
}
.typing-indicator {
@@ -213,7 +213,7 @@ watch([() => messages.value.length, streamingContent], () => {
width: 7px;
height: 7px;
border-radius: 50%;
background: #2080f0;
background: var(--wp-css);
animation: bounce 1.4s ease-in-out infinite;
}
@@ -283,7 +283,7 @@ onMounted(() => {
.history-toolbar {
height: 42px;
padding: 0 12px;
border-bottom: 1px solid var(--n-border-color, #efeff5);
border-bottom: 1px solid var(--n-border-color, var(--wp-line));
flex-shrink: 0;
}
@@ -307,12 +307,12 @@ onMounted(() => {
.history-card.is-selected {
--n-color: #f7fffa;
--n-color: var(--wp-brand-soft);
box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14);
}
.history-card.is-selected .history-main {
background: #eefaf3;
background: var(--wp-brand-soft);
}
.history-main {
@@ -323,7 +323,7 @@ onMounted(() => {
.prompt-markdown {
margin: 0;
padding: 0 12px 12px;
color: #333;
color: var(--wp-ink);
font-size: 14px;
line-height: 1.6;
word-break: break-word;
@@ -356,8 +356,8 @@ onMounted(() => {
.thumbnail {
height: 160px;
border-top: 1px solid var(--n-border-color, #efeff5);
background: #f7f7f9;
border-top: 1px solid var(--n-border-color, var(--wp-line));
background: var(--wp-paper);
overflow: hidden;
display: flex;
align-items: center;
@@ -365,15 +365,15 @@ onMounted(() => {
}
.history-card.is-selected .thumbnail {
border-top-color: #d8f1e2;
background: #f4fbf6;
border-top-color: var(--wp-brand-soft);
background: var(--wp-brand-soft);
}
.thumbnail iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
background: var(--wp-surface);
pointer-events: none;
}
</style>
+9 -9
View File
@@ -274,11 +274,11 @@ watch([() => messages.value.length, streamingContent], () => {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #666;
color: var(--wp-muted);
}
.message.user .message-role {
color: #2080f0;
color: var(--wp-css);
}
.message.assistant .message-role {
@@ -321,7 +321,7 @@ watch([() => messages.value.length, streamingContent], () => {
}
.message-content :deep(pre) {
background: #f5f5f5;
background: var(--wp-paper);
padding: 8px 12px;
border-radius: 4px;
overflow-x: auto;
@@ -379,7 +379,7 @@ watch([() => messages.value.length, streamingContent], () => {
.streaming-hint {
font-size: 11px;
color: #aaa;
color: var(--wp-faint);
margin-top: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
@@ -387,7 +387,7 @@ watch([() => messages.value.length, streamingContent], () => {
.input-area {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #e0e0e0;
border-top: 1px solid var(--wp-line);
}
.message-pair {
@@ -405,11 +405,11 @@ watch([() => messages.value.length, streamingContent], () => {
top: 0;
right: 0;
background: none;
border: 1px solid #e0e0e0;
border: 1px solid var(--wp-line);
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
color: #bbb;
color: var(--wp-faint);
line-height: 1;
transition:
color 0.15s,
@@ -417,8 +417,8 @@ watch([() => messages.value.length, streamingContent], () => {
}
.pair-delete-btn:hover {
color: #e03e3e;
border-color: #e03e3e;
color: var(--wp-bad);
border-color: var(--wp-bad);
}
.message-pair.has-delete:hover .pair-delete-btn {
@@ -1,5 +1,10 @@
<template>
<MdEditor v-bind="props" v-model="modelValue" @upload-img="uploadImg" />
<MdEditor
v-bind="props"
v-model="modelValue"
:theme="isDark ? 'dark' : 'light'"
@upload-img="uploadImg"
/>
</template>
<script lang="ts" setup>
import { MdEditor } from "md-editor-v3"
@@ -7,6 +12,7 @@ import { MdEditor } from "md-editor-v3"
import "md-editor-v3/lib/style.css"
import { Helper } from "../../api"
import { useMessage } from "naive-ui"
import { isDark } from "../../store/theme"
const message = useMessage()
@@ -28,7 +28,7 @@
</template>
<script lang="ts" setup>
import { ref } from "vue"
import { useMessage } from "naive-ui"
import { useDialog, useMessage } from "naive-ui"
import { Account } from "../../api"
import { Role, type User } from "../../utils/type"
import UserFormModal from "./UserFormModal.vue"
@@ -41,15 +41,27 @@ const props = defineProps<Props>()
const emits = defineEmits(["reload"])
const message = useMessage()
const dialog = useDialog()
const showEdit = ref(false)
function edit() {
showEdit.value = true
}
async function toggleUserActive() {
async function doToggle() {
const data = await Account.toggleActive(props.user.id)
message.success(data.message)
emits("reload")
}
function toggleUserActive() {
if (!props.user.is_active) return doToggle()
dialog.warning({
title: `封禁「${props.user.username}」?`,
content: "封禁后这个账号不能登录,已经登录的也会被退出。之后可以再解封。",
positiveText: "封禁",
negativeText: "取消",
onPositiveClick: doToggle,
})
}
</script>
+19 -5
View File
@@ -5,6 +5,9 @@ import { css } from "@codemirror/lang-css"
import { javascript } from "@codemirror/lang-javascript"
import { html } from "@codemirror/lang-html"
import { computed } from "vue"
import { syntaxHighlighting } from "@codemirror/language"
import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
import { isDark } from "../../store/theme"
const styleTheme = EditorView.baseTheme({
"& .cm-scroller": {
@@ -44,13 +47,24 @@ const lang = computed(() => {
const bgTheme = computed(() => {
const bg = bgColors[props.language] ?? "var(--wp-surface)"
return EditorView.theme({
"&": { backgroundColor: bg },
".cm-gutters": { backgroundColor: bg },
})
return EditorView.theme(
{
"&": { backgroundColor: bg, color: "var(--wp-ink)" },
".cm-gutters": { backgroundColor: bg, color: "var(--wp-faint)", borderColor: "var(--wp-line)" },
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "rgba(127, 127, 127, 0.08)" },
".cm-cursor": { borderLeftColor: "var(--wp-ink)" },
},
{ dark: isDark.value },
)
})
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value])
// 暗色时换成 One Dark 的语法配色(默认配色是给浅色背景设计的)
const extensions = computed(() => [
styleTheme,
bgTheme.value,
...(isDark.value ? [syntaxHighlighting(oneDarkHighlightStyle)] : []),
lang.value,
])
</script>
<template>
<Codemirror
+21
View File
@@ -16,6 +16,14 @@
</router-link>
</nav>
<div class="spacer"></div>
<n-tooltip>
<template #trigger>
<n-button quaternary circle size="small" @click="cycleTheme">
<Icon :icon="themeIcon" :width="16" />
</n-button>
</template>
外观:{{ themeLabel }}(点击切换)
</n-tooltip>
<template v-if="user.loaded">
<router-link
v-if="roleStaff"
@@ -55,6 +63,19 @@ import { authed, roleStaff, user } from "../../store/user"
import { loginModal } from "../../store/modal"
import { step } from "../../store/tutorial"
import { logout } from "../../store/session"
import { themeMode, type ThemeMode } from "../../store/theme"
import { Icon } from "../../icons"
const themeOrder: ThemeMode[] = ["auto", "light", "dark"]
const themeLabel = computed(
() => ({ auto: "跟随系统", light: "亮色", dark: "暗色" })[themeMode.value],
)
const themeIcon = computed(
() => ({ auto: "lucide:sun-moon", light: "lucide:sun", dark: "lucide:moon" })[themeMode.value],
)
function cycleTheme() {
themeMode.value = themeOrder[(themeOrder.indexOf(themeMode.value) + 1) % themeOrder.length]!
}
const route = useRoute()
const router = useRouter()
+1 -1
View File
@@ -135,7 +135,7 @@ async function rate(w: PeerWork, score: number) {
border: 0;
border-bottom: 1px solid var(--wp-line);
overflow: hidden;
background: #fff;
background: var(--wp-surface);
cursor: zoom-in;
}
@@ -10,7 +10,7 @@
<span>提示词</span>
<n-tooltip placement="bottom-start">
<template #trigger>
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
<Icon icon="lucide:info" :width="20" style="color: var(--wp-faint)" />
</template>
<div style="line-height: 2">
<div>
@@ -71,7 +71,7 @@
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid #e0e0e0;
border-right: 1px solid var(--wp-line);
display: flex;
flex-direction: column;
gap: 8px;
@@ -94,8 +94,8 @@
width: '22px',
height: '22px',
borderRadius: '50%',
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
color: '#fff',
background: selectedRound === index ? 'var(--wp-css)' : 'var(--wp-css-soft)',
color: 'var(--wp-surface)',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
@@ -112,11 +112,11 @@
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
background: selectedRound === index ? 'var(--wp-css-soft)' : 'var(--wp-paper)',
border:
selectedRound === index
? '1px solid #2080f0'
: '1px solid #e0e0e0',
? '1px solid var(--wp-css)'
: '1px solid var(--wp-line)',
fontSize: '13px',
lineHeight: '1.6',
transition: 'all 0.2s',
@@ -154,9 +154,9 @@
padding: '1px 5px',
borderRadius: '4px',
background:
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
round.source === 'conversation' ? 'var(--wp-css-soft)' : 'var(--wp-line)',
color:
round.source === 'conversation' ? '#2060c0' : '#888',
round.source === 'conversation' ? 'var(--wp-css)' : 'var(--wp-muted)',
fontWeight: 500,
whiteSpace: 'nowrap',
}"
@@ -212,7 +212,7 @@
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: #555">
<div style="font-weight: bold; font-size: 13px; color: var(--wp-ink)">
第 {{ selectedRound + 1 }} 轮网页
</div>
<iframe
@@ -222,9 +222,9 @@
sandbox="allow-scripts"
style="
flex: 1;
border: 1px solid #e0e0e0;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: #fff;
background: var(--wp-surface);
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
@@ -285,12 +285,12 @@ async function deleteRound(index: number) {
}
const levelColors: Record<number, string> = {
1: "#aaa",
2: "#6aab9c",
3: "#4a9ade",
1: "var(--wp-faint)",
2: "var(--wp-brand)",
3: "var(--wp-css)",
4: "#c47ab8",
5: "#e0a040",
6: "#e05c5c",
5: "var(--wp-warn)",
6: "var(--wp-bad)",
}
const selectedPageHtml = computed(() => {
@@ -71,7 +71,7 @@ const subColumns = computed((): 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),
],
)
},
@@ -1,83 +0,0 @@
<template>
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
<n-flex v-else vertical :size="12">
<n-card
v-for="item in challenges"
:key="item.display"
hoverable
:class="['challenge-card', { submitted: item.submitted }]"
@click="select(item)"
>
<template #header>
<n-flex align="center" :size="6">
<span v-if="item.submitted" class="check-icon">✓</span>
<span :class="{ 'submitted-title': item.submitted }">{{
item.title
}}</span>
</n-flex>
</template>
<template #header-extra>
<n-flex :size="6">
<n-tag type="warning" size="small">{{ item.score }} 分</n-tag>
<n-tag v-if="item.pass_score != null" size="small"
>及格 {{ item.pass_score }} 分</n-tag
>
</n-flex>
</template>
<n-text depth="3" class="challenge-author">
出题人:{{ item.author_name || "未设置" }}
</n-text>
</n-card>
</n-flex>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { Challenge } from "../../api"
import { taskTab } from "../../store/task"
import { TASK_TYPE } from "../../utils/const"
import type { ChallengeSlim } from "../../utils/type"
const router = useRouter()
const challenges = ref<ChallengeSlim[]>([])
function select(item: ChallengeSlim) {
router.push({ name: "home-challenge", params: { display: item.display } })
}
onMounted(async () => {
challenges.value = await Challenge.listDisplay()
})
</script>
<style scoped>
.container {
padding: 16px;
overflow: auto;
}
.challenge-card {
cursor: pointer;
}
.challenge-card.submitted {
background-color: #f6ffed;
border-color: #b7eb8f;
}
.check-icon {
color: #52c41a;
font-size: 14px;
font-weight: bold;
}
.submitted-title {
color: #888;
}
.challenge-author {
display: block;
font-size: 13px;
}
</style>
+7 -18
View File
@@ -2,9 +2,7 @@
<div class="container">
<n-flex align="center" justify="space-between" class="title" :wrap="false">
<n-flex align="center" :wrap="false">
<span class="panel-name">
{{ taskTab === TASK_TYPE.Tutorial ? "教程" : "挑战" }}
</span>
<span class="panel-name">教程</span>
<template v-if="!hideNav">
<n-tooltip>
<template #trigger>
@@ -59,8 +57,7 @@
</n-tooltip>
</n-flex>
</n-flex>
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
<ChallengeList v-else />
<TutorialContent />
</div>
<PeersDrawer
v-model:show="showPeers"
@@ -109,7 +106,6 @@ import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE } from "../../utils/const"
import { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type"
import ChallengeList from "./ChallengeList.vue"
import TutorialContent from "./TutorialContent.vue"
const tutorialAssets = ref<TaskAsset[]>([])
@@ -134,16 +130,11 @@ const route = useRoute()
const router = useRouter()
// 路由同步:初始执行 + watch 响应 SPA 内部导航
// 这个面板只用在教程页(挑战有自己的页面)
function syncRoute(routeName: string) {
if (routeName.startsWith("home-tutorial")) {
taskTab.value = TASK_TYPE.Tutorial
if (route.params.display) step.value = Number(route.params.display)
} else if (routeName.startsWith("home-challenge")) {
taskTab.value = TASK_TYPE.Challenge
if (route.params.display)
challengeDisplay.value = Number(route.params.display)
// 挑战列表里没有选中的任务,“提交”不可用
else taskId.value = 0
taskTab.value = TASK_TYPE.Tutorial
if (routeName.startsWith("home-tutorial") && route.params.display) {
step.value = Number(route.params.display)
}
}
syncRoute(route.name as string)
@@ -155,9 +146,7 @@ watch(
defineEmits(["hide"])
const hideNav = computed(
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
)
const hideNav = computed(() => tutorialIds.value.length <= 1)
const progressText = computed(() => {
const ids = tutorialIds.value
+50 -50
View File
@@ -21,7 +21,7 @@
align="center"
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
>
<span style="color: #666; font-size: 12px">班级筛选:</span>
<span style="color: var(--wp-muted); font-size: 12px">班级筛选:</span>
<n-button
size="small"
:type="selectedClass === null ? 'primary' : 'default'"
@@ -44,7 +44,7 @@
style="
display: grid;
grid-template-columns: repeat(5, 1fr);
border: 1px solid #eee;
border: 1px solid var(--wp-line);
border-radius: 6px;
overflow: hidden;
margin-bottom: 12px;
@@ -56,7 +56,7 @@
:style="{
padding: '12px 8px',
textAlign: 'center',
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
borderRight: i < metrics.length - 1 ? '1px solid var(--wp-line)' : 'none',
}"
>
<div
@@ -68,7 +68,7 @@
>
{{ metric.value }}
</div>
<div style="color: #888; font-size: 11px; margin-top: 2px">
<div style="color: var(--wp-muted); font-size: 11px; margin-top: 2px">
{{ metric.label }}
</div>
</div>
@@ -77,7 +77,7 @@
<!-- 未提交名单(可折叠) -->
<div
style="
border: 1px solid #eee;
border: 1px solid var(--wp-line);
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
@@ -90,7 +90,7 @@
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fff8f8;
background: var(--wp-bad-soft);
"
@click="showUnsubmitted = !showUnsubmitted"
>
@@ -99,12 +99,12 @@
style="
width: 7px;
height: 7px;
background: #d03050;
background: var(--wp-bad);
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #d03050; font-size: 12px"
<span style="font-weight: 600; color: var(--wp-bad); font-size: 12px"
>未提交({{ stats.unsubmitted_count }}人)</span
>
</n-flex>
@@ -115,14 +115,14 @@
: 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
style="color: var(--wp-faint)"
/>
</div>
<div
v-if="showUnsubmitted"
style="
padding: 10px 14px;
background: #fff8f8;
background: var(--wp-bad-soft);
display: flex;
flex-wrap: wrap;
gap: 5px;
@@ -133,12 +133,12 @@
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0d0; background: #fff"
style="border-color: var(--wp-bad-soft); background: var(--wp-surface)"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unsubmitted_users.length"
style="color: #aaa; font-size: 12px"
style="color: var(--wp-faint); font-size: 12px"
>暂无</span
>
</div>
@@ -147,7 +147,7 @@
<!-- 未打分名单(可折叠) -->
<div
style="
border: 1px solid #eee;
border: 1px solid var(--wp-line);
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
@@ -160,7 +160,7 @@
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fffaf5;
background: var(--wp-warn-soft);
"
@click="showUnrated = !showUnrated"
>
@@ -169,15 +169,15 @@
style="
width: 7px;
height: 7px;
background: #e07800;
background: var(--wp-warn);
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #e07800; font-size: 12px"
<span style="font-weight: 600; color: var(--wp-warn); font-size: 12px"
>未打分({{ stats.unrated_count }}人)</span
>
<span style="font-size: 11px; color: #aaa"
<span style="font-size: 11px; color: var(--wp-faint)"
>— 还没给任何提交打过分</span
>
</n-flex>
@@ -186,14 +186,14 @@
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
style="color: var(--wp-faint)"
/>
</div>
<div
v-if="showUnrated"
style="
padding: 10px 14px;
background: #fffaf5;
background: var(--wp-warn-soft);
display: flex;
flex-wrap: wrap;
gap: 5px;
@@ -204,12 +204,12 @@
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0a0; background: #fff"
style="border-color: var(--wp-warn-soft); background: var(--wp-surface)"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unrated_users.length"
style="color: #aaa; font-size: 12px"
style="color: var(--wp-faint); font-size: 12px"
>暂无</span
>
</div>
@@ -222,11 +222,11 @@
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
color: var(--wp-ink);
"
>
提交次数分布
<span style="font-size: 11px; color: #aaa; font-weight: 400"
<span style="font-size: 11px; color: var(--wp-faint); font-weight: 400"
>(已提交的 {{ stats.submitted_count }} 人)</span
>
</div>
@@ -252,13 +252,13 @@
>
{{ bucket.value }}
</div>
<div style="color: #aaa; font-size: 11px; margin-top: 2px">
<div style="color: var(--wp-faint); font-size: 11px; margin-top: 2px">
{{ bucket.label }}
</div>
<div
style="
margin-top: 8px;
background: #e8e8e8;
background: var(--wp-line);
border-radius: 3px;
height: 4px;
"
@@ -272,7 +272,7 @@
}"
></div>
</div>
<div style="color: #bbb; font-size: 10px; margin-top: 3px">
<div style="color: var(--wp-faint); font-size: 10px; margin-top: 3px">
{{ bucketPct(bucket.value) }}
</div>
</div>
@@ -286,14 +286,14 @@
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
color: var(--wp-ink);
"
>
人气前五
</div>
<div
v-if="stats.top_viewed.length === 0"
style="color: #aaa; font-size: 12px"
style="color: var(--wp-faint); font-size: 12px"
>
暂无
</div>
@@ -306,10 +306,10 @@
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #eee;
border: 1px solid var(--wp-line);
border-radius: 6px;
cursor: pointer;
background: #fafafa;
background: var(--wp-paper);
"
@click="viewSubmission(item.submission_id)"
>
@@ -319,8 +319,8 @@
height: '20px',
borderRadius: '50%',
background:
i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
color: '#fff',
i < 3 ? ['var(--wp-warn)', 'var(--wp-muted)', 'var(--wp-warn)'][i] : 'var(--wp-line)',
color: 'var(--wp-surface)',
fontSize: '11px',
fontWeight: '700',
display: 'flex',
@@ -330,10 +330,10 @@
}"
>{{ i + 1 }}</span
>
<span style="flex: 1; font-size: 13px; color: #333">
<span style="flex: 1; font-size: 13px; color: var(--wp-ink)">
{{ displayName(item.username, item.classname) }}
</span>
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
<span style="font-size: 12px; color: var(--wp-css); font-weight: 600">
{{ item.view_count }} 次
</span>
</div>
@@ -395,14 +395,14 @@ function bucketPct(value: number) {
const metrics = computed(() => {
if (!stats.value) return []
return [
{ label: "已提交", value: stats.value.submitted_count, color: "#18a058" },
{ label: "未提交", value: stats.value.unsubmitted_count, color: "#d03050" },
{ label: "已提交", value: stats.value.submitted_count, color: "var(--wp-brand)" },
{ label: "未提交", value: stats.value.unsubmitted_count, color: "var(--wp-bad)" },
{
label: "平均分",
value: stats.value.average_score?.toFixed(1) ?? "—",
color: "#2080f0",
color: "var(--wp-css)",
},
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
{ label: "未打分", value: stats.value.unrated_count, color: "var(--wp-bad)" },
]
})
@@ -413,30 +413,30 @@ const countBuckets = computed(() => {
{
label: "4 次+",
value: d.count_4_plus,
color: "#f0a020",
bg: "#fffbf0",
borderColor: "#ffe0a0",
color: "var(--wp-warn)",
bg: "var(--wp-warn-soft)",
borderColor: "var(--wp-warn-soft)",
},
{
label: "3 次",
value: d.count_3,
color: "#18a058",
bg: "#f0fff4",
borderColor: "#c8e8d0",
color: "var(--wp-brand)",
bg: "var(--wp-brand-soft)",
borderColor: "var(--wp-brand-soft)",
},
{
label: "2 次",
value: d.count_2,
color: "#2080f0",
bg: "#f0f7ff",
borderColor: "#d0e8ff",
color: "var(--wp-css)",
bg: "var(--wp-css-soft)",
borderColor: "var(--wp-css-soft)",
},
{
label: "仅 1 次",
value: d.count_1,
color: "#888",
bg: "#fafafa",
borderColor: "#e0e0e0",
color: "var(--wp-muted)",
bg: "var(--wp-paper)",
borderColor: "var(--wp-line)",
},
]
})
+10 -7
View File
@@ -8,6 +8,7 @@ import { trustedExtensions } from "../../utils/markdown"
import copyFn from "copy-text-to-clipboard"
import { css, html, js, tab } from "../../store/editors"
import { Tutorial } from "../../api"
import { readTaskCode } from "../../composables/useTaskCode"
import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
import { taskId, assetBaseUrl } from "../../store/task"
import { useRouter } from "vue-router"
@@ -45,11 +46,13 @@ async function render() {
const seq = ++renderSeq
const data = await Tutorial.get(step.value)
if (seq !== renderSeq) return
// 这一节写过代码就接着写,没写过才放示例代码(以前每次切换都会被示例覆盖)
const saved = readTaskCode(data.task_ptr)
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 ?? ""
html.value = saved?.html ?? data.example_html ?? ""
css.value = saved?.css ?? data.example_css ?? ""
js.value = saved?.js ?? data.example_js ?? ""
const merged = `# ${data.display}. ${data.title}\n${data.content}`
const rendered = await tutorialMarked.parse(merged, { async: true })
if (seq !== renderSeq) return
@@ -124,7 +127,7 @@ watch(step, (v) => {
.markdown-body .codeblock-wrapper {
padding: 1rem;
background-color: #f6f8fa;
background-color: var(--wp-paper);
border-radius: 6px;
margin-bottom: 1rem;
overflow: auto;
@@ -170,9 +173,9 @@ watch(step, (v) => {
padding: 0 14px;
font-size: 14px;
border-radius: 3px;
border: 1px solid #d9d9d9;
background-color: #fff;
color: #333;
border: 1px solid var(--wp-line);
background-color: var(--wp-surface);
color: var(--wp-ink);
cursor: pointer;
transition: all 0.2s ease;
}
@@ -169,7 +169,7 @@ watch(
border-radius: 6px;
overflow: hidden;
cursor: zoom-in;
background: #fff;
background: var(--wp-surface);
}
/* 按 4 倍大小渲染再缩小,看起来像网页缩略图 */
+4
View File
@@ -4,6 +4,10 @@ import { emptyCode, type Code } from "../utils/code"
const key = (taskId: number) => `web-code:${user.username}:${taskId}`
export function readTaskCode(taskId: number): Code | null {
return user.username ? read(taskId) : null
}
function read(taskId: number): Code | null {
try {
const saved = JSON.parse(localStorage.getItem(key(taskId)) ?? "null")
+6
View File
@@ -82,3 +82,9 @@ iframe {
.lang-js::before {
background: var(--wp-js);
}
/* markdown 区域用页面的底色,暗色时不出现一块 GitHub 的深蓝底 */
:root[data-theme="dark"] body .markdown-body {
background-color: transparent;
color: var(--wp-ink);
}
+6
View File
@@ -21,6 +21,7 @@ import {
Lock,
LogOut,
Monitor,
Moon,
Play,
Plus,
RefreshCw,
@@ -28,6 +29,8 @@ import {
Search,
Smartphone,
Sparkles,
Sun,
SunMoon,
SquarePen,
Star,
Swords,
@@ -72,6 +75,9 @@ const registry: Record<string, Component> = {
users: Users,
"graduation-cap": GraduationCap,
x: X,
sun: Sun,
moon: Moon,
"sun-moon": SunMoon,
// 原 material-symbols / pepicons
"close-rounded": X,
"desktop-windows-outline": Monitor,
+3 -2
View File
@@ -4,7 +4,7 @@ import App from "./App.vue"
import "@fontsource/jetbrains-mono/400.css"
import "@fontsource/jetbrains-mono/600.css"
import "./global.css"
import { applyCssVars, light } from "./theme"
import "./store/theme"
//@ts-ignore
import "github-markdown-css/github-markdown-light.css"
@@ -13,10 +13,11 @@ import { marked } from "marked"
import { trustedExtensions } from "./utils/markdown"
//@ts-ignore
import "highlight.js/styles/github.min.css"
// 暗色时覆盖上面两份亮色样式
import "./styles/dark-content.css"
import { router } from "./router"
marked.use(...trustedExtensions)
applyCssVars(light)
const app = createApp(App)
const naive = create()
-313
View File
@@ -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>
+178
View File
@@ -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>
+1 -1
View File
@@ -197,6 +197,6 @@ onMounted(async () => {
.creation-desc {
font-size: 13px;
color: #666;
color: var(--wp-muted);
}
</style>
+5 -5
View File
@@ -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) {
+1 -1
View File
@@ -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);
+5 -5
View File
@@ -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>
+7 -7
View File
@@ -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 {
+4 -12
View File
@@ -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>
+342
View File
@@ -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>
-275
View File
@@ -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>
+31 -8
View File
@@ -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="张三&#10;李四&#10;王五"
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: "创建时间",
+7
View File
@@ -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,
+5 -3
View File
@@ -34,7 +34,7 @@ const routes: RouteRecordRaw[] = [
{
path: "/challenge",
name: "home-challenge-list",
component: Workspace,
component: () => import("./pages/ChallengeHome.vue"),
meta: { auth: true },
},
{
@@ -83,13 +83,15 @@ const routes: RouteRecordRaw[] = [
{
path: "tutorial/:display",
name: "tutorial-editor",
component: () => import("./pages/TutorialEditor.vue"),
component: () => import("./pages/TaskEditor.vue"),
props: { kind: "tutorial" },
meta: { role: "super" },
},
{
path: "challenge/:display",
name: "challenge-editor",
component: () => import("./pages/ChallengeEditor.vue"),
component: () => import("./pages/TaskEditor.vue"),
props: { kind: "challenge" },
},
{
path: "user-manage/:page",
+25
View File
@@ -0,0 +1,25 @@
import { computed, watch } from "vue"
import { usePreferredDark, useStorage } from "@vueuse/core"
import { applyCssVars, dark, light } from "../theme"
// auto:跟随系统;投屏时老师可以手动切回亮色
export type ThemeMode = "auto" | "light" | "dark"
export const themeMode = useStorage<ThemeMode>("theme-mode", "auto")
const prefersDark = usePreferredDark()
export const isDark = computed(
() => themeMode.value === "dark" || (themeMode.value === "auto" && prefersDark.value),
)
// CSS 变量和 data-theme(markdown、代码高亮的暗色样式按它切换)跟着走
watch(
isDark,
(d) => {
applyCssVars(d ? dark : light)
const root = document.documentElement
root.dataset.theme = d ? "dark" : "light"
root.style.colorScheme = d ? "dark" : "light"
},
{ immediate: true },
)
+384
View File
@@ -0,0 +1,384 @@
/* 由 github-markdown-css 的 github-markdown-dark.css 和 highlight.js 的 github-dark.css 生成:
每个选择器前加 :root[data-theme="dark"],只保留颜色相关的声明。暗色模式下覆盖亮色样式 */
:root[data-theme="dark"] .markdown-body {
color-scheme: dark;
color: #f0f6fc;
background-color: #0d1117;
}
:root[data-theme="dark"] .markdown-body .octicon {
fill: currentColor;
}
:root[data-theme="dark"] .markdown-body h1:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h2:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h3:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h4:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h5:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h6:hover .anchor .octicon-link:before {
background-color: currentColor;
}
:root[data-theme="dark"] .markdown-body a {
background-color: rgba(0,0,0,0);
color: #4493f8;
}
:root[data-theme="dark"] .markdown-body abbr[title] {
border-bottom: none;
}
:root[data-theme="dark"] .markdown-body h1 {
border-bottom: 1px solid #3d444db3;
}
:root[data-theme="dark"] .markdown-body mark {
background-color: #bb800926;
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body hr {
background: rgba(0,0,0,0);
border-bottom: 1px solid #3d444db3;
background-color: #3d444d;
border: 0;
}
:root[data-theme="dark"] .markdown-body ::-webkit-input-placeholder {
color: inherit;
}
:root[data-theme="dark"] .markdown-body ::placeholder {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body a:focus,
:root[data-theme="dark"] .markdown-body [role=button]:focus,
:root[data-theme="dark"] .markdown-body input[type=radio]:focus,
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus {
outline: 2px solid var(--borderColor-accent-emphasis);
box-shadow: none;
}
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body input[type=radio]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus:not(:focus-visible) {
outline: solid 1px rgba(0,0,0,0);
}
:root[data-theme="dark"] .markdown-body a:focus-visible,
:root[data-theme="dark"] .markdown-body [role=button]:focus-visible,
:root[data-theme="dark"] .markdown-body input[type=radio]:focus-visible,
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus-visible {
outline: 2px solid var(--borderColor-accent-emphasis);
box-shadow: none;
}
:root[data-theme="dark"] .markdown-body kbd {
color: #f0f6fc;
background-color: #151b23;
border: solid 1px var(--borderColor-muted);
border-bottom-color: var(--borderColor-muted);
box-shadow: inset 0 -1px 0 var(--borderColor-muted);
}
:root[data-theme="dark"] .markdown-body h2 {
border-bottom: 1px solid #3d444db3;
}
:root[data-theme="dark"] .markdown-body h6 {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body blockquote {
color: #9198a1;
border-left: .25em solid #3d444d;
}
:root[data-theme="dark"] .markdown-body .octicon {
fill: currentColor;
}
:root[data-theme="dark"] .markdown-body a:not([href]) {
color: inherit;
}
:root[data-theme="dark"] .markdown-body .absent {
color: #f85149;
}
:root[data-theme="dark"] .markdown-body .anchor:focus {
outline: none;
}
:root[data-theme="dark"] .markdown-body h1 .octicon-link,
:root[data-theme="dark"] .markdown-body h2 .octicon-link,
:root[data-theme="dark"] .markdown-body h3 .octicon-link,
:root[data-theme="dark"] .markdown-body h4 .octicon-link,
:root[data-theme="dark"] .markdown-body h5 .octicon-link,
:root[data-theme="dark"] .markdown-body h6 .octicon-link {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body summary h1,
:root[data-theme="dark"] .markdown-body summary h2 {
border-bottom: 0;
}
:root[data-theme="dark"] .markdown-body table th,
:root[data-theme="dark"] .markdown-body table td {
border: 1px solid #3d444d;
}
:root[data-theme="dark"] .markdown-body table tr {
background-color: #0d1117;
border-top: 1px solid #3d444db3;
}
:root[data-theme="dark"] .markdown-body table tr:nth-child(2n) {
background-color: #151b23;
}
:root[data-theme="dark"] .markdown-body table img {
background-color: rgba(0,0,0,0);
}
:root[data-theme="dark"] .markdown-body .emoji {
background-color: rgba(0,0,0,0);
}
:root[data-theme="dark"] .markdown-body span.frame>span {
border: 1px solid #3d444d;
}
:root[data-theme="dark"] .markdown-body span.frame span span {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body code,
:root[data-theme="dark"] .markdown-body tt {
background-color: #656c7633;
}
:root[data-theme="dark"] .markdown-body pre>code {
background: rgba(0,0,0,0);
border: 0;
}
:root[data-theme="dark"] .markdown-body .highlight pre,
:root[data-theme="dark"] .markdown-body pre {
color: #f0f6fc;
background-color: #151b23;
}
:root[data-theme="dark"] .markdown-body pre code,
:root[data-theme="dark"] .markdown-body pre tt {
background-color: rgba(0,0,0,0);
border: 0;
}
:root[data-theme="dark"] .markdown-body .csv-data .blob-num {
background: #0d1117;
border: 0;
}
:root[data-theme="dark"] .markdown-body .csv-data tr {
border-top: 0;
}
:root[data-theme="dark"] .markdown-body .csv-data th {
background: #151b23;
border-top: 0;
}
:root[data-theme="dark"] .markdown-body .footnotes {
color: #9198a1;
border-top: 1px solid #3d444d;
}
:root[data-theme="dark"] .markdown-body .footnotes li:target::before {
border: 2px solid #1f6feb;
}
:root[data-theme="dark"] .markdown-body .footnotes li:target {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-c {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body .pl-c1,
:root[data-theme="dark"] .markdown-body .pl-s .pl-v {
color: #79c0ff;
}
:root[data-theme="dark"] .markdown-body .pl-e,
:root[data-theme="dark"] .markdown-body .pl-en {
color: #d2a8ff;
}
:root[data-theme="dark"] .markdown-body .pl-smi,
:root[data-theme="dark"] .markdown-body .pl-s .pl-s1 {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-ent {
color: #7ee787;
}
:root[data-theme="dark"] .markdown-body .pl-k {
color: #ff7b72;
}
:root[data-theme="dark"] .markdown-body .pl-s,
:root[data-theme="dark"] .markdown-body .pl-pds,
:root[data-theme="dark"] .markdown-body .pl-s .pl-pse .pl-s1,
:root[data-theme="dark"] .markdown-body .pl-sr,
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce,
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sre,
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sra {
color: #a5d6ff;
}
:root[data-theme="dark"] .markdown-body .pl-v,
:root[data-theme="dark"] .markdown-body .pl-smw {
color: #ffa657;
}
:root[data-theme="dark"] .markdown-body .pl-bu {
color: #f85149;
}
:root[data-theme="dark"] .markdown-body .pl-ii {
color: var(--fgColor-danger);
background-color: var(--bgColor-danger-muted);
}
:root[data-theme="dark"] .markdown-body .pl-c2 {
color: #f0f6fc;
background-color: #b62324;
}
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce {
color: #7ee787;
}
:root[data-theme="dark"] .markdown-body .pl-ml {
color: #f2cc60;
}
:root[data-theme="dark"] .markdown-body .pl-mh,
:root[data-theme="dark"] .markdown-body .pl-mh .pl-en,
:root[data-theme="dark"] .markdown-body .pl-ms {
color: #1f6feb;
}
:root[data-theme="dark"] .markdown-body .pl-mi {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-mb {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-md {
color: #ffdcd7;
background-color: #67060c;
}
:root[data-theme="dark"] .markdown-body .pl-mi1 {
color: #aff5b4;
background-color: #033a16;
}
:root[data-theme="dark"] .markdown-body .pl-mc {
color: #ffdfb6;
background-color: #5a1e02;
}
:root[data-theme="dark"] .markdown-body .pl-mi2 {
color: #f0f6fc;
background-color: #1158c7;
}
:root[data-theme="dark"] .markdown-body .pl-mdr {
color: #d2a8ff;
}
:root[data-theme="dark"] .markdown-body .pl-ba {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body .pl-sg {
color: #3d444d;
}
:root[data-theme="dark"] .markdown-body .pl-corl {
color: #a5d6ff;
}
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body button:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body summary:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
:root[data-theme="dark"] .markdown-body [tabindex="0"]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body details-dialog:focus:not(:focus-visible) {
outline: none;
}
:root[data-theme="dark"] .markdown-body a:has(>p,
:root[data-theme="dark"] >div,
:root[data-theme="dark"] >pre,
:root[data-theme="dark"] >blockquote):has(.snippet-clipboard-content,
:root[data-theme="dark"] >pre):focus-visible {
outline: 2px solid var(--borderColor-accent-emphasis);
}
:root[data-theme="dark"] .markdown-body .markdown-alert {
color: inherit;
border-left: .25em solid #3d444d;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note {
border-left-color: #1f6feb;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {
color: #4493f8;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important {
border-left-color: #8957e5;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {
color: #ab7df8;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning {
border-left-color: #9e6a03;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {
color: #d29922;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip {
border-left-color: #238636;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {
color: #3fb950;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution {
border-left-color: #da3633;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {
color: #f85149;
}
:root[data-theme="dark"] .hljs {
color: #c9d1d9;
background: #0d1117;
}
:root[data-theme="dark"] .hljs-doctag,
:root[data-theme="dark"] .hljs-keyword,
:root[data-theme="dark"] .hljs-meta .hljs-keyword,
:root[data-theme="dark"] .hljs-template-tag,
:root[data-theme="dark"] .hljs-template-variable,
:root[data-theme="dark"] .hljs-type,
:root[data-theme="dark"] .hljs-variable.language_ {
color: #ff7b72;
}
:root[data-theme="dark"] .hljs-title,
:root[data-theme="dark"] .hljs-title.class_,
:root[data-theme="dark"] .hljs-title.class_.inherited__,
:root[data-theme="dark"] .hljs-title.function_ {
color: #d2a8ff;
}
:root[data-theme="dark"] .hljs-attr,
:root[data-theme="dark"] .hljs-attribute,
:root[data-theme="dark"] .hljs-literal,
:root[data-theme="dark"] .hljs-meta,
:root[data-theme="dark"] .hljs-number,
:root[data-theme="dark"] .hljs-operator,
:root[data-theme="dark"] .hljs-variable,
:root[data-theme="dark"] .hljs-selector-attr,
:root[data-theme="dark"] .hljs-selector-class,
:root[data-theme="dark"] .hljs-selector-id {
color: #79c0ff;
}
:root[data-theme="dark"] .hljs-regexp,
:root[data-theme="dark"] .hljs-string,
:root[data-theme="dark"] .hljs-meta .hljs-string {
color: #a5d6ff;
}
:root[data-theme="dark"] .hljs-built_in,
:root[data-theme="dark"] .hljs-symbol {
color: #ffa657;
}
:root[data-theme="dark"] .hljs-comment,
:root[data-theme="dark"] .hljs-code,
:root[data-theme="dark"] .hljs-formula {
color: #8b949e;
}
:root[data-theme="dark"] .hljs-name,
:root[data-theme="dark"] .hljs-quote,
:root[data-theme="dark"] .hljs-selector-tag,
:root[data-theme="dark"] .hljs-selector-pseudo {
color: #7ee787;
}
:root[data-theme="dark"] .hljs-subst {
color: #c9d1d9;
}
:root[data-theme="dark"] .hljs-section {
color: #1f6feb;
}
:root[data-theme="dark"] .hljs-bullet {
color: #f2cc60;
}
:root[data-theme="dark"] .hljs-emphasis {
color: #c9d1d9;
}
:root[data-theme="dark"] .hljs-strong {
color: #c9d1d9;
}
:root[data-theme="dark"] .hljs-addition {
color: #aff5b4;
background-color: #033a16;
}
:root[data-theme="dark"] .hljs-deletion {
color: #ffdcd7;
background-color: #67060c;
}
+2
View File
@@ -127,6 +127,8 @@ export interface User {
role: Role
classname: string
is_active: boolean
// 批量创建时生成的初始密码;超管和改过密码的为空
raw_password?: string | null
}
export interface SubmissionOut {