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

+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>