diff --git a/package.json b/package.json index 112bf60..34a65af 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "nprogress": "^0.2.0", "path-browserify": "^1.0.1", "pinia": "^2.2.4", + "pinia-plugin-persistedstate": "^3.2.3", "vue": "^3.5.12", "vue-router": "^4.4.5" }, diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts new file mode 100644 index 0000000..99a9109 --- /dev/null +++ b/src/api/auth/index.ts @@ -0,0 +1,27 @@ +import request from '@/utils/request' +import type { LoginForm, LoginResult, UserInfo } from '@/types/auth' +import type { RouteVO } from '@/types/router' + +const BASE_URL = '/auth' + +export const AuthAPI = { + /** 登录 */ + login(data: LoginForm) { + return request({ url: `${BASE_URL}/login`, method: 'post', data }) + }, + + /** 登出 */ + logout() { + return request({ url: `${BASE_URL}/logout`, method: 'post' }) + }, + + /** 获取当前登录用户信息(含角色与权限) */ + getMe() { + return request({ url: `${BASE_URL}/me`, method: 'get' }) + }, + + /** 获取当前用户可访问的菜单路由 */ + getRoutes() { + return request({ url: `${BASE_URL}/routes`, method: 'get' }) + }, +} diff --git a/src/api/system/user/index.ts b/src/api/system/user/index.ts new file mode 100644 index 0000000..846d5ba --- /dev/null +++ b/src/api/system/user/index.ts @@ -0,0 +1,32 @@ +import request from '@/utils/request' +import type { PageResult } from '@/types/common' +import type { UserForm, UserQuery, UserVO } from '@/types/system/user' + +const BASE_URL = '/users' + +export const UserAPI = { + /** 分页查询用户列表 */ + query(params: UserQuery) { + return request>({ url: `${BASE_URL}/page`, method: 'get', params }) + }, + + /** 用户详情 */ + detail(id: number) { + return request({ url: `${BASE_URL}/${id}`, method: 'get' }) + }, + + /** 新增用户 */ + create(data: UserForm) { + return request({ url: BASE_URL, method: 'post', data }) + }, + + /** 修改用户 */ + update(id: number, data: UserForm) { + return request({ url: `${BASE_URL}/${id}`, method: 'put', data }) + }, + + /** 删除用户,ids 为逗号分隔 */ + delete(ids: string) { + return request({ url: `${BASE_URL}/${ids}`, method: 'delete' }) + }, +} diff --git a/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 0000000..df022ff --- /dev/null +++ b/src/directives/index.ts @@ -0,0 +1,8 @@ +import type { App } from 'vue' +import { hasPerm, hasRole } from './permission' + +/** 全局指令注册入口 */ +export function setupDirectives(app: App): void { + app.directive('hasPerm', hasPerm) + app.directive('hasRole', hasRole) +} diff --git a/src/directives/permission/index.ts b/src/directives/permission/index.ts new file mode 100644 index 0000000..a97b699 --- /dev/null +++ b/src/directives/permission/index.ts @@ -0,0 +1,35 @@ +import type { Directive, DirectiveBinding } from 'vue' +import { useUserStore } from '@/store' + +type AuthValue = string | string[] + +function normalize(binding: DirectiveBinding): string[] { + const value = binding.value + if (Array.isArray(value)) return value + return value ? [value] : [] +} + +/** 校验不通过时直接移除 DOM 节点,而非仅禁用 */ +function check(el: HTMLElement, binding: DirectiveBinding, checker: (code: string) => boolean) { + const codes = normalize(binding) + if (!codes.length) return + + const passed = codes.some(checker) + if (!passed) { + el.remove() + } +} + +/** 按钮级权限:v-hasPerm="'sys:user:add'" 或 v-hasPerm="['sys:user:add']" */ +export const hasPerm: Directive = { + mounted(el, binding) { + check(el, binding, (code) => useUserStore().hasPermission(code)) + }, +} + +/** 角色级权限:v-hasRole="'ADMIN'" */ +export const hasRole: Directive = { + mounted(el, binding) { + check(el, binding, (code) => useUserStore().hasRole(code)) + }, +} diff --git a/src/enums/user.ts b/src/enums/user.ts new file mode 100644 index 0000000..71b0e08 --- /dev/null +++ b/src/enums/user.ts @@ -0,0 +1,14 @@ +import type { Option } from '@/types/common' +import type { UserStatus } from '@/types/system/user' + +/** 用户状态枚举 */ +export const UserStatusEnum = { + ENABLED: 1, + DISABLED: 0, +} as const + +/** 用户状态下拉选项 */ +export const USER_STATUS_OPTIONS: Option[] = [ + { label: '启用', value: UserStatusEnum.ENABLED }, + { label: '禁用', value: UserStatusEnum.DISABLED }, +] diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..6b2eadb --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,88 @@ +import NProgress from 'nprogress' +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { usePermissionStore, useUserStore } from '@/store' + +const DEFAULT_TITLE = import.meta.env.VITE_APP_TITLE || 'Portal 管理后台' + +/** 静态路由:登录页与首页骨架,不依赖后端返回 */ +export const constantRoutes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/login/index.vue'), + meta: { hidden: true }, + }, + { + path: '/', + component: () => import('@/layouts/index.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '仪表盘', icon: 'Odometer' }, + }, + ], + }, +] + +/** 兜底 404,必须在动态路由之后注册 */ +export const notFoundRoute: RouteRecordRaw = { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/error/404.vue'), + meta: { hidden: true }, +} + +const router = createRouter({ + history: createWebHistory(import.meta.env.VITE_APP_BASE_URL), + routes: constantRoutes, + scrollBehavior: () => ({ top: 0 }), +}) + +router.beforeEach(async (to, _from, next) => { + NProgress.start() + + const title = (to.meta.title as string | undefined) ?? '' + document.title = title ? `${title} - ${DEFAULT_TITLE}` : DEFAULT_TITLE + + const userStore = useUserStore() + const permissionStore = usePermissionStore() + + // 已登录访问登录页 → 回首页 + if (to.path === '/login') { + next(userStore.token ? { path: '/' } : undefined) + return + } + + // 未登录 → 跳转登录并携带回跳地址 + if (!userStore.token) { + next({ path: '/login', query: { redirect: to.fullPath } }) + return + } + + // 首次进入:拉取用户信息与动态路由 + if (!permissionStore.isLoaded) { + try { + await userStore.getUserInfo() + const dynamicRoutes = await permissionStore.generateRoutes() + dynamicRoutes.forEach((route) => router.addRoute(route)) + router.addRoute(notFoundRoute) + next({ ...to, replace: true }) + } catch { + userStore.resetToken() + permissionStore.resetRoutes() + next({ path: '/login', query: { redirect: to.fullPath } }) + } + return + } + + next() +}) + +router.afterEach(() => { + NProgress.done() +}) + +export default router diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..92c9f15 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,12 @@ +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +const pinia = createPinia() +pinia.use(piniaPluginPersistedstate) + +export default pinia + +export * from './modules/app' +export * from './modules/permission' +export * from './modules/settings' +export * from './modules/user' diff --git a/src/store/modules/app.ts b/src/store/modules/app.ts new file mode 100644 index 0000000..1406030 --- /dev/null +++ b/src/store/modules/app.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' + +export type DeviceType = 'desktop' | 'mobile' + +export const useAppStore = defineStore( + 'app', + () => { + const sidebarCollapsed = ref(false) + const device = ref('desktop') + + function toggleSidebar(collapsed?: boolean) { + sidebarCollapsed.value = collapsed ?? !sidebarCollapsed.value + } + + function setDevice(value: DeviceType) { + device.value = value + } + + return { sidebarCollapsed, device, toggleSidebar, setDevice } + }, + { + persist: { paths: ['sidebarCollapsed'] }, + }, +) diff --git a/src/store/modules/permission.ts b/src/store/modules/permission.ts new file mode 100644 index 0000000..8450a94 --- /dev/null +++ b/src/store/modules/permission.ts @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia' +import type { RouteRecordRaw } from 'vue-router' +import { AuthAPI } from '@/api/auth' +import type { RouteVO } from '@/types/router' + +/** views 下所有页面组件,用于后端路由动态映射 */ +const viewModules = import.meta.glob('../../views/**/*.vue') +const Layout = () => import('@/layouts/index.vue') + +function resolveView(component?: string) { + if (!component) return undefined + if (component === 'Layout') return Layout + + const loader = viewModules[`../../views/${component}.vue`] + if (!loader) { + console.warn(`[router] 未找到组件文件:views/${component}.vue`) + return undefined + } + return loader +} + +function joinPath(parentPath: string, path: string): string { + if (path.startsWith('/')) return path + return `${parentPath}/${path}`.replace(/\/{2,}/g, '/') +} + +/** 将后端返回的路由树转换为 vue-router 可识别的路由记录 */ +function transformRoutes(routes: RouteVO[], parentPath = ''): RouteRecordRaw[] { + const result: RouteRecordRaw[] = [] + + routes.forEach((route) => { + if (route.meta?.hidden) return + + const fullPath = joinPath(parentPath, route.path) + const children = route.children?.length ? transformRoutes(route.children, fullPath) : [] + + // 布局容器节点 + if (route.component === 'Layout') { + result.push({ + path: fullPath, + component: Layout, + redirect: route.redirect, + meta: route.meta ?? {}, + children, + }) + return + } + + const component = resolveView(route.component) + if (!component) return + + result.push({ + path: fullPath, + name: route.name, + component, + meta: route.meta ?? {}, + }) + }) + + return result +} + +export const usePermissionStore = defineStore('permission', () => { + /** 后端返回的动态路由 */ + const routes = ref([]) + const isLoaded = ref(false) + + async function generateRoutes(): Promise { + const data = await AuthAPI.getRoutes() + routes.value = transformRoutes(data) + isLoaded.value = true + return routes.value + } + + function resetRoutes() { + routes.value = [] + isLoaded.value = false + } + + return { routes, isLoaded, generateRoutes, resetRoutes } +}) diff --git a/src/store/modules/settings.ts b/src/store/modules/settings.ts new file mode 100644 index 0000000..7e13940 --- /dev/null +++ b/src/store/modules/settings.ts @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia' + +export const DEFAULT_THEME_COLOR = '#409eff' + +export const useSettingsStore = defineStore( + 'settings', + () => { + const themeColor = ref(DEFAULT_THEME_COLOR) + const showLogo = ref(true) + + /** 通过 CSS 变量覆盖 Element Plus 主色,避免使用 !important 打补丁 */ + function setThemeColor(color: string) { + themeColor.value = color + document.documentElement.style.setProperty('--el-color-primary', color) + } + + function toggleLogo(visible?: boolean) { + showLogo.value = visible ?? !showLogo.value + } + + return { themeColor, showLogo, setThemeColor, toggleLogo } + }, + { + persist: { paths: ['themeColor', 'showLogo'] }, + }, +) diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts new file mode 100644 index 0000000..bd0cbd9 --- /dev/null +++ b/src/store/modules/user.ts @@ -0,0 +1,70 @@ +import { defineStore } from 'pinia' +import { AuthAPI } from '@/api/auth' +import type { LoginForm, UserInfo } from '@/types/auth' +import { getToken, removeToken, setToken } from '@/utils/storage' + +export const useUserStore = defineStore('user', () => { + /** Token 初始从本地恢复,避免刷新后丢失登录态 */ + const token = ref(getToken()) + const userInfo = ref(null) + const roles = ref([]) + const permissions = ref([]) + + const nickname = computed(() => userInfo.value?.nickname ?? '') + + /** 登录 */ + async function login(data: LoginForm) { + const result = await AuthAPI.login(data) + token.value = result.accessToken + setToken(result.accessToken) + } + + /** 拉取用户信息(角色 + 权限码) */ + async function getUserInfo() { + const data = await AuthAPI.getMe() + userInfo.value = data + roles.value = data.roles ?? [] + permissions.value = data.permissions ?? [] + return data + } + + /** 登出 */ + async function logout() { + try { + await AuthAPI.logout() + } finally { + resetToken() + } + } + + /** 清空登录态 */ + function resetToken() { + token.value = '' + userInfo.value = null + roles.value = [] + permissions.value = [] + removeToken() + } + + function hasPermission(code: string): boolean { + return permissions.value.includes(code) + } + + function hasRole(role: string): boolean { + return roles.value.includes(role) + } + + return { + token, + userInfo, + roles, + permissions, + nickname, + login, + getUserInfo, + logout, + resetToken, + hasPermission, + hasRole, + } +}) diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000..00db3ed --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,44 @@ +// 该文件由 Vite scss additionalData 自动注入,请只放置变量与 mixin,不要输出任何样式 + +// 布局尺寸 +$sidebar-width: 220px; +$sidebar-width-collapsed: 64px; +$navbar-height: 50px; +$container-padding: 16px; + +// 主题色(Element Plus 主色通过 CSS 变量覆盖,见 styles/element/index.scss) +$color-primary: #409eff; +$color-success: #67c23a; +$color-warning: #e6a23c; +$color-danger: #f56c6c; +$color-info: #909399; + +// 文字 +$text-color-primary: #303133; +$text-color-regular: #606266; +$text-color-secondary: #909399; + +// 背景与边框 +$bg-page: #f5f7fa; +$bg-sidebar: #ffffff; +$border-color: #e4e7ed; +$border-color-light: #ebeef5; + +// 层级 +$z-index-sidebar: 1001; +$z-index-navbar: 1002; + +// 过渡 +$transition-duration: 0.28s; + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin text-ellipsis { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..fb8082e --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,30 @@ +import type { RouteVO } from './router' + +/** 登录表单 */ +export interface LoginForm { + username: string + password: string + captchaKey?: string + captchaCode?: string +} + +/** 登录响应 */ +export interface LoginResult { + accessToken: string + refreshToken?: string + tokenType: string + expiresIn?: number +} + +/** 当前登录用户 */ +export interface UserInfo { + userId: number + username: string + nickname: string + avatar: string + roles: string[] + permissions: string[] +} + +/** 登录接口返回的完整信息(含菜单) */ +export type UserSession = UserInfo & { routes: RouteVO[] } diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..88b9390 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,25 @@ +/** 后端统一响应结构 */ +export interface ApiResponse { + /** 业务状态码,00000 表示成功 */ + code: string + data: T + msg: string +} + +/** 分页查询基础参数 */ +export interface PageQuery { + pageNum: number + pageSize: number +} + +/** 分页响应结构 */ +export interface PageResult { + list: T[] + total: number +} + +/** 下拉选项 */ +export interface Option { + label: string + value: T +} diff --git a/src/types/router.ts b/src/types/router.ts new file mode 100644 index 0000000..b5a278d --- /dev/null +++ b/src/types/router.ts @@ -0,0 +1,21 @@ +/** 路由元信息(与后端菜单字段保持一致) */ +export interface RouteMetaVO { + title?: string + icon?: string + hidden?: boolean + /** 是否缓存该页面 */ + keepAlive?: boolean + /** 只有一个子路由时是否始终展示父级菜单 */ + alwaysShow?: boolean + roles?: string[] +} + +/** 后端返回的路由/菜单结构,component 为 views 下的相对路径 */ +export interface RouteVO { + path: string + name?: string + component?: string + redirect?: string + meta?: RouteMetaVO + children?: RouteVO[] +} diff --git a/src/types/system/user.ts b/src/types/system/user.ts new file mode 100644 index 0000000..89d5a43 --- /dev/null +++ b/src/types/system/user.ts @@ -0,0 +1,35 @@ +import type { PageQuery } from '@/types/common' + +/** 用户状态:1 启用 / 0 禁用 */ +export type UserStatus = 0 | 1 + +/** 用户列表查询参数 */ +export interface UserQuery extends PageQuery { + username?: string + status?: UserStatus + deptId?: number +} + +/** 用户列表实体(后端返回) */ +export interface UserVO { + id: number + username: string + nickname: string + mobile: string + email: string + status: UserStatus + deptName: string + roleNames: string[] + createTime: string +} + +/** 新增/编辑表单 */ +export interface UserForm { + id?: number + username: string + nickname: string + mobile: string + email: string + status: UserStatus + roleNames: string[] +} diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000..7c46ea2 --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,92 @@ +import axios, { + AxiosError, + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, +} from 'axios' +import router from '@/router' +import type { ApiResponse } from '@/types/common' +import { getToken, removeToken } from '@/utils/storage' + +/** 业务成功状态码 */ +export const SUCCESS_CODE = '00000' +/** Token 失效 / 未授权 */ +export const TOKEN_INVALID_CODE = 'A0230' + +const service: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_APP_BASE_API, + timeout: 50000, + headers: { 'Content-Type': 'application/json;charset=utf-8' }, +}) + +/** 防止并发请求重复触发登录过期弹窗 */ +let unauthorizedHandling = false + +function handleUnauthorized(msg: string): void { + removeToken() + if (unauthorizedHandling) return + unauthorizedHandling = true + + ElMessageBox.alert(msg || '登录状态已过期,请重新登录', '提示', { type: 'warning' }) + .catch(() => undefined) + .finally(() => { + unauthorizedHandling = false + const redirect = encodeURIComponent(router.currentRoute.value.fullPath) + void router.replace(`/login?redirect=${redirect}`) + }) +} + +// 请求拦截器 +service.interceptors.request.use((config) => { + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse): unknown => { + // 文件流直接透传,不做业务码判断 + if (response.config.responseType === 'blob') { + return response.data + } + + const { code, data, msg } = response.data + + if (code === SUCCESS_CODE) { + return data + } + + if (code === TOKEN_INVALID_CODE) { + handleUnauthorized(msg) + return Promise.reject(new Error(msg)) + } + + ElMessage.error(msg || '请求失败,请稍后重试') + return Promise.reject(new Error(msg || '请求失败')) + }, + (error: AxiosError) => { + const status = error.response?.status + const msg = error.response?.data?.msg || error.message + + if (status === 401) { + handleUnauthorized(msg) + } else if (status === 403) { + ElMessage.error('没有权限访问该资源') + } else { + ElMessage.error(msg || '网络异常,请稍后重试') + } + + return Promise.reject(error) + }, +) + +/** + * 统一请求入口 + * @returns 直接返回业务数据(已在响应拦截器中剥离外层结构) + */ +export default function request(config: AxiosRequestConfig): Promise { + return service.request(config) +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..d014439 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,14 @@ +const TOKEN_KEY = 'portal_token' + +/** 读取 Token(SSR / 隐私模式下 localStorage 可能不可用,需容错) */ +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +export function removeToken(): void { + localStorage.removeItem(TOKEN_KEY) +}