- Vue Router 4 with navigation guards and dynamic route loading - Pinia stores for user, permission, and app state management - Axios API layer with request/response interceptors - Permission control (v-hasPermi, v-hasRole directives) - Utility functions (auth, validate, permission) - Layout component with sidebar, navbar, and breadcrumbs - Error pages (401, 404) and redirect view - Login API integration with user storefeature/20260628/refactor-frontend-modernization
parent
6762d22d8e
commit
2eb6a4444e
@ -1,3 +1,3 @@
|
|||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
VITE_API_BASE_URL=/dev-api
|
VITE_APP_BASE_API=/dev-api
|
||||||
VITE_APP_TITLE=RuoYi 管理系统
|
VITE_APP_TITLE=RuoYi 管理系统
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
# 生产环境配置
|
# 生产环境配置
|
||||||
VITE_API_BASE_URL=/prod-api
|
VITE_APP_BASE_API=/prod-api
|
||||||
VITE_APP_TITLE=RuoYi 管理系统
|
VITE_APP_TITLE=RuoYi 管理系统
|
||||||
|
|||||||
@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Parent view for routes with children
|
||||||
|
</script>
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import type { Directive, DirectiveBinding } from 'vue'
|
||||||
|
import { hasPermission, hasRole } from '@/utils/permission'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v-hasPermi 权限指令
|
||||||
|
* 用法: <el-button v-hasPermi="'system:user:add'">新增</el-button>
|
||||||
|
* <el-button v-hasPermi="['system:user:add', 'system:user:edit']">操作</el-button>
|
||||||
|
*/
|
||||||
|
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 角色指令
|
||||||
|
* 用法: <div v-hasRole="'admin'">管理员可见</div>
|
||||||
|
* <div v-hasRole="['admin', 'editor']">管理员或编辑可见</div>
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-wrapper" :class="{ 'sidebar-collapsed': appStore.sidebarCollapsed }">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<h1>RuoYi</h1>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeMenu"
|
||||||
|
:collapse="appStore.sidebarCollapsed"
|
||||||
|
background-color="var(--bg1)"
|
||||||
|
text-color="var(--t2)"
|
||||||
|
active-text-color="var(--primary)"
|
||||||
|
router
|
||||||
|
>
|
||||||
|
<template v-for="route in permissionStore.sidebarRoutes" :key="route.path">
|
||||||
|
<el-menu-item v-if="!route.children || route.children.length === 0" :index="resolvePath(route.path)">
|
||||||
|
<el-icon v-if="route.meta?.icon">
|
||||||
|
<component :is="route.meta.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>{{ route.meta?.title }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-sub-menu v-else :index="resolvePath(route.path)">
|
||||||
|
<template #title>
|
||||||
|
<el-icon v-if="route.meta?.icon">
|
||||||
|
<component :is="route.meta.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ route.meta?.title }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item
|
||||||
|
v-for="child in route.children"
|
||||||
|
:key="child.path"
|
||||||
|
:index="resolvePath(route.path, child.path)"
|
||||||
|
>
|
||||||
|
{{ child.meta?.title }}
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Container -->
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- Navbar -->
|
||||||
|
<header class="navbar">
|
||||||
|
<div class="navbar-left">
|
||||||
|
<el-icon class="hamburger" @click="appStore.toggleSidebar">
|
||||||
|
<Fold v-if="!appStore.sidebarCollapsed" />
|
||||||
|
<Expand v-else />
|
||||||
|
</el-icon>
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||||
|
{{ item.meta?.title }}
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-right">
|
||||||
|
<el-dropdown @command="handleCommand">
|
||||||
|
<span class="user-info">
|
||||||
|
<el-avatar :size="28" :src="userStore.userInfo?.avatar" />
|
||||||
|
<span class="username">{{ userStore.userInfo?.userName }}</span>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- App Main -->
|
||||||
|
<main class="app-main">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<keep-alive :include="cachedViews">
|
||||||
|
<component :is="Component" />
|
||||||
|
</keep-alive>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Fold, Expand } from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
import { useAppStore } from '@/stores/modules/app'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const activeMenu = computed(() => route.path)
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return route.matched.filter(item => item.meta?.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
const cachedViews = computed(() => permissionStore.cachedViews)
|
||||||
|
|
||||||
|
function resolvePath(parentPath: string, childPath?: string): string {
|
||||||
|
if (!childPath) return parentPath
|
||||||
|
if (parentPath.endsWith('/')) return `${parentPath}${childPath}`
|
||||||
|
return `${parentPath}/${childPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(command: string) {
|
||||||
|
if (command === 'logout') {
|
||||||
|
await userStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
} else if (command === 'profile') {
|
||||||
|
// TODO: navigate to profile page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-wrapper {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 210px;
|
||||||
|
background-color: var(--bg1);
|
||||||
|
border-right: 1px solid var(--bd);
|
||||||
|
transition: width 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu {
|
||||||
|
flex: 1;
|
||||||
|
border-right: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background-color: var(--bg1);
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
|
||||||
|
.navbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.hamburger {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--t1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right {
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--t1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--bg0);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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<boolean>(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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -1,7 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
export interface RouteMeta {
|
||||||
|
title?: string
|
||||||
declare module '*.vue' {
|
icon?: string
|
||||||
import type { DefineComponent } from 'vue'
|
hidden?: boolean
|
||||||
const component: DefineComponent<object, object, any>
|
|
||||||
export default component
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-content">
|
||||||
|
<h1 class="error-code">404</h1>
|
||||||
|
<p class="error-message">页面不存在</p>
|
||||||
|
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 404 error page
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--t2);
|
||||||
|
margin: 16px 0 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const { params, query } = route
|
||||||
|
const { path } = params
|
||||||
|
router.replace({ path: `/${path}`, query })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Loading…
Reference in new issue