Template
1
0
mirror of https://github.com/un-pany/v3-admin-vite.git synced 2025-04-20 10:59:21 +08:00

wip: 迁移 v3-admin v3.1.3 版本代码

This commit is contained in:
pany 2022-04-21 18:20:39 +08:00
parent 9b4af24d41
commit 83979683b1
78 changed files with 3070 additions and 149 deletions

View File

@ -19,8 +19,7 @@ module.exports = {
'plugin:vue/vue3-strongly-recommended',
'plugin:@typescript-eslint/recommended',
'@vue/standard',
'@vue/typescript/recommended',
'plugin:prettier/recommended' // 添加 prettier 插件,必须放在数组最后
'@vue/typescript/recommended'
],
rules: {
'vue/multi-word-component-names': 'off',
@ -40,18 +39,18 @@ module.exports = {
}
}
],
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always'
},
svg: 'always',
math: 'always'
}
],
// 'vue/html-self-closing': [
// 'error',
// {
// html: {
// void: 'always',
// normal: 'always',
// component: 'always'
// },
// svg: 'always',
// math: 'always'
// }
// ],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'prefer-regex-literals': 'off',

View File

@ -8,9 +8,7 @@
"build:prod": "vue-tsc --noEmit && vite build",
"preview:stage": "pnpm build:stage && vite preview",
"preview:prod": "pnpm build:prod && vite preview",
"lint:eslint": "eslint \"{src,mock}/**/*.{vue,ts,tsx}\" --fix",
"lint:prettier": "prettier --write \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"",
"lint": "pnpm lint:eslint && pnpm lint:prettier"
"lint": "eslint \"{src,mock}/**/*.{vue,ts,tsx}\" --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^1.1.4",
@ -38,14 +36,11 @@
"@vue/eslint-config-standard": "^6.1.0",
"@vue/eslint-config-typescript": "^10.0.0",
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-vue": "^8.6.0",
"lint-staged": "^12.4.0",
"prettier": "^2.6.2",
"sass": "^1.50.1",
"typescript": "^4.6.3",
"unplugin-auto-import": "^0.7.1",

49
pnpm-lock.yaml generated
View File

@ -15,10 +15,8 @@ specifiers:
dayjs: ^1.11.1
element-plus: ^2.1.10
eslint: ^8.13.0
eslint-config-prettier: ^8.5.0
eslint-plugin-import: ^2.26.0
eslint-plugin-node: ^11.1.0
eslint-plugin-prettier: ^4.0.0
eslint-plugin-promise: ^6.0.0
eslint-plugin-vue: ^8.6.0
js-cookie: ^3.0.1
@ -28,7 +26,6 @@ specifiers:
nprogress: ^0.2.0
path-to-regexp: ^6.2.0
pinia: ^2.0.13
prettier: ^2.6.2
sass: ^1.50.1
screenfull: ^6.0.1
typescript: ^4.6.3
@ -66,14 +63,11 @@ devDependencies:
'@vue/eslint-config-standard': 6.1.0_4095fd5e90f154d95a11e7814517bad8
'@vue/eslint-config-typescript': 10.0.0_a62cbc2f4797496d74696b1f6538012a
eslint: 8.13.0
eslint-config-prettier: 8.5.0_eslint@8.13.0
eslint-plugin-import: 2.26.0_eslint@8.13.0
eslint-plugin-node: 11.1.0_eslint@8.13.0
eslint-plugin-prettier: 4.0.0_1815ac95b7fb26c13c7d48a8eef62d0f
eslint-plugin-promise: 6.0.0_eslint@8.13.0
eslint-plugin-vue: 8.6.0_eslint@8.13.0
lint-staged: 12.4.0
prettier: 2.6.2
sass: 1.50.1
typescript: 4.6.3
unplugin-auto-import: 0.7.1_vite@2.9.5
@ -1450,15 +1444,6 @@ packages:
engines: {node: '>=10'}
dev: true
/eslint-config-prettier/8.5.0_eslint@8.13.0:
resolution: {integrity: sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
dependencies:
eslint: 8.13.0
dev: true
/eslint-config-standard/16.0.3_4e3767bb061a43bc3978ec6596bc3350:
resolution: {integrity: sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==}
peerDependencies:
@ -1557,23 +1542,6 @@ packages:
semver: 6.3.0
dev: true
/eslint-plugin-prettier/4.0.0_1815ac95b7fb26c13c7d48a8eef62d0f:
resolution: {integrity: sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==}
engines: {node: '>=6.0.0'}
peerDependencies:
eslint: '>=7.28.0'
eslint-config-prettier: '*'
prettier: '>=2.0.0'
peerDependenciesMeta:
eslint-config-prettier:
optional: true
dependencies:
eslint: 8.13.0
eslint-config-prettier: 8.5.0_eslint@8.13.0
prettier: 2.6.2
prettier-linter-helpers: 1.0.0
dev: true
/eslint-plugin-promise/6.0.0_eslint@8.13.0:
resolution: {integrity: sha512-7GPezalm5Bfi/E22PnQxDWH2iW9GTvAlUNTztemeHb6c1BniSyoeTrM87JkC0wYdi6aQrZX9p2qEiAno8aTcbw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -1797,10 +1765,6 @@ packages:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true
/fast-diff/1.2.0:
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
dev: true
/fast-glob/3.2.11:
resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
engines: {node: '>=8.6.0'}
@ -2984,19 +2948,6 @@ packages:
engines: {node: '>= 0.8.0'}
dev: true
/prettier-linter-helpers/1.0.0:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
dependencies:
fast-diff: 1.2.0
dev: true
/prettier/2.6.2:
resolution: {integrity: sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==}
engines: {node: '>=10.13.0'}
hasBin: true
dev: true
/punycode/2.1.1:
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
engines: {node: '>=6'}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -1,30 +1,15 @@
<script setup lang="ts">
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup
import HelloWorld from './components/HelloWorld.vue'
console.log('测试 console.log')
console.info('测试 console.info')
console.error('测试 console.error')
console.info('测试 VITE_BASE_API', import.meta.env.VITE_BASE_API)
console.info('测试 MODE', import.meta.env.MODE)
console.info('测试 BASE_URL', import.meta.env.BASE_URL)
console.info('测试 DEV', import.meta.env.DEV)
console.info('测试 PROD', import.meta.env.PROD)
</script>
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Hello Vue 3 + TypeScript + Vite" />
<el-button>测试 element-plus 按钮</el-button>
<ElConfigProvider :locale="locale">
<router-view />
</ElConfigProvider>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
<script lang="ts" setup>
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/lib/locale/lang/zh-cn'
import { useAppStore } from '@/store/modules/app'
const locale = zhCn // element-plus
useAppStore().initTheme() // Theme
</script>

22
src/api/login.ts Normal file
View File

@ -0,0 +1,22 @@
import { request } from '@/utils/service'
interface IUserRequestData {
username: string
password: string
}
/** 登录,返回 token */
export function accountLogin(data: IUserRequestData) {
return request({
url: 'users/login',
method: 'post',
data
})
}
/** 获取用户详情 */
export function userInfoRequest() {
return request({
url: 'users/info',
method: 'post'
})
}

BIN
src/assets/docs/qq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

BIN
src/assets/layout/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1646185144620" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3301" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><defs><style type="text/css"></style></defs><path d="M899.7 116.1 645 116.1 296.8 440.7 296.8 116.1 65 116.1 65 208.8 157.7 208.8 157.7 904.4Z" p-id="3302" fill="#409EFF"></path><path d="M603.9 626.7 603.9 517c23.7-13.9 49.9-24.6 78.5-31.9 28.6-7.3 58.1-10.9 88.7-10.9 53 0 93.5 11.4 121.5 34.1 28 22.7 42 55.4 42 98.1 0 29.4-7.1 53.1-21.3 71.1-14.2 18.1-35.9 31-65.1 38.9 30.1 4.2 52.7 16.1 67.7 35.5 15.1 19.4 22.6 46.3 22.6 80.5 0 47.5-14.5 83.4-43.5 107.5C866 964 822.9 976 765.8 976c-28.1 0-56.5-3.8-85-11.4-28.6-7.6-56.1-18.6-82.4-33L598.4 816.9l114 0 0 3.8c0 19 4.6 33.5 13.9 43.2 9.2 9.9 22.8 14.8 40.9 14.8 16.9 0 29.8-4.9 38.7-14.8 8.9-9.8 13.4-24 13.4-42.6 0-21.3-5.1-36.5-15.3-45.5-10.2-9-28.3-13.4-54.2-13.4l-63 0 0-81.8 66.9 0c21.6 0 38.1-4.8 49.6-14.4 11.5-9.6 17.3-23.2 17.3-40.7 0-17.3-5-30.7-15.2-40.3-10.1-9.5-24.2-14.3-42.2-14.3-15.6 0-27.6 4.7-35.9 14.1-8.4 9.4-13.1 23.3-14.2 41.7L603.9 626.7z" p-id="3303" fill="#409EFF"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -1,40 +0,0 @@
<script setup lang="ts">
defineProps<{ msg: string }>()
</script>
<template>
<h1>{{ msg }}</h1>
<p>
Recommended IDE setup:
<a href="https://code.visualstudio.com/" target="_blank">VS Code</a>
+
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
</p>
<p>See <code>README.md</code> for more information.</p>
<p>
<a href="https://vitejs.dev/guide/features.html" target="_blank"> Vite Docs </a>
|
<a href="https://v3.vuejs.org/" target="_blank">Vue 3 Docs</a>
</p>
</template>
<style scoped>
a {
color: #42b983;
}
label {
margin: 0 0.5em;
font-weight: bold;
}
code {
background-color: #eee;
padding: 2px 4px;
border-radius: 4px;
color: #304455;
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div @click="click">
<el-tooltip effect="dark" content="全屏" placement="bottom">
<el-icon :size="20">
<full-screen />
</el-icon>
</el-tooltip>
</div>
</template>
<script lang="ts" setup>
import { FullScreen } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import screenfull from 'screenfull'
const click = () => {
if (!screenfull.isEnabled) {
ElMessage.warning('您的浏览器无法工作')
return
}
screenfull.toggle()
}
</script>

View File

@ -0,0 +1,38 @@
<template>
<el-dropdown trigger="click" @command="handleSetTheme">
<el-tooltip effect="dark" content="主题模式" placement="bottom">
<el-icon :size="20">
<magic-stick />
</el-icon>
</el-tooltip>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(theme, index) in themeList"
:key="index"
:disabled="activeThemeName === theme.name"
:command="theme.name"
>
<span>{{ theme.title }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import { MagicStick } from '@element-plus/icons-vue'
import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app'
const appStore = useAppStore()
const themeList = computed(() => {
return appStore.themeList
})
const activeThemeName = computed(() => {
return appStore.activeThemeName
})
const handleSetTheme = (name: string) => {
appStore.setTheme(name)
}
</script>

26
src/config/layout.ts Normal file
View File

@ -0,0 +1,26 @@
/** 布局配置 */
interface ILayoutSettings {
/** 控制 settings panel 显示 */
showSettings: boolean
/** 控制 tagsview 显示 */
showTagsView: boolean
/** 控制 siderbar logo 显示 */
showSidebarLogo: boolean
/** 如果为真,将固定 header */
fixedHeader: boolean
/** 控制 换肤按钮 显示 */
showThemeSwitch: boolean
/** 控制 全屏按钮 显示 */
showScreenfull: boolean
}
const layoutSettings: ILayoutSettings = {
showSettings: true,
showTagsView: true,
fixedHeader: false,
showSidebarLogo: true,
showThemeSwitch: true,
showScreenfull: true
}
export default layoutSettings

14
src/config/roles.ts Normal file
View File

@ -0,0 +1,14 @@
/** 角色配置 */
interface IRolesSettings {
/** 是否开启角色功能(开启后需要后端配合,在查询用户详情接口返回当前用户的所属角色) */
openRoles: boolean
/** 当角色功能关闭时当前登录用户的默认角色将生效默认为admin拥有所有权限 */
defaultRoles: Array<string>
}
const rolesSettings: IRolesSettings = {
openRoles: true,
defaultRoles: ['admin']
}
export default rolesSettings

13
src/config/theme.ts Normal file
View File

@ -0,0 +1,13 @@
/** 注册的主题 */
const themeList = [
{
title: '默认',
name: 'normal'
},
{
title: '黑暗',
name: 'dark'
}
]
export default themeList

4
src/config/white-list.ts Normal file
View File

@ -0,0 +1,4 @@
/** 免登录白名单 */
const whiteList = ['/login']
export { whiteList }

8
src/constant/key.ts Normal file
View File

@ -0,0 +1,8 @@
class Keys {
static sidebarStatus = 'v3-admin-sidebar-status-key'
static language = 'v3-admin-language-key'
static token = 'v3-admin-token-key'
static activeThemeName = 'v3-admin-active-theme-name'
}
export default Keys

1
src/directives/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './permission'

View File

@ -0,0 +1,21 @@
import { useUserStoreHook } from '@/store/modules/user'
import { Directive } from 'vue'
/** 权限指令 */
export const permission: Directive = {
mounted(el, binding) {
const { value } = binding
const roles = useUserStoreHook().roles
if (value && value instanceof Array && value.length > 0) {
const permissionRoles = value
const hasPermission = roles.some((role: any) => {
return permissionRoles.includes(role)
})
if (!hasPermission) {
el.style.display = 'none'
}
} else {
throw new Error("need roles! Like v-permission=\"['admin','editor']\"")
}
}
}

View File

@ -0,0 +1,48 @@
<!-- 主视图 -->
<template>
<section class="app-main">
<router-view v-slot="{Component}">
<transition name="fade-transform" mode="out-in">
<!-- <keep-alive> -->
<component :is="Component" :key="key" />
<!-- </keep-alive> -->
</transition>
</router-view>
</section>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const key = computed(() => {
return route.path
})
</script>
<style lang="scss" scoped>
.app-main {
/* 50 = navbar height */
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
}
.fixed-header + .app-main {
padding-top: 50px;
height: 100vh;
overflow: auto;
}
.hasTagsView {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px);
}
.fixed-header + .app-main {
padding-top: 84px;
}
}
</style>

View File

@ -0,0 +1,83 @@
<!-- 面包屑组件 -->
<template>
<el-breadcrumb class="app-breadcrumb">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in state.breadcrumbs" :key="item.path">
<span v-if="item.redirect === 'noRedirect' || index === state.breadcrumbs.length - 1" class="no-redirect">{{
item.meta.title
}}</span>
<a v-else @click.prevent="state.handleLink(item)">
{{ item.meta.title }}
</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script lang="ts" setup>
import { onBeforeMount, reactive, watch } from 'vue'
import { useRoute, useRouter, RouteLocationMatched } from 'vue-router'
import { compile } from 'path-to-regexp'
const currentRoute = useRoute()
const router = useRouter()
const pathCompile = (path: string) => {
const { params } = currentRoute
const toPath = compile(path)
return toPath(params)
}
const state = reactive({
breadcrumbs: [] as Array<RouteLocationMatched>,
getBreadcrumb: () => {
const matched = currentRoute.matched.filter((item) => item.meta && item.meta.title)
state.breadcrumbs = matched.filter((item) => {
return item.meta && item.meta.title && item.meta.breadcrumb !== false
})
},
handleLink(item: any) {
const { redirect, path } = item
if (redirect) {
router.push(redirect).catch((err) => {
console.warn(err)
})
return
}
router.push(pathCompile(path)).catch((err) => {
console.warn(err)
})
}
})
watch(
() => currentRoute.path,
(path) => {
if (path.startsWith('/redirect/')) {
return
}
state.getBreadcrumb()
}
)
onBeforeMount(() => {
state.getBreadcrumb()
})
</script>
<style lang="scss" scoped>
.el-breadcrumb__inner,
.el-breadcrumb__inner a {
font-weight: 400 !important;
}
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>

View File

@ -0,0 +1,32 @@
<!-- 折叠边栏按钮 -->
<template>
<div @click="toggleClick">
<el-icon :size="20" class="icon">
<fold v-if="isActive" />
<expand v-else />
</el-icon>
</div>
</template>
<script lang="ts" setup>
import { Expand, Fold } from '@element-plus/icons-vue'
defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle-click'])
const toggleClick = () => {
emit('toggle-click')
}
</script>
<style lang="scss" scoped>
.icon {
vertical-align: middle;
}
</style>

View File

@ -0,0 +1,102 @@
<!-- 导航栏 -->
<template>
<div class="navbar">
<Hamburger :is-active="sidebar.opened" class="hamburger" @toggle-click="state.toggleSideBar" />
<BreadCrumb class="breadcrumb" />
<div class="right-menu">
<Screenfull v-if="showScreenfull" class="right-menu-item" />
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
<el-dropdown class="right-menu-item">
<el-avatar :icon="UserFilled" :size="34" />
<template #dropdown>
<el-dropdown-menu>
<a target="_blank" href="https://juejin.cn/post/6963876125428678693">
<el-dropdown-item>中文文档</el-dropdown-item>
</a>
<a target="_blank" href="https://github.com/un-pany/v3-admin/blob/master/README.en.md">
<el-dropdown-item>English Docs</el-dropdown-item>
</a>
<a target="_blank" href="https://github.com/un-pany/v3-admin">
<el-dropdown-item>GitHub</el-dropdown-item>
</a>
<a target="_blank" href="https://gitee.com/un-pany/v3-admin">
<el-dropdown-item>Gitee</el-dropdown-item>
</a>
<el-dropdown-item divided @click="state.logout">
<span style="display: block">退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { computed, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/store/modules/app'
import { useSettingsStore } from '@/store/modules/settings'
import { useUserStore } from '@/store/modules/user'
import BreadCrumb from '../bread-crumb/index.vue'
import Hamburger from '../hamburger/index.vue'
import ThemeSwitch from '@/components/ThemeSwitch/index.vue'
import Screenfull from '@/components/Screenfull/index.vue'
const router = useRouter()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const sidebar = computed(() => {
return appStore.sidebar
})
const showThemeSwitch = computed(() => {
return settingsStore.showThemeSwitch
})
const showScreenfull = computed(() => {
return settingsStore.showScreenfull
})
const state = reactive({
toggleSideBar: () => {
appStore.toggleSidebar(false)
},
logout: () => {
userStore.logout()
router.push('/login').catch((err) => {
console.warn(err)
})
}
})
</script>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
background: #fff;
.hamburger {
display: flex;
align-items: center;
height: 100%;
float: left;
padding: 0 15px;
cursor: pointer;
}
.breadcrumb {
float: left;
}
.right-menu {
float: right;
margin-right: 10px;
height: 100%;
display: flex;
align-items: center;
color: #5a5e66;
.right-menu-item {
padding: 0 10px;
cursor: pointer;
}
}
}
</style>

View File

@ -0,0 +1,45 @@
<!-- 右侧悬浮设置面板 -->
<template>
<div class="handle-button" :style="{top: buttonTop + 'px'}" @click="show = true">
<el-icon :size="24">
<Setting />
</el-icon>
</div>
<el-drawer v-model="show" size="300px" :with-header="false">
<slot />
</el-drawer>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { Setting } from '@element-plus/icons-vue'
defineProps({
buttonTop: {
type: Number,
default: 250
}
})
const show = ref(false)
</script>
<style lang="scss" scoped>
.handle-button {
width: 48px;
height: 48px;
background-color: #152d3d;
position: absolute;
right: 0px;
text-align: center;
font-size: 24px;
border-radius: 6px 0 0 6px !important;
z-index: 10;
cursor: pointer;
pointer-events: auto;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,118 @@
<!-- 设置页面 -->
<template>
<div class="drawer-container">
<div>
<h3 class="drawer-title">
系统布局配置
</h3>
<div class="drawer-item">
<span>显示 Tags-View</span>
<el-switch v-model="state.showTagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示侧边栏 Logo</span>
<el-switch v-model="state.showSidebarLogo" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>固定 Header</span>
<el-switch v-model="state.fixedHeader" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示换肤按钮</span>
<el-switch v-model="state.showThemeSwitch" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示全屏按钮</span>
<el-switch v-model="state.showScreenfull" class="drawer-switch" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useSettingsStore } from '@/store/modules/settings'
import { reactive, watch } from 'vue'
const settingsStore = useSettingsStore()
const state = reactive({
fixedHeader: settingsStore.fixedHeader,
showTagsView: settingsStore.showTagsView,
showSidebarLogo: settingsStore.showSidebarLogo,
showThemeSwitch: settingsStore.showThemeSwitch,
showScreenfull: settingsStore.showScreenfull
})
watch(
() => state.fixedHeader,
(value) => {
settingsStore.changeSetting({
key: 'fixedHeader',
value
})
}
)
watch(
() => state.showTagsView,
(value) => {
settingsStore.changeSetting({
key: 'showTagsView',
value
})
}
)
watch(
() => state.showSidebarLogo,
(value) => {
settingsStore.changeSetting({
key: 'showSidebarLogo',
value
})
}
)
watch(
() => state.showThemeSwitch,
(value) => {
settingsStore.changeSetting({
key: 'showThemeSwitch',
value
})
}
)
watch(
() => state.showScreenfull,
(value) => {
settingsStore.changeSetting({
key: 'showScreenfull',
value
})
}
)
</script>
<style lang="scss" scoped>
.drawer-container {
padding: 24px;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
.drawer-title {
margin-bottom: 12px;
color: #303133;
font-size: 14px;
line-height: 22px;
}
.drawer-item {
color: #303133;
font-size: 14px;
padding: 12px 0;
}
.drawer-switch {
float: right;
}
}
</style>

View File

@ -0,0 +1,118 @@
<!-- 侧边栏 Item -->
<template>
<div v-if="!item.meta || !item.meta.hidden" :class="{'simple-mode': isCollapse, 'first-level': isFirstLevel}">
<template v-if="!alwaysShowRootMenu && theOnlyOneChild && !theOnlyOneChild.children">
<SidebarItemLink v-if="theOnlyOneChild.meta" :to="resolvePath(theOnlyOneChild.path)">
<el-menu-item :index="resolvePath(theOnlyOneChild.path)">
<svg-icon v-if="theOnlyOneChild.meta.icon" :name="theOnlyOneChild.meta.icon" />
<template v-if="theOnlyOneChild.meta.title" #title>
{{ theOnlyOneChild.meta.title }}
</template>
</el-menu-item>
</SidebarItemLink>
</template>
<el-sub-menu v-else :index="resolvePath(item.path)" popper-append-to-body>
<template #title>
<svg-icon v-if="item.meta && item.meta.icon" :name="item.meta.icon" />
<span v-if="item.meta && item.meta.title">{{ item.meta.title }}</span>
</template>
<template v-if="item.children">
<sidebar-item
v-for="child in item.children"
:key="child.path"
:item="child"
:is-collapse="isCollapse"
:is-first-level="false"
:base-path="resolvePath(child.path)"
/>
</template>
</el-sub-menu>
</div>
</template>
<script lang="ts" setup>
import path from 'path'
import { computed, PropType } from 'vue'
import { RouteRecordRaw } from 'vue-router'
import { isExternal } from '@/utils/validate'
import SidebarItemLink from './sidebar-item-link.vue'
const props = defineProps({
item: {
type: Object as PropType<RouteRecordRaw>,
required: true
},
isCollapse: {
type: Boolean,
required: false
},
isFirstLevel: {
type: Boolean,
default: true
},
basePath: {
type: String,
required: true
}
})
const alwaysShowRootMenu = computed(() => {
return !!(props.item.meta && props.item.meta.alwaysShow)
})
const showingChildNumber = computed(() => {
if (props.item.children) {
const showingChildren = props.item.children.filter((item) => {
return !(item.meta && item.meta.hidden)
})
return showingChildren.length
}
return 0
})
const theOnlyOneChild = computed(() => {
if (showingChildNumber.value > 1) {
return null
}
if (props.item.children) {
for (const child of props.item.children) {
if (!child.meta || !child.meta.hidden) {
return child
}
}
}
// If there is no children, return itself with path removed,
// because this.basePath already contains item's path information
return { ...props.item, path: '' }
})
const resolvePath = (routePath: string) => {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
return path.resolve(props.basePath, routePath)
}
</script>
<style lang="scss" scoped>
.svg-icon {
margin-right: 20px;
min-width: 1em;
font-size: 16px;
}
.simple-mode {
&.first-level {
::v-deep(.el-sub-menu) {
.el-sub-menu__icon-arrow {
display: none;
}
span {
visibility: hidden;
}
}
}
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<a v-if="isExternal(to)" :href="to" target="_blank" rel="noopener">
<slot />
</a>
<div v-else @click="push">
<slot />
</div>
</template>
<script lang="ts" setup>
import { isExternal } from '@/utils/validate'
import { useRouter } from 'vue-router'
const props = defineProps({
to: {
type: String,
required: true
}
})
const router = useRouter()
const push = () => {
router.push(props.to).catch((err) => {
console.warn(err)
})
}
</script>

View File

@ -0,0 +1,77 @@
<!-- 侧边栏 Logo需要跟随侧边栏折叠 -->
<template>
<div class="sidebar-logo-container" :class="{collapse: collapse}">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img src="@/assets/layout/logo.png" class="sidebar-logo">
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img src="@/assets/layout/logo-text-1.png" class="sidebar-logo-text">
</router-link>
</transition>
</div>
</template>
<script lang="ts" setup>
defineProps({
collapse: {
type: Boolean,
default: true
}
})
</script>
<style lang="scss" scoped>
.sidebarLogoFade-enter-active,
.sidebarLogoFade-leave-active {
transition: opacity 1.5s;
}
.sidebarLogoFade-enter-from,
.sidebarLogoFade-leave-to {
opacity: 0;
}
.sidebar-logo-container {
position: relative;
width: 100%;
height: 84px;
line-height: 84px;
background: #0c202b;
text-align: center;
overflow: hidden;
.sidebar-logo {
display: none;
}
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo-text {
height: 100%;
vertical-align: middle;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 16px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 0;
display: inline-block;
}
.sidebar-logo-text {
display: none;
}
}
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<div :class="{'has-logo': showLogo}">
<SidebarLogo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:collapse="isCollapse"
:unique-opened="true"
:default-active="activeMenu"
background-color="#152d3d"
text-color="#C0C4CC"
active-text-color="#fff"
mode="vertical"
>
<SidebarItem
v-for="routeItem in routes"
:key="routeItem.path"
:item="routeItem"
:base-path="routeItem.path"
:is-collapse="isCollapse"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { useSettingsStore } from '@/store/modules/settings'
import SidebarItem from './sidebar-item.vue'
import SidebarLogo from './sidebar-logo.vue'
const route = useRoute()
const sidebar = computed(() => {
return useAppStore().sidebar
})
const routes = computed(() => {
return usePermissionStore().routes
})
const showLogo = computed(() => {
return useSettingsStore().showSidebarLogo
})
const activeMenu = computed(() => {
const { meta, path } = route
if (meta !== null || meta !== undefined) {
if (meta.activeMenu) {
return meta.activeMenu
}
}
return path
})
const isCollapse = computed(() => {
return !sidebar.value.opened
})
</script>
<style lang="scss">
.sidebar-container {
// element-plus css, scoped sidebar-container
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__view {
height: 100%;
}
.el-scrollbar__bar {
&.is-vertical {
right: 0;
}
&.is-horizontal {
display: none;
}
}
}
</style>
<style lang="scss" scoped>
@mixin tip-line {
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: #66b1ff;
}
}
.el-scrollbar {
height: 100%;
}
.has-logo {
.el-scrollbar {
// 84px logo height
height: calc(100% - 84px);
}
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
}
::v-deep(.el-menu-item),
::v-deep(.el-sub-menu__title),
::v-deep(.el-sub-menu .el-menu-item) {
height: 60px;
line-height: 60px;
&:hover {
background-color: #ffffff10;
}
display: block;
* {
vertical-align: middle;
}
}
::v-deep(.el-menu-item) {
&.is-active {
@include tip-line;
}
}
.el-menu--collapse {
::v-deep(.el-sub-menu) {
&.is-active {
.el-sub-menu__title {
color: #fff !important;
@include tip-line;
}
}
}
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<el-scrollbar :vertical="false" class="scroll-container">
<slot />
</el-scrollbar>
</template>
<style lang="scss" scoped>
.scroll-container {
//
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
}
</style>

View File

@ -0,0 +1,298 @@
<template>
<div class="tags-view-container">
<ScrollPane class="tags-view-wrapper">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="state.isActive(tag) ? 'active' : ''"
:to="{path: tag.path, query: tag.query, fullPath: tag.fullPath}"
class="tags-view-item"
@click.middle="!state.isAffix(tag) ? state.closeSelectedTag(tag) : ''"
@contextmenu.prevent="state.openMenu(tag, $event)"
>
{{ tag.meta?.title }}
<el-icon v-if="!state.isAffix(tag)" :size="12" @click.prevent.stop="state.closeSelectedTag(tag)">
<Close />
</el-icon>
</router-link>
</ScrollPane>
<ul v-show="state.visible" :style="{left: state.left + 'px', top: state.top + 'px'}" class="contextmenu">
<li @click="state.refreshSelectedTag(state.selectedTag)">
刷新
</li>
<li v-if="!state.isAffix(state.selectedTag)" @click="state.closeSelectedTag(state.selectedTag)">
关闭
</li>
<li @click="state.closeOthersTags">
关闭其它
</li>
<li @click="state.closeAllTags(state.selectedTag)">
关闭所有
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import path from 'path'
import { useTagsViewStore, ITagView } from '@/store/modules/tags-view'
import { usePermissionStore } from '@/store/modules/permission'
import { computed, getCurrentInstance, nextTick, onBeforeMount, reactive, watch } from 'vue'
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router'
import ScrollPane from './scroll-pane.vue'
import { Close } from '@element-plus/icons-vue'
const tagsViewStore = useTagsViewStore()
const permissionStore = usePermissionStore()
const router = useRouter()
const instance = getCurrentInstance()
const currentRoute = useRoute()
const { proxy } = instance as any
const toLastView = (visitedViews: ITagView[], view: ITagView) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView !== undefined && latestView.fullPath !== undefined) {
router.push(latestView.fullPath).catch((err) => {
console.warn(err)
})
} else {
// tags-view
if (view.name === 'Dashboard') {
//
router.push({ path: '/redirect' + view.fullPath }).catch((err) => {
console.warn(err)
})
} else {
router.push('/').catch((err) => {
console.warn(err)
})
}
}
}
const state = reactive({
visible: false,
top: 0,
left: 0,
selectedTag: {} as ITagView,
affixTags: [] as ITagView[],
isActive: (route: ITagView) => {
return route.path === currentRoute.path
},
isAffix: (tag: ITagView) => {
return tag.meta && tag.meta.affix
},
refreshSelectedTag: (view: ITagView) => {
const { fullPath } = view
nextTick(() => {
router.replace({ path: '/redirect' + fullPath }).catch((err) => {
console.warn(err)
})
})
},
closeSelectedTag: (view: ITagView) => {
tagsViewStore.delVisitedView(view)
if (state.isActive(view)) {
toLastView(tagsViewStore.visitedViews, view)
}
},
closeOthersTags: () => {
if (state.selectedTag.fullPath !== currentRoute.path && state.selectedTag.fullPath !== undefined) {
router.push(state.selectedTag.fullPath).catch((err) => {
console.warn(err)
})
}
tagsViewStore.delOthersVisitedViews(state.selectedTag as ITagView)
},
closeAllTags: (view: ITagView) => {
tagsViewStore.delAllVisitedViews()
if (state.affixTags.some((tag) => tag.path === currentRoute.path)) {
return
}
toLastView(tagsViewStore.visitedViews, view)
},
openMenu: (tag: ITagView, e: MouseEvent) => {
const menuMinWidth = 105
const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
const offsetWidth = proxy.$el.offsetWidth // container width
const maxLeft = offsetWidth - menuMinWidth // left boundary
const left = e.clientX - offsetLeft + 15 // 15: margin right
if (left > maxLeft) {
state.left = maxLeft
} else {
state.left = left
}
state.top = e.clientY
state.visible = true
state.selectedTag = tag
},
closeMenu: () => {
state.visible = false
}
})
const visitedViews = computed(() => {
return tagsViewStore.visitedViews
})
const routes = computed(() => permissionStore.routes)
const filterAffixTags = (routes: RouteRecordRaw[], basePath = '/') => {
let tags: ITagView[] = []
routes.forEach((route) => {
if (route.meta && route.meta.affix) {
const tagPath = path.resolve(basePath, route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
})
}
if (route.children) {
const childTags = filterAffixTags(route.children, route.path)
if (childTags.length >= 1) {
tags = tags.concat(childTags)
}
}
})
return tags
}
const initTags = () => {
state.affixTags = filterAffixTags(routes.value)
for (const tag of state.affixTags) {
// name
if (tag.name) {
tagsViewStore.addVisitedView(tag as ITagView)
}
}
}
const addTags = () => {
if (currentRoute.name) {
tagsViewStore.addVisitedView(currentRoute)
}
return false
}
const moveToCurrentTag = () => {
const tags = instance?.refs.tag as any[]
if (tags === null || tags === undefined || !Array.isArray(tags)) {
return
}
for (const tag of tags) {
if ((tag.to as ITagView).path === currentRoute.path) {
// When query is different then update
if ((tag.to as ITagView).fullPath !== currentRoute.fullPath) {
tagsViewStore.updateVisitedView(currentRoute)
}
}
}
}
watch(
() => currentRoute.name,
() => {
addTags()
moveToCurrentTag()
}
)
watch(
() => state.visible,
(value) => {
if (value) {
document.body.addEventListener('click', state.closeMenu)
} else {
document.body.removeEventListener('click', state.closeMenu)
}
}
)
// life cricle
onBeforeMount(() => {
initTags()
addTags()
})
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 #00000010, 0 0 3px 0 #00000010;
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: #409eff;
color: #fff;
border-color: #409eff;
&::before {
content: '';
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 2px;
}
}
.el-icon {
margin: 0 2px;
vertical-align: middle;
border-radius: 50%;
&:hover {
background-color: #00000030;
color: #fff;
}
}
}
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 #00000030;
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>

View File

@ -0,0 +1,6 @@
export { default as AppMain } from './AppMain.vue'
export { default as NavigationBar } from './navigation-bar/index.vue'
export { default as Settings } from './settings/index.vue'
export { default as Sidebar } from './sidebar/index.vue'
export { default as TagsView } from './tags-view/index.vue'
export { default as RightPanel } from './right-panel/index.vue'

159
src/layout/index.vue Normal file
View File

@ -0,0 +1,159 @@
<!-- 布局入口 -->
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="classObj.mobile && sidebar.opened" class="drawer-bg" @click="state.handleClickOutside" />
<Sidebar class="sidebar-container" />
<div :class="{hasTagsView: showTagsView}" class="main-container">
<div :class="{'fixed-header': fixedHeader}">
<NavigationBar />
<TagsView v-if="showTagsView" />
</div>
<AppMain />
<RightPanel v-if="showSettings">
<Settings />
</RightPanel>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAppStore, DeviceType } from '@/store/modules/app'
import { useSettingsStore } from '@/store/modules/settings'
import { computed, onBeforeMount, onBeforeUnmount, onMounted, reactive } from 'vue'
import { AppMain, NavigationBar, Settings, Sidebar, TagsView, RightPanel } from './components'
import useResize from './useResize'
const { sidebar, device, addEventListenerOnResize, resizeMounted, removeEventListenerResize, watchRouter } = useResize()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const state = reactive({
handleClickOutside: () => {
appStore.closeSidebar(false)
}
})
const classObj = computed(() => {
return {
hideSidebar: !sidebar.value.opened,
openSidebar: sidebar.value.opened,
withoutAnimation: sidebar.value.withoutAnimation,
mobile: device.value === DeviceType.Mobile
}
})
const showSettings = computed(() => {
return settingsStore.showSettings
})
const showTagsView = computed(() => {
return settingsStore.showTagsView
})
const fixedHeader = computed(() => {
return settingsStore.fixedHeader
})
watchRouter()
onBeforeMount(() => {
addEventListenerOnResize()
})
onMounted(() => {
resizeMounted()
})
onBeforeUnmount(() => {
removeEventListenerResize()
})
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
$sideBarWidth: 220px;
.app-wrapper {
@include clearfix;
position: relative;
width: 100%;
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.main-container {
min-height: 100%;
transition: margin-left 0.28s;
margin-left: $sideBarWidth;
position: relative;
}
.sidebar-container {
transition: width 0.28s;
width: $sideBarWidth !important;
height: 100%;
position: fixed;
font-size: 0;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar {
.main-container {
margin-left: 54px;
}
.sidebar-container {
width: 54px !important;
}
.fixed-header {
width: calc(100% - 54px);
}
}
// for mobile response
.mobile {
.main-container {
margin-left: 0;
}
.sidebar-container {
transition: transform 0.28s;
width: $sideBarWidth !important;
}
&.openSidebar {
position: fixed;
top: 0;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-$sideBarWidth, 0, 0);
}
}
.fixed-header {
width: 100%;
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
</style>

68
src/layout/useResize.ts Normal file
View File

@ -0,0 +1,68 @@
import { useAppStore, DeviceType } from '@/store/modules/app'
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
/** 参考 Bootstrap 的响应式设计 width = 992 */
const WIDTH = 992
/** 根据大小变化重新布局 */
export default function() {
const appStore = useAppStore()
const device = computed(() => {
return appStore.device
})
const sidebar = computed(() => {
return appStore.sidebar
})
const currentRoute = useRoute()
const watchRouter = watch(
() => currentRoute.name,
() => {
if (appStore.device === DeviceType.Mobile && appStore.sidebar.opened) {
appStore.closeSidebar(false)
}
}
)
const isMobile = () => {
const rect = document.body.getBoundingClientRect()
return rect.width - 1 < WIDTH
}
const resizeMounted = () => {
if (isMobile()) {
appStore.toggleDevice(DeviceType.Mobile)
appStore.closeSidebar(true)
}
}
const resizeHandler = () => {
if (!document.hidden) {
appStore.toggleDevice(isMobile() ? DeviceType.Mobile : DeviceType.Desktop)
if (isMobile()) {
appStore.closeSidebar(true)
}
}
}
const addEventListenerOnResize = () => {
window.addEventListener('resize', resizeHandler)
}
const removeEventListenerResize = () => {
window.removeEventListener('resize', resizeHandler)
}
return {
device,
sidebar,
resizeMounted,
addEventListenerOnResize,
removeEventListenerResize,
watchRouter
}
}

View File

@ -1,4 +1,19 @@
import { createApp } from 'vue'
import { createApp, Directive } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '@/styles/index.scss'
import 'normalize.css'
import * as directives from '@/directives'
import '@/router/permission'
import loadSvg from '@/icons'
createApp(App).mount('#app')
const app = createApp(App)
// 加载全局 SVG
loadSvg(app)
// 自定义指令
Object.keys(directives).forEach((key) => {
app.directive(key, (directives as { [key: string]: Directive })[key])
})
app.use(store).use(router).mount('#app')

134
src/router/index.ts Normal file
View File

@ -0,0 +1,134 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
const Layout = () => import('@/layout/index.vue')
/** 常驻路由 */
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/redirect',
component: Layout,
meta: {
hidden: true
},
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect/index.vue')
}
]
},
{
path: '/login',
component: () => import('@/views/login/index.vue'),
meta: {
hidden: true
}
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
component: () => import('@/views/dashboard/index.vue'),
name: 'Dashboard',
meta: {
title: '首页',
icon: 'dashboard',
affix: true
}
}
]
}
]
/**
*
*
* name
*/
export const asyncRoutes: Array<RouteRecordRaw> = [
{
path: '/permission',
component: Layout,
redirect: '/permission/page',
name: 'Permission',
meta: {
title: '权限管理',
icon: 'lock',
roles: ['admin', 'editor'], // 可以在根路由中设置角色
alwaysShow: true // 将始终显示根菜单
},
children: [
{
path: 'page',
component: () => import('@/views/permission/page.vue'),
name: 'PagePermission',
meta: {
title: '页面权限',
roles: ['admin'] // 或者在子导航中设置角色
}
},
{
path: 'directive',
component: () => import('@/views/permission/directive.vue'),
name: 'DirectivePermission',
meta: {
title: '指令权限' // 如果未设置角色,则表示:该页面不需要权限,但会继承根路由的角色
}
}
]
},
{
path: '/:pathMatch(.*)*', // 必须将 'ErrorPage' 路由放在最后, Must put the 'ErrorPage' route at the end
component: Layout,
redirect: '/404',
name: 'ErrorPage',
meta: {
title: '错误页面',
icon: '404',
hidden: true
},
children: [
{
path: '401',
component: () => import('@/views/error-page/401.vue'),
name: '401',
meta: {
title: '401'
}
},
{
path: '404',
component: () => import('@/views/error-page/404.vue'),
name: '404',
meta: {
title: '404'
}
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes
})
/** 重置路由 */
export function resetRouter() {
// 注意:所有动态路由路由必须带有 name 属性,否则可能会不能完全重置干净
try {
router.getRoutes().forEach((route) => {
const { name, meta } = route
if (name && meta.roles?.length) {
router.hasRoute(name) && router.removeRoute(name)
}
})
} catch (error) {
// 强制刷新浏览器,不过体验不是很好
window.location.reload()
}
}
export default router

73
src/router/permission.ts Normal file
View File

@ -0,0 +1,73 @@
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import router from '@/router'
import { RouteLocationNormalized } from 'vue-router'
import { useUserStoreHook } from '@/store/modules/user'
import { usePermissionStoreHook } from '@/store/modules/permission'
import { ElMessage } from 'element-plus'
import { whiteList } from '@/config/white-list'
import rolesSettings from '@/config/roles'
import { getToken } from '@/utils/cookies'
const userStore = useUserStoreHook()
const permissionStore = usePermissionStoreHook()
NProgress.configure({ showSpinner: false })
router.beforeEach(async(to: RouteLocationNormalized, _: RouteLocationNormalized, next: any) => {
NProgress.start()
// 判断该用户是否登录
if (getToken()) {
if (to.path === '/login') {
// 如果登录,并准备进入 login 页面,则重定向到主页
next({ path: '/' })
NProgress.done()
} else {
// 检查用户是否已获得其权限角色
if (userStore.roles.length === 0) {
try {
if (rolesSettings.openRoles) {
// 注意:角色必须是一个数组! 例如: ['admin'] 或 ['developer', 'editor']
await userStore.getInfo()
// 获取接口返回的 roles
const roles = userStore.roles
// 根据角色生成可访问的 routes
permissionStore.setRoutes(roles)
} else {
// 没有开启角色功能,则启用默认角色
userStore.setRoles(rolesSettings.defaultRoles)
permissionStore.setRoutes(rolesSettings.defaultRoles)
}
// 动态地添加可访问的 routes
permissionStore.dynamicRoutes.forEach((route) => {
router.addRoute(route)
})
// 确保添加路由已完成
// 设置 replace: true, 因此导航将不会留下历史记录
next({ ...to, replace: true })
} catch (err: any) {
// 删除 token并重定向到登录页面
userStore.resetToken()
ElMessage.error(err.message || 'Has Error')
next('/login')
NProgress.done()
}
} else {
next()
}
}
} else {
// 如果没有 token
if (whiteList.indexOf(to.path) !== -1) {
// 如果在免登录的白名单中,则直接进入
next()
} else {
// 其他没有访问权限的页面将被重定向到登录页面
next('/login')
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done()
})

5
src/store/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const store = createPinia()
export default store

68
src/store/modules/app.ts Normal file
View File

@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { getSidebarStatus, getActiveThemeName, setSidebarStatus, setActiveThemeName } from '@/utils/cookies'
import themeList from '@/config/theme'
export enum DeviceType {
Mobile,
Desktop
}
interface IAppState {
device: DeviceType
sidebar: {
opened: boolean
withoutAnimation: boolean
}
/** 主题列表 */
themeList: { title: string, name: string }[]
/** 正在应用的主题的名字 */
activeThemeName: string
}
export const useAppStore = defineStore({
id: 'app',
state: (): IAppState => {
return {
device: DeviceType.Desktop,
sidebar: {
opened: getSidebarStatus() !== 'closed',
withoutAnimation: false
},
themeList: themeList,
activeThemeName: getActiveThemeName() || 'normal'
}
},
actions: {
toggleSidebar(withoutAnimation: boolean) {
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = withoutAnimation
if (this.sidebar.opened) {
setSidebarStatus('opened')
} else {
setSidebarStatus('closed')
}
},
closeSidebar(withoutAnimation: boolean) {
this.sidebar.opened = false
this.sidebar.withoutAnimation = withoutAnimation
setSidebarStatus('closed')
},
toggleDevice(device: DeviceType) {
this.device = device
},
setTheme(activeThemeName: string) {
// 检查这个主题在主题列表里是否存在
this.activeThemeName = this.themeList.find((theme) => theme.name === activeThemeName)
? activeThemeName
: this.themeList[0].name
// 应用到 dom
document.body.className = `theme-${this.activeThemeName}`
// 持久化
setActiveThemeName(this.activeThemeName)
},
initTheme() {
// 初始化
document.body.className = `theme-${this.activeThemeName}`
}
}
})

View File

@ -0,0 +1,64 @@
import store from '@/store'
import { defineStore } from 'pinia'
import { RouteRecordRaw } from 'vue-router'
import { constantRoutes, asyncRoutes } from '@/router'
interface IPermissionState {
routes: RouteRecordRaw[]
dynamicRoutes: RouteRecordRaw[]
}
const hasPermission = (roles: string[], route: RouteRecordRaw) => {
if (route.meta && route.meta.roles) {
return roles.some((role) => {
if (route.meta?.roles !== undefined) {
return route.meta.roles.includes(role)
} else {
return false
}
})
} else {
return true
}
}
const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
const res: RouteRecordRaw[] = []
routes.forEach((route) => {
const r = { ...route }
if (hasPermission(roles, r)) {
if (r.children) {
r.children = filterAsyncRoutes(r.children, roles)
}
res.push(r)
}
})
return res
}
export const usePermissionStore = defineStore({
id: 'permission',
state: (): IPermissionState => {
return {
routes: [],
dynamicRoutes: []
}
},
actions: {
setRoutes(roles: string[]) {
let accessedRoutes
if (roles.includes('admin')) {
accessedRoutes = asyncRoutes
} else {
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
}
this.routes = constantRoutes.concat(accessedRoutes)
this.dynamicRoutes = accessedRoutes
}
}
})
/** 在 setup 外使用 */
export function usePermissionStoreHook() {
return usePermissionStore(store)
}

View File

@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import layoutSettings from '@/config/layout'
interface ISettingsState {
fixedHeader: boolean
showSettings: boolean
showTagsView: boolean
showSidebarLogo: boolean
showThemeSwitch: boolean
showScreenfull: boolean
}
export const useSettingsStore = defineStore({
id: 'settings',
state: (): ISettingsState => {
return {
fixedHeader: layoutSettings.fixedHeader,
showSettings: layoutSettings.showSettings,
showTagsView: layoutSettings.showTagsView,
showSidebarLogo: layoutSettings.showSidebarLogo,
showThemeSwitch: layoutSettings.showThemeSwitch,
showScreenfull: layoutSettings.showScreenfull
}
},
actions: {
changeSetting(payload: { key: string, value: any }) {
const { key, value } = payload
switch (key) {
case 'fixedHeader':
this.fixedHeader = value
break
case 'showSettings':
this.showSettings = value
break
case 'showSidebarLogo':
this.showSidebarLogo = value
break
case 'showTagsView':
this.showTagsView = value
break
case 'showThemeSwitch':
this.showThemeSwitch = value
break
case 'showScreenfull':
this.showScreenfull = value
break
default:
break
}
}
}
})

View File

@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { _RouteLocationBase, RouteLocationNormalized } from 'vue-router'
export interface ITagView extends Partial<RouteLocationNormalized> {
title?: string
to?: _RouteLocationBase
}
interface ITagsViewState {
visitedViews: ITagView[]
}
export const useTagsViewStore = defineStore({
id: 'tags-view',
state: (): ITagsViewState => {
return {
visitedViews: []
}
},
actions: {
addVisitedView(view: ITagView) {
if (this.visitedViews.some((v) => v.path === view.path)) return
this.visitedViews.push(
Object.assign({}, view, {
title: view.meta?.title || 'no-name'
})
)
},
delVisitedView(view: ITagView) {
for (const [i, v] of this.visitedViews.entries()) {
if (v.path === view.path) {
this.visitedViews.splice(i, 1)
break
}
}
},
delOthersVisitedViews(view: ITagView) {
this.visitedViews = this.visitedViews.filter((v) => {
return v.meta?.affix || v.path === view.path
})
},
delAllVisitedViews() {
// keep affix tags
const affixTags = this.visitedViews.filter((tag) => tag.meta?.affix)
this.visitedViews = affixTags
},
updateVisitedView(view: ITagView) {
for (let v of this.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view)
break
}
}
}
}
})

89
src/store/modules/user.ts Normal file
View File

@ -0,0 +1,89 @@
import store from '@/store'
import { defineStore } from 'pinia'
import { usePermissionStore } from './permission'
import { getToken, removeToken, setToken } from '@/utils/cookies'
import router, { resetRouter } from '@/router'
import { accountLogin, userInfoRequest } from '@/api/login'
import { RouteRecordRaw } from 'vue-router'
interface IUserState {
token: string
roles: string[]
}
export const useUserStore = defineStore({
id: 'user',
state: (): IUserState => {
return {
token: getToken() || '',
roles: []
}
},
actions: {
/** 设置角色数组 */
setRoles(roles: string[]) {
this.roles = roles
},
/** 登录 */
login(userInfo: { username: string, password: string }) {
return new Promise((resolve, reject) => {
accountLogin({
username: userInfo.username.trim(),
password: userInfo.password
})
.then((res: any) => {
setToken(res.data.accessToken)
this.token = res.data.accessToken
resolve(true)
})
.catch((error) => {
reject(error)
})
})
},
/** 获取用户详情 */
getInfo() {
return new Promise((resolve, reject) => {
userInfoRequest()
.then((res: any) => {
this.roles = res.data.user.roles
resolve(res)
})
.catch((error) => {
reject(error)
})
})
},
/** 切换角色 */
async changeRoles(role: string) {
const token = role + '-token'
this.token = token
setToken(token)
await this.getInfo()
const permissionStore = usePermissionStore()
permissionStore.setRoutes(this.roles)
resetRouter()
permissionStore.dynamicRoutes.forEach((item: RouteRecordRaw) => {
router.addRoute(item)
})
},
/** 登出 */
logout() {
removeToken()
this.token = ''
this.roles = []
resetRouter()
},
/** 重置 token */
resetToken() {
removeToken()
this.token = ''
this.roles = []
}
}
})
/** 在 setup 外使用 */
export function useUserStoreHook() {
return useUserStore(store)
}

42
src/styles/index.scss Normal file
View File

@ -0,0 +1,42 @@
@import './mixins.scss'; // mixins
@import './transition.scss'; // transition
@import './theme/register.scss'; // 注册主题
.app-container {
padding: 20px;
}
html {
height: 100%;
}
body {
height: 100%;
background-color: #f0f2f5; // 全局背景色
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial,
sans-serif;
}
#app {
height: 100%;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
a,
a:focus,
a:hover {
color: inherit;
outline: none;
text-decoration: none;
}
div:focus {
outline: none;
}

7
src/styles/mixins.scss Normal file
View File

@ -0,0 +1,7 @@
@mixin clearfix {
&:after {
content: '';
display: table;
clear: both;
}
}

View File

@ -0,0 +1,2 @@
@import './setting.scss';
@import '../theme.scss';

View File

@ -0,0 +1,14 @@
// 主题名称
$theme-name: 'dark';
// 主题背景颜色
$theme-bg-color: #151515;
// active 状态下主题背景颜色
$active-theme-bg-color: #409eff;
// 默认文字颜色
$font-color: #c0c4cc;
// active 状态下文字颜色
$active-font-color: #fff;
// hover 状态下文字颜色
$hover-color: #fff;
// 边框颜色
$border-color: #303133;

View File

@ -0,0 +1,2 @@
// 注册的主题
@import '@/styles/theme/dark/index.scss';

214
src/styles/theme/theme.scss Normal file
View File

@ -0,0 +1,214 @@
.theme-#{$theme-name} {
/** Layout */
.app-wrapper {
background-color: $theme-bg-color;
color: $font-color;
// 侧边栏
.sidebar-container {
.sidebar-logo-container {
background-color: lighten($theme-bg-color, 2%) !important;
}
.el-menu {
background-color: lighten($theme-bg-color, 4%) !important;
.el-menu-item {
background-color: lighten($theme-bg-color, 4%) !important;
&.is-active,
&:hover {
background-color: lighten($theme-bg-color, 8%) !important;
color: $active-font-color !important;
}
}
}
.el-sub-menu__title {
background-color: lighten($theme-bg-color, 4%) !important;
}
}
// 顶部导航栏
.navbar {
background-color: $theme-bg-color;
.el-breadcrumb__inner {
a {
color: $font-color;
&:hover {
color: $hover-color;
}
}
.no-redirect {
color: $font-color;
}
}
.right-menu {
.el-icon {
color: $font-color;
}
.el-avatar {
background: lighten($theme-bg-color, 20%);
.el-icon {
color: #fff;
}
}
}
}
// tags-view
.tags-view-container {
background-color: $theme-bg-color !important;
border-bottom: 1px solid lighten($theme-bg-color, 10%) !important;
.tags-view-item {
background-color: $theme-bg-color !important;
color: $font-color !important;
border: 1px solid $border-color !important;
&.active {
background-color: $active-theme-bg-color !important;
color: $active-font-color !important;
border-color: $border-color !important;
}
}
.contextmenu {
// 右键菜单
background-color: lighten($theme-bg-color, 8%);
color: $font-color;
li:hover {
background-color: lighten($theme-bg-color, 16%);
color: $active-font-color;
}
}
}
// 右侧设置面板
.handle-button {
background-color: lighten($theme-bg-color, 20%) !important;
}
.el-drawer.rtl {
background-color: $theme-bg-color;
.drawer-title,
.drawer-item {
color: $font-color;
}
}
}
/** app-main 主要写 view 页面的黑暗样式 */
.app-main {
// 指令权限页面 /permission/directive
.permission-alert {
background-color: lighten($theme-bg-color, 8%);
}
// 监控页面 /monitor
.monitor {
background-color: $theme-bg-color;
}
}
/** login 页面 */
.login-container {
background-color: $theme-bg-color;
color: $font-color;
.login-card {
background-color: lighten($theme-bg-color, 4%) !important;
}
.el-icon {
color: $font-color;
}
}
/** element-plus */
// 侧边栏的 item popper
.el-popper {
border: none !important;
.el-menu {
background-color: lighten($theme-bg-color, 4%) !important;
.el-menu-item {
background-color: lighten($theme-bg-color, 4%) !important;
&.is-active,
&:hover {
background-color: lighten($theme-bg-color, 8%) !important;
color: $active-font-color !important;
}
}
.el-sub-menu__title {
background-color: lighten($theme-bg-color, 4%) !important;
}
}
}
// 下拉菜单
.el-dropdown__popper .el-dropdown__list {
background-color: lighten($theme-bg-color, 8%);
.el-dropdown-menu {
background-color: lighten($theme-bg-color, 8%);
.el-dropdown-menu__item {
color: $font-color;
&.is-disabled {
color: #606266;
}
&:not(.is-disabled):hover {
background-color: lighten($theme-bg-color, 16%);
color: $active-font-color;
}
}
.el-dropdown-menu__item--divided:before {
background-color: lighten($theme-bg-color, 8%);
}
}
}
.el-popper__arrow::before {
// 下拉菜单顶部三角区域
background-color: lighten($theme-bg-color, 8%) !important;
border: lighten($theme-bg-color, 8%) !important;
}
// 单选框按钮样式
.el-radio-button__inner {
background-color: lighten($theme-bg-color, 8%);
color: $active-font-color;
border: 1px solid $border-color;
}
.el-radio-button:first-child .el-radio-button__inner {
border-left: none;
}
// el-tag
.el-tag {
background-color: lighten($theme-bg-color, 8%);
border-color: $border-color;
color: $active-font-color;
&.el-tag--info {
background-color: lighten($theme-bg-color, 8%);
border-color: $border-color;
color: $active-font-color;
}
}
// tabs 标签页
.el-tabs--border-card {
background: lighten($theme-bg-color, 8%);
border: 1px solid $border-color;
.el-tabs__header {
background-color: lighten($theme-bg-color, 8%);
border-bottom: 1px solid $border-color;
.el-tabs__item.is-active {
background-color: lighten($theme-bg-color, 8%);
border-right-color: $border-color;
border-left-color: $border-color;
}
}
}
// 卡片 card
.el-card {
background: lighten($theme-bg-color, 8%);
border: 1px solid $border-color;
color: $font-color;
}
// 输入框 input
.el-input__wrapper {
background: lighten($theme-bg-color, 8%) !important;
}
}

View File

@ -0,0 +1,48 @@
// See https://vuejs.org/v2/guide/transitions.html for detail
// fade
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.28s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
// fade-transform
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.5s;
}
.fade-transform-enter {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
// breadcrumb
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-move {
transition: all 0.5s;
}
.breadcrumb-leave-active {
position: absolute;
}

View File

@ -6,10 +6,19 @@ import '@vue/runtime-core'
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
HelloWorld: typeof import('./../components/HelloWorld.vue')['default']
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']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Screenfull: typeof import('./../components/Screenfull/index.vue')['default']
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
ThemeSwitch: typeof import('./../components/ThemeSwitch/index.vue')['default']
}
}

8
src/types/env.d.ts vendored
View File

@ -1,13 +1,5 @@
/// <reference types="vite/client" />
/** 声明自动引入的 vue 组件 */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
/** 声明 vite 环境变量的类型(如果未声明则默认是 any */
declare interface ImportMetaEnv {
readonly VITE_BASE_API: string

10
src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/** 项目类型声明 */
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'
declare module '*.tiff'
declare module '*.yaml'
declare module '*.json'

15
src/types/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
/** 声明自动引入的 vue 组件 */
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.gif' {
export const gif: any
}
declare module '*.svg' {
const content: any
export default content
}

14
src/types/vue-proptery.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
import { ElMessage } from 'element-plus'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$message: ElMessage
}
}
declare module 'vue-router' {
interface RouteMeta {
roles?: string[]
activeMenu?: string
}
}

16
src/utils/cookies.ts Normal file
View File

@ -0,0 +1,16 @@
/** cookies 封装 */
import Keys from '@/constant/key'
import Cookies from 'js-cookie'
export const getSidebarStatus = () => Cookies.get(Keys.sidebarStatus)
export const setSidebarStatus = (sidebarStatus: string) => Cookies.set(Keys.sidebarStatus, sidebarStatus)
export const getToken = () => Cookies.get(Keys.token)
export const setToken = (token: string) => Cookies.set(Keys.token, token)
export const removeToken = () => Cookies.remove(Keys.token)
export const getActiveThemeName = () => Cookies.get(Keys.activeThemeName)
export const setActiveThemeName = (themeName: string) => {
Cookies.set(Keys.activeThemeName, themeName)
}

10
src/utils/index.ts Normal file
View File

@ -0,0 +1,10 @@
import dayjs from 'dayjs'
/** 格式化时间 */
export const formatDateTime = (time: any) => {
if (time == null || time === '') {
return 'N/A'
}
const date = new Date(time)
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}

15
src/utils/permission.ts Normal file
View File

@ -0,0 +1,15 @@
import { useUserStoreHook } from '@/store/modules/user'
/** 全局权限判断函数,和指令 v-permission 功能类似 */
export const checkPermission = (value: string[]): boolean => {
if (value && value instanceof Array && value.length > 0) {
const roles = useUserStoreHook().roles
const permissionRoles = value
return roles.some((role) => {
return permissionRoles.includes(role)
})
} else {
console.error("need roles! Like v-permission=\"['admin','editor']\"")
return false
}
}

112
src/utils/service.ts Normal file
View File

@ -0,0 +1,112 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import { get } from 'lodash-es'
import { ElMessage } from 'element-plus'
import { getToken } from '@/utils/cookies'
import { useUserStoreHook } from '@/store/modules/user'
/** 创建请求实例 */
function createService() {
// 创建一个 axios 实例
const service = axios.create()
// 请求拦截
service.interceptors.request.use(
(config) => config,
// 发送失败
(error) => Promise.reject(error)
)
// 响应拦截(可根据具体业务作出相应的调整)
service.interceptors.response.use(
(response) => {
// apiData 是 api 返回的数据
const apiData = response.data as any
// 这个 code 是和后端约定的业务 code
const code = apiData.code
// 如果没有 code, 代表这不是项目后端开发的 api
if (code === undefined) {
ElMessage.error('非本系统的接口')
return Promise.reject(new Error('非本系统的接口'))
} else {
switch (code) {
case 0:
// code === 0 代表没有错误
return apiData
case 20000:
// code === 20000 代表没有错误
return apiData
default:
// 不是正确的 code
ElMessage.error(apiData.msg || 'Error')
return Promise.reject(new Error('Error'))
}
}
},
(error) => {
// status 是 HTTP 状态码
const status = get(error, 'response.status')
switch (status) {
case 400:
error.message = '请求错误'
break
case 401:
error.message = '未授权,请登录'
break
case 403:
// token 过期时,直接退出登录并强制刷新页面(会重定向到登录页)
useUserStoreHook().logout()
location.reload()
break
case 404:
error.message = '请求地址出错'
break
case 408:
error.message = '请求超时'
break
case 500:
error.message = '服务器内部错误'
break
case 501:
error.message = '服务未实现'
break
case 502:
error.message = '网关错误'
break
case 503:
error.message = '服务不可用'
break
case 504:
error.message = '网关超时'
break
case 505:
error.message = 'HTTP版本不受支持'
break
default:
break
}
ElMessage.error(error.message)
return Promise.reject(error)
}
)
return service
}
/** 创建请求方法 */
function createRequestFunction(service: AxiosInstance) {
return function(config: AxiosRequestConfig) {
const configDefault = {
headers: {
// 携带 token
'X-Access-Token': getToken(),
'Content-Type': get(config, 'headers.Content-Type', 'application/json')
},
timeout: 5000,
baseURL: process.env.VUE_APP_BASE_API,
data: {}
}
return service(Object.assign(configDefault, config))
}
}
/** 用于网络请求的实例 */
export const service = createService()
/** 用于网络请求的方法 */
export const request = createRequestFunction(service)

14
src/utils/validate.ts Normal file
View File

@ -0,0 +1,14 @@
export const isExternal = (path: string) => /^(https?:|mailto:|tel:)/.test(path)
export const isArray = (arg: any) => {
if (typeof Array.isArray === 'undefined') {
return Object.prototype.toString.call(arg) === '[object Array]'
}
return Array.isArray(arg)
}
export const isValidURL = (url: string) => {
const reg =
/^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
return reg.test(url)
}

View File

@ -0,0 +1,6 @@
<!-- admin 权限主页 -->
<template>
<div class="app-container">
Admin 权限可见
</div>
</template>

View File

@ -0,0 +1,6 @@
<!-- editor 权限主页 -->
<template>
<div class="app-container">
Editor 权限可见
</div>
</template>

View File

@ -0,0 +1,20 @@
<template>
<component :is="currentRole === 'admin' ? AdminDashboard : EditorDashboard" />
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store/modules/user'
import { computed, onBeforeMount, ref } from 'vue'
import AdminDashboard from './admin/index.vue'
import EditorDashboard from './editor/index.vue'
const currentRole = ref('admin')
const roles = computed(() => {
return useUserStore().roles
})
onBeforeMount(() => {
if (!roles.value.includes('admin')) {
currentRole.value = 'editor'
}
})
</script>

View File

@ -0,0 +1,10 @@
<template>
<div>
<p style="text-align: center; font-size: 140px; margin-bottom: 50px">
401
</p>
<p style="text-align: center; font-size: 40px">
你没有权限去该页面
</p>
</div>
</template>

View File

@ -0,0 +1,10 @@
<template>
<div>
<p style="text-align: center; font-size: 140px; margin-bottom: 50px">
404
</p>
<p style="text-align: center; font-size: 40px">
未找到你想要的页面
</p>
</div>
</template>

220
src/views/login/index.vue Normal file
View File

@ -0,0 +1,220 @@
<template>
<div class="login-container">
<ThemeSwitch class="theme-switch" />
<div class="login-card">
<div class="title">
<img src="@/assets/layout/logo-text-2.png">
</div>
<div class="content">
<el-form ref="loginFormDom" :model="loginForm" :rules="loginRules" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
type="text"
tabindex="1"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
placeholder="密码"
type="password"
tabindex="2"
:prefix-icon="Lock"
size="large"
/>
</el-form-item>
<el-form-item prop="code">
<el-input
v-model="loginForm.code"
placeholder="验证码"
type="text"
tabindex="3"
:prefix-icon="Key"
maxlength="4"
size="large"
/>
<span class="show-code">
<img :src="codeUrl" @click="createCode">
</span>
</el-form-item>
<el-button :loading="loading" type="primary" size="large" @click.prevent="handleLogin">
</el-button>
</el-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { useRouter } from 'vue-router'
import { User, Lock, Key } from '@element-plus/icons-vue'
import ThemeSwitch from '@/components/ThemeSwitch/index.vue'
interface ILoginForm {
/** admin 或 editor */
username: string
/** 密码 */
password: string
/** 验证码 */
code: string
/** 随机数 */
codeToken: string
}
const router = useRouter()
const loading = ref<boolean>(false)
const loginFormDom = ref<any>()
const codeUrl = ref<string>('')
const loginForm = reactive<ILoginForm>({
username: 'admin',
password: '123456',
code: '1234',
codeToken: ''
})
const loginRules = reactive({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 18, message: '长度在 6 到 18 个字符', trigger: 'blur' }
],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
})
const handleLogin = () => {
loginFormDom.value.validate((valid: boolean) => {
if (valid) {
loading.value = true
useUserStore()
.login({
username: loginForm.username,
password: loginForm.password
})
.then(() => {
loading.value = false
router.push({ path: '/' }).catch((err) => {
console.warn(err)
})
})
.catch(() => {
loading.value = false
createCode()
})
} else {
return false
}
})
}
/** 创建验证码 */
const createCode: () => void = () => {
//
loginForm.code = ''
let codeToken = ''
const codeLength = 12
//
const random: Array<number | string> = [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z'
]
for (let i = 0; i < codeLength; i++) {
const index = Math.floor(Math.random() * 36)
codeToken += random[index]
}
loginForm.codeToken = codeToken
//
codeUrl.value = `/api/v1/login/authcode?token=${codeToken}`
}
//
// createCode()
</script>
<style lang="scss" scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100%;
.theme-switch {
position: fixed;
top: 5%;
right: 5%;
cursor: pointer;
}
.login-card {
width: 480px;
border-radius: 20px;
box-shadow: 0 0 10px #dcdfe6;
background-color: #fff;
overflow: hidden;
.title {
display: flex;
justify-content: center;
align-items: center;
height: 150px;
img {
height: 100%;
}
}
.content {
padding: 20px 50px 50px 50px;
.show-code {
position: absolute;
right: 0px;
top: 0px;
cursor: pointer;
user-select: none;
img {
width: 100px;
height: 40px;
border-radius: 4px;
}
}
.el-button {
width: 100%;
margin-top: 10px;
}
}
}
}
</style>

View File

@ -0,0 +1,29 @@
<!-- 切换角色 -->
<template>
<div>
<div style="margin-bottom: 15px">
你的权限{{ roles }}
</div>
<div style="display: flex; align-items: center">
<span>切换权限</span>
<el-radio-group v-model="currentRole">
<el-radio-button label="editor" />
<el-radio-button label="admin" />
</el-radio-group>
</div>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store/modules/user'
import { computed, ref, watch } from 'vue'
const userStore = useUserStore()
const emit = defineEmits(['change'])
const roles = computed(() => userStore.roles)
const currentRole = ref(roles.value[0])
watch(currentRole, async(value) => {
await userStore.changeRoles(value)
emit('change')
})
</script>

View File

@ -0,0 +1,92 @@
<!-- 指令权限权限判断函数的使用 -->
<template>
<div class="app-container">
<SwitchRoles @change="state.handleRolesChange" />
<div :key="state.key" style="margin-top: 30px">
<div>
<span v-permission="['admin']" class="permission-alert">
只有
<el-tag>admin</el-tag>可以看见这个
</span>
<el-tag v-permission="['admin']" class="permission-sourceCode" type="info" size="large">
v-permission="['admin']"
</el-tag>
</div>
<div>
<span v-permission="['editor']" class="permission-alert">
只有
<el-tag>editor</el-tag>可以看见这个
</span>
<el-tag v-permission="['editor']" class="permission-sourceCode" type="info" size="large">
v-permission="['editor']"
</el-tag>
</div>
<div>
<span v-permission="['admin', 'editor']" class="permission-alert">
两者
<el-tag>admin</el-tag> <el-tag>editor</el-tag>都可以看见这个
</span>
<el-tag v-permission="['admin', 'editor']" class="permission-sourceCode" type="info" size="large">
v-permission="['admin', 'editor']"
</el-tag>
</div>
</div>
<div :key="'checkPermission' + state.key" style="margin-top: 60px">
<el-tag type="info" size="large">
在某些情况下不适合使用 v-permission例如element-plus el-tab el-table-column 以及其它动态渲染 dom
的场景你只能通过手动设置 v-if 来实现
</el-tag>
<el-tabs type="border-card" style="width: 550px; margin-top: 60px">
<el-tab-pane v-if="checkPermission(['admin'])" label="admin">
admin 可以看见这个
<el-tag class="permission-sourceCode" type="info">
v-if="checkPermission(['admin'])"
</el-tag>
</el-tab-pane>
<el-tab-pane v-if="checkPermission(['editor'])" label="editor">
editor 可以看见这个
<el-tag class="permission-sourceCode" type="info">
v-if="checkPermission(['editor'])"
</el-tag>
</el-tab-pane>
<el-tab-pane v-if="checkPermission(['admin', 'editor'])" label="admin 和 editor">
两者 admin editor 都可以看见这个
<el-tag class="permission-sourceCode" type="info">
v-if="checkPermission(['admin', 'editor'])"
</el-tag>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive } from 'vue'
import { checkPermission } from '@/utils/permission' //
import SwitchRoles from './components/switch-roles.vue'
const state = reactive({
key: 1,
handleRolesChange: () => {
state.key++
}
})
</script>
<style lang="scss" scoped>
.permission-alert {
width: 320px;
margin-top: 15px;
background-color: #f0f9eb;
color: #67c23a;
padding: 8px 16px;
border-radius: 4px;
display: inline-block;
}
.permission-sourceCode {
margin-left: 15px;
}
</style>

View File

@ -0,0 +1,21 @@
<!-- 页面权限测试页 -->
<template>
<div class="app-container">
<el-tag type="success" size="large" style="margin-bottom: 15px">
当前页面只有 admin 权限可见
</el-tag>
<SwitchRoles @change="handleRolesChange" />
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import SwitchRoles from './components/switch-roles.vue'
const router = useRouter()
const handleRolesChange = () => {
router.push({ path: '/401' }).catch((err) => {
console.warn(err)
})
}
</script>

View File

@ -0,0 +1,16 @@
<!-- 重定向 -->
<template>
<div />
</template>
<script lang="ts" setup>
import { useRoute, useRouter } from 'vue-router'
const { params, query } = useRoute()
const { path } = params
useRouter()
.replace({ path: '/' + path, query })
.catch((err) => {
console.warn(err)
})
</script>

View File

@ -1,4 +1,4 @@
import { ConfigEnv, UserConfigExport } from 'vite'
import { UserConfigExport } from 'vite'
import path, { resolve } from 'path'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
@ -7,7 +7,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
/** 配置项文档https://vitejs.dev/config */
export default (env: ConfigEnv): UserConfigExport => {
export default (): UserConfigExport => {
return {
/** build 打包时根据实际情况修改 base */
base: '/',