- 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 管理系统
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=/prod-api
|
||||
VITE_APP_BASE_API=/prod-api
|
||||
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" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, any>
|
||||
export default component
|
||||
export interface RouteMeta {
|
||||
title?: string
|
||||
icon?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
@ -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