This commit is contained in:
2026-10-25 23:25:54 +08:00
parent c178ea5160
commit c5a6ada6a6
19 changed files with 679 additions and 0 deletions
+1
View File
@@ -23,6 +23,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"pinia": "^2.2.4", "pinia": "^2.2.4",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.5.12", "vue": "^3.5.12",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
}, },
+27
View File
@@ -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<LoginResult>({ url: `${BASE_URL}/login`, method: 'post', data })
},
/** 登出 */
logout() {
return request<boolean>({ url: `${BASE_URL}/logout`, method: 'post' })
},
/** 获取当前登录用户信息(含角色与权限) */
getMe() {
return request<UserInfo>({ url: `${BASE_URL}/me`, method: 'get' })
},
/** 获取当前用户可访问的菜单路由 */
getRoutes() {
return request<RouteVO[]>({ url: `${BASE_URL}/routes`, method: 'get' })
},
}
+32
View File
@@ -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<PageResult<UserVO>>({ url: `${BASE_URL}/page`, method: 'get', params })
},
/** 用户详情 */
detail(id: number) {
return request<UserVO>({ url: `${BASE_URL}/${id}`, method: 'get' })
},
/** 新增用户 */
create(data: UserForm) {
return request<number>({ url: BASE_URL, method: 'post', data })
},
/** 修改用户 */
update(id: number, data: UserForm) {
return request<boolean>({ url: `${BASE_URL}/${id}`, method: 'put', data })
},
/** 删除用户,ids 为逗号分隔 */
delete(ids: string) {
return request<boolean>({ url: `${BASE_URL}/${ids}`, method: 'delete' })
},
}
+8
View File
@@ -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)
}
+35
View File
@@ -0,0 +1,35 @@
import type { Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/store'
type AuthValue = string | string[]
function normalize(binding: DirectiveBinding<AuthValue>): string[] {
const value = binding.value
if (Array.isArray(value)) return value
return value ? [value] : []
}
/** 校验不通过时直接移除 DOM 节点,而非仅禁用 */
function check(el: HTMLElement, binding: DirectiveBinding<AuthValue>, 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<HTMLElement, AuthValue> = {
mounted(el, binding) {
check(el, binding, (code) => useUserStore().hasPermission(code))
},
}
/** 角色级权限:v-hasRole="'ADMIN'" */
export const hasRole: Directive<HTMLElement, AuthValue> = {
mounted(el, binding) {
check(el, binding, (code) => useUserStore().hasRole(code))
},
}
+14
View File
@@ -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<UserStatus>[] = [
{ label: '启用', value: UserStatusEnum.ENABLED },
{ label: '禁用', value: UserStatusEnum.DISABLED },
]
+88
View File
@@ -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
+12
View File
@@ -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'
+24
View File
@@ -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<DeviceType>('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'] },
},
)
+81
View File
@@ -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<RouteRecordRaw[]>([])
const isLoaded = ref(false)
async function generateRoutes(): Promise<RouteRecordRaw[]> {
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 }
})
+26
View File
@@ -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'] },
},
)
+70
View File
@@ -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<UserInfo | null>(null)
const roles = ref<string[]>([])
const permissions = ref<string[]>([])
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,
}
})
+44
View File
@@ -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;
}
+30
View File
@@ -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[] }
+25
View File
@@ -0,0 +1,25 @@
/** 后端统一响应结构 */
export interface ApiResponse<T = unknown> {
/** 业务状态码,00000 表示成功 */
code: string
data: T
msg: string
}
/** 分页查询基础参数 */
export interface PageQuery {
pageNum: number
pageSize: number
}
/** 分页响应结构 */
export interface PageResult<T> {
list: T[]
total: number
}
/** 下拉选项 */
export interface Option<T = number> {
label: string
value: T
}
+21
View File
@@ -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[]
}
+35
View File
@@ -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[]
}
+92
View File
@@ -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<ApiResponse>): 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<ApiResponse>) => {
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<T>(config: AxiosRequestConfig): Promise<T> {
return service.request<unknown, T>(config)
}
+14
View File
@@ -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)
}