feat(auth): 添加用户认证模块与登录页面
All checks were successful
CI/CD Pipeline / playwright (push) Successful in 4m9s
CI/CD Pipeline / build-and-deploy (push) Successful in 4m38s

This commit is contained in:
严浩
2025-11-03 13:46:47 +08:00
parent b669889bb0
commit b4fcde324d
15 changed files with 293 additions and 61 deletions

2
auto-imports.d.ts vendored
View File

@@ -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<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAuthStore: UnwrapRef<typeof import('./src/stores/auth-store-auto-imports')['useAuthStore']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>

View File

@@ -9,6 +9,10 @@ import AppNaiveUIProvider from './AppNaiveUIProvider.vue';
<Toast />
<AppNaiveUIProvider>
<RouterView />
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</AppNaiveUIProvider>
</template>

View File

@@ -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';
</script>
<template>
@@ -11,6 +12,7 @@ import ToggleSiderButton from './components/ToggleSiderButton.vue';
<div class="flex items-center">
<LanguageSwitchButton />
<ThemeSwitchButton />
<UserDropdown />
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="tsx">
const router = useRouter();
const userStore = useAuthStore();
const dialog = useDialog();
const options = computed(() => [
{
label: userStore.userInfo?.nickname || userStore.userInfo?.username || '用户',
key: 'user',
disabled: true,
},
{
type: 'divider',
key: 'd1',
},
{
label: '退出登录',
key: 'logout',
icon: () => <icon-material-symbols-logout class="w-4 h-4" />,
},
]);
function handleSelect(key: string) {
if (key === 'logout') {
dialog.warning({
title: '退出登录',
content: '确定要退出登录吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
userStore.clearToken('用户退出登录');
router.push('/login');
},
});
}
}
</script>
<template>
<NDropdown :options="options" placement="bottom-end" @select="handleSelect">
<NButton quaternary circle>
<icon-material-symbols-account-circle w-5 h-5 />
</NButton>
</NDropdown>
</template>

View File

@@ -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;
}
</style>

View File

@@ -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');

View File

@@ -1,5 +1,87 @@
<script setup lang="ts"></script>
<script setup lang="ts">
definePage({ meta: { ignoreAuth: true, layout: false } });
const router = useRouter();
const userStore = useAuthStore();
const message = useMessage();
const formValue = ref({
username: 'admin',
password: 'admin',
});
const loading = ref(false);
async function handleLogin() {
if (!formValue.value.username || !formValue.value.password) {
message.warning('请输入用户名和密码');
return;
}
loading.value = true;
try {
const result = await userStore.login(formValue.value.username, formValue.value.password);
if (result.success) {
message.success('登录成功');
router.push('/');
} else {
message.error(result.message || '登录失败');
}
} catch {
message.error('登录异常');
} finally {
loading.value = false;
}
}
</script>
<template>
<div>Login</div>
<div class="login-page">
<NCard class="login-card" title="用户登录">
<NForm :model="formValue" label-placement="left" label-width="80">
<NFormItem label="用户名" path="username">
<NInput
v-model:value="formValue.username"
placeholder="请输入用户名"
@keyup.enter="handleLogin"
/>
</NFormItem>
<NFormItem label="密码" path="password">
<NInput
v-model:value="formValue.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
@keyup.enter="handleLogin"
/>
</NFormItem>
<NFormItem :show-label="false">
<NButton type="primary" block :loading="loading" @click="handleLogin"> 登录 </NButton>
</NFormItem>
</NForm>
<div class="login-hint">
<NText depth="3">提示用户名和密码均为 admin</NText>
</div>
</NCard>
</div>
</template>
<style scoped lang="scss">
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
max-width: 90%;
}
.login-hint {
margin-top: 16px;
text-align: center;
}
</style>

View File

@@ -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<Element> }) {
// 警告:路由守卫的创建顺序会影响执行流程,请勿调整
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<keyof RouteNamedMap, string>;
}
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 };

View File

@@ -0,0 +1,42 @@
import type { RouteNamedMap } from 'vue-router/auto-routes';
declare global {
type PageTitleLocalizations = Record<keyof RouteNamedMap, string>;
}
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;
}
}

View File

@@ -5,12 +5,15 @@ type UserPlugin = (ctx: UserPluginContext) => void;
type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin };
type UserPluginContext = { app: import('vue').App<Element> };
const autoInstallModules: AutoInstallModule = import.meta.glob('./!(index).ts', {
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) {

View File

@@ -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();
}
});
}

View File

@@ -0,0 +1,45 @@
export const useAuthStore = defineStore('auth', () => {
const token = useLocalStorage<string | null>('auth-token', null);
const userInfo = ref<Record<string, any> | 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 };
});

View File

@@ -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;
}

View File

@@ -1,6 +1,8 @@
import 'nprogress/nprogress.css'; // <link rel="stylesheet" href="https://testingcf.jsdelivr.net/npm/nprogress/nprogress.css" />
import 'primeicons/primeicons.css';
import './reset-primevue.css';
import './css/reset-primevue.css';
import './css/transition.css';
import 'virtual:uno.css';