diff --git a/auto-imports.d.ts b/auto-imports.d.ts index a1e2a71..3f8d1fa 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -156,6 +156,7 @@ declare global { const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue'] const useAsyncState: typeof import('@vueuse/core')['useAsyncState'] const useAttrs: typeof import('vue')['useAttrs'] + const useAuthStore: typeof import('./src/stores/auth-store-auto-imports')['useAuthStore'] const useBase64: typeof import('@vueuse/core')['useBase64'] const useBattery: typeof import('@vueuse/core')['useBattery'] const useBluetooth: typeof import('@vueuse/core')['useBluetooth'] @@ -499,6 +500,7 @@ declare module 'vue' { readonly useAsyncQueue: UnwrapRef readonly useAsyncState: UnwrapRef readonly useAttrs: UnwrapRef + readonly useAuthStore: UnwrapRef readonly useBase64: UnwrapRef readonly useBattery: UnwrapRef readonly useBluetooth: UnwrapRef diff --git a/src/App.vue b/src/App.vue index 6440045..bd4d765 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,10 @@ import AppNaiveUIProvider from './AppNaiveUIProvider.vue'; - + + + + + diff --git a/src/layouts/base-layout/base-layout-header/base-layout-header.vue b/src/layouts/base-layout/base-layout-header/base-layout-header.vue index b631f15..643280a 100644 --- a/src/layouts/base-layout/base-layout-header/base-layout-header.vue +++ b/src/layouts/base-layout/base-layout-header/base-layout-header.vue @@ -2,6 +2,7 @@ import LanguageSwitchButton from './components/LanguageSwitchButton.vue'; import ThemeSwitchButton from './components/ThemeSwitchButton.vue'; import ToggleSiderButton from './components/ToggleSiderButton.vue'; +import UserDropdown from './components/UserDropdown.vue'; diff --git a/src/layouts/base-layout/base-layout-header/components/UserDropdown.vue b/src/layouts/base-layout/base-layout-header/components/UserDropdown.vue new file mode 100644 index 0000000..47650a4 --- /dev/null +++ b/src/layouts/base-layout/base-layout-header/components/UserDropdown.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/layouts/base-layout/the-base-layout.vue b/src/layouts/base-layout/the-base-layout.vue index 2a1cbb9..c7f113c 100644 --- a/src/layouts/base-layout/the-base-layout.vue +++ b/src/layouts/base-layout/the-base-layout.vue @@ -49,14 +49,4 @@ const appStore = useAppStore(); #__SCROLL_EL_ID__ { @include scrollbar; } - -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.25s ease-in-out; -} - -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} diff --git a/src/main.ts b/src/main.ts index be643ba..887e053 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,12 +3,11 @@ import './styles/index.ts'; import { LogLevels } from 'consola'; import App from './App.vue'; import { setupPlugins } from './plugins'; -import { router } from './plugins/00.router-plugin.ts'; consola.level = LogLevels.verbose; const app = createApp(App); setupPlugins(app); -await router.isReady(); + await new Promise((resolve) => setTimeout(resolve, 280)); app.mount('#app'); diff --git a/src/pages/Login.page.vue b/src/pages/Login.page.vue index 707dd24..29c1e87 100644 --- a/src/pages/Login.page.vue +++ b/src/pages/Login.page.vue @@ -1,5 +1,87 @@ - + + + diff --git a/src/plugins/00.router-plugin.ts b/src/plugins/00.router-plugin.ts index 013cfb7..14bd122 100644 --- a/src/plugins/00.router-plugin.ts +++ b/src/plugins/00.router-plugin.ts @@ -2,8 +2,8 @@ import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders'; import { setupLayouts } from 'virtual:meta-layouts'; // import { createGetRoutes, setupLayouts } from 'virtual:generated-layouts'; import { createRouter, createWebHistory } from 'vue-router'; -import type { RouteNamedMap } from 'vue-router/auto-routes'; -import { routes, handleHotUpdate } from 'vue-router/auto-routes'; +import type { Router } from 'vue-router'; +import { handleHotUpdate, routes } from 'vue-router/auto-routes'; const setupLayoutsResult = setupLayouts(routes); const router = createRouter({ @@ -15,6 +15,10 @@ const router = createRouter({ strict: true, }); +router.isReady().then(() => { + console.debug('✅ [router is ready]'); +}); + router.onError((error) => { console.debug('🚨 [router error]:', error); }); @@ -33,49 +37,24 @@ export function install({ app }: { app: import('vue').App }) { // 警告:路由守卫的创建顺序会影响执行流程,请勿调整 createNProgressGuard(router); if (import.meta.env.VITE_APP_ENABLE_ROUTER_LOG_GUARD === 'true') createLogGuard(router); - Object.assign(globalThis, { stack: createStackGuard(router) }); + Object.assign(window, { stack: createStackGuard(router) }); + + // >>> + Object.values( + import.meta.glob<{ + createGuard?: (router: Router) => void; + }>('./router-guard/*.ts', { eager: true /* true 为同步,false 为异步 */ }), + ).forEach((module) => { + module.createGuard?.(router); + }); + // <<< } -declare module 'vue-router' { - /* definePage({ meta: { title: '示例演示' } }); */ - interface RouteMeta { - /** - * @description 是否在菜单中隐藏 - */ - hideInMenu?: boolean; +if (__DEV__) Object.assign(window, { router }); - /** - * @description 菜单标题 //!⚠️通过多语言标题方案(搜`PageTitleLocalizations`)维护标题 - */ - title?: string; - - /** - * @description 使用的布局,设置为 false 则表示不使用布局 - */ - layout?: string | false; - - /** - * @description 菜单项是否渲染为可点击链接,默认为 true - * - true: 使用 RouterLink 包装,可点击跳转 - * - false: 仅渲染纯文本标签,不可点击(适用于分组标题) - */ - link?: boolean; - - /** - * @description 菜单排序权重,数值越小越靠前,未设置则按路径字母顺序排序 - */ - order?: number; - } -} - -export { router, setupLayoutsResult }; - -declare global { - type PageTitleLocalizations = Record; -} - -if (__DEV__) Object.assign(globalThis, { router }); // This will update routes at runtime without reloading the page if (import.meta.hot) { handleHotUpdate(router); } + +export { router, setupLayoutsResult }; diff --git a/src/plugins/00.router-plugin.types.ts b/src/plugins/00.router-plugin.types.ts new file mode 100644 index 0000000..0658753 --- /dev/null +++ b/src/plugins/00.router-plugin.types.ts @@ -0,0 +1,42 @@ +import type { RouteNamedMap } from 'vue-router/auto-routes'; + +declare global { + type PageTitleLocalizations = Record; +} + +declare module 'vue-router' { + /* definePage({ meta: { title: '示例演示' } }); */ + interface RouteMeta { + /** + * @description 是否在菜单中隐藏 + */ + hideInMenu?: boolean; + + /** + * @description 菜单标题 //!⚠️通过多语言标题方案(搜`PageTitleLocalizations`)维护标题 + */ + title?: string; + + /** + * @description 使用的布局,设置为 false 则表示不使用布局 + */ + layout?: string | false; + + /** + * @description 菜单项是否渲染为可点击链接,默认为 true + * - true: 使用 RouterLink 包装,可点击跳转 + * - false: 仅渲染纯文本标签,不可点击(适用于分组标题) + */ + link?: boolean; + + /** + * @description 菜单排序权重,数值越小越靠前,未设置则按路径字母顺序排序 + */ + order?: number; + + /** + * @description 是否忽略权限,默认为 false + */ + ignoreAuth?: boolean; + } +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index bfa2bb1..fd16a43 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -5,12 +5,15 @@ type UserPlugin = (ctx: UserPluginContext) => void; type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin }; type UserPluginContext = { app: import('vue').App }; -const autoInstallModules: AutoInstallModule = import.meta.glob('./!(index).ts', { - eager: true /* true 为同步,false 为异步 */, -}); +const autoInstallModules: AutoInstallModule = import.meta.glob( + ['./*.ts', '!./**/*.types.ts', '!./index.ts'], + { + eager: true /* true 为同步,false 为异步 */, + }, +); export function setupPlugins(app: import('vue').App) { - console.group('🔌 Plugins'); + console.group(`🔌 Installing ${Object.keys(autoInstallModules).length} plugins`); for (const path in autoInstallModules) { const module = autoInstallModules[path] as AutoInstallModule; if (module.install) { diff --git a/src/plugins/router-guard/router-permission-guard.ts b/src/plugins/router-guard/router-permission-guard.ts new file mode 100644 index 0000000..5ea4506 --- /dev/null +++ b/src/plugins/router-guard/router-permission-guard.ts @@ -0,0 +1,28 @@ +import type { Router } from 'vue-router'; + +export function createGuard(router: Router) { + router.beforeEach(async (to /* , from */) => { + const userStore = useAuthStore(); + + if (to.name === 'Login') { + userStore.clearToken('User navigated to login page'); + } + + if (to.meta.ignoreAuth) { + return true; + } + + if (!userStore.isLoggedIn) { + console.debug('🔑 [permission-guard] 用户未登录,重定向到登录页'); + return { name: 'Login' }; + } + }); + + router.beforeResolve(async (/* to, from */) => { + const userStore = useAuthStore(); + if (userStore.isLoggedIn && !userStore.userInfo) { + console.debug('🔑 [permission-guard] 用户信息不存在,尝试获取用户信息'); + await userStore.fetchUserInfo(); + } + }); +} diff --git a/src/stores/auth-store-auto-imports.ts b/src/stores/auth-store-auto-imports.ts new file mode 100644 index 0000000..7fd8dc6 --- /dev/null +++ b/src/stores/auth-store-auto-imports.ts @@ -0,0 +1,45 @@ +export const useAuthStore = defineStore('auth', () => { + const token = useLocalStorage('auth-token', null); + const userInfo = ref | null>(null); + + const isLoggedIn = computed(() => !!token.value); + + function clearToken(reason?: string) { + consola.info('🚮 [auth-store] clear: ', reason); + token.value = null; + userInfo.value = null; + } + + async function login(username: string, password: string) { + // 模拟登录延迟 + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 模拟验证 + if (username === 'admin' && password === 'admin') { + token.value = `mock-token-${Date.now()}`; + await fetchUserInfo(); + return { success: true }; + } + + return { success: false, message: '用户名或密码错误' }; + } + + async function fetchUserInfo() { + if (!token.value) { + return; + } + + // 模拟获取用户信息延迟 + await new Promise((resolve) => setTimeout(resolve, 300)); + + // 模拟从服务器获取用户信息 + userInfo.value = { + id: 1, + username: 'admin', + nickname: '管理员', + roles: ['admin'], + }; + } + + return { token, isLoggedIn, userInfo, clearToken, login, fetchUserInfo }; +}); diff --git a/src/styles/reset-primevue.css b/src/styles/css/reset-primevue.css similarity index 100% rename from src/styles/reset-primevue.css rename to src/styles/css/reset-primevue.css diff --git a/src/styles/css/transition.css b/src/styles/css/transition.css new file mode 100644 index 0000000..61caa26 --- /dev/null +++ b/src/styles/css/transition.css @@ -0,0 +1,9 @@ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.25s ease-in-out; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index a540f47..eed891d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1,6 +1,8 @@ import 'nprogress/nprogress.css'; // import 'primeicons/primeicons.css'; -import './reset-primevue.css'; + +import './css/reset-primevue.css'; +import './css/transition.css'; import 'virtual:uno.css';