init
This commit is contained in:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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'] },
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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'] },
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[] }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user