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
+1207
-850
No files matched your search
@@ -28,9 +28,10 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
||||
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
|
||||
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
|
||||
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
|
||||
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors. No `color-mix()` (Chrome 105)
|
||||
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on `<html>`; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode
|
||||
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
||||
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page still uses the shared `store/editors.ts`
|
||||
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
|
||||
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
|
||||
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — keys: J/K next/prev student, 1–5 rate, P present; `components/review/PeersDrawer.vue` is the students' “同学作品”
|
||||
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
||||
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
||||
|
||||
@@ -12,6 +12,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",
|
||||
@@ -295,6 +297,8 @@
|
||||
|
||||
"@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
||||
|
||||
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
||||
|
||||
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -127,6 +127,8 @@ export interface User {
|
||||
role: Role
|
||||
classname: string
|
||||
is_active: boolean
|
||||
// 批量创建时生成的初始密码;超管和改过密码的为空
|
||||
raw_password?: string | null
|
||||
}
|
||||
|
||||
export interface SubmissionOut {
|
||||
|
||||
@@ -274,8 +274,9 @@ challenge.post("/", async (c) => {
|
||||
return c.json({ message: "创建成功" })
|
||||
})
|
||||
|
||||
// 管理员保存挑战时本来就能设“公开”,这里和它保持一致
|
||||
challenge.put("/public/:display", (c) => {
|
||||
requireSuper(c)
|
||||
requireAdmin(c)
|
||||
const found = orNotFound(findChallenge(intParam(c, "display")))
|
||||
return c.json(togglePublic(found.id))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user