feat: 改版阶段 1:统一顶栏和教学侧栏、配色和图标规范、路由角色守卫
Deploy / deploy (build, debian, 22) (push) Canceled after 0s

- 所有页面共用顶栏(教程 / 挑战 / 自由创作 / 作品展,右边“教学”和用户菜单);
  没登录时顶栏有“登录”按钮,关掉登录框不再白屏;/submission/:id 全屏页不带顶栏
- 后台改成“教学”侧栏,分出题 / 班级 / 管理三组;“全部提交”留在侧栏里打开;
  admin 也能进作品展管理(后端本来就允许)
- 路由按角色守卫:meta.role 为 staff / super,学生和 admin 进不了无权页面
- 配色只在 theme.ts 定义,同时生成 --wp-* CSS 变量和 Naive UI 主题,暗色值先定好
- 图标改用打包进来的 lucide(断网也能显示),去掉 @iconify/vue 和 PUBLIC_ICONIFY_URL;
  代码字体换成打包的 JetBrains Mono,编辑器默认字号 24 → 16
- 教程面板去掉和顶栏重复的切换和图标按钮,图标按钮都补上文字或提示

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 23:39:55 +08:00
1 parent e47fac1732
commit ff1eb2982e
43 files changed
+837 -429

No files matched your search

-1
View File
@@ -1,3 +1,2 @@
PUBLIC_WEB_URL=https://web.xuyue.cc
PUBLIC_BASE_URL=https://web.xuyue.cc/api
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
-1
View File
@@ -1,3 +1,2 @@
PUBLIC_WEB_URL=http://10.13.114.114:91
PUBLIC_BASE_URL=http://10.13.114.114:91/api
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
-1
View File
@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>前端开发学习平台</title>
<link rel="stylesheet" href="/normalize.min.css" />
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="app"></div>
+2 -1
View File
@@ -15,12 +15,14 @@
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/view": "^6.41.1",
"@fontsource/jetbrains-mono": "^5.3.0",
"@vueuse/core": "^15.0.0",
"axios": "^1.20.0",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",
"github-markdown-css": "^5.9.0",
"highlight.js": "^11.12.0",
"lucide-vue-next": "^1.0.0",
"marked": "^18.1.1",
"marked-alert": "^2.1.2",
"marked-code-preview": "^1.3.7",
@@ -32,7 +34,6 @@
"vue-router": "^5.4.0"
},
"devDependencies": {
"@iconify/vue": "^5.0.4",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.9.1",
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
@font-face {
font-family: "Monaco";
src: url(/Monaco.ttf);
}
+10 -23
View File
@@ -1,32 +1,17 @@
<script setup lang="ts">
import { dateZhCN, zhCN } from "naive-ui"
import type { GlobalThemeOverrides } from "naive-ui"
import Login from "./components/Login.vue"
import AppShell from "./components/layout/AppShell.vue"
import { onMounted } from "vue"
import { Account } from "./api"
import { user } from "./store/user"
import { useRoute } from "vue-router"
import { loadUser } from "./store/session"
import { light, naiveOverrides } from "./theme"
import hljs from "highlight.js/lib/core"
const themeOverrides: GlobalThemeOverrides = {
common: {
borderRadius: "6px",
borderRadiusSmall: "4px",
},
Card: {
borderRadius: "8px",
},
}
const route = useRoute()
const themeOverrides = naiveOverrides(light)
onMounted(async () => {
try {
const data = await Account.getMyProfile()
user.loaded = true
user.username = data.username
user.role = data.role
} catch {
user.loaded = true
}
})
onMounted(loadUser)
</script>
<template>
@@ -40,7 +25,9 @@ onMounted(async () => {
<n-modal-provider>
<n-message-provider :max="1">
<n-dialog-provider>
<router-view></router-view>
<!-- 全屏预览这类页面不要顶栏 -->
<router-view v-if="route.meta.bare" />
<AppShell v-else />
<Login />
</n-dialog-provider>
</n-message-provider>
+3 -3
View File
@@ -84,7 +84,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import { renderMarkdown } from "../../utils/markdown"
import {
messages,
@@ -168,7 +168,7 @@ watch([() => messages.value.length, streamingContent], () => {
}
.guidance-msg.assistant .msg-role {
color: #18a058;
color: var(--wp-brand);
}
.guidance-msg.assistant .msg-content {
@@ -194,7 +194,7 @@ watch([() => messages.value.length, streamingContent], () => {
display: flex;
align-items: center;
gap: 6px;
color: #18a058;
color: var(--wp-brand);
font-size: 13px;
font-weight: 500;
padding: 6px 10px;
@@ -130,7 +130,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import { useMessage } from "naive-ui"
import { renderMarkdown } from "../../utils/markdown"
import { Prompt } from "../../api"
@@ -307,7 +307,7 @@ onMounted(() => {
.history-card.is-selected {
--n-color: #f7fffa;
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14);
}
.history-card.is-selected .history-main {
+3 -3
View File
@@ -120,7 +120,7 @@
import { ref, watch, nextTick, computed } from "vue"
import { useStorage } from "@vueuse/core"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import GuidancePanel from "./GuidancePanel.vue"
import { openGuidance } from "../../store/guidance"
import {
@@ -289,7 +289,7 @@ watch([() => messages.value.length, streamingContent], () => {
}
.message.assistant .message-role {
color: #18a058;
color: var(--wp-brand);
}
.message-content {
@@ -329,7 +329,7 @@ watch([() => messages.value.length, streamingContent], () => {
width: 8px;
height: 8px;
border-radius: 50%;
background: #18a058;
background: var(--wp-brand);
animation: bounce 1.4s ease-in-out infinite;
}
+7 -7
View File
@@ -8,20 +8,20 @@ import { computed } from "vue"
const styleTheme = EditorView.baseTheme({
"& .cm-scroller": {
"font-family": "Monaco",
"font-family": "var(--wp-font-mono)",
},
"&.cm-editor.cm-focused": {
outline: "none",
},
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
"font-family": "Monaco",
"font-family": "var(--wp-font-mono)",
},
})
const bgColors: Record<string, string> = {
html: "#fff6f3",
css: "#f3f6ff",
js: "#fffdf0",
html: "var(--wp-html-soft)",
css: "var(--wp-css-soft)",
js: "var(--wp-js-soft)",
}
interface Props {
@@ -31,7 +31,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
language: "html",
fontSize: 20,
fontSize: 16,
})
const code = defineModel<string>("value")
@@ -43,7 +43,7 @@ const lang = computed(() => {
})
const bgTheme = computed(() => {
const bg = bgColors[props.language] ?? "#ffffff"
const bg = bgColors[props.language] ?? "var(--wp-surface)"
return EditorView.theme({
"&": { backgroundColor: bg },
".cm-gutters": { backgroundColor: bg },
+47 -24
View File
@@ -10,37 +10,25 @@
>
<n-tab-pane name="html" tab="HTML">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:html"></Icon>
<span>HTML</span>
</n-flex>
<span class="lang-tab lang-html">HTML</span>
</template>
<Editor v-model:value="html" :font-size="size" language="html" />
</n-tab-pane>
<n-tab-pane name="css" tab="CSS">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:css"></Icon>
<span>CSS</span>
</n-flex>
<span class="lang-tab lang-css">CSS</span>
</template>
<Editor v-model:value="css" :font-size="size" language="css" />
</n-tab-pane>
<n-tab-pane name="js" tab="JS">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:javascript"></Icon>
<span>JS</span>
</n-flex>
<span class="lang-tab lang-js">JS</span>
</template>
<Editor v-model:value="js" :font-size="size" language="js" />
</n-tab-pane>
<n-tab-pane name="actions" tab="选项">
<template #tab>
<n-flex align="center">
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
<span>选项</span>
</n-flex>
<span class="lang-tab">选项</span>
</template>
<n-flex class="wrapper" vertical>
<n-flex align="center">
@@ -53,7 +41,7 @@
<span class="label">字号</span>
<n-flex align="center">
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
<n-button :disabled="size === 20" @click="changeSize(size - 2)">
<n-button :disabled="size <= 12" @click="changeSize(size - 2)">
调小
</n-button>
<n-button :disabled="size === 40" @click="changeSize(size + 2)">
@@ -78,7 +66,6 @@
</div>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import Editor from "./Editor.vue"
import Toolbar from "./Toolbar.vue"
import { html, css, js, tab, size, reset } from "../../store/editors"
@@ -193,14 +180,50 @@ async function formatAndSubmit() {
font-size: 16px;
}
/* HTML / CSS / JS 各有固定颜色,见 theme.ts */
.lang-tab {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--wp-font-mono);
font-weight: 600;
font-size: 13px;
}
.lang-tab.lang-html::before,
.lang-tab.lang-css::before,
.lang-tab.lang-js::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
}
.lang-html {
color: var(--wp-html);
}
.lang-html::before {
background: var(--wp-html);
}
.lang-css {
color: var(--wp-css);
}
.lang-css::before {
background: var(--wp-css);
}
.lang-js {
color: var(--wp-js);
}
.lang-js::before {
background: var(--wp-js);
}
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
background-color: #fff6f3 !important;
background-color: var(--wp-html-soft) !important;
}
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
background-color: #f3f6ff !important;
background-color: var(--wp-css-soft) !important;
}
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
background-color: #fffdf0 !important;
background-color: var(--wp-js-soft) !important;
}
.editors-root {
@@ -208,12 +231,12 @@ async function formatAndSubmit() {
}
:deep(.tab-active-html .n-tab-pane) {
background-color: #fff6f3;
background-color: var(--wp-html-soft);
}
:deep(.tab-active-css .n-tab-pane) {
background-color: #f3f6ff;
background-color: var(--wp-css-soft);
}
:deep(.tab-active-js .n-tab-pane) {
background-color: #fffdf0;
background-color: var(--wp-js-soft);
}
</style>
+1 -1
View File
@@ -57,7 +57,7 @@ import { useRouter } from "vue-router"
import { Submission } from "../../api"
import { submission } from "../../store/submission"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import copy from "copy-text-to-clipboard"
import {
buildPreviewDocument,
+11 -88
View File
@@ -1,43 +1,31 @@
<template>
<n-flex align="center" class="corner">
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button>
<n-button quaternary v-if="!show" @click="showTutorial">显示教程</n-button>
<template v-if="user.loaded && authed">
<n-button quaternary @click="emit('format')">整理</n-button>
<n-tooltip>
<template #trigger>
<n-button quaternary @click="emit('format')">整理代码</n-button>
</template>
自动整理缩进和换行
</n-tooltip>
<n-button
type="primary"
secondary
:disabled="submitDisabled"
:loading="submitLoading"
@click="emit('submit')"
>
提交
</n-button>
<n-dropdown :options="menu" @select="clickMenu">
<n-button>{{ user.username }}</n-button>
</n-dropdown>
</template>
<n-button
v-if="user.loaded && !authed"
@click="handleLogin"
secondary
type="primary"
>
登录
</n-button>
</n-flex>
</template>
<script lang="ts" setup>
import { computed, h } from "vue"
import { Icon } from "@iconify/vue"
import { authed, roleAdmin, roleNormal, user } from "../../store/user"
import { loginModal } from "../../store/modal"
import { computed } from "vue"
import { authed, user } from "../../store/user"
import { show, panelSize } from "../../store/panel"
import { taskId } from "../../store/task"
import { Account } from "../../api"
import { Role } from "../../utils/type"
import { router } from "../../router"
const props = defineProps<{
defineProps<{
submitLoading: boolean
}>()
const emit = defineEmits(["format", "submit"])
@@ -46,78 +34,13 @@ const submitDisabled = computed(() => {
return taskId.value === 0
})
const menu = computed(() => [
{
label: "后台管理",
key: "dashboard",
show: !roleNormal.value,
icon: () =>
h(Icon, {
icon: "streamline-emojis:robot-face-1",
width: 20,
}),
},
{
label: "我的提交",
key: "submissions",
icon: () =>
h(Icon, {
icon: "streamline-emojis:bar-chart",
width: 20,
}),
},
{
label: "退出账号",
key: "logout",
icon: () =>
h(Icon, {
icon: "streamline-emojis:hot-beverage-2",
width: 20,
}),
},
])
function showTutorial() {
show.value = true
panelSize.value = 2 / 5
}
function clickMenu(name: string) {
switch (name) {
case "dashboard": {
const route = roleAdmin.value ? "challenge-editor" : "tutorial-editor"
router.push({ name: route, params: { display: 0 } })
break
}
case "submissions":
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
break
case "logout":
handleLogout()
break
}
}
function handleLogin() {
loginModal.value = true
}
async function handleLogout() {
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
try {
await Account.logout()
} finally {
user.username = ""
user.role = Role.Normal
}
}
</script>
<style scoped>
.corner {
margin-right: 20px;
margin-right: 12px;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
<template>
<div class="shell">
<header class="topbar">
<router-link class="brand" :to="tutorialRoute">
<img src="/noto--honeybee.ico" alt="" width="20" height="20" />
<span>前端开发学习平台</span>
</router-link>
<nav class="nav">
<router-link
v-for="item in nav"
:key="item.label"
:to="item.to"
:class="['nav-item', { active: item.active }]"
>
{{ item.label }}
</router-link>
</nav>
<div class="spacer"></div>
<template v-if="user.loaded">
<router-link
v-if="roleStaff"
:to="{ name: 'dashboard' }"
:class="['nav-item', { active: inTeach }]"
>
教学
</router-link>
<n-dropdown
v-if="authed"
:options="userMenu"
trigger="click"
@select="clickUserMenu"
>
<n-button quaternary class="user-btn">
{{ user.username }}
</n-button>
</n-dropdown>
<n-button v-else type="primary" size="small" @click="loginModal = true">
登录
</n-button>
</template>
</header>
<main class="main">
<router-view v-if="$route.matched.length" />
<div v-else-if="user.loaded && !authed" class="signed-out">
<p>登录后开始学习</p>
<n-button type="primary" @click="loginModal = true">登录</n-button>
</div>
</main>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { authed, roleStaff, user } from "../../store/user"
import { loginModal } from "../../store/modal"
import { step } from "../../store/tutorial"
import { logout } from "../../store/session"
const route = useRoute()
const router = useRouter()
const routeName = computed(() => String(route.name ?? ""))
const inTeach = computed(() => route.path.startsWith("/dashboard"))
const tutorialRoute = computed(() =>
step.value
? { name: "home-tutorial", params: { display: step.value } }
: { name: "home-tutorial-list" },
)
const nav = computed(() => [
{
label: "教程",
to: tutorialRoute.value,
active:
routeName.value === "home" || routeName.value.startsWith("home-tutorial"),
},
{
label: "挑战",
to: { name: "home-challenge-list" },
active: routeName.value.startsWith("home-challenge"),
},
{
label: "自由创作",
to: { name: "creation-list" },
active: routeName.value.startsWith("creation"),
},
{
label: "作品展",
to: { name: "showcase" },
active: routeName.value === "showcase",
},
])
const userMenu = [
{ label: "我的提交", key: "submissions" },
{ type: "divider", key: "d1" },
{ label: "退出登录", key: "logout" },
]
async function clickUserMenu(key: string) {
if (key === "submissions") {
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
} else if (key === "logout") {
await logout()
// 留在需要登录或特定角色的页面上没有意义
if (route.meta.auth || route.meta.role) router.push(tutorialRoute.value)
}
}
</script>
<style scoped>
.shell {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--wp-paper);
}
.topbar {
height: var(--wp-topbar-h);
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 0 16px;
background: var(--wp-surface);
border-bottom: 1px solid var(--wp-line);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
margin-right: 20px;
color: var(--wp-ink);
font-weight: 600;
font-size: 15px;
text-decoration: none;
white-space: nowrap;
}
.nav {
display: flex;
gap: 2px;
}
.nav-item {
padding: 6px 12px;
border-radius: 6px;
color: var(--wp-muted);
font-size: 14px;
text-decoration: none;
white-space: nowrap;
}
.nav-item:hover {
color: var(--wp-ink);
background: var(--wp-paper);
}
.nav-item.active {
color: var(--wp-brand);
background: var(--wp-brand-soft);
font-weight: 600;
}
.nav-item:focus-visible,
.brand:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 2px;
}
.spacer {
flex: 1;
}
.user-btn {
margin-left: 4px;
}
.main {
height: var(--wp-main-h);
min-height: 0;
overflow: auto;
}
.signed-out {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--wp-muted);
}
</style>
@@ -239,7 +239,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import { renderMarkdown } from "../../utils/markdown"
import { storageShim } from "../../utils/previewStorage"
import { Prompt, Submission } from "../../api"
@@ -67,7 +67,7 @@
<script lang="ts" setup>
import { ref, watch } from "vue"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import { useMessage } from "naive-ui"
import type { UploadFileInfo } from "naive-ui"
import { TaskAssets } from "../../api"
+48 -93
View File
@@ -1,93 +1,53 @@
<template>
<div class="container">
<n-flex align="center" justify="space-between" class="title">
<n-flex align="center">
<Icon
:icon="
taskTab === TASK_TYPE.Tutorial
? 'twemoji:books'
: 'twemoji:crossed-swords'
"
:width="20"
></Icon>
<n-tabs
style="width: 150px"
type="segment"
animated
:value="taskTab"
@update:value="changeTab"
>
<n-tab name="tutorial" tab="教程"></n-tab>
<n-tab name="challenge" tab="挑战"></n-tab>
</n-tabs>
<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>
<template v-if="!hideNav">
<n-button text @click="prev()" :disabled="prevDisabled()">
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon>
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="prev()" :disabled="prevDisabled()">
<Icon :width="18" icon="lucide:arrow-left" />
</n-button>
</template>
上一节
</n-tooltip>
<span v-if="progressText" class="progress-text">
{{ progressText }}
</span>
<n-button text @click="next()" :disabled="nextDisabled()">
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon>
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="next()" :disabled="nextDisabled()">
<Icon :width="18" icon="lucide:arrow-right" />
</n-button>
</template>
下一节
</n-tooltip>
</template>
</n-flex>
<n-flex>
<n-tooltip
<n-flex align="center" :wrap="false">
<n-button
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
trigger="hover"
size="small"
quaternary
@click="showAssets = true"
>
<template #trigger>
<n-button text @click="showAssets = true">
<Icon :width="16" icon="lucide:image"></Icon>
</n-button>
</template>
<template #icon><Icon icon="lucide:image" /></template>
素材
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button
text
@click="
$router.push({ name: 'submissions', params: { page: 1 } })
"
>
<Icon :width="16" icon="lucide:list"></Icon>
</n-button>
</template>
提交记录
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'creation-list' })">
<Icon :width="16" icon="lucide:sparkles"></Icon>
</n-button>
</template>
自由创作
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'showcase' })">
<Icon :width="16" icon="lucide:award"></Icon>
</n-button>
</template>
创意工坊
</n-tooltip>
<n-tooltip v-if="roleSuper" trigger="hover">
<template #trigger>
<n-button text @click="edit">
<Icon :width="16" icon="lucide:edit"></Icon>
</n-button>
</template>
</n-button>
<n-button v-if="roleSuper" size="small" quaternary @click="edit">
<template #icon><Icon icon="lucide:edit" /></template>
编辑
</n-tooltip>
<n-tooltip trigger="hover">
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="$emit('hide')">
<Icon :width="24" icon="material-symbols:close-rounded"></Icon>
<Icon :width="18" icon="lucide:x" />
</n-button>
</template>
关闭
收起教程,编辑器占满屏幕
</n-tooltip>
</n-flex>
</n-flex>
@@ -119,7 +79,7 @@
</n-modal>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import { computed, ref, watch } from "vue"
import {
step,
@@ -129,7 +89,7 @@ import {
prevDisabled,
nextDisabled,
} from "../../store/tutorial"
import { authed, roleSuper } from "../../store/user"
import { roleSuper } from "../../store/user"
import { taskTab, taskId, challengeDisplay } from "../../store/task"
import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE } from "../../utils/const"
@@ -167,6 +127,8 @@ function syncRoute(routeName: string) {
taskTab.value = TASK_TYPE.Challenge
if (route.params.display)
challengeDisplay.value = Number(route.params.display)
// 挑战列表里没有选中的任务,“提交”不可用
else taskId.value = 0
}
}
syncRoute(route.name as string)
@@ -189,20 +151,6 @@ const progressText = computed(() => {
return i === -1 ? "" : `${i + 1} / ${ids.length}`
})
function changeTab(v: TASK_TYPE) {
taskId.value = 0
taskTab.value = v
if (v === TASK_TYPE.Tutorial) {
router.push(
step.value
? { name: "home-tutorial", params: { display: step.value } }
: { name: "home-tutorial-list" },
)
} else if (v === TASK_TYPE.Challenge) {
router.push({ name: "home-challenge-list" })
}
}
function edit() {
const name =
taskTab.value === TASK_TYPE.Tutorial
@@ -222,15 +170,22 @@ function edit() {
.title {
height: 43px;
padding: 0 20px;
padding: 0 12px 0 16px;
flex-shrink: 0;
border-bottom: 1px solid rgb(239, 239, 245);
border-bottom: 1px solid var(--wp-line);
background: var(--wp-surface);
box-sizing: border-box;
}
.panel-name {
font-weight: 600;
color: var(--wp-ink);
}
.progress-text {
font-size: 12px;
color: #999;
color: var(--wp-faint);
font-family: var(--wp-font-mono);
min-width: 36px;
text-align: center;
user-select: none;
@@ -392,7 +392,7 @@
<script lang="ts" setup>
import { ref, computed, watch } from "vue"
import { Icon } from "@iconify/vue"
import { Icon } from "../../icons"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import type { TaskStatsOut } from "../../utils/type"
@@ -119,7 +119,7 @@ watch(step, (v) => {
.markdown-body pre code {
padding: 0;
font-size: 1rem;
font-family: Monaco;
font-family: var(--wp-font-mono);
}
.markdown-body .codeblock-wrapper {
@@ -178,7 +178,7 @@ watch(step, (v) => {
}
.codeblock-action .action-btn:hover {
border-color: #18a058;
color: #18a058;
border-color: var(--wp-brand);
color: var(--wp-brand);
}
</style>
-1
View File
@@ -3,7 +3,6 @@
interface ImportMetaEnv {
readonly PUBLIC_WEB_URL: string
readonly PUBLIC_BASE_URL: string
readonly PUBLIC_ICONIFY_URL: string
readonly PUBLIC_WS_URL: string
}
+23 -2
View File
@@ -4,10 +4,26 @@
body {
margin: 0;
background: var(--wp-paper);
color: var(--wp-ink);
font-family: var(--wp-font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 顶栏下面的内容区高度;页面用它代替 100vh */
:root {
--wp-topbar-h: 48px;
--wp-main-h: calc(100vh - var(--wp-topbar-h));
}
code,
kbd,
pre,
samp {
font-family: var(--wp-font-mono);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -18,10 +34,15 @@ body {
}
::-webkit-scrollbar-thumb {
background: #d0d0d0;
background: var(--wp-line);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #b0b0b0;
background: var(--wp-faint);
}
/* iframe 里都是学生写的网页,网页默认是白底;不设的话会透出页面的底色 */
iframe {
background: #fff;
}
+99
View File
@@ -0,0 +1,99 @@
import { defineComponent, h, type Component } from "vue"
import {
ArrowLeft,
ArrowRight,
Award,
BookOpen,
ChartNoAxesColumn,
Check,
ChevronDown,
ChevronRight,
ChevronUp,
CircleCheck,
Download,
GraduationCap,
Eye,
Globe,
Image,
Info,
Lightbulb,
List,
Lock,
LogOut,
Monitor,
Play,
Plus,
RefreshCw,
Save,
Search,
Smartphone,
Sparkles,
SquarePen,
Star,
Swords,
Tablet,
Trash2,
Users,
X,
} from "lucide-vue-next"
// 全站只用 lucide 一套图标,并且打包进前端(以前走在线的 iconify 接口,机房断网时图标会全部消失)。
// 沿用 <Icon icon="集合:名字" /> 的写法;原来其他图标集里用到的几个映射到 lucide 的对应图标
const registry: Record<string, Component> = {
"arrow-left": ArrowLeft,
"arrow-right": ArrowRight,
award: Award,
"book-open": BookOpen,
"bar-chart-2": ChartNoAxesColumn,
check: Check,
"chevron-down": ChevronDown,
"chevron-right": ChevronRight,
"chevron-up": ChevronUp,
globe: Globe,
lock: Lock,
"check-circle": CircleCheck,
download: Download,
eye: Eye,
image: Image,
info: Info,
lightbulb: Lightbulb,
list: List,
"log-out": LogOut,
play: Play,
plus: Plus,
"refresh-cw": RefreshCw,
save: Save,
search: Search,
sparkles: Sparkles,
edit: SquarePen,
star: Star,
swords: Swords,
"trash-2": Trash2,
users: Users,
"graduation-cap": GraduationCap,
x: X,
// 原 material-symbols / pepicons
"close-rounded": X,
"desktop-windows-outline": Monitor,
"smartphone-outline": Smartphone,
"tablet-outline": Tablet,
}
export const Icon = defineComponent({
name: "Icon",
props: {
icon: { type: String, required: true },
width: { type: [Number, String], default: 16 },
},
setup(props) {
return () => {
const name = props.icon.slice(props.icon.indexOf(":") + 1)
const comp = registry[name]
if (!comp) {
if (import.meta.env.DEV) console.warn(`图标 ${props.icon} 没有登记在 icons.ts 里`)
return null
}
return h(comp, { size: props.width, strokeWidth: 2, "aria-hidden": "true" })
}
},
})
+4 -7
View File
@@ -1,8 +1,10 @@
import { createApp } from "vue"
import { create } from "naive-ui"
import App from "./App.vue"
import "@fontsource/jetbrains-mono/400.css"
import "@fontsource/jetbrains-mono/600.css"
import "./global.css"
import { addAPIProvider } from "@iconify/vue"
import { applyCssVars, light } from "./theme"
//@ts-ignore
import "github-markdown-css/github-markdown-light.css"
@@ -14,6 +16,7 @@ import "highlight.js/styles/github.min.css"
import { router } from "./router"
marked.use(...trustedExtensions)
applyCssVars(light)
const app = createApp(App)
const naive = create()
@@ -21,9 +24,3 @@ const naive = create()
app.use(naive)
app.use(router)
app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
addAPIProvider("", {
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
})
}
+29 -27
View File
@@ -3,31 +3,33 @@
<div class="challenge-sider">
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
<template #prefix>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="18" icon="lucide:arrow-left" />
</n-button>
</template>
返回挑战列表
</n-tooltip>
</template>
<template #suffix>
<n-flex style="margin: 0 8px">
<n-button
v-if="roleAdmin || roleSuper"
text
@click="showStats = true"
>
<Icon :width="16" icon="lucide:bar-chart-2" />
</n-button>
<n-button
v-if="authed"
text
@click="
$router.push({ name: 'submissions', params: { page: 1 } })
"
>
<Icon :width="16" icon="lucide:list" />
</n-button>
<n-button v-if="roleSuper" text @click="edit">
<Icon :width="16" icon="lucide:edit" />
</n-button>
<n-tooltip v-if="roleAdmin || roleSuper">
<template #trigger>
<n-button text @click="showStats = true">
<Icon :width="16" icon="lucide:bar-chart-2" />
</n-button>
</template>
本题统计
</n-tooltip>
<n-tooltip v-if="roleSuper">
<template #trigger>
<n-button text @click="edit">
<Icon :width="16" icon="lucide:edit" />
</n-button>
</template>
编辑这道题
</n-tooltip>
</n-flex>
</template>
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
@@ -141,7 +143,7 @@
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { marked, type MarkedOptions } from "marked"
import copyFn from "copy-text-to-clipboard"
import PromptPanel from "../components/ai/PromptPanel.vue"
@@ -312,7 +314,7 @@ onUnmounted(() => {
<style scoped>
.challenge-layout {
display: flex;
height: 100vh;
height: 100%;
overflow: hidden;
}
@@ -396,7 +398,7 @@ onUnmounted(() => {
.challenge-sider .codeblock-wrapper pre code {
padding: 0;
font-size: 1rem;
font-family: Monaco, monospace;
font-family: var(--wp-font-mono);
user-select: text;
}
@@ -426,7 +428,7 @@ onUnmounted(() => {
}
.challenge-sider .action-btn:hover {
border-color: #18a058;
color: #18a058;
border-color: var(--wp-brand);
color: var(--wp-brand);
}
</style>
+6 -6
View File
@@ -26,8 +26,8 @@
width="24"
:icon="
item.is_public
? 'twemoji:check-mark-button'
: 'twemoji:locked'
? 'lucide:globe'
: 'lucide:lock'
"
></Icon>
</n-button>
@@ -97,7 +97,7 @@
</n-button>
</n-flex>
<MarkdownEditor
style="height: calc(100vh - 100px)"
style="height: calc(var(--wp-main-h) - 100px)"
v-model="challenge.content"
/>
</n-flex>
@@ -132,7 +132,7 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { Challenge } from "../api"
import { roleSuper } from "../store/user"
import type { ChallengeSlim } from "../utils/type"
@@ -296,7 +296,7 @@ onMounted(getContent)
<style scoped>
.list {
overflow: auto;
height: calc(100vh - 20px);
height: calc(var(--wp-main-h) - 20px);
}
.col {
@@ -308,6 +308,6 @@ onMounted(getContent)
}
.editor {
height: calc(100vh - 200px);
height: calc(var(--wp-main-h) - 200px);
}
</style>
+10 -5
View File
@@ -3,9 +3,14 @@
<div class="creation-sider">
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
<template #prefix>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="18" icon="lucide:arrow-left" />
</n-button>
</template>
返回我的创作
</n-tooltip>
</template>
<template #suffix>
<n-flex style="margin: 0 8px">
@@ -112,7 +117,7 @@
import { onMounted, onUnmounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import PromptPanel from "../components/ai/PromptPanel.vue"
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
import Preview from "../components/editor/Preview.vue"
@@ -229,7 +234,7 @@ onUnmounted(() => {
<style scoped>
.creation-layout {
display: flex;
height: 100vh;
height: 100%;
overflow: hidden;
}
+2 -7
View File
@@ -6,12 +6,7 @@
style="margin-bottom: 32px"
>
<div>
<n-flex align="center" :size="8">
<n-button text @click="$router.push({ name: 'home' })">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
<n-h2 style="margin: 0">自由创作</n-h2>
</n-flex>
<n-h2 style="margin: 0">自由创作</n-h2>
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
</div>
<n-button type="primary" @click="openCreate">
@@ -106,7 +101,7 @@
import { onMounted, reactive, ref } from "vue"
import { useRouter } from "vue-router"
import { useDialog, useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { Creation } from "../api"
import type { CreationSlim } from "../utils/type"
+106 -81
View File
@@ -1,126 +1,151 @@
<template>
<n-flex class="container" :wrap="false">
<div class="sidebar">
<div class="back-btn" @click="() => goHome($router, taskTab, step)">
← 返回
</div>
<n-divider style="margin: 8px 0" />
<div
v-for="item in menu"
:key="item.label"
:class="['nav-item', { active: $route.name === item.route.name }]"
@click="$router.push(item.route)"
>
{{ item.label }}
</div>
</div>
<div class="teach">
<nav class="sidebar">
<template v-for="group in groups" :key="group.label">
<div class="group-label">{{ group.label }}</div>
<router-link
v-for="item in group.items"
:key="item.label"
:to="item.route"
:class="['nav-item', { active: item.names.includes(String($route.name)) }]"
>
<Icon :icon="item.icon" :width="16" />
<span>{{ item.label }}</span>
</router-link>
</template>
</nav>
<div class="content">
<router-view></router-view>
</div>
</n-flex>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import { taskTab } from "../store/task"
import { Icon } from "../icons"
import { step } from "../store/tutorial"
import { roleAdmin, roleSuper } from "../store/user"
import { goHome } from "../utils/helper"
import { roleSuper } from "../store/user"
const menu = computed(() =>
// admin 只能出挑战、看提交、管作品展;其余是超管的(和后端权限一致)
const groups = computed(() =>
[
{
label: "教程",
route: { name: "tutorial-editor", params: { display: step.value } },
show: roleSuper.value,
label: "出题",
items: [
{
label: "教程",
icon: "lucide:book-open",
route: { name: "tutorial-editor", params: { display: step.value } },
names: ["tutorial-editor"],
show: roleSuper.value,
},
{
label: "挑战",
icon: "lucide:swords",
route: { name: "challenge-editor", params: { display: 0 } },
names: ["challenge-editor"],
show: true,
},
],
},
{
label: "挑战",
route: { name: "challenge-editor", params: { display: 0 } },
show: roleAdmin.value || roleSuper.value,
label: "班级",
items: [
{
label: "成绩册",
icon: "lucide:graduation-cap",
route: { name: "gradebook" },
names: ["gradebook"],
show: roleSuper.value,
},
{
label: "全部提交",
icon: "lucide:list",
route: { name: "teach-submissions", params: { page: 1 } },
names: ["teach-submissions"],
show: true,
},
],
},
{
label: "用户",
route: { name: "user-manage", params: { page: 1 } },
show: roleSuper.value,
label: "管理",
items: [
{
label: "作品展",
icon: "lucide:award",
route: { name: "showcase-manage" },
names: ["showcase-manage"],
show: true,
},
{
label: "用户",
icon: "lucide:users",
route: { name: "user-manage", params: { page: 1 } },
names: ["user-manage"],
show: roleSuper.value,
},
],
},
{
label: "工坊",
route: { name: "showcase-manage" },
show: roleSuper.value,
},
{
label: "成绩",
route: { name: "gradebook" },
show: roleSuper.value,
},
{
label: "提交",
route: { name: "submissions", params: { page: 1 } },
show: roleAdmin.value || roleSuper.value,
},
].filter((item) => item.show),
]
.map((g) => ({ ...g, items: g.items.filter((i) => i.show) }))
.filter((g) => g.items.length),
)
</script>
<style scoped>
.container {
height: 100vh;
width: 100vw;
box-sizing: border-box;
.teach {
height: 100%;
display: flex;
}
.sidebar {
width: 110px;
min-width: 110px;
padding: 12px 8px;
border-right: 1px solid #efeff5;
background: #fafafa;
width: 148px;
flex: none;
padding: 8px;
border-right: 1px solid var(--wp-line);
background: var(--wp-surface);
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.back-btn {
padding: 7px 10px;
font-size: 13px;
color: #888;
cursor: pointer;
border-radius: 6px;
transition:
background-color 0.15s,
color 0.15s;
user-select: none;
}
.back-btn:hover {
background-color: #f0f0f0;
color: #444;
.group-label {
padding: 12px 10px 4px;
font-size: 12px;
color: var(--wp-faint);
letter-spacing: 0.04em;
}
.nav-item {
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
color: #444;
transition:
background-color 0.15s,
color 0.15s;
user-select: none;
color: var(--wp-muted);
text-decoration: none;
}
.nav-item:hover {
background-color: #f0f0f0;
background: var(--wp-paper);
color: var(--wp-ink);
}
.nav-item.active {
background-color: #e8f8f0;
color: #18a058;
font-weight: 500;
background: var(--wp-brand-soft);
color: var(--wp-brand);
font-weight: 600;
}
.nav-item:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 1px;
}
.content {
flex: 1;
box-sizing: border-box;
min-width: 0;
overflow: hidden;
background: var(--wp-surface);
}
</style>
+1 -1
View File
@@ -87,7 +87,7 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref, watch } from "vue"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { watchDebounced } from "@vueuse/core"
import {
NButton,
+1 -1
View File
@@ -82,7 +82,7 @@
import { storageShim } from "../utils/previewStorage"
import { onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { Showcase } from "../api"
import type { AwardSection, ShowcaseItem } from "../utils/type"
+3 -3
View File
@@ -248,7 +248,7 @@ import {
useMessage,
type DataTableColumn,
} from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { Showcase } from "../api"
import type {
AwardItemManageOut,
@@ -640,8 +640,8 @@ onMounted(async () => {
}
.award-row.active {
background: #e8f8f0;
color: #18a058;
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
:deep(.table-number-input) {
+1 -1
View File
@@ -98,7 +98,7 @@
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { Submission } from "../api"
import type { SubmissionOut, FlagType } from "../utils/type"
import { parseTime } from "../utils/helper"
+6 -6
View File
@@ -20,8 +20,8 @@
width="24"
:icon="
item.is_public
? 'twemoji:check-mark-button'
: 'twemoji:locked'
? 'lucide:globe'
: 'lucide:lock'
"
></Icon>
</n-button>
@@ -76,7 +76,7 @@
</n-button>
</n-flex>
<MarkdownEditor
style="height: calc(100vh - 100px)"
style="height: calc(var(--wp-main-h) - 100px)"
v-model="tutorial.content"
/>
</n-flex>
@@ -105,7 +105,7 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Icon } from "../icons"
import { Tutorial } from "../api"
import type { TutorialSlim } from "../utils/type"
import { useDialog, useMessage } from "naive-ui"
@@ -258,7 +258,7 @@ onMounted(getContent)
<style scoped>
.list {
overflow: auto;
height: calc(100vh - 20px);
height: calc(var(--wp-main-h) - 20px);
}
.col {
@@ -270,6 +270,6 @@ onMounted(getContent)
}
.editor {
height: calc(100vh - 200px);
height: calc(var(--wp-main-h) - 200px);
}
</style>
+1 -1
View File
@@ -198,7 +198,7 @@ onMounted(init)
<style scoped>
.container {
padding: 10px 10px 10px 0;
height: 100vh;
height: 100%;
overflow: auto;
}
</style>
+38 -6
View File
@@ -1,12 +1,23 @@
import { createWebHistory, createRouter } from "vue-router"
import { createWebHistory, createRouter, type RouteRecordRaw } from "vue-router"
import { loginModal } from "./store/modal"
// 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
const Workspace = () => import("./pages/Workspace.vue")
import { hasLogin } from "./token"
import { authed } from "./store/user"
import { authed, roleStaff, roleSuper } from "./store/user"
import { loadUser } from "./store/session"
const routes = [
declare module "vue-router" {
interface RouteMeta {
auth?: boolean
// 教学页面:staff 是 admin 和 super,super 只有超管(和后端的 requireAdmin / requireSuper 对应)
role?: "staff" | "super"
// 不带顶栏的全屏页面
bare?: boolean
}
}
const routes: RouteRecordRaw[] = [
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
{
path: "/tutorial",
@@ -43,7 +54,7 @@ const routes = [
name: "submission",
component: () => import("./pages/Submission.vue"),
props: true,
meta: { auth: true },
meta: { auth: true, bare: true },
},
{
path: "/creation",
@@ -67,12 +78,13 @@ const routes = [
path: "/dashboard",
name: "dashboard",
component: () => import("./pages/Dashboard.vue"),
meta: { auth: true },
meta: { auth: true, role: "staff" },
children: [
{
path: "tutorial/:display",
name: "tutorial-editor",
component: () => import("./pages/TutorialEditor.vue"),
meta: { role: "super" },
},
{
path: "challenge/:display",
@@ -83,6 +95,7 @@ const routes = [
path: "user-manage/:page",
name: "user-manage",
component: () => import("./pages/UserManage.vue"),
meta: { role: "super" },
},
{
path: "showcase",
@@ -93,6 +106,13 @@ const routes = [
path: "gradebook",
name: "gradebook",
component: () => import("./pages/Gradebook.vue"),
meta: { role: "super" },
},
{
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
path: "submissions/:page",
name: "teach-submissions",
component: () => import("./pages/Submissions.vue"),
},
],
},
@@ -103,10 +123,22 @@ export const router = createRouter({
routes,
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const isLoggedIn = authed.value || hasLogin()
if (to.meta.auth && !isLoggedIn) {
loginModal.value = true
return false
}
if (to.meta.role || to.name === "dashboard") {
await loadUser()
const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value
if (!allowed) return { name: "home" }
}
// 教学的默认页:超管从教程开始,管理员只能出挑战
if (to.name === "dashboard") {
return {
name: roleSuper.value ? "tutorial-editor" : "challenge-editor",
params: { display: 0 },
}
}
})
+1 -1
View File
@@ -9,7 +9,7 @@ export const css = useStorage(STORAGE_KEY.CSS, defaultCSS)
export const js = useStorage(STORAGE_KEY.JS, "")
export const tab = useStorage(STORAGE_KEY.TAB, "html")
export const size = useStorage(STORAGE_KEY.FONTSIZE, 24)
export const size = useStorage(STORAGE_KEY.FONTSIZE, 16)
export function reset(lang: "html" | "css" | "js") {
if (lang === "html") {
+31
View File
@@ -0,0 +1,31 @@
import { Account } from "../api"
import { Role } from "../utils/type"
import { user } from "./user"
let loading: Promise<void> | null = null
// 取一次当前登录用户(路由守卫要按角色判断,可能比 App 挂载还早)
export function loadUser(): Promise<void> {
if (user.loaded) return Promise.resolve()
loading ??= Account.getMyProfile()
.then((data) => {
user.username = data.username
user.role = data.role
})
.catch(() => {})
.finally(() => {
user.loaded = true
loading = null
})
return loading
}
export async function logout() {
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
try {
await Account.logout()
} finally {
user.username = ""
user.role = Role.Normal
}
}
+2
View File
@@ -16,3 +16,5 @@ export const roleAdmin = computed(
export const roleSuper = computed(
() => authed.value && user.role === Role.Super,
)
// 老师端(教学):admin 和 super
export const roleStaff = computed(() => roleAdmin.value || roleSuper.value)
+113
View File
@@ -0,0 +1,113 @@
import type { GlobalThemeOverrides } from "naive-ui"
// 全站配色只在这里定义:同一份值既生成 CSS 变量(组件样式里用 var(--wp-*)),
// 也生成 Naive UI 的主题设置。机房是 Chrome 105,不支持 color-mix(),浅色底色单独列出来
export interface Palette {
brand: string
brandHover: string
brandPressed: string
brandSoft: string
ink: string
muted: string
faint: string
paper: string
surface: string
line: string
html: string
htmlSoft: string
css: string
cssSoft: string
js: string
jsSoft: string
warn: string
warnSoft: string
bad: string
badSoft: string
}
export const light: Palette = {
brand: "#0d7a52",
brandHover: "#118f61",
brandPressed: "#0a6343",
brandSoft: "#e3f2eb",
ink: "#17201c",
muted: "#5d6b65",
faint: "#8a9791",
paper: "#f5f7f6",
surface: "#ffffff",
line: "#dfe5e2",
html: "#d9532c",
htmlSoft: "#fdf3ef",
css: "#2c6bd6",
cssSoft: "#f1f5fd",
js: "#b98900",
jsSoft: "#fdf9ec",
warn: "#b7791f",
warnSoft: "#fbf3e6",
bad: "#c2413b",
badSoft: "#fbeceb",
}
// 暗色(阶段 4 才接上切换开关,值先定好)
export const dark: Palette = {
brand: "#3cc189",
brandHover: "#5ad19d",
brandPressed: "#2ea374",
brandSoft: "#173326",
ink: "#e4ebe7",
muted: "#9aaba3",
faint: "#6f7f78",
paper: "#0f1412",
surface: "#161d1a",
line: "#2a3531",
html: "#f07a52",
htmlSoft: "#2a1c17",
css: "#6c9cf0",
cssSoft: "#18202e",
js: "#e2b93b",
jsSoft: "#262113",
warn: "#e0a34a",
warnSoft: "#2b2316",
bad: "#ec6b65",
badSoft: "#2e1a19",
}
export const fontSans =
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", system-ui, sans-serif'
export const fontMono =
'"JetBrains Mono", Consolas, "Microsoft YaHei", monospace'
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())
export function applyCssVars(p: Palette) {
const root = document.documentElement.style
for (const [k, v] of Object.entries(p)) root.setProperty(`--wp-${kebab(k)}`, v)
root.setProperty("--wp-font-sans", fontSans)
root.setProperty("--wp-font-mono", fontMono)
}
export function naiveOverrides(p: Palette): GlobalThemeOverrides {
return {
common: {
primaryColor: p.brand,
primaryColorHover: p.brandHover,
primaryColorPressed: p.brandPressed,
primaryColorSuppl: p.brandHover,
warningColor: p.warn,
errorColor: p.bad,
textColorBase: p.ink,
textColor1: p.ink,
textColor2: p.ink,
textColor3: p.muted,
borderColor: p.line,
dividerColor: p.line,
fontFamily: fontSans,
fontFamilyMono: fontMono,
borderRadius: "6px",
borderRadiusSmall: "4px",
},
Card: {
borderRadius: "8px",
},
}
}
+2 -1
View File
@@ -33,7 +33,8 @@ export const STORAGE_KEY = {
CSS: "web-css",
JS: "web-js",
TAB: "web-tab",
FONTSIZE: "web-fontsize",
// 默认字号从 24 改成 16 时换了 key,让机房电脑上存着的旧值失效
FONTSIZE: "web-fontsize-v2",
}
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL