feat(auth): 添加用户认证模块与登录页面
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
42
src/plugins/00.router-plugin.types.ts
Normal file
42
src/plugins/00.router-plugin.types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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', {
|
||||
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) {
|
||||
|
||||
28
src/plugins/router-guard/router-permission-guard.ts
Normal file
28
src/plugins/router-guard/router-permission-guard.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
45
src/stores/auth-store-auto-imports.ts
Normal file
45
src/stores/auth-store-auto-imports.ts
Normal 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 };
|
||||
});
|
||||
9
src/styles/css/transition.css
Normal file
9
src/styles/css/transition.css
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user