diff --git a/.github/assets/pr/29/dark-mode.png b/.github/assets/pr/29/dark-mode.png new file mode 100644 index 00000000..b0fc5c30 Binary files /dev/null and b/.github/assets/pr/29/dark-mode.png differ diff --git a/.github/assets/pr/29/light-mode.png b/.github/assets/pr/29/light-mode.png new file mode 100644 index 00000000..e07d8f34 Binary files /dev/null and b/.github/assets/pr/29/light-mode.png differ 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/App.test.ts b/frontend/src/App.test.ts new file mode 100644 index 00000000..a4e1b20c --- /dev/null +++ b/frontend/src/App.test.ts @@ -0,0 +1,80 @@ +import ElementPlus from "element-plus"; +import { createApp, defineComponent, h, onMounted, type App as VueApp } from "vue"; +import { createMemoryHistory, createRouter } from "vue-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import App from "./App.vue"; + +vi.mock("./authSession", () => ({ + clearSession: vi.fn(), + onSessionChange: vi.fn(() => () => undefined), + readAuth: vi.fn(() => null), +})); + +vi.mock("./composables/useTheme", () => ({ + initializeTheme: vi.fn(), +})); + +vi.mock("./apiClient", () => ({ + apiClient: { logoutCurrentSession: vi.fn() }, +})); + +vi.mock("./components/shell/AppShell.vue", () => ({ + default: { name: "AppShellStub" }, +})); + +let mountedApp: VueApp | null = null; + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true })), + ); +}); + +afterEach(() => { + mountedApp?.unmount(); + mountedApp = null; + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +describe("应用会话恢复", () => { + it("会话恢复完成前不会挂载受保护路由组件", async () => { + const protectedMounted = vi.fn(); + const ProtectedPage = defineComponent({ + setup() { + onMounted(protectedMounted); + return () => h("div", "受保护页面"); + }, + }); + const AuthPage = defineComponent({ + setup: () => () => h("div", "登录页面"), + }); + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/auth", component: AuthPage }, + { path: "/protected", component: ProtectedPage }, + ], + }); + + await router.push("/protected"); + await router.isReady(); + + const host = document.createElement("div"); + document.body.appendChild(host); + mountedApp = createApp(App); + mountedApp.use(ElementPlus); + mountedApp.use(router); + mountedApp.mount(host); + + expect(protectedMounted).not.toHaveBeenCalled(); + expect(host.textContent).not.toContain("受保护页面"); + + await vi.waitFor(() => { + expect(router.currentRoute.value.path).toBe("/auth"); + expect(host.textContent).toContain("登录页面"); + }); + expect(protectedMounted).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b359d19f..2bf3964d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,19 +1,24 @@ - 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..660b322b --- /dev/null +++ b/frontend/src/components/shell/AppShell.vue @@ -0,0 +1,468 @@ + + + + + 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..1afc0a82 --- /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..3faae482 --- /dev/null +++ b/frontend/src/components/ui/uiComponents.test.ts @@ -0,0 +1,72 @@ +import { createApp, type Component } from "vue"; +import ElementPlus from "element-plus"; +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.use(ElementPlus); + 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("button") as HTMLButtonElement).click(); + await Promise.resolve(); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("错误状态在重试文案为空时不渲染无名称按钮", () => { + const host = mount(AppErrorState, { retryLabel: "" }); + + expect(host.querySelector("button")).toBeNull(); + }); +}); 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/designSystemCompliance.test.ts b/frontend/src/designSystemCompliance.test.ts new file mode 100644 index 00000000..5dc7fd46 --- /dev/null +++ b/frontend/src/designSystemCompliance.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import appShellSource from "./components/shell/AppShell.vue?raw"; + +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", +]; + +const standardizedHeaderViews = [ + "ActivityList.vue", + "AuthFlow.vue", + "AwardList.vue", + "BudgetManagement.vue", + "ClubList.vue", + "EvaluationList.vue", + "ForumCenter.vue", + "LearningCenter.vue", + "MaterialBorrow.vue", + "NoticeCenter.vue", + "ProjectList.vue", + "ProjectWorkspace.vue", + "RecruitmentList.vue", + "VenueManage.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); + }, + ); + + it.each(standardizedHeaderViews)("%s 使用统一页面标题规范", (fileName) => { + expect(viewSources[`./views/${fileName}`]).toContain("app-page-header"); + }); + + it("由应用壳层统一页面宽度、居中和全屏留白", () => { + expect(appShellSource).toContain("max-width: var(--club-content-width)"); + expect(appShellSource).toContain("padding: clamp(var(--club-space-5), 2.2vw, 40px)"); + expect(appShellSource).toContain(":global(.page-content > *)"); + expect(appShellSource).toContain("max-width: 100% !important"); + expect(appShellSource).toContain("margin-inline: auto !important"); + }); + + it("应用壳层使用透明背景品牌图标", () => { + expect(appShellSource.match(/src="\/favicon\.svg"/g)).toHaveLength(2); + expect(appShellSource).toContain("background: transparent"); + expect(appShellSource).toContain("object-fit: contain"); + }); +}); 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/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 } }; diff --git a/frontend/src/style.css b/frontend/src/style.css index 605bf592..1e9fdf98 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,12 +1,196 @@ +: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: 1480px; + --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; + } } diff --git a/frontend/src/views/ActivityList.vue b/frontend/src/views/ActivityList.vue index 06a12080..eea24957 100644 --- a/frontend/src/views/ActivityList.vue +++ b/frontend/src/views/ActivityList.vue @@ -716,7 +716,7 @@ async function openParticipations(activity: Activity) {