修改了 README
This commit is contained in:
1 parent
9ea096538b
commit
2b7e02108c
5 files changed
+496
-1
No files matched your search
@@ -1,4 +1,6 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# GERP
|
||||
|
||||
An ERP backend built using Vue, TypeScript, and Vite, handed over for use in the SAAS scenarios under GVSDS.
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getAvatarUrl } from '@/api/auth'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 顶部导航标题(取路由 meta.title)
|
||||
const navTitle = computed(() => (route.meta.title as string) || 'GERP')
|
||||
|
||||
// 当前激活的菜单路径
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
// 头像 URL
|
||||
const avatarUrl = getAvatarUrl()
|
||||
|
||||
onMounted(() => {
|
||||
appStore.initTheme()
|
||||
})
|
||||
|
||||
function handleMenuSelect(index: string): void {
|
||||
router.push(index)
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleToggleSidebar(): void {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function handleToggleTheme(): void {
|
||||
appStore.toggleTheme()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gerp-layout">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="gerp-header">
|
||||
<div class="gerp-header-left">
|
||||
<el-button text class="gerp-toggle-btn" @click="handleToggleSidebar">
|
||||
<el-icon :size="18"><Menu /></el-icon>
|
||||
</el-button>
|
||||
<div class="gerp-logo">
|
||||
<img src="/favicon.svg" alt="logo" class="gerp-logo-icon" />
|
||||
<span class="gerp-logo-text">GERP 管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gerp-header-center">
|
||||
<h1 class="gerp-nav-title">{{ navTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="gerp-header-right">
|
||||
<div class="gerp-header-actions">
|
||||
<el-button text circle title="搜索">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
<el-badge :value="3" class="gerp-notify-badge">
|
||||
<el-button text circle title="通知">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
<el-button text circle title="主题" @click="handleToggleTheme">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(cmd) => cmd === 'logout' && handleLogout()">
|
||||
<div class="gerp-user-info">
|
||||
<img :src="avatarUrl" alt="头像" class="gerp-user-avatar" />
|
||||
<span class="gerp-user-name">{{ userStore.displayName }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-icon><User /></el-icon>个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="gerp-main">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="gerp-sidebar" :class="{ collapsed: appStore.sidebarCollapsed }">
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="appStore.sidebarCollapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<template v-for="menu in userStore.menus" :key="menu.Path || menu.Title">
|
||||
<!-- 含子菜单 -->
|
||||
<el-sub-menu v-if="menu.Children?.length" :index="menu.Path || menu.Title">
|
||||
<template #title>
|
||||
<el-icon v-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
|
||||
<span>{{ menu.Title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in menu.Children"
|
||||
:key="child.Path || child.Title"
|
||||
:index="child.Path || ''"
|
||||
>
|
||||
<el-icon v-if="child.Icon"><component :is="child.Icon" /></el-icon>
|
||||
<template #title>{{ child.Title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 无子菜单 -->
|
||||
<el-menu-item v-else :index="menu.Path || ''">
|
||||
<el-icon v-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
|
||||
<template #title>{{ menu.Title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="gerp-content">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gerp-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gerp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--gerp-border-color);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.gerp-header-left,
|
||||
.gerp-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gerp-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.gerp-toggle-btn {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.gerp-nav-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gerp-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gerp-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.gerp-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.gerp-user-name {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.gerp-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gerp-sidebar {
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--gerp-border-color);
|
||||
transition: width 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gerp-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
background: var(--gerp-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
/** 静态路由(无需权限) */
|
||||
export const staticRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录', requiresAuth: false, layout: 'blank' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: { title: '个人中心', icon: 'User', requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { title: '页面不存在', requiresAuth: false, layout: 'blank' },
|
||||
},
|
||||
]
|
||||
|
||||
/** 动态路由(从后端菜单加载后注入) */
|
||||
export const dynamicRoutes: RouteRecordRaw[] = []
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: staticRoutes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
/** 全局前置守卫:登录态校验、动态路由注入 */
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = (to.meta.title as string) || 'GERP 管理系统'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 不需要登录的页面直接放行
|
||||
if (to.meta.requiresAuth === false) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录但未加载用户信息:尝试加载
|
||||
if (!userStore.isLoggedIn) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
await userStore.fetchMenus()
|
||||
// TODO: 注入动态路由后 next({ ...to, replace: true })
|
||||
} catch {
|
||||
// 加载失败:跳转登录
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 应用状态:侧边栏折叠、主题、语言等全局 UI 状态 */
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
/** 侧边栏是否折叠 */
|
||||
const sidebarCollapsed = ref(false)
|
||||
/** 当前主题:light / dark */
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
/** 当前语言 */
|
||||
const locale = ref<'zh-CN' | 'en-US'>('zh-CN')
|
||||
|
||||
/** 切换侧边栏 */
|
||||
function toggleSidebar(): void {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
/** 设置主题 */
|
||||
function setTheme(value: 'light' | 'dark'): void {
|
||||
theme.value = value
|
||||
document.documentElement.classList.toggle('dark', value === 'dark')
|
||||
localStorage.setItem('gerp-theme', value)
|
||||
}
|
||||
|
||||
/** 初始化主题(从 localStorage 恢复) */
|
||||
function initTheme(): void {
|
||||
const saved = localStorage.getItem('gerp-theme') as 'light' | 'dark' | null
|
||||
if (saved) setTheme(saved)
|
||||
}
|
||||
|
||||
/** 切换主题 */
|
||||
function toggleTheme(): void {
|
||||
setTheme(theme.value === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
locale,
|
||||
toggleSidebar,
|
||||
setTheme,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, MenuItem } from '@/types/api'
|
||||
import { getCurrentUser, getUserRoles, login as apiLogin, logout as apiLogout, type LoginParams } from '@/api/auth'
|
||||
import { getMenuList } from '@/api/menu'
|
||||
|
||||
/** 用户状态管理:当前登录用户、角色、菜单、权限 */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// === 状态 ===
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const roles = ref<string[]>([])
|
||||
const menus = ref<MenuItem[]>([])
|
||||
const token = ref<string>('')
|
||||
const loading = ref(false)
|
||||
|
||||
// === 计算属性 ===
|
||||
const isLoggedIn = computed(() => !!userInfo.value)
|
||||
const isAdmin = computed(() => userInfo.value?.IsAdmin === true)
|
||||
const currentRole = computed(() => userInfo.value?.CurrentRole || '')
|
||||
const avatar = computed(() => userInfo.value?.Avatar || '')
|
||||
const displayName = computed(() => userInfo.value?.DisplayName || userInfo.value?.UserName || '未登录')
|
||||
|
||||
/** 扁平化后的菜单(用于面包屑、菜单搜索) */
|
||||
const flatMenus = computed<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = []
|
||||
const walk = (items: MenuItem[]) => {
|
||||
for (const item of items) {
|
||||
result.push(item)
|
||||
if (item.Children?.length) walk(item.Children)
|
||||
}
|
||||
}
|
||||
walk(menus.value)
|
||||
return result
|
||||
})
|
||||
|
||||
// === 动作 ===
|
||||
/** 登录 */
|
||||
async function login(params: LoginParams): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
await apiLogin(params)
|
||||
await fetchUserInfo()
|
||||
await fetchMenus()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 注销 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await apiLogout()
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
async function fetchUserInfo(): Promise<UserInfo> {
|
||||
const info = await getCurrentUser()
|
||||
userInfo.value = info
|
||||
return info
|
||||
}
|
||||
|
||||
/** 获取角色列表 */
|
||||
async function fetchRoles(): Promise<string[]> {
|
||||
const list = await getUserRoles()
|
||||
roles.value = list
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取菜单 */
|
||||
async function fetchMenus(): Promise<MenuItem[]> {
|
||||
const list = await getMenuList()
|
||||
menus.value = list
|
||||
return list
|
||||
}
|
||||
|
||||
/** 重置状态(登出或登录失效时调用) */
|
||||
function reset(): void {
|
||||
userInfo.value = null
|
||||
roles.value = []
|
||||
menus.value = []
|
||||
token.value = ''
|
||||
localStorage.removeItem('gerp-user')
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
roles,
|
||||
menus,
|
||||
token,
|
||||
loading,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
currentRole,
|
||||
avatar,
|
||||
displayName,
|
||||
flatMenus,
|
||||
// 动作
|
||||
login,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
fetchRoles,
|
||||
fetchMenus,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user