From 62d4c7a57fd05f9ac58fb88901edd13255a6f495 Mon Sep 17 00:00:00 2001 From: Aphrody-Dy <051012atw@gmail.com> Date: Wed, 19 Aug 2026 16:21:24 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(frontend):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=85=A8=E7=AB=99=E4=B8=BB=E9=A2=98=E8=AE=BE=E8=AE=A1=E4=BB=A4?= =?UTF-8?q?=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增蓝粉配色、浅深色语义 Token 与 Element Plus 主题映射。 * 实现 system/light/dark 三态主题、持久化及系统偏好监听。 * 补充主题状态测试与减少动画、毛玻璃等全局样式基线。 --- frontend/src/composables/useTheme.test.ts | 81 ++++++++++ frontend/src/composables/useTheme.ts | 74 +++++++++ frontend/src/main.ts | 1 + frontend/src/style.css | 185 +++++++++++++++++++++- 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 frontend/src/composables/useTheme.test.ts create mode 100644 frontend/src/composables/useTheme.ts diff --git a/frontend/src/composables/useTheme.test.ts b/frontend/src/composables/useTheme.test.ts new file mode 100644 index 00000000..bd121c57 --- /dev/null +++ b/frontend/src/composables/useTheme.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { initializeTheme, setTheme, stopThemeListener, useTheme } from "./useTheme"; + +function installMatchMedia(matches: boolean) { + const listeners = new Set<(event: MediaQueryListEvent) => void>(); + const mediaQuery = { + matches, + media: "(prefers-color-scheme: dark)", + onchange: null, + addEventListener: vi.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => { + listeners.add(listener); + }), + removeEventListener: vi.fn((_type: string, listener: (event: MediaQueryListEvent) => void) => { + listeners.delete(listener); + }), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + } as unknown as MediaQueryList; + + vi.stubGlobal( + "matchMedia", + vi.fn(() => mediaQuery), + ); + return { + mediaQuery, + change(nextMatches: boolean) { + Object.defineProperty(mediaQuery, "matches", { value: nextMatches, configurable: true }); + listeners.forEach((listener) => listener({ matches: nextMatches } as MediaQueryListEvent)); + }, + }; +} + +describe("useTheme", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.className = ""; + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("data-theme-preference"); + stopThemeListener(); + }); + + afterEach(() => { + stopThemeListener(); + vi.unstubAllGlobals(); + }); + + it("默认跟随系统主题并响应系统变化", () => { + const media = installMatchMedia(false); + initializeTheme(); + + expect(document.documentElement.dataset.theme).toBe("light"); + expect(document.documentElement.dataset.themePreference).toBe("system"); + + media.change(true); + expect(document.documentElement.dataset.theme).toBe("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("持久化显式主题且不再跟随系统变化", () => { + const media = installMatchMedia(false); + const { resolvedTheme } = useTheme(); + + setTheme("dark"); + expect(localStorage.getItem("clubhub-theme")).toBe("dark"); + expect(resolvedTheme.value).toBe("dark"); + expect(document.documentElement.style.colorScheme).toBe("dark"); + + media.change(false); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("忽略损坏的主题偏好", () => { + localStorage.setItem("clubhub-theme", "sepia"); + installMatchMedia(true); + + initializeTheme(); + expect(document.documentElement.dataset.themePreference).toBe("system"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); +}); diff --git a/frontend/src/composables/useTheme.ts b/frontend/src/composables/useTheme.ts new file mode 100644 index 00000000..653f3e7e --- /dev/null +++ b/frontend/src/composables/useTheme.ts @@ -0,0 +1,74 @@ +import { computed, ref } from "vue"; + +export type ThemePreference = "system" | "light" | "dark"; +export type ResolvedTheme = Exclude; + +const themeStorageKey = "clubhub-theme"; +const darkModeQuery = "(prefers-color-scheme: dark)"; + +const preference = ref("system"); +const systemTheme = ref("light"); +let initialized = false; +let mediaQuery: MediaQueryList | null = null; + +function isThemePreference(value: string | null): value is ThemePreference { + return value === "system" || value === "light" || value === "dark"; +} + +function resolveSystemTheme(matches: boolean): ResolvedTheme { + return matches ? "dark" : "light"; +} + +function applyTheme() { + const resolved = preference.value === "system" ? systemTheme.value : preference.value; + const root = document.documentElement; + + root.dataset.theme = resolved; + root.dataset.themePreference = preference.value; + root.classList.toggle("dark", resolved === "dark"); + root.style.colorScheme = resolved; +} + +function handleSystemThemeChange(event: MediaQueryListEvent | MediaQueryList) { + systemTheme.value = resolveSystemTheme(event.matches); + if (preference.value === "system") applyTheme(); +} + +export function initializeTheme() { + if (initialized) { + applyTheme(); + return; + } + + const storedTheme = localStorage.getItem(themeStorageKey); + preference.value = isThemePreference(storedTheme) ? storedTheme : "system"; + mediaQuery = window.matchMedia(darkModeQuery); + systemTheme.value = resolveSystemTheme(mediaQuery.matches); + mediaQuery.addEventListener("change", handleSystemThemeChange); + initialized = true; + applyTheme(); +} + +export function setTheme(nextTheme: ThemePreference) { + preference.value = nextTheme; + localStorage.setItem(themeStorageKey, nextTheme); + applyTheme(); +} + +export function useTheme() { + initializeTheme(); + + return { + theme: computed(() => preference.value), + resolvedTheme: computed(() => + preference.value === "system" ? systemTheme.value : preference.value, + ), + setTheme, + }; +} + +export function stopThemeListener() { + mediaQuery?.removeEventListener("change", handleSystemThemeChange); + mediaQuery = null; + initialized = false; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 465b89be..aed59b56 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,7 @@ import { createApp } from "vue"; import ElementPlus from "element-plus"; import "element-plus/dist/index.css"; +import "element-plus/theme-chalk/dark/css-vars.css"; import "./style.css"; import App from "./App.vue"; import router from "./router"; diff --git a/frontend/src/style.css b/frontend/src/style.css index 605bf592..7c1b187f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,12 +1,195 @@ +:root { + color-scheme: light; + font-family: + Inter, + "SF Pro Display", + "PingFang SC", + "Microsoft YaHei", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + + --club-primary: #1976d2; + --club-primary-soft: #e8f2ff; + --club-primary-strong: #0d5fb8; + --club-accent: #ec4899; + --club-accent-soft: #fce7f3; + --club-success: #16a46b; + --club-warning: #d97706; + --club-danger: #dc4564; + --club-info: #64748b; + --club-bg: #f3f7fc; + --club-bg-elevated: rgba(255, 255, 255, 0.88); + --club-bg-muted: #eaf0f7; + --club-surface: rgba(255, 255, 255, 0.78); + --club-surface-solid: #ffffff; + --club-surface-hover: rgba(25, 118, 210, 0.08); + --club-text: #172033; + --club-text-secondary: #5c6980; + --club-text-muted: #8490a3; + --club-border: rgba(99, 119, 146, 0.18); + --club-border-strong: rgba(80, 103, 135, 0.28); + --club-radius-sm: 8px; + --club-radius-md: 12px; + --club-radius-lg: 18px; + --club-radius-xl: 24px; + --club-space-1: 4px; + --club-space-2: 8px; + --club-space-3: 12px; + --club-space-4: 16px; + --club-space-5: 20px; + --club-space-6: 24px; + --club-space-8: 32px; + --club-shadow-sm: 0 8px 24px rgba(35, 65, 105, 0.08); + --club-shadow-md: 0 18px 45px rgba(35, 65, 105, 0.12); + --club-glass-blur: blur(18px) saturate(135%); + --club-content-width: 1600px; + --club-sidebar-width: 264px; + --club-header-height: 72px; + + --el-color-primary: var(--club-primary); + --el-color-success: var(--club-success); + --el-color-warning: var(--club-warning); + --el-color-danger: var(--club-danger); + --el-color-info: var(--club-info); + --el-bg-color-page: var(--club-bg); + --el-bg-color: var(--club-surface-solid); + --el-fill-color-blank: var(--club-surface-solid); + --el-text-color-primary: var(--club-text); + --el-text-color-regular: var(--club-text-secondary); + --el-text-color-secondary: var(--club-text-muted); + --el-border-color: var(--club-border-strong); + --el-border-color-light: var(--club-border); + --el-border-radius-base: var(--club-radius-sm); + --el-border-radius-round: 999px; + --el-box-shadow-light: var(--club-shadow-sm); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --club-primary: #5ca8ff; + --club-primary-soft: rgba(62, 145, 235, 0.18); + --club-primary-strong: #86bdff; + --club-accent: #f472b6; + --club-accent-soft: rgba(244, 114, 182, 0.16); + --club-success: #39c98a; + --club-warning: #f5a524; + --club-danger: #ff6b85; + --club-info: #94a3b8; + --club-bg: #0c1321; + --club-bg-elevated: rgba(18, 28, 45, 0.9); + --club-bg-muted: #172235; + --club-surface: rgba(23, 34, 53, 0.78); + --club-surface-solid: #172235; + --club-surface-hover: rgba(92, 168, 255, 0.12); + --club-text: #eef4ff; + --club-text-secondary: #b4c0d4; + --club-text-muted: #8290a7; + --club-border: rgba(151, 170, 199, 0.16); + --club-border-strong: rgba(151, 170, 199, 0.28); + --club-shadow-sm: 0 8px 24px rgba(0, 0, 0, 0.22); + --club-shadow-md: 0 18px 45px rgba(0, 0, 0, 0.3); +} + html { box-sizing: border-box; + min-width: 320px; + background: var(--club-bg); } + *, *::before, *::after { box-sizing: inherit; } + body { + min-width: 320px; + min-height: 100vh; margin: 0; - background: #f5f5f5; + color: var(--club-text); + background: + radial-gradient(circle at 8% 0%, rgba(25, 118, 210, 0.1), transparent 32rem), + radial-gradient(circle at 94% 8%, rgba(236, 72, 153, 0.08), transparent 28rem), var(--club-bg); + transition: + color 180ms ease, + background-color 180ms ease; +} + +button, +input, +textarea, +select { + font: inherit; +} + +a { + color: var(--club-primary); +} + +#app { + min-height: 100vh; +} + +.el-card, +.el-dialog, +.el-message-box, +.el-drawer { + border-color: var(--club-border); + background: var(--club-bg-elevated); + box-shadow: var(--club-shadow-sm); + backdrop-filter: var(--club-glass-blur); +} + +.el-card, +.el-dialog, +.el-message-box { + border-radius: var(--club-radius-lg); +} + +.el-button { + font-weight: 600; +} + +.el-table, +.el-table tr, +.el-table th.el-table__cell, +.el-table td.el-table__cell { + background: transparent; +} + +.el-table { + --el-table-header-bg-color: var(--club-bg-muted); + --el-table-row-hover-bg-color: var(--club-surface-hover); + border-radius: var(--club-radius-md); +} + +.el-overlay { + background-color: rgba(15, 23, 42, 0.46); + backdrop-filter: blur(8px); +} + +::selection { + color: #ffffff; + background: var(--club-primary); +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--club-primary) 58%, transparent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } From ab20aee463d02e9d8ccf9eaff52aac0ac48c4a68 Mon Sep 17 00:00:00 2001 From: Aphrody-Dy <051012atw@gmail.com> Date: Wed, 19 Aug 2026 16:29:24 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(frontend):=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BC=8F=E5=BA=94=E7=94=A8=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 将原横向导航重组为权限感知的分组侧栏与移动端抽屉。 * 新增毛玻璃顶栏、页面上下文、健康状态、账号菜单和主题入口。 * 为路由补充页面标题与过渡,并用测试覆盖受限导航可见性。 --- frontend/src/App.vue | 276 ++--------- .../src/components/shell/AppNavigation.vue | 111 +++++ frontend/src/components/shell/AppShell.vue | 448 ++++++++++++++++++ frontend/src/navigation.test.ts | 56 +++ frontend/src/navigation.ts | 113 +++++ frontend/src/router/index.ts | 70 ++- 6 files changed, 825 insertions(+), 249 deletions(-) create mode 100644 frontend/src/components/shell/AppNavigation.vue create mode 100644 frontend/src/components/shell/AppShell.vue create mode 100644 frontend/src/navigation.test.ts create mode 100644 frontend/src/navigation.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b359d19f..05b249ee 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,13 @@ - diff --git a/frontend/src/components/shell/AppNavigation.vue b/frontend/src/components/shell/AppNavigation.vue new file mode 100644 index 00000000..449fd6c2 --- /dev/null +++ b/frontend/src/components/shell/AppNavigation.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/components/shell/AppShell.vue b/frontend/src/components/shell/AppShell.vue new file mode 100644 index 00000000..65eadead --- /dev/null +++ b/frontend/src/components/shell/AppShell.vue @@ -0,0 +1,448 @@ + + + + + diff --git a/frontend/src/navigation.test.ts b/frontend/src/navigation.test.ts new file mode 100644 index 00000000..088bef7d --- /dev/null +++ b/frontend/src/navigation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildNavigationGroups, resolveActiveNavigation } from "./navigation"; + +function visiblePaths(permissions: string[]) { + return buildNavigationGroups(permissions).flatMap((group) => + group.items.map((item) => item.path), + ); +} + +describe("navigation", () => { + it("普通用户只看到无权限门槛的入口", () => { + const paths = visiblePaths([]); + + expect(paths).toContain("/clubs"); + expect(paths).toContain("/learning"); + expect(paths).not.toContain("/club-registration"); + expect(paths).not.toContain("/budgets"); + expect(paths).not.toContain("/venues"); + expect(paths).not.toContain("/materials"); + }); + + it("按既有权限显示受限入口", () => { + const paths = visiblePaths([ + "club:review", + "budget:view", + "venue:reserve", + "material:borrow:use", + ]); + + expect(paths).toContain("/club-registration"); + expect(paths).toContain("/budgets"); + expect(paths).toContain("/venue-reservations"); + expect(paths).toContain("/materials"); + expect(paths).not.toContain("/venues"); + }); + + it("通配权限显示所有入口", () => { + const paths = visiblePaths(["*"]); + + expect(paths).toEqual( + expect.arrayContaining([ + "/club-registration", + "/budgets", + "/venues", + "/venue-reservations", + "/materials", + ]), + ); + }); + + it("详情页保持所属主导航高亮", () => { + expect(resolveActiveNavigation("/projects/42/workspace")).toBe("/projects"); + expect(resolveActiveNavigation("/recruitments/3/applications")).toBe("/recruitments"); + expect(resolveActiveNavigation("/activities")).toBe("/activities"); + }); +}); diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts new file mode 100644 index 00000000..89089428 --- /dev/null +++ b/frontend/src/navigation.ts @@ -0,0 +1,113 @@ +import { BUDGET_ACCESS_PERMISSIONS } from "./budgetPermissions"; +import { MATERIAL_ACCESS_PERMISSIONS } from "./materialPermissions"; + +export type NavigationIcon = + | "account" + | "activity" + | "award" + | "budget" + | "club" + | "evaluation" + | "forum" + | "learning" + | "material" + | "member" + | "notice" + | "organization" + | "project" + | "recruitment" + | "registration" + | "venue"; + +export interface NavigationItem { + label: string; + path: string; + icon: NavigationIcon; +} + +export interface NavigationGroup { + label: string; + items: NavigationItem[]; +} + +function hasAnyPermission(permissions: string[], required: readonly string[]) { + return ( + permissions.includes("*") || required.some((permission) => permissions.includes(permission)) + ); +} + +export function buildNavigationGroups(permissions: string[]): NavigationGroup[] { + const canAccessClubRegistration = hasAnyPermission(permissions, ["club:apply", "club:review"]); + const canManageVenues = hasAnyPermission(permissions, [ + "venue:create", + "venue:update", + "venue:disable", + ]); + const canAccessVenueReservations = hasAnyPermission(permissions, [ + "venue:reserve", + "venue:review", + ]); + const canAccessMaterialBorrows = hasAnyPermission(permissions, MATERIAL_ACCESS_PERMISSIONS); + const canAccessBudgets = hasAnyPermission(permissions, BUDGET_ACCESS_PERMISSIONS); + + return [ + { + label: "个人空间", + items: [{ label: "账号与权限", path: "/auth", icon: "account" }], + }, + { + label: "社团运营", + items: [ + { label: "我的社团", path: "/clubs", icon: "club" }, + { label: "社团架构", path: "/club-organization", icon: "organization" }, + { label: "成员管理", path: "/club-members", icon: "member" }, + ...(canAccessClubRegistration + ? [{ label: "社团注册", path: "/club-registration", icon: "registration" as const }] + : []), + { label: "纳新", path: "/recruitments", icon: "recruitment" }, + { label: "成员考核", path: "/evaluations", icon: "evaluation" }, + { label: "评奖评优", path: "/awards", icon: "award" }, + ], + }, + { + label: "协作沟通", + items: [ + { label: "活动", path: "/activities", icon: "activity" }, + { label: "通知", path: "/notices", icon: "notice" }, + { label: "讨论区", path: "/forum", icon: "forum" }, + { label: "项目", path: "/projects", icon: "project" }, + ], + }, + { + label: "资源服务", + items: [ + ...(canAccessBudgets + ? [{ label: "经费管理", path: "/budgets", icon: "budget" as const }] + : []), + ...(canManageVenues + ? [{ label: "场地管理", path: "/venues", icon: "venue" as const }] + : []), + ...(canAccessVenueReservations + ? [{ label: "场地预约", path: "/venue-reservations", icon: "venue" as const }] + : []), + { label: "学习中心", path: "/learning", icon: "learning" }, + ...(canAccessMaterialBorrows + ? [{ label: "物资借还", path: "/materials", icon: "material" as const }] + : []), + ], + }, + ]; +} + +export function resolveActiveNavigation(path: string) { + const prefixes = [ + "/recruitments", + "/evaluations", + "/awards", + "/learning", + "/projects", + "/budgets", + "/forum", + ]; + return prefixes.find((prefix) => path.startsWith(prefix)) ?? path; +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5dfe357d..e8bdd5b3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -22,28 +22,60 @@ const router = createRouter({ history: createWebHistory(), routes: [ { path: "/", redirect: "/auth" }, - { path: "/auth", component: AuthFlow }, - { path: "/clubs", component: ClubList }, - { path: "/club-organization", component: ClubList, props: { workspace: "organization" } }, - { path: "/club-members", component: ClubList, props: { workspace: "members" } }, - { path: "/club-registration", component: ClubList, props: { workspace: "registration" } }, - { path: "/recruitments", component: RecruitmentList }, - { path: "/recruitments/:recruitmentId/applications", component: RecruitmentList }, - { path: "/evaluations", component: EvaluationList }, - { path: "/awards", component: AwardList }, - { path: "/activities", component: ActivityList }, - { path: "/notices", component: NoticeCenter }, - { path: "/projects", component: ProjectList }, - { path: "/projects/:projectId/workspace", component: ProjectWorkspace }, - { path: "/venues", component: VenueManage }, - { path: "/venue-reservations", component: VenueReservationApply }, - { path: "/learning", component: LearningCenter }, - { path: "/materials", component: MaterialBorrow }, - { path: "/budgets", component: BudgetManagement }, - { path: "/forum", component: ForumCenter }, + { path: "/auth", component: AuthFlow, meta: { title: "账号与权限" } }, + { path: "/clubs", component: ClubList, meta: { title: "我的社团" } }, + { + path: "/club-organization", + component: ClubList, + props: { workspace: "organization" }, + meta: { title: "社团架构" }, + }, + { + path: "/club-members", + component: ClubList, + props: { workspace: "members" }, + meta: { title: "成员管理" }, + }, + { + path: "/club-registration", + component: ClubList, + props: { workspace: "registration" }, + meta: { title: "社团注册" }, + }, + { path: "/recruitments", component: RecruitmentList, meta: { title: "纳新管理" } }, + { + path: "/recruitments/:recruitmentId/applications", + component: RecruitmentList, + meta: { title: "纳新申请" }, + }, + { path: "/evaluations", component: EvaluationList, meta: { title: "成员考核" } }, + { path: "/awards", component: AwardList, meta: { title: "评奖评优" } }, + { path: "/activities", component: ActivityList, meta: { title: "活动中心" } }, + { path: "/notices", component: NoticeCenter, meta: { title: "通知中心" } }, + { path: "/projects", component: ProjectList, meta: { title: "项目协作" } }, + { + path: "/projects/:projectId/workspace", + component: ProjectWorkspace, + meta: { title: "项目工作区" }, + }, + { path: "/venues", component: VenueManage, meta: { title: "场地管理" } }, + { + path: "/venue-reservations", + component: VenueReservationApply, + meta: { title: "场地预约" }, + }, + { path: "/learning", component: LearningCenter, meta: { title: "学习中心" } }, + { path: "/materials", component: MaterialBorrow, meta: { title: "物资借还" } }, + { path: "/budgets", component: BudgetManagement, meta: { title: "经费管理" } }, + { path: "/forum", component: ForumCenter, meta: { title: "讨论区" } }, ], }); +router.afterEach((to) => { + const title = typeof to.meta.title === "string" ? to.meta.title : "ClubHub"; + document.title = title === "ClubHub" ? title : `${title} · ClubHub`; +}); + router.beforeEach((to) => { if (to.path !== "/auth" && !hasCompletedSession()) { return { path: "/auth", query: { redirect: to.fullPath } }; From 5f687b8982b2cab81eaf864aa9f8202c93e715d5 Mon Sep 17 00:00:00 2001 From: Aphrody-Dy <051012atw@gmail.com> Date: Wed, 19 Aug 2026 16:46:12 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(frontend):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E9=A1=B5=E9=9D=A2=E7=8A=B6=E6=80=81=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 提供统一页面标题、毛玻璃面板、加载骨架、空状态与错误状态组件。 * 支持响应式操作区、业务自定义文案、重试事件和可访问状态语义。 * 增加组件渲染测试,为后续业务页面渐进迁移提供稳定基础。 --- frontend/src/components/ui/AppEmptyState.vue | 82 ++++++++++++ frontend/src/components/ui/AppErrorState.vue | 85 +++++++++++++ .../src/components/ui/AppLoadingState.vue | 59 +++++++++ frontend/src/components/ui/AppPageHeader.vue | 93 ++++++++++++++ frontend/src/components/ui/AppPanel.vue | 119 ++++++++++++++++++ frontend/src/components/ui/index.ts | 5 + .../src/components/ui/uiComponents.test.ts | 65 ++++++++++ 7 files changed, 508 insertions(+) create mode 100644 frontend/src/components/ui/AppEmptyState.vue create mode 100644 frontend/src/components/ui/AppErrorState.vue create mode 100644 frontend/src/components/ui/AppLoadingState.vue create mode 100644 frontend/src/components/ui/AppPageHeader.vue create mode 100644 frontend/src/components/ui/AppPanel.vue create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/components/ui/uiComponents.test.ts diff --git a/frontend/src/components/ui/AppEmptyState.vue b/frontend/src/components/ui/AppEmptyState.vue new file mode 100644 index 00000000..a1cc18c8 --- /dev/null +++ b/frontend/src/components/ui/AppEmptyState.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/ui/AppErrorState.vue b/frontend/src/components/ui/AppErrorState.vue new file mode 100644 index 00000000..fad514e5 --- /dev/null +++ b/frontend/src/components/ui/AppErrorState.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/ui/AppLoadingState.vue b/frontend/src/components/ui/AppLoadingState.vue new file mode 100644 index 00000000..9fa4ec35 --- /dev/null +++ b/frontend/src/components/ui/AppLoadingState.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/components/ui/AppPageHeader.vue b/frontend/src/components/ui/AppPageHeader.vue new file mode 100644 index 00000000..01c2779f --- /dev/null +++ b/frontend/src/components/ui/AppPageHeader.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/components/ui/AppPanel.vue b/frontend/src/components/ui/AppPanel.vue new file mode 100644 index 00000000..ae428855 --- /dev/null +++ b/frontend/src/components/ui/AppPanel.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..801137c8 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,5 @@ +export { default as AppEmptyState } from "./AppEmptyState.vue"; +export { default as AppErrorState } from "./AppErrorState.vue"; +export { default as AppLoadingState } from "./AppLoadingState.vue"; +export { default as AppPageHeader } from "./AppPageHeader.vue"; +export { default as AppPanel } from "./AppPanel.vue"; diff --git a/frontend/src/components/ui/uiComponents.test.ts b/frontend/src/components/ui/uiComponents.test.ts new file mode 100644 index 00000000..3a5150b0 --- /dev/null +++ b/frontend/src/components/ui/uiComponents.test.ts @@ -0,0 +1,65 @@ +import { createApp, type Component } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import AppEmptyState from "./AppEmptyState.vue"; +import AppErrorState from "./AppErrorState.vue"; +import AppLoadingState from "./AppLoadingState.vue"; +import AppPageHeader from "./AppPageHeader.vue"; + +const mountedApps: ReturnType[] = []; + +function mount(component: Component, props: Record = {}) { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp(component, props); + app.config.warnHandler = () => undefined; + app.mount(host); + mountedApps.push(app); + return host; +} + +afterEach(() => { + mountedApps.splice(0).forEach((app) => app.unmount()); + document.body.replaceChildren(); +}); + +describe("公共页面组件", () => { + it("页面标题呈现标题、描述和眉题", () => { + const host = mount(AppPageHeader, { + eyebrow: "社团运营", + title: "成员管理", + description: "维护社团成员与任期信息。", + }); + + expect(host.textContent).toContain("社团运营"); + expect(host.querySelector("h2")?.textContent).toBe("成员管理"); + expect(host.textContent).toContain("维护社团成员与任期信息。"); + }); + + it("加载状态提供可访问的状态提示", () => { + const host = mount(AppLoadingState, { title: "正在加载活动" }); + const status = host.querySelector('[role="status"]'); + + expect(status?.getAttribute("aria-label")).toBe("正在加载活动"); + expect(status?.textContent).toContain("正在加载活动"); + }); + + it("空状态支持业务自定义文案", () => { + const host = mount(AppEmptyState, { + title: "暂无通知", + description: "已读完全部通知。", + }); + + expect(host.textContent).toContain("暂无通知"); + expect(host.textContent).toContain("已读完全部通知。"); + }); + + it("错误状态提供告警语义和重试事件", async () => { + const onRetry = vi.fn(); + const host = mount(AppErrorState, { title: "请求失败", onRetry }); + + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + (host.querySelector("el-button") as HTMLElement).click(); + await Promise.resolve(); + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); From 7ab2133f9407496fa00c6bcfba6780b9fc7cf6f1 Mon Sep 17 00:00:00 2001 From: Aphrody-Dy <051012atw@gmail.com> Date: Wed, 19 Aug 2026 17:39:57 +0800 Subject: [PATCH 04/11] =?UTF-8?q?fix(frontend):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=97=A7=E9=A1=B5=E9=9D=A2=E6=B7=B1=E8=89=B2=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 将登录、社团、考核、评奖和通知页面的硬编码浅色替换为设计 Token。 * 修复统计带、工作区、空状态、组织树及弹窗内容在深色模式下的对比度。 * 新增样式约束测试,防止关键页面再次强制使用浅色表面。 --- frontend/src/designSystemCompliance.test.ts | 36 ++++++++ frontend/src/views/AuthFlow.vue | 8 +- frontend/src/views/AwardList.vue | 32 +++---- frontend/src/views/ClubList.vue | 90 ++++++++++---------- frontend/src/views/EvaluationList.vue | 2 +- frontend/src/views/MaterialBorrow.vue | 2 +- frontend/src/views/NoticeCenter.vue | 24 +++--- frontend/src/views/VenueReservationApply.vue | 2 +- 8 files changed, 117 insertions(+), 79 deletions(-) create mode 100644 frontend/src/designSystemCompliance.test.ts diff --git a/frontend/src/designSystemCompliance.test.ts b/frontend/src/designSystemCompliance.test.ts new file mode 100644 index 00000000..2175ddeb --- /dev/null +++ b/frontend/src/designSystemCompliance.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +const viewSources = import.meta.glob("./views/*.vue", { + eager: true, + import: "default", + query: "?raw", +}) as Record; + +const darkModeCriticalViews = [ + "AuthFlow.vue", + "AwardList.vue", + "ClubList.vue", + "EvaluationList.vue", + "MaterialBorrow.vue", + "NoticeCenter.vue", + "VenueReservationApply.vue", +]; + +describe("设计系统样式约束", () => { + it.each(darkModeCriticalViews)("%s 不再强制使用浅色表面", (fileName) => { + const source = viewSources[`./views/${fileName}`]; + + expect(source).toBeDefined(); + expect(source).not.toMatch(/background(?:-color)?:\s*(?:#fff(?:fff)?|white);/i); + }); + + it.each(["AwardList.vue", "ClubList.vue", "NoticeCenter.vue"])( + "%s 使用主题文本颜色", + (fileName) => { + const source = viewSources[`./views/${fileName}`]; + + expect(source).toBeDefined(); + expect(source).not.toMatch(/color:\s*#(?:1f2d3d|20262e|374151|66727f|6b7280);/i); + }, + ); +}); diff --git a/frontend/src/views/AuthFlow.vue b/frontend/src/views/AuthFlow.vue index 9aae4193..187f1332 100644 --- a/frontend/src/views/AuthFlow.vue +++ b/frontend/src/views/AuthFlow.vue @@ -556,10 +556,12 @@ loadPermissionCatalog(); .auth-panel, .info-panel { - background: #fff; - border: 1px solid var(--el-border-color-light); - border-radius: 8px; + border: 1px solid var(--club-border); + border-radius: var(--club-radius-md); padding: 20px; + background: var(--club-bg-elevated); + box-shadow: var(--club-shadow-sm); + backdrop-filter: var(--club-glass-blur); } .auth-panel h2, diff --git a/frontend/src/views/AwardList.vue b/frontend/src/views/AwardList.vue index 08cc8c77..c70c5180 100644 --- a/frontend/src/views/AwardList.vue +++ b/frontend/src/views/AwardList.vue @@ -2737,22 +2737,22 @@ onUnmounted(() => { .subtitle, .muted { - color: #6b7280; + color: var(--el-text-color-secondary); font-size: 13px; } .summary-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); - border: 1px solid #e5e7eb; + border: 1px solid var(--el-border-color-light); border-radius: 8px; overflow: hidden; } .summary-strip > div { padding: 14px 18px; - border-right: 1px solid #e5e7eb; - background: #fff; + border-right: 1px solid var(--el-border-color-light); + background: var(--club-surface-solid); } .summary-strip > div:last-child { @@ -2761,7 +2761,7 @@ onUnmounted(() => { .summary-strip span { display: block; - color: #6b7280; + color: var(--el-text-color-secondary); font-size: 13px; } @@ -2793,9 +2793,9 @@ onUnmounted(() => { .workspace-panel { min-height: 380px; - border: 1px solid #e5e7eb; + border: 1px solid var(--el-border-color-light); border-radius: 8px; - background: #fff; + background: var(--club-surface-solid); padding: 8px; } @@ -2812,7 +2812,7 @@ onUnmounted(() => { .rule-expand p, .rule-reader-content { margin: 8px 0 0; - color: #374151; + color: var(--el-text-color-primary); line-height: 1.7; white-space: pre-wrap; } @@ -2841,9 +2841,9 @@ onUnmounted(() => { } .rule-reader-content { - border: 1px solid #e5e7eb; + border: 1px solid var(--el-border-color-light); border-radius: 8px; - background: #f9fafb; + background: var(--el-fill-color-light); padding: 14px 16px; } @@ -2890,7 +2890,7 @@ onUnmounted(() => { } .field-tip { - color: #6b7280; + color: var(--el-text-color-secondary); font-size: 12px; } @@ -2914,9 +2914,9 @@ onUnmounted(() => { } .level-card { - border: 1px solid #e5e7eb; + border: 1px solid var(--el-border-color-light); border-radius: 8px; - background: #f9fafb; + background: var(--el-fill-color-light); padding: 12px; } @@ -2929,7 +2929,7 @@ onUnmounted(() => { } .level-card-title { - color: #374151; + color: var(--el-text-color-primary); font-size: 13px; font-weight: 600; } @@ -2951,7 +2951,7 @@ onUnmounted(() => { } .field-label { - color: #6b7280; + color: var(--el-text-color-secondary); font-size: 12px; line-height: 1; } @@ -2981,7 +2981,7 @@ onUnmounted(() => { .summary-strip > div { border-right: 0; - border-bottom: 1px solid #e5e7eb; + border-bottom: 1px solid var(--el-border-color-light); } .summary-strip > div:last-child { diff --git a/frontend/src/views/ClubList.vue b/frontend/src/views/ClubList.vue index 9c8be04e..8362e1b2 100644 --- a/frontend/src/views/ClubList.vue +++ b/frontend/src/views/ClubList.vue @@ -4931,14 +4931,14 @@ onUnmounted(() => { flex-direction: column; gap: 18px; padding: 24px; - color: #20262e; + color: var(--el-text-color-primary); } .toolbar, .identity-band, .workspace-tabs { - border: 1px solid #d9e1ea; - background: #fff; + border: 1px solid var(--el-border-color-light); + background: var(--club-surface-solid); } .toolbar { @@ -4958,7 +4958,7 @@ onUnmounted(() => { .subtitle, .workspace-head p, .muted { - color: #66727f; + color: var(--el-text-color-secondary); } .subtitle { @@ -4997,7 +4997,7 @@ onUnmounted(() => { .identity-main span { display: block; margin-top: 2px; - color: #66727f; + color: var(--el-text-color-secondary); font-size: 13px; } @@ -5017,7 +5017,7 @@ onUnmounted(() => { .identity-switch span, .identity-switch small, .context-option span { - color: #66727f; + color: var(--el-text-color-secondary); font-size: 12px; } @@ -5044,8 +5044,8 @@ onUnmounted(() => { } .empty-workspace { - border: 1px solid #d9e1ea; - background: #fff; + border: 1px solid var(--el-border-color-light); + background: var(--club-surface-solid); padding: 48px 0; } @@ -5132,12 +5132,12 @@ onUnmounted(() => { flex-wrap: wrap; gap: 10px; padding: 0 0 14px; - color: #66727f; + color: var(--el-text-color-secondary); font-size: 13px; } .member-summary span { - border: 1px solid #d9e1ea; + border: 1px solid var(--el-border-color-light); padding: 4px 8px; } @@ -5202,11 +5202,11 @@ onUnmounted(() => { align-items: center; justify-content: space-between; gap: 12px; - color: #455a6f; + color: var(--el-text-color-regular); } .group-member-panel-head strong { - color: #1f2d3d; + color: var(--el-text-color-primary); font-weight: 650; } @@ -5230,8 +5230,8 @@ onUnmounted(() => { height: 30px; border: 1px solid var(--el-border-color); border-radius: 6px; - color: #66727f; - background: #fff; + color: var(--el-text-color-secondary); + background: var(--club-surface-solid); cursor: grab; transition: color 0.18s ease, @@ -5249,8 +5249,8 @@ onUnmounted(() => { height: 30px; border: 1px solid var(--el-border-color); border-radius: 6px; - color: #66727f; - background: #fff; + color: var(--el-text-color-secondary); + background: var(--club-surface-solid); cursor: pointer; transition: color 0.18s ease, @@ -5292,7 +5292,7 @@ onUnmounted(() => { .organization-map { margin-top: 18px; - border-top: 1px solid #e2e8f0; + border-top: 1px solid var(--el-border-color-light); padding-top: 18px; } @@ -5328,7 +5328,7 @@ onUnmounted(() => { position: relative; margin-left: 46px; padding: 16px 0 0 24px; - border-left: 2px solid #d7e2ee; + border-left: 2px solid var(--el-border-color-light); } .tree-department-branch { @@ -5341,7 +5341,7 @@ onUnmounted(() => { content: ""; position: absolute; width: 24px; - border-top: 2px solid #d7e2ee; + border-top: 2px solid var(--el-border-color-light); } .tree-department-branch::before { @@ -5353,7 +5353,7 @@ onUnmounted(() => { position: relative; margin-left: 58px; padding: 12px 0 0 22px; - border-left: 2px solid #e4edf6; + border-left: 2px solid var(--el-border-color-light); } .tree-group-line { @@ -5365,14 +5365,14 @@ onUnmounted(() => { top: 17px; left: -22px; width: 22px; - border-color: #e4edf6; + border-color: var(--el-border-color-light); } .tree-member-list { position: relative; margin-left: 58px; padding: 2px 0 6px 22px; - border-left: 2px solid #edf2d7; + border-left: 2px solid var(--el-border-color-light); } .tree-member-line { @@ -5386,18 +5386,18 @@ onUnmounted(() => { top: 16px; left: -22px; width: 22px; - border-top: 2px solid #edf2d7; + border-top: 2px solid var(--el-border-color-light); } .tree-empty-member { display: inline-flex; align-items: center; min-height: 34px; - border: 1px dashed #d7dfca; + border: 1px dashed var(--el-border-color-light); border-radius: 8px; padding: 0 12px; - color: #7a8794; - background: #fcfdf8; + color: var(--el-text-color-secondary); + background: var(--el-fill-color-light); font-size: 13px; } @@ -5408,10 +5408,10 @@ onUnmounted(() => { justify-content: center; width: 32px; height: 32px; - border: 1px solid #bfcedc; + border: 1px solid var(--el-border-color); border-radius: 50%; - color: #455a6f; - background: #fff; + color: var(--el-text-color-regular); + background: var(--club-surface-solid); cursor: pointer; transition: color 0.18s ease, @@ -5428,9 +5428,9 @@ onUnmounted(() => { } .tree-toggle:disabled { - color: #a6b2bf; + color: var(--el-text-color-disabled); cursor: default; - background: #f7fafc; + background: var(--el-fill-color-light); } .tree-node { @@ -5439,10 +5439,10 @@ onUnmounted(() => { align-items: center; justify-content: space-between; gap: 12px; - border: 1px solid #d7e2ee; + border: 1px solid var(--el-border-color-light); border-radius: 8px; - background: #fff; - box-shadow: 0 10px 24px rgb(31 41 55 / 6%); + background: var(--club-surface-solid); + box-shadow: var(--club-shadow-sm); } .tree-node > div > strong, @@ -5451,14 +5451,14 @@ onUnmounted(() => { } .tree-node > div > strong { - color: #1f2d3d; + color: var(--el-text-color-primary); font-size: 14px; font-weight: 650; } .tree-node > div > span { margin-top: 3px; - color: #66727f; + color: var(--el-text-color-secondary); font-size: 12px; } @@ -5468,14 +5468,14 @@ onUnmounted(() => { } .tree-node-member strong { - color: #1f2d3d; + color: var(--el-text-color-primary); font-size: 13px; font-weight: 650; } .tree-node-member span { margin-top: 2px; - color: #66727f; + color: var(--el-text-color-secondary); font-size: 12px; } @@ -5490,8 +5490,8 @@ onUnmounted(() => { .tree-node-root { min-width: 230px; padding: 12px 16px; - border-color: #9ac1ea; - background: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%); + border-color: color-mix(in srgb, var(--club-primary) 45%, var(--club-border)); + background: linear-gradient(180deg, var(--club-primary-soft), var(--club-surface-solid)); } .tree-node-department { @@ -5502,8 +5502,8 @@ onUnmounted(() => { .tree-node-group { min-width: 230px; padding: 9px 12px; - border-color: #dce7d5; - background: #fbfdf9; + border-color: var(--el-border-color-light); + background: var(--el-fill-color-light); } .tree-node-member { @@ -5512,8 +5512,8 @@ onUnmounted(() => { justify-content: flex-start; display: block; padding: 8px 12px; - border-color: #e1e8cf; - background: #fdfef9; + border-color: var(--el-border-color-light); + background: var(--el-fill-color-light); box-shadow: none; } @@ -5530,7 +5530,7 @@ onUnmounted(() => { .transition-toolbar p { margin: 0; - color: #66727f; + color: var(--el-text-color-secondary); } .transition-actions { diff --git a/frontend/src/views/EvaluationList.vue b/frontend/src/views/EvaluationList.vue index 9c4f8efc..24c0ba62 100644 --- a/frontend/src/views/EvaluationList.vue +++ b/frontend/src/views/EvaluationList.vue @@ -1205,7 +1205,7 @@ onUnmounted(() => { .process-strip div { padding: 14px 16px; border-right: 1px solid var(--el-border-color-light); - background: #fff; + background: var(--club-surface-solid); } .process-strip div:last-child { diff --git a/frontend/src/views/MaterialBorrow.vue b/frontend/src/views/MaterialBorrow.vue index 92eea54d..6576f1ad 100644 --- a/frontend/src/views/MaterialBorrow.vue +++ b/frontend/src/views/MaterialBorrow.vue @@ -1147,7 +1147,7 @@ onMounted(async () => { border: 1px solid var(--el-border-color-light); border-radius: 8px; padding: 12px; - background: #fff; + background: var(--club-surface-solid); } .stat-label { display: block; diff --git a/frontend/src/views/NoticeCenter.vue b/frontend/src/views/NoticeCenter.vue index c1eabfa9..7323d952 100644 --- a/frontend/src/views/NoticeCenter.vue +++ b/frontend/src/views/NoticeCenter.vue @@ -940,14 +940,14 @@ onUnmounted(() => { flex-direction: column; gap: 18px; padding: 24px; - color: #20262e; + color: var(--el-text-color-primary); } .notice-head, .summary-band, .filter-band { - border: 1px solid #d9e1ea; - background: #fff; + border: 1px solid var(--el-border-color-light); + background: var(--club-surface-solid); } .notice-head { @@ -965,7 +965,7 @@ onUnmounted(() => { .notice-head p { margin: 6px 0 0; - color: #66727f; + color: var(--el-text-color-secondary); } .head-actions, @@ -989,12 +989,12 @@ onUnmounted(() => { } .summary-band div + div { - border-left: 1px solid #d9e1ea; + border-left: 1px solid var(--el-border-color-light); } .summary-band span { display: block; - color: #66727f; + color: var(--el-text-color-secondary); font-size: 13px; } @@ -1027,7 +1027,7 @@ onUnmounted(() => { .notice-dialog__heading h3 { margin: 10px 0 0; - color: #20262e; + color: var(--el-text-color-primary); font-size: 22px; line-height: 1.4; overflow-wrap: anywhere; @@ -1035,13 +1035,13 @@ onUnmounted(() => { .notice-dialog__content { padding: 18px 20px; - border: 1px solid #d9e1ea; + border: 1px solid var(--el-border-color-light); border-radius: 8px; - background: #f8fafc; + background: var(--el-fill-color-light); } .notice-dialog__content span { - color: #66727f; + color: var(--el-text-color-secondary); font-size: 13px; } @@ -1059,7 +1059,7 @@ onUnmounted(() => { justify-content: space-between; gap: 16px; width: 100%; - color: #66727f; + color: var(--el-text-color-secondary); font-size: 13px; } @@ -1086,7 +1086,7 @@ onUnmounted(() => { .summary-band div + div { border-left: none; - border-top: 1px solid #d9e1ea; + border-top: 1px solid var(--el-border-color-light); } .filter-item { diff --git a/frontend/src/views/VenueReservationApply.vue b/frontend/src/views/VenueReservationApply.vue index 2cd5335a..bb703f40 100644 --- a/frontend/src/views/VenueReservationApply.vue +++ b/frontend/src/views/VenueReservationApply.vue @@ -1133,7 +1133,7 @@ onMounted(refreshVenueData); padding: 12px; border: 1px solid var(--el-border-color-light); border-radius: 8px; - background: #fff; + background: var(--club-surface-solid); } .slot-date { display: flex; From 17a0b36eb639a59dc6e9b9cda7df91e1bddea980 Mon Sep 17 00:00:00 2001 From: Aphrody-Dy <051012atw@gmail.com> Date: Wed, 19 Aug 2026 17:42:10 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(frontend):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E5=93=81=E7=89=8C=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 将指定的 logo_small.svg 原样纳入前端公共资源。 * 桌面侧栏与移动端抽屉统一使用正式图标,保留品牌文字和可访问语义。 --- frontend/public/logo_small.svg | 189 +++++++++++++++++++++ frontend/src/components/shell/AppShell.vue | 28 +-- 2 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 frontend/public/logo_small.svg diff --git a/frontend/public/logo_small.svg b/frontend/public/logo_small.svg new file mode 100644 index 00000000..0220de4d --- /dev/null +++ b/frontend/public/logo_small.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/shell/AppShell.vue b/frontend/src/components/shell/AppShell.vue index 65eadead..067f8e59 100644 --- a/frontend/src/components/shell/AppShell.vue +++ b/frontend/src/components/shell/AppShell.vue @@ -40,7 +40,7 @@ function changeTheme(command: string | number | object) {