diff --git a/ruoyi-ui-next/.env.development b/ruoyi-ui-next/.env.development index 20b5c2f..f478b40 100644 --- a/ruoyi-ui-next/.env.development +++ b/ruoyi-ui-next/.env.development @@ -1,3 +1,3 @@ # 开发环境配置 -VITE_API_BASE_URL=/dev-api +VITE_APP_BASE_API=/dev-api VITE_APP_TITLE=RuoYi 管理系统 diff --git a/ruoyi-ui-next/.env.production b/ruoyi-ui-next/.env.production index 59864c0..90a8298 100644 --- a/ruoyi-ui-next/.env.production +++ b/ruoyi-ui-next/.env.production @@ -1,3 +1,3 @@ # 生产环境配置 -VITE_API_BASE_URL=/prod-api +VITE_APP_BASE_API=/prod-api VITE_APP_TITLE=RuoYi 管理系统 diff --git a/ruoyi-ui-next/src/api/modules/login.ts b/ruoyi-ui-next/src/api/modules/login.ts new file mode 100644 index 0000000..177ef8f --- /dev/null +++ b/ruoyi-ui-next/src/api/modules/login.ts @@ -0,0 +1,42 @@ +import request from '@/api/request' + +/** + * 登录 + */ +export function login(username: string, password: string, code?: string, uuid?: string) { + return request({ + url: '/login', + method: 'post', + data: { username, password, code, uuid }, + }) +} + +/** + * 获取用户信息 + */ +export function getInfo() { + return request({ + url: '/getInfo', + method: 'get', + }) +} + +/** + * 退出登录 + */ +export function logout() { + return request({ + url: '/logout', + method: 'post', + }) +} + +/** + * 获取验证码 + */ +export function getCodeImg() { + return request({ + url: '/captchaImage', + method: 'get', + }) +} diff --git a/ruoyi-ui-next/src/api/modules/menu.ts b/ruoyi-ui-next/src/api/modules/menu.ts new file mode 100644 index 0000000..2f1b978 --- /dev/null +++ b/ruoyi-ui-next/src/api/modules/menu.ts @@ -0,0 +1,11 @@ +import request from '@/api/request' + +/** + * 获取路由(菜单) + */ +export function getRouters() { + return request({ + url: '/getRouters', + method: 'get', + }) +} diff --git a/ruoyi-ui-next/src/api/modules/system/user.ts b/ruoyi-ui-next/src/api/modules/system/user.ts new file mode 100644 index 0000000..da005b0 --- /dev/null +++ b/ruoyi-ui-next/src/api/modules/system/user.ts @@ -0,0 +1,88 @@ +import request from '@/api/request' + +/** + * 查询用户列表 + */ +export function getUserList(params: any) { + return request({ + url: '/system/user/list', + method: 'get', + params, + }) +} + +/** + * 查询用户详情 + */ +export function getUser(userId: number | string) { + return request({ + url: `/system/user/${userId}`, + method: 'get', + }) +} + +/** + * 新增用户 + */ +export function addUser(data: any) { + return request({ + url: '/system/user', + method: 'post', + data, + }) +} + +/** + * 修改用户 + */ +export function updateUser(data: any) { + return request({ + url: '/system/user', + method: 'put', + data, + }) +} + +/** + * 删除用户 + */ +export function delUser(userId: number | string | (number | string)[]) { + return request({ + url: `/system/user/${userId}`, + method: 'delete', + }) +} + +/** + * 重置用户密码 + */ +export function resetUserPwd(userId: number | string, password: string) { + return request({ + url: `/system/user/resetPwd`, + method: 'put', + data: { userId, password }, + }) +} + +/** + * 修改用户状态 + */ +export function changeUserStatus(userId: number | string, status: string) { + return request({ + url: `/system/user/changeStatus`, + method: 'put', + data: { userId, status }, + }) +} + +/** + * 导出用户 + */ +export function exportUser(params: any) { + return request({ + url: '/system/user/export', + method: 'post', + data: params, + responseType: 'blob', + }) +} diff --git a/ruoyi-ui-next/src/api/request.ts b/ruoyi-ui-next/src/api/request.ts index acbb9fc..0e707cf 100644 --- a/ruoyi-ui-next/src/api/request.ts +++ b/ruoyi-ui-next/src/api/request.ts @@ -1,10 +1,11 @@ import axios from 'axios' -import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' -import { ElMessage } from 'element-plus' +import type { AxiosInstance, AxiosResponse } from 'axios' +import { ElMessage, ElMessageBox } from 'element-plus' +import { getToken, removeToken } from '@/utils/auth' const service: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || '/dev-api', - timeout: 15000, + baseURL: import.meta.env.VITE_APP_BASE_API || '/dev-api', + timeout: 60000, headers: { 'Content-Type': 'application/json;charset=utf-8', }, @@ -13,10 +14,23 @@ const service: AxiosInstance = axios.create({ // 请求拦截器 service.interceptors.request.use( (config) => { - // 添加 token - const token = localStorage.getItem('token') + // 自动注入 Token + const token = getToken() if (token) { - config.headers.Authorization = `Bearer ${token}` + config.headers['Authorization'] = `Bearer ${token}` + } + // GET 请求参数处理 + if (config.method === 'get' && config.params) { + const url = config.url || '' + const separator = url.includes('?') ? '&' : '?' + const queryString = Object.entries(config.params) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v as string)}`) + .join('&') + if (queryString) { + config.url = `${url}${separator}${queryString}` + } + config.params = {} } return config }, @@ -28,22 +42,39 @@ service.interceptors.request.use( // 响应拦截器 service.interceptors.response.use( (response: AxiosResponse) => { - const { code, msg, data } = response.data + const res = response.data + + // 二进制数据直接返回 + if (res instanceof Blob || response.config.responseType === 'blob') { + return res + } + + const { code, msg, data } = res if (code === 200) { - return data + return { data, msg, code } } else if (code === 401) { - ElMessage.error('登录已过期,请重新登录') - localStorage.removeItem('token') - window.location.href = '/login' + ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', { + confirmButtonText: '重新登录', + cancelButtonText: '取消', + type: 'warning', + }) + .then(() => { + removeToken() + window.location.href = '/login' + }) + .catch(() => {}) return Promise.reject(new Error(msg || '登录已过期')) + } else if (code === 500) { + ElMessage.error(msg || '系统内部错误') + return Promise.reject(new Error(msg || '系统内部错误')) } else { ElMessage.error(msg || '请求失败') return Promise.reject(new Error(msg || '请求失败')) } }, (error) => { - let message = '网络错误' + let message = '网络异常' if (error.response) { switch (error.response.status) { case 401: @@ -59,31 +90,16 @@ service.interceptors.response.use( message = '服务器内部错误' break default: - message = `连接错误${error.response.status}` + message = `连接错误 ${error.response.status}` } } else if (error.code === 'ECONNABORTED') { message = '请求超时' + } else { + message = error.message || '网络异常' } ElMessage.error(message) return Promise.reject(error) } ) -// 封装请求方法 -export const get = (url: string, params?: object, config?: AxiosRequestConfig): Promise => { - return service.get(url, { params, ...config }) -} - -export const post = (url: string, data?: object, config?: AxiosRequestConfig): Promise => { - return service.post(url, data, config) -} - -export const put = (url: string, data?: object, config?: AxiosRequestConfig): Promise => { - return service.put(url, data, config) -} - -export const del = (url: string, params?: object, config?: AxiosRequestConfig): Promise => { - return service.delete(url, { params, ...config }) -} - export default service diff --git a/ruoyi-ui-next/src/components.d.ts b/ruoyi-ui-next/src/components.d.ts index 073f3e2..4735919 100644 --- a/ruoyi-ui-next/src/components.d.ts +++ b/ruoyi-ui-next/src/components.d.ts @@ -11,12 +11,23 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] + ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] HelloWorld: typeof import('./components/HelloWorld.vue')['default'] + ParentView: typeof import('./components/ParentView.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Sidebar: typeof import('./components/layout/Sidebar/index.vue')['default'] diff --git a/ruoyi-ui-next/src/components/ParentView.vue b/ruoyi-ui-next/src/components/ParentView.vue new file mode 100644 index 0000000..22181e1 --- /dev/null +++ b/ruoyi-ui-next/src/components/ParentView.vue @@ -0,0 +1,7 @@ + + + diff --git a/ruoyi-ui-next/src/directives/permission.ts b/ruoyi-ui-next/src/directives/permission.ts new file mode 100644 index 0000000..413a560 --- /dev/null +++ b/ruoyi-ui-next/src/directives/permission.ts @@ -0,0 +1,38 @@ +import type { Directive, DirectiveBinding } from 'vue' +import { hasPermission, hasRole } from '@/utils/permission' + +/** + * v-hasPermi 权限指令 + * 用法: 新增 + * 操作 + */ +export const hasPermi: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + const { value } = binding + if (value) { + const permissions = Array.isArray(value) ? value : [value] + const hasPerm = permissions.some((p: string) => hasPermission(p)) + if (!hasPerm) { + el.parentNode?.removeChild(el) + } + } + }, +} + +/** + * v-hasRole 角色指令 + * 用法:
管理员可见
+ *
管理员或编辑可见
+ */ +export const hasRoleDir: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + const { value } = binding + if (value) { + const roles = Array.isArray(value) ? value : [value] + const hasRolePerm = roles.some((r: string) => hasRole(r)) + if (!hasRolePerm) { + el.parentNode?.removeChild(el) + } + } + }, +} diff --git a/ruoyi-ui-next/src/layout/index.vue b/ruoyi-ui-next/src/layout/index.vue new file mode 100644 index 0000000..576b09e --- /dev/null +++ b/ruoyi-ui-next/src/layout/index.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/ruoyi-ui-next/src/main.ts b/ruoyi-ui-next/src/main.ts index 9c46ef2..12b07e9 100644 --- a/ruoyi-ui-next/src/main.ts +++ b/ruoyi-ui-next/src/main.ts @@ -1,15 +1,13 @@ import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' -import 'element-plus/theme-chalk/dark/css-vars.css' import * as ElementPlusIconsVue from '@element-plus/icons-vue' -import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' import router from './router' -import { createPinia } from 'pinia' +import pinia from './stores' -import '@/styles/index.scss' +import { hasPermi, hasRoleDir } from '@/directives/permission' const app = createApp(App) @@ -18,8 +16,12 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } -app.use(createPinia()) +// Register custom directives +app.directive('hasPermi', hasPermi) +app.directive('hasRole', hasRoleDir) + +app.use(pinia) app.use(router) -app.use(ElementPlus, { locale: zhCn }) +app.use(ElementPlus) app.mount('#app') diff --git a/ruoyi-ui-next/src/router/constant.ts b/ruoyi-ui-next/src/router/constant.ts index 2d44baf..6ebed11 100644 --- a/ruoyi-ui-next/src/router/constant.ts +++ b/ruoyi-ui-next/src/router/constant.ts @@ -1,7 +1,7 @@ import type { RouteRecordRaw } from 'vue-router' /** - * 静态路由(无需权限) + * 静态路由(无需权限即可访问) */ export const constantRoutes: RouteRecordRaw[] = [ { @@ -11,15 +11,40 @@ export const constantRoutes: RouteRecordRaw[] = [ meta: { title: '登录', hidden: true }, }, { - path: '/', - name: 'Home', - component: () => import('@/views/index.vue'), - meta: { title: '首页' }, + path: '/redirect/:path(.*)', + name: 'Redirect', + component: () => import('@/views/redirect.vue'), + meta: { hidden: true }, }, { - path: '/:pathMatch(.*)*', + path: '/404', name: 'NotFound', - component: () => import('@/views/404.vue'), + component: () => import('@/views/error/404.vue'), meta: { title: '404', hidden: true }, }, + { + path: '/401', + name: 'Unauthorized', + component: () => import('@/views/error/401.vue'), + meta: { title: '401', hidden: true }, + }, + { + path: '/', + component: () => import('@/layout/index.vue'), + redirect: '/index', + children: [ + { + path: 'index', + name: 'Index', + component: () => import('@/views/index.vue'), + meta: { title: '首页', icon: 'HomeFilled', affix: true }, + }, + ], + }, + // Catch-all 404 - must be added last + { + path: '/:pathMatch(.*)*', + redirect: '/404', + meta: { hidden: true }, + }, ] diff --git a/ruoyi-ui-next/src/router/dynamic.ts b/ruoyi-ui-next/src/router/dynamic.ts index a8e23fb..c4fbce4 100644 --- a/ruoyi-ui-next/src/router/dynamic.ts +++ b/ruoyi-ui-next/src/router/dynamic.ts @@ -1,22 +1,52 @@ import type { RouteRecordRaw } from 'vue-router' /** - * 动态路由(根据权限动态添加) + * 动态路由配置(服务端返回的菜单转换后的路由) + * + * 路由加载流程: + * 1. 用户登录成功 + * 2. 调用 /getRouters 获取菜单列表 + * 3. 使用 usePermissionStore.generateRoutes() 将菜单转换为路由 + * 4. 通过 router.addRoute() 动态注册路由 + * + * 后端菜单格式: + * { + * path: '/system', + * menuName: '系统管理', + * component: 'Layout', + * icon: 'Setting', + * visible: '0', // '0' 显示, '1' 隐藏 + * children: [...] + * } */ -export const dynamicRoutes: RouteRecordRaw[] = [ - // 示例:系统管理路由 - // { - // path: '/system', - // name: 'System', - // component: () => import('@/components/layout/index.vue'), - // meta: { title: '系统管理' }, - // children: [ - // { - // path: 'user', - // name: 'SystemUser', - // component: () => import('@/views/system/user/index.vue'), - // meta: { title: '用户管理' }, - // }, - // ], - // }, -] + +// 动态路由占位(实际由 generateRoutes 从服务端获取) +export const dynamicRoutes: RouteRecordRaw[] = [] + +/** + * 根据角色过滤路由 + * @param routes 路由列表 + * @param roles 用户角色 + */ +export function filterAsyncRoutes(routes: RouteRecordRaw[], roles: string[]): RouteRecordRaw[] { + const result: RouteRecordRaw[] = [] + + routes.forEach(route => { + const tmp = { ...route } + if (hasPermission(roles, tmp)) { + if (tmp.children) { + tmp.children = filterAsyncRoutes(tmp.children, roles) + } + result.push(tmp) + } + }) + + return result +} + +function hasPermission(roles: string[], route: RouteRecordRaw): boolean { + if (!route.meta?.roles) { + return true + } + return roles.some(role => (route.meta?.roles as string[])?.includes(role)) +} diff --git a/ruoyi-ui-next/src/router/index.ts b/ruoyi-ui-next/src/router/index.ts index 945d5f0..500d2b6 100644 --- a/ruoyi-ui-next/src/router/index.ts +++ b/ruoyi-ui-next/src/router/index.ts @@ -1,6 +1,12 @@ import { createRouter, createWebHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import { constantRoutes } from './constant' +import { getToken } from '@/utils/auth' +import { useUserStore } from '@/stores/modules/user' +import { usePermissionStore } from '@/stores/modules/permission' + +// White list of paths that don't require authentication +const whiteList = ['/login', '/404', '/401', '/register'] const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -8,4 +14,52 @@ const router = createRouter({ scrollBehavior: () => ({ top: 0 }), }) +// Navigation guard +router.beforeEach(async (to, _from, next) => { + const token = getToken() + const userStore = useUserStore() + const permissionStore = usePermissionStore() + + if (token) { + // Already logged in + if (to.path === '/login') { + next({ path: '/' }) + } else { + // Check if user info has been loaded + if (userStore.roles.length === 0) { + try { + // Get user info + await userStore.getInfo() + + // Generate dynamic routes + const accessRoutes = await permissionStore.generateRoutes() + + // Add dynamic routes to router + accessRoutes.forEach(route => { + if (!router.hasRoute(route.name || route.path)) { + router.addRoute(route) + } + }) + + // Re-navigate to the target route with newly added routes + next({ ...to, replace: true }) + } catch (error) { + // Failed to get user info, reset token and redirect to login + await userStore.fedLogout() + next(`/login?redirect=${to.path}`) + } + } else { + next() + } + } + } else { + // No token + if (whiteList.includes(to.path)) { + next() + } else { + next(`/login?redirect=${to.path}`) + } + } +}) + export default router diff --git a/ruoyi-ui-next/src/stores/modules/app.ts b/ruoyi-ui-next/src/stores/modules/app.ts new file mode 100644 index 0000000..0ef17e6 --- /dev/null +++ b/ruoyi-ui-next/src/stores/modules/app.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +const SIDEBAR_STATUS_KEY = 'sidebarStatus' + +function getSidebarStatus(): boolean { + const status = localStorage.getItem(SIDEBAR_STATUS_KEY) + return status !== null ? status === '1' : false +} + +export const useAppStore = defineStore('app', () => { + const sidebarCollapsed = ref(getSidebarStatus()) + const device = ref<'desktop' | 'mobile'>('desktop') + const size = ref<'default' | 'large' | 'small'>('default') + + function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value + localStorage.setItem(SIDEBAR_STATUS_KEY, sidebarCollapsed.value ? '1' : '0') + } + + function closeSidebar() { + sidebarCollapsed.value = true + localStorage.setItem(SIDEBAR_STATUS_KEY, '1') + } + + function openSidebar() { + sidebarCollapsed.value = false + localStorage.setItem(SIDEBAR_STATUS_KEY, '0') + } + + function setDevice(val: 'desktop' | 'mobile') { + device.value = val + } + + function setSize(val: 'default' | 'large' | 'small') { + size.value = val + localStorage.setItem('size', val) + } + + return { + sidebarCollapsed, + device, + size, + toggleSidebar, + closeSidebar, + openSidebar, + setDevice, + setSize, + } +}) diff --git a/ruoyi-ui-next/src/stores/modules/permission.ts b/ruoyi-ui-next/src/stores/modules/permission.ts new file mode 100644 index 0000000..3a098ee --- /dev/null +++ b/ruoyi-ui-next/src/stores/modules/permission.ts @@ -0,0 +1,160 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +import { constantRoutes } from '@/router/constant' +import request from '@/api/request' + +// Component loader for dynamic routes +const modules = import.meta.glob('@/views/**/*.vue') + +// Layout component - lazy import +const Layout = () => import('@/layout/index.vue') +const ParentView = () => import('@/components/ParentView.vue') + +export interface RouteMeta { + title?: string + icon?: string + hidden?: boolean + noCache?: boolean + affix?: boolean + link?: string +} + +export const usePermissionStore = defineStore('permission', () => { + const routes = ref([]) + const addRoutes = ref([]) + const sidebarRoutes = ref([]) + const cachedViews = ref([]) + + /** + * 生成路由(从后端获取菜单) + */ + async function generateRoutes(): Promise { + try { + const res = await request({ + url: '/getRouters', + method: 'get', + }) + const rawMenus = res.data || res + const accessedRoutes = filterAsyncRouter(rawMenus) + + routes.value = [...constantRoutes, ...accessedRoutes] + addRoutes.value = accessedRoutes + sidebarRoutes.value = filterHiddenRoutes(accessedRoutes) + + return accessedRoutes + } catch (error) { + console.error('Failed to generate routes:', error) + return [] + } + } + + /** + * 将后端菜单数据转换为路由对象 + */ + function filterAsyncRouter(menuList: any[]): RouteRecordRaw[] { + return menuList + .filter((menu: any) => menu.visible !== '1' || menu.children) + .map((menu: any) => { + const route: any = { + path: menu.path, + name: menu.name || convertRouteName(menu.path), + meta: { + title: menu.menuName || menu.meta?.title, + icon: menu.icon || menu.meta?.icon, + hidden: menu.visible === '1', + noCache: menu.noCache === true, + link: menu.isFrame === 0 ? menu.path : undefined, + }, + } + + // Handle component + if (menu.component === 'Layout') { + route.component = Layout + } else if (menu.component === 'ParentView') { + route.component = ParentView + } else if (menu.component) { + const componentPath = menu.component.startsWith('/') + ? menu.component + : `/src/views/${menu.component}.vue` + const componentLoader = modules[componentPath] + route.component = componentLoader || (() => import('@/views/error/404.vue')) + } + + // Handle children + if (menu.children && menu.children.length > 0) { + route.children = filterAsyncRouter(menu.children) + } + + // Handle redirect + if (menu.redirect) { + route.redirect = menu.redirect + } + + return route as RouteRecordRaw + }) + } + + /** + * 过滤隐藏的菜单项 + */ + function filterHiddenRoutes(routeList: RouteRecordRaw[]): RouteRecordRaw[] { + return routeList + .filter(r => !r.meta?.hidden) + .map(r => ({ + ...r, + children: r.children ? filterHiddenRoutes(r.children) : undefined, + })) as RouteRecordRaw[] + } + + /** + * Convert path to route name (e.g., /system/user -> SystemUser) + */ + function convertRouteName(path: string): string { + return path + .split('/') + .filter(Boolean) + .map(segment => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join('') + } + + /** + * 添加缓存视图 + */ + function addCachedView(name: string) { + if (name && !cachedViews.value.includes(name)) { + cachedViews.value.push(name) + } + } + + /** + * 移除缓存视图 + */ + function delCachedView(name: string) { + const index = cachedViews.value.indexOf(name) + if (index > -1) { + cachedViews.value.splice(index, 1) + } + } + + /** + * 重置状态 + */ + function resetRoutes() { + routes.value = [] + addRoutes.value = [] + sidebarRoutes.value = [] + cachedViews.value = [] + } + + return { + routes, + addRoutes, + sidebarRoutes, + cachedViews, + generateRoutes, + addCachedView, + delCachedView, + resetRoutes, + } +}) diff --git a/ruoyi-ui-next/src/stores/modules/user.ts b/ruoyi-ui-next/src/stores/modules/user.ts index 32e0cc1..6e180aa 100644 --- a/ruoyi-ui-next/src/stores/modules/user.ts +++ b/ruoyi-ui-next/src/stores/modules/user.ts @@ -1,5 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { getToken, setToken, removeToken } from '@/utils/auth' +import axios from 'axios' + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_APP_BASE_API || '/dev-api', + timeout: 60000, +}) export interface UserInfo { userId: number @@ -11,27 +18,56 @@ export interface UserInfo { } export const useUserStore = defineStore('user', () => { - const token = ref('') + const token = ref(getToken()) const userInfo = ref(null) + const roles = ref([]) + const permissions = ref([]) + + async function login(username: string, password: string, code?: string, uuid?: string) { + const { data } = await apiClient.post('/login', { username, password, code, uuid }) + setToken(data.token) + token.value = data.token + return data + } + + async function getInfo() { + const { data } = await apiClient.get('/getInfo') + const user = data.user + userInfo.value = user + roles.value = user.roles || [] + permissions.value = user.permissions || [] + return data + } - function setToken(newToken: string) { - token.value = newToken + async function logout() { + try { + await apiClient.post('/logout') + } finally { + resetState() + } } - function setUserInfo(info: UserInfo) { - userInfo.value = info + async function fedLogout() { + resetState() } - function logout() { + function resetState() { token.value = '' userInfo.value = null + roles.value = [] + permissions.value = [] + removeToken() } return { token, userInfo, - setToken, - setUserInfo, + roles, + permissions, + login, + getInfo, logout, + fedLogout, + resetState, } }) diff --git a/ruoyi-ui-next/src/types/index.ts b/ruoyi-ui-next/src/types/index.ts index 356c5d6..5fc6a5b 100644 --- a/ruoyi-ui-next/src/types/index.ts +++ b/ruoyi-ui-next/src/types/index.ts @@ -1,7 +1,5 @@ -/// - -declare module '*.vue' { - import type { DefineComponent } from 'vue' - const component: DefineComponent - export default component +export interface RouteMeta { + title?: string + icon?: string + hidden?: boolean } diff --git a/ruoyi-ui-next/src/utils/auth.ts b/ruoyi-ui-next/src/utils/auth.ts new file mode 100644 index 0000000..f1207a8 --- /dev/null +++ b/ruoyi-ui-next/src/utils/auth.ts @@ -0,0 +1,13 @@ +const TOKEN_KEY = 'Admin-Token' + +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) +} diff --git a/ruoyi-ui-next/src/utils/permission.ts b/ruoyi-ui-next/src/utils/permission.ts new file mode 100644 index 0000000..b185e8a --- /dev/null +++ b/ruoyi-ui-next/src/utils/permission.ts @@ -0,0 +1,48 @@ +import { useUserStore } from '@/stores/modules/user' + +/** + * 检查用户是否拥有指定权限 + */ +export function hasPermission(permission: string): boolean { + const userStore = useUserStore() + const allPermission = '*:*:*' + + if (userStore.roles.includes('admin')) { + return true + } + + return userStore.permissions.includes(allPermission) || userStore.permissions.includes(permission) +} + +/** + * 检查用户是否拥有指定角色 + */ +export function hasRole(role: string): boolean { + const userStore = useUserStore() + + if (userStore.roles.includes('admin')) { + return true + } + + return userStore.roles.includes(role) +} + +/** + * 检查权限(支持数组,满足其一即可) + */ +export function checkPermi(permissions: string[]): boolean { + if (!permissions || permissions.length === 0) { + return false + } + return permissions.some(p => hasPermission(p)) +} + +/** + * 检查角色(支持数组,满足其一即可) + */ +export function checkRole(roles: string[]): boolean { + if (!roles || roles.length === 0) { + return false + } + return roles.some(r => hasRole(r)) +} diff --git a/ruoyi-ui-next/src/utils/validate.ts b/ruoyi-ui-next/src/utils/validate.ts new file mode 100644 index 0000000..b65055a --- /dev/null +++ b/ruoyi-ui-next/src/utils/validate.ts @@ -0,0 +1,34 @@ +/** + * 判断是否为有效 URL + */ +export function isExternal(path: string): boolean { + return /^(https?:|mailto:|tel:)/.test(path) +} + +/** + * 判断是否为有效用户名 + */ +export function validUsername(str: string): boolean { + return str.trim().length >= 2 +} + +/** + * 判断是否为有效密码 + */ +export function validPassword(str: string): boolean { + return str.length >= 5 +} + +/** + * 判断是否为合法手机号 + */ +export function isPhone(str: string): boolean { + return /^1[3-9]\d{9}$/.test(str) +} + +/** + * 判断是否为合法邮箱 + */ +export function isEmail(str: string): boolean { + return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(str) +} diff --git a/ruoyi-ui-next/src/views/error/401.vue b/ruoyi-ui-next/src/views/error/401.vue new file mode 100644 index 0000000..0a0ef79 --- /dev/null +++ b/ruoyi-ui-next/src/views/error/401.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/ruoyi-ui-next/src/views/error/404.vue b/ruoyi-ui-next/src/views/error/404.vue new file mode 100644 index 0000000..68149e1 --- /dev/null +++ b/ruoyi-ui-next/src/views/error/404.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/ruoyi-ui-next/src/views/login.vue b/ruoyi-ui-next/src/views/login.vue index 85c8df5..60032b8 100644 --- a/ruoyi-ui-next/src/views/login.vue +++ b/ruoyi-ui-next/src/views/login.vue @@ -36,12 +36,11 @@ diff --git a/ruoyi-ui-next/tsconfig.app.json b/ruoyi-ui-next/tsconfig.app.json index 286c971..dafdc41 100644 --- a/ruoyi-ui-next/tsconfig.app.json +++ b/ruoyi-ui-next/tsconfig.app.json @@ -4,6 +4,7 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "types": ["vite/client"], "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "@/*": ["src/*"] },