commit
0ec27c9c14
66 changed files with 12959 additions and 0 deletions
@ -0,0 +1,14 @@
|
||||
# http://editorconfig.org |
||||
root = true |
||||
|
||||
[*] |
||||
charset = utf-8 |
||||
end_of_line = lf |
||||
indent_size = 2 |
||||
indent_style = space |
||||
insert_final_newline = true |
||||
max_line_length = 160 |
||||
trim_trailing_whitespace = true |
||||
|
||||
[*.md] |
||||
max_line_length = 0 |
@ -0,0 +1,23 @@
|
||||
module.exports = { |
||||
root: true, |
||||
env: { |
||||
node: true, |
||||
}, |
||||
extends: ["plugin:vue/essential", "eslint:recommended"], |
||||
parserOptions: { |
||||
parser: "babel-eslint", |
||||
}, |
||||
rules: { |
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", |
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", |
||||
"no-process-env": "error", |
||||
}, |
||||
overrides: [ |
||||
{ |
||||
files: ["**/__tests__/*.{j,t}s?(x)", "**/tests/unit/**/*.spec.{j,t}s?(x)"], |
||||
env: { |
||||
jest: true, |
||||
}, |
||||
}, |
||||
], |
||||
}; |
@ -0,0 +1,23 @@
|
||||
.DS_Store |
||||
node_modules |
||||
/dist |
||||
|
||||
|
||||
# local env files |
||||
.env.local |
||||
.env.*.local |
||||
|
||||
# Log files |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
|
||||
# Editor directories and files |
||||
.idea |
||||
.vscode |
||||
*.suo |
||||
*.ntvs* |
||||
*.njsproj |
||||
*.sln |
||||
*.sw? |
@ -0,0 +1,29 @@
|
||||
# agk-admin |
||||
|
||||
## Project setup |
||||
``` |
||||
yarn install |
||||
``` |
||||
|
||||
### Compiles and hot-reloads for development |
||||
``` |
||||
yarn serve |
||||
``` |
||||
|
||||
### Compiles and minifies for production |
||||
``` |
||||
yarn build |
||||
``` |
||||
|
||||
### Run your unit tests |
||||
``` |
||||
yarn test:unit |
||||
``` |
||||
|
||||
### Lints and fixes files |
||||
``` |
||||
yarn lint |
||||
``` |
||||
|
||||
### Customize configuration |
||||
See [Configuration Reference](https://cli.vuejs.org/config/). |
@ -0,0 +1,3 @@
|
||||
module.exports = { |
||||
presets: ["@vue/cli-plugin-babel/preset"], |
||||
}; |
@ -0,0 +1,3 @@
|
||||
module.exports = { |
||||
preset: "@vue/cli-plugin-unit-jest", |
||||
}; |
@ -0,0 +1,10 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"baseUrl": ".", |
||||
"paths": { |
||||
"@/*": [ |
||||
"src/*" |
||||
] |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,51 @@
|
||||
{ |
||||
"name": "agk-admin", |
||||
"version": "0.1.0", |
||||
"private": true, |
||||
"license": "MIT", |
||||
"scripts": { |
||||
"dev": "vue-cli-service serve", |
||||
"build": "vue-cli-service build", |
||||
"test:unit": "vue-cli-service test:unit", |
||||
"lint": "vue-cli-service lint", |
||||
"jsdoc": "./node_modules/.bin/jsdoc" |
||||
}, |
||||
"dependencies": { |
||||
"@vue/composition-api": "^1.4.1", |
||||
"axios": "^0.24.0", |
||||
"axios-case-converter": "^0.9.0", |
||||
"core-js": "^3.6.5", |
||||
"element-ui": "^2.15.7", |
||||
"lodash": "^4.17.21", |
||||
"modern-normalize": "^1.1.0", |
||||
"pinia": "^2.0.6", |
||||
"resize-observer-polyfill": "^1.5.1", |
||||
"vue": "^2.6.14", |
||||
"vue-router": "^3.2.0", |
||||
"vue-tippy": "^4.13.0", |
||||
"vue2-helpers": "^1.1.6" |
||||
}, |
||||
"devDependencies": { |
||||
"@fortawesome/fontawesome-free": "^5.15.4", |
||||
"@types/webpack-env": "^1.16.3", |
||||
"@vue/cli-plugin-babel": "~4.5.0", |
||||
"@vue/cli-plugin-eslint": "~4.5.0", |
||||
"@vue/cli-plugin-router": "~4.5.0", |
||||
"@vue/cli-plugin-unit-jest": "~4.5.0", |
||||
"@vue/cli-plugin-vuex": "~4.5.0", |
||||
"@vue/cli-service": "~4.5.0", |
||||
"@vue/eslint-config-prettier": "^6.0.0", |
||||
"@vue/test-utils": "^1.0.3", |
||||
"babel-eslint": "^10.1.0", |
||||
"cross-env": "^7.0.3", |
||||
"eslint": "^6.7.2", |
||||
"eslint-plugin-prettier": "^3.3.1", |
||||
"eslint-plugin-vue": "^6.2.2", |
||||
"git-describe": "^4.1.0", |
||||
"jsdoc": "^3.6.7", |
||||
"prettier": "^2.2.1", |
||||
"sass": "^1.44.0", |
||||
"sass-loader": "^8.0.2", |
||||
"vue-template-compiler": "^2.6.11" |
||||
} |
||||
} |
After Width: | Height: | Size: 4.2 KiB |
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html> |
||||
<html lang=""> |
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> |
||||
<title><%= htmlWebpackPlugin.options.title %></title> |
||||
</head> |
||||
<body> |
||||
<noscript> |
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
||||
</noscript> |
||||
<div id="app"></div> |
||||
<!-- built files will be auto injected --> |
||||
</body> |
||||
</html> |
@ -0,0 +1,9 @@
|
||||
<template> |
||||
<div id="app"> |
||||
<router-view /> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="sass"> |
||||
@import '@/styles/global.scss' |
||||
</style> |
After Width: | Height: | Size: 560 KiB |
Binary file not shown.
After Width: | Height: | Size: 449 KiB |
After Width: | Height: | Size: 729 B |
@ -0,0 +1,21 @@
|
||||
<script> |
||||
import { defineComponent } from "@vue/composition-api"; |
||||
import { useUserStore } from "@/stores/modules/user"; |
||||
|
||||
export default defineComponent({ |
||||
setup() { |
||||
const user = useUserStore(); |
||||
|
||||
return { user }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="page-container"> |
||||
<p>{{ user.username || "none" }}</p> |
||||
<p>{{ user.token || "none" }}</p> |
||||
<el-button type="primary" size="default" @click="user.login">Login</el-button> |
||||
<el-button type="primary" size="default" @click="user.logout">Logout</el-button> |
||||
</div> |
||||
</template> |
@ -0,0 +1,5 @@
|
||||
/* eslint no-process-env: 0 */ |
||||
export const appName = "agk-admin"; |
||||
export const baseUrl = process.env.BASE_URL; |
||||
export const gitVersion = process.env.VUE_APP_GIT_HASH; |
||||
export const apiBaseUrl = process.env.VUE_APP_BASE_URL; |
@ -0,0 +1,26 @@
|
||||
<script> |
||||
import { defineComponent, computed } from "@vue/composition-api"; |
||||
import { useRoute } from "vue2-helpers/vue-router"; |
||||
import { useRouteStore } from "@/stores/modules/route"; |
||||
|
||||
export default defineComponent({ |
||||
name: "AppMain", |
||||
setup() { |
||||
const route = useRoute(); |
||||
const routeStore = useRouteStore(); |
||||
const keepAliveComponents = computed(() => routeStore.keepAliveComponents); |
||||
const fullPath = computed(() => route.fullPath); |
||||
return { keepAliveComponents, fullPath }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<section> |
||||
<transition name="fade-transform" mode="out-in"> |
||||
<keep-alive :include="keepAliveComponents"> |
||||
<router-view :key="fullPath" /> |
||||
</keep-alive> |
||||
</transition> |
||||
</section> |
||||
</template> |
@ -0,0 +1,43 @@
|
||||
<script> |
||||
import { defineComponent, computed } from "@vue/composition-api"; |
||||
|
||||
export default defineComponent({ |
||||
setup(props, { root }) { |
||||
const generator = (routerMap) => { |
||||
return routerMap.map((item) => { |
||||
const currentMenu = { |
||||
...item, |
||||
label: item.meta.title, |
||||
key: item.name, |
||||
disabled: item.path === "/", |
||||
}; |
||||
return currentMenu; |
||||
}); |
||||
}; |
||||
const breadcrumbList = computed(() => { |
||||
return generator(root.$route.matched); |
||||
}); |
||||
return { breadcrumbList, r: root.$route }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<el-breadcrumb separator="/"> |
||||
<el-breadcrumb-item v-for="routeItem in breadcrumbList" :key="routeItem.name"> |
||||
<span class="link-text"> |
||||
{{ routeItem.meta.title }} |
||||
</span> |
||||
</el-breadcrumb-item> |
||||
</el-breadcrumb> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
@use "../../styles/theme"; |
||||
::v-deep { |
||||
.el-breadcrumb__inner { |
||||
color: theme.$text-primary-color; |
||||
font-weight: 500; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,71 @@
|
||||
<script> |
||||
import { defineComponent } from "@vue/composition-api"; |
||||
import Breadcrumb from "./Breadcrumb.vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "Header", |
||||
components: { Breadcrumb }, |
||||
props: { |
||||
isMenuOpen: { |
||||
type: Boolean, |
||||
default: true, |
||||
}, |
||||
}, |
||||
setup() { |
||||
const vTippy = { placement: "bottom", arrow: true, arrowType: "round", theme: "light" }; |
||||
return { vTippy }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<header class="header"> |
||||
<div class="left"> |
||||
<!-- <div class="icon-button" :content="`${$props.isMenuOpen ? '折叠' : '展开'}菜单`" v-tippy="vTippy"> |
||||
<i :class="`fas fa-${$props.isMenuOpen ? 'outdent' : 'indent'}`"></i> |
||||
</div> --> |
||||
<Breadcrumb></Breadcrumb> |
||||
</div> |
||||
<div class="right"> |
||||
<div class="icon-button" content="设置" v-tippy="vTippy"> |
||||
<i class="fas fa-sliders-h"></i> |
||||
</div> |
||||
<div class="icon-button" content="账户" v-tippy="vTippy"> |
||||
<i class="fas fa-user-circle"></i> |
||||
</div> |
||||
</div> |
||||
</header> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
@use "../../styles/theme"; |
||||
.header { |
||||
height: 52px; |
||||
width: 100%; |
||||
background: theme.$block-bg-primary; |
||||
user-select: none; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
> * { |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
> .left { |
||||
justify-content: flex-start; |
||||
> * { |
||||
margin-left: 1rem; |
||||
} |
||||
} |
||||
> .right { |
||||
justify-content: flex-end; |
||||
> * { |
||||
margin-right: 1rem; |
||||
} |
||||
} |
||||
.icon-button { |
||||
font-size: 1.2rem; |
||||
cursor: pointer; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,35 @@
|
||||
<script lang="jsx"> |
||||
import { defineComponent, computed } from "@vue/composition-api"; |
||||
import { isExternal as isExternalFn } from "@/utils/validate.js"; |
||||
|
||||
export default defineComponent({ |
||||
name: "Link", |
||||
props: { |
||||
to: { type: String, required: true }, |
||||
}, |
||||
setup(props) { |
||||
const isExternal = computed(() => isExternalFn(props.to)); |
||||
const type = computed(() => (isExternal.value ? "a" : "router-link")); |
||||
|
||||
const linkAttrs = computed(() => |
||||
isExternal.value |
||||
? { |
||||
href: props.to, |
||||
target: "_blank", |
||||
rel: "noopener noreferrer", |
||||
} |
||||
: { |
||||
to: props.to, |
||||
} |
||||
); |
||||
|
||||
return { type, linkAttrs }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<component :is="type" v-bind="linkAttrs"> |
||||
<slot /> |
||||
</component> |
||||
</template> |
@ -0,0 +1,38 @@
|
||||
<script> |
||||
import { defineComponent } from "@vue/composition-api"; |
||||
import { appName } from "@/configs"; |
||||
|
||||
export default defineComponent({ |
||||
setup() { |
||||
return { appName }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="logo"> |
||||
<img class="image" :src="require('@/assets/images/trademarks/app-logo.svg')" alt="" /> |
||||
<div class="title">{{ appName }}</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.logo { |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
padding: 1rem; |
||||
.image { |
||||
width: 1.6rem; |
||||
height: 1.6rem; |
||||
object-fit: contain; |
||||
} |
||||
.title { |
||||
color: #ffffff; |
||||
font-size: 1.2rem; |
||||
font-weight: 900; |
||||
text-transform: uppercase; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,64 @@
|
||||
<script> |
||||
import { defineComponent, computed } from "@vue/composition-api"; |
||||
import { useRouteStore } from "@/stores/modules/route"; |
||||
import SideBarItem from "./SideBarItem.vue"; |
||||
import Logo from "./Logo.vue"; |
||||
import SideBarFooter from "./SideBarFooter.vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "SideBar", |
||||
components: { SideBarItem, Logo, SideBarFooter }, |
||||
setup() { |
||||
const routeStore = useRouteStore(); |
||||
const routes = computed(() => routeStore.getRouters); |
||||
|
||||
return { routes }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="side-bar"> |
||||
<div class="logo"> |
||||
<Logo></Logo> |
||||
</div> |
||||
<el-scrollbar class="menu"> |
||||
<el-menu class="el-menu" background-color="transparent" text-color="#ffffff" active-text-color="#ffffff"> |
||||
<template v-for="item in routes"> |
||||
<SideBarItem v-if="!item.meta.hidden" :key="item.path" :item="item" base-path="/"></SideBarItem> |
||||
</template> |
||||
</el-menu> |
||||
</el-scrollbar> |
||||
<div class="menu-footer"> |
||||
<SideBarFooter></SideBarFooter> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
::v-deep { |
||||
.el-scrollbar__wrap { |
||||
overflow-x: hidden; |
||||
} |
||||
.el-menu { |
||||
border-right: none; |
||||
} |
||||
} |
||||
.side-bar { |
||||
width: 200px; |
||||
display: flex; |
||||
flex-flow: column nowrap; |
||||
.logo { |
||||
flex: 0 0 auto; |
||||
} |
||||
.menu { |
||||
flex: 1 1 auto; |
||||
height: 100%; |
||||
} |
||||
.menu-footer { |
||||
flex: 0 0 auto; |
||||
align-self: flex-end; |
||||
width: 100%; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,23 @@
|
||||
<script> |
||||
import { defineComponent } from "@vue/composition-api"; |
||||
import { gitVersion } from "@/configs"; |
||||
|
||||
export default defineComponent({ |
||||
setup() { |
||||
return { gitVersion }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<p class="footer">©2021 (build {{ gitVersion }})</p> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.footer { |
||||
text-align: center; |
||||
color: #ffffff; |
||||
font-size: 1rem; |
||||
margin: 0.5rem; |
||||
} |
||||
</style> |
@ -0,0 +1,88 @@
|
||||
<script> |
||||
import { defineComponent, computed } from "@vue/composition-api"; |
||||
import Link from "./Link.vue"; |
||||
import { isExternal } from "@/utils/validate"; |
||||
import path from "path"; |
||||
import SideBarTitle from "./SideBarTitle.vue"; |
||||
|
||||
export default defineComponent({ |
||||
name: "SidebarItem", |
||||
components: { Link, SideBarTitle }, |
||||
props: { |
||||
item: { type: Object, required: true }, |
||||
isNest: { type: Boolean, default: false }, |
||||
basePath: { type: String, default: "" }, |
||||
}, |
||||
setup(props, { root }) { |
||||
const hasChildren = computed(() => props.item.children && props.item.children.length > 0); |
||||
|
||||
const resolvePath = (currentPath) => { |
||||
if (isExternal(currentPath)) { |
||||
return currentPath; |
||||
} |
||||
if (isExternal(props.basePath)) { |
||||
return props.basePath; |
||||
} |
||||
return path.join(props.basePath, currentPath); |
||||
}; |
||||
|
||||
const currentFullPath = computed(() => root.$route.fullPath); |
||||
|
||||
return { hasChildren, resolvePath, currentFullPath }; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<el-submenu v-if="hasChildren" :index="$props.item.path"> |
||||
<template slot="title"> |
||||
<SideBarTitle :icon="$props.item.meta.icon" :context="$props.item.meta.title"></SideBarTitle> |
||||
</template> |
||||
<SidebarItem |
||||
v-for="child in $props.item.children" |
||||
:key="child.path" |
||||
:isNest="true" |
||||
:item="child" |
||||
:basePath="resolvePath($props.item.path)" |
||||
class="nest-menu" |
||||
/> |
||||
</el-submenu> |
||||
|
||||
<Link v-else :to="resolvePath($props.item.path)"> |
||||
<el-menu-item :index="$props.item.path" :class="{ active: resolvePath($props.item.path) === currentFullPath }"> |
||||
<template slot="title"> |
||||
<SideBarTitle :icon="$props.item.meta.icon" :context="$props.item.meta.title"></SideBarTitle> |
||||
</template> |
||||
</el-menu-item> |
||||
</Link> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
@use "../../styles/theme"; |
||||
@mixin item-common { |
||||
background-color: transparent !important; |
||||
// font-size: 1rem; |
||||
font-weight: 600; |
||||
border-radius: 0.5rem; |
||||
margin: 0.1rem 0.5rem; |
||||
min-width: unset !important; |
||||
&:hover { |
||||
box-shadow: theme.$block-box-shadow; |
||||
} |
||||
&:hover, |
||||
&.active { |
||||
background-color: theme.$block-bg-light !important; |
||||
} |
||||
} |
||||
::v-deep { |
||||
.el-menu-item { |
||||
@include item-common; |
||||
} |
||||
.el-submenu__title { |
||||
@include item-common; |
||||
} |
||||
.el-submenu__icon-arrow { |
||||
color: theme.$text-white-color !important; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,38 @@
|
||||
<script> |
||||
import { defineComponent } from "@vue/composition-api"; |
||||
|
||||
export default defineComponent({ |
||||
name: "SideBarTitle", |
||||
props: { |
||||
context: String, |
||||
icon: String, |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="title"> |
||||
<template v-if="icon"> |
||||
<img v-if="/(.*)\.[svg|png|jpg|jpeg|gif]/i.test($props.icon)" class="icon" :src="$props.icon" /> |
||||
<i v-else :class="['icon', $props.icon]"></i> |
||||
</template> |
||||
<span>{{ $props.context }}</span> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
@use "../../styles/theme"; |
||||
.title { |
||||
color: theme.$text-white-color; |
||||
display: flex; |
||||
justify-content: flex-start; |
||||
align-items: center; |
||||
font-size: 0.9rem; |
||||
.icon { |
||||
height: 1rem; |
||||
font-size: 1rem; |
||||
color: theme.$text-white-color; |
||||
margin-right: 0.5rem; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,293 @@
|
||||
<script> |
||||
import { defineComponent, ref, computed, onMounted, onActivated, onBeforeUnmount, onDeactivated, nextTick, watch } from "@vue/composition-api"; |
||||
import useResizeObserver from "@/utils/hooks/useResizeObserver"; |
||||
import normalizeWheel from "@/utils/normalizeWheel"; |
||||
import { useTabsStore } from "@/stores/modules/tabs"; |
||||
import { useRouteStore } from "@/stores/modules/route"; |
||||
import { useRouter } from "vue2-helpers/vue-router"; |
||||
import { Notification } from "element-ui"; |
||||
import { throttle } from "lodash"; |
||||
|
||||
export default defineComponent({ |
||||
name: "Tabs", |
||||
setup(props, { root }) { |
||||
const router = useRouter(); |
||||
const route = computed(() => root.$route); |
||||
// Pinia devtools bug |
||||
// https://github.com/vuejs/pinia/issues/905 |
||||
const tabsStore = useTabsStore(); |
||||
const routeStore = useRouteStore(); |
||||
|
||||
// drop useless route properties |
||||
const getSimpleRoute = (route) => { |
||||
const { fullPath, hash, meta, name, params, path, query } = route; |
||||
return { fullPath, hash, meta, name, params, path, query }; |
||||
}; |
||||
|
||||
tabsStore.initTabs([]); |
||||
|
||||
const currentActive = ref(); |
||||
|
||||
const tabs = computed(() => tabsStore?.list ?? []); |
||||
const whiteList = []; |
||||
|
||||
// close specific tab |
||||
const handleCloseThisTab = (route) => { |
||||
if (tabs.value.length === 1) { |
||||
return Notification({ |
||||
message: "这已经是最后一页,不能再关闭了!", |
||||
type: "warning", |
||||
duration: 1000, |
||||
}); |
||||
} |
||||
tabsStore.closeThisTab(route); |
||||
removeFromKeepAliveComponentList(route); |
||||
// handle if is closing current page |
||||
if (currentActive.value === route.fullPath) { |
||||
const lastRoute = tabs.value[Math.max(0, tabs.value.length - 1)]; |
||||
currentActive.value = lastRoute.fullPath; |
||||
router.push(lastRoute).catch(() => {}); |
||||
} |
||||
}; |
||||
|
||||
const handleClickTab = (route) => { |
||||
if (currentActive.value === route.fullPath) { |
||||
return; |
||||
} |
||||
router.push(route.fullPath).catch(() => {}); |
||||
}; |
||||
|
||||
// remove from keep alive components list |
||||
// @TODO: not tested |
||||
const removeFromKeepAliveComponentList = (route) => { |
||||
if (route.meta.keepAlive) { |
||||
const name = router.currentRoute.matched.find((item) => item.name == route.name)?.components?.default.name; |
||||
if (name) { |
||||
routeStore.setKeepAliveComponents(routeStore.keepAliveComponents.filter((item) => item != name)); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
const scrollViewRef = ref(null); |
||||
const scrollContainerRef = ref(null); |
||||
|
||||
const scrollViewSize = useResizeObserver(scrollViewRef); |
||||
const scrollContainerSize = useResizeObserver(scrollContainerRef); |
||||
|
||||
const shouldDisplayScrollController = computed(() => { |
||||
if (!scrollViewSize.value || !scrollContainerSize.value) { |
||||
return false; |
||||
} |
||||
|
||||
return scrollViewSize.value.width > scrollContainerSize.value.width; |
||||
}); |
||||
|
||||
/** |
||||
* @param {number} scrollLeft |
||||
*/ |
||||
const scrollController = (scrollLeft) => { |
||||
scrollLeft = Math.max(0, Math.min(scrollLeft, scrollViewSize.value.width - scrollContainerSize.value.width)); |
||||
scrollContainerRef.value.scrollTo({ |
||||
top: 0, |
||||
left: scrollLeft, |
||||
behavior: "smooth", |
||||
}); |
||||
}; |
||||
|
||||
const scrollToTab = async (route) => { |
||||
const tab = tabs.value.find((item) => item.fullPath === route.fullPath)?.fullPath; |
||||
|
||||
if (!tab) { |
||||
return; |
||||
} |
||||
await nextTick(); |
||||
const element = document.getElementById(`tags-${tab.split("/").join("-")}`); |
||||
if (!element) return; |
||||
// const tabRect = element.getBoundingClientRect(); |
||||
const offsetLeft = element.offsetLeft; |
||||
const scrollLeft = offsetLeft; // + scrollContainerSize.value.width / 2 - tabRect.width / 2; |
||||
scrollController(scrollLeft); |
||||
}; |
||||
|
||||
const scrollToLeft = () => scrollController(0); |
||||
const scrollToRight = () => scrollController(scrollViewSize.value.width - scrollContainerSize.value.width); |
||||
const scrollNext = () => scrollController(scrollContainerRef.value.scrollLeft + 100); |
||||
const scrollPrev = () => scrollController(scrollContainerRef.value.scrollLeft - 100); |
||||
|
||||
/** |
||||
* Event listeners to remove |
||||
* @type {number[]} |
||||
*/ |
||||
const removers = []; |
||||
|
||||
/** |
||||
* on wheel event |
||||
* @param {WheelEvent} event |
||||
*/ |
||||
const mouseWheelHandler = (event) => { |
||||
event.preventDefault(); |
||||
|
||||
const _event = normalizeWheel(event); |
||||
|
||||
const container = scrollContainerRef.value; |
||||
|
||||
scrollController(container.scrollLeft + _event.pixelY * 3); |
||||
}; |
||||
|
||||
const mouseWheelHandlerThrottle = throttle(mouseWheelHandler, 200); |
||||
|
||||
const componentDidMount = async () => { |
||||
await nextTick(); |
||||
const container = scrollContainerRef.value; |
||||
|
||||
container.addEventListener("wheel", mouseWheelHandlerThrottle); |
||||
removers.push(() => container.removeEventListener("wheel", mouseWheelHandlerThrottle)); |
||||
// Old Chrome |
||||
container.addEventListener("mousewheel", mouseWheelHandlerThrottle); |
||||
removers.push(() => container.removeEventListener("mousewheel", mouseWheelHandlerThrottle)); |
||||
// Old Firefox |
||||
container.addEventListener("DOMMouseScroll", mouseWheelHandlerThrottle); |
||||
removers.push(() => container.removeEventListener("DOMMouseScroll", mouseWheelHandlerThrottle)); |
||||
}; |
||||
|
||||
const componentWillUnmount = () => { |
||||
removers.forEach((remover) => remover()); |
||||
}; |
||||
|
||||
onMounted(componentDidMount); |
||||
onBeforeUnmount(componentWillUnmount); |
||||
onActivated(componentDidMount); |
||||
onDeactivated(componentWillUnmount); |
||||
|
||||
watch( |
||||
() => route.value.fullPath, |
||||
(to) => { |
||||
// console.log(to, route.value); |
||||
if (whiteList.includes(route.value.name)); |
||||
currentActive.value = to; |
||||
tabsStore.addTabs(getSimpleRoute(route.value)); |
||||
scrollToTab(route.value); |
||||
}, |
||||
{ immediate: true } |
||||
); |
||||
|
||||
return { |
||||
tabs, |
||||
currentActive, |
||||
handleCloseThisTab, |
||||
handleClickTab, |
||||
scrollContainerRef, |
||||
scrollViewRef, |
||||
shouldDisplayScrollController, |
||||
scrollNext, |
||||
scrollPrev, |
||||
scrollToLeft, |
||||
scrollToRight, |
||||
}; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<section class="tabs"> |
||||
<div class="scroll-wrapper"> |
||||
<el-tag v-show="shouldDisplayScrollController" class="tag scroll-controller" size="normal" effect="plain" @click="scrollPrev" |
||||
><i class="el-icon-arrow-left"></i |
||||
></el-tag> |
||||
<div ref="scrollContainerRef" class="scroll-container"> |
||||
<div ref="scrollViewRef" class="scroll-view"> |
||||
<el-tag |
||||
:id="`tags-${tab.fullPath.split('/').join('-')}`" |
||||
:class="['tag', { active: currentActive === tab.fullPath }]" |
||||
:data-full="[currentActive, tab.fullPath]" |
||||
v-for="tab in tabs" |
||||
:key="tab.fullPath" |
||||
size="normal" |
||||
effect="plain" |
||||
closable |
||||
@close="handleCloseThisTab(tab)" |
||||
@click="handleClickTab(tab)" |
||||
>{{ tab.meta.title }}</el-tag |
||||
> |
||||
</div> |
||||
</div> |
||||
<el-tag v-show="shouldDisplayScrollController" class="tag scroll-controller" size="normal" effect="plain" @click="scrollNext" |
||||
><i class="el-icon-arrow-right"></i |
||||
></el-tag> |
||||
</div> |
||||
</section> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
@use "../../styles/theme"; |
||||
|
||||
.tabs { |
||||
--side-bar-width: 200px; |
||||
--gap-size: 0.5rem; |
||||
width: 100%; |
||||
user-select: none; |
||||
.scroll-wrapper { |
||||
width: calc(100vw - var(--gap-size, 0.5rem) * 2 - var(--side-bar-width, 200px)); |
||||
margin: var(--gap-size, 0.5rem); |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
.tag { |
||||
cursor: pointer; |
||||
margin-right: var(--gap-size, 0.5rem); |
||||
&:last-child { |
||||
margin-right: 0; |
||||
} |
||||
&.active { |
||||
background-color: theme.$primary-color; |
||||
color: #ffffff; |
||||
::v-deep { |
||||
.el-tag__close { |
||||
color: #ffffff; |
||||
&:hover { |
||||
background-color: theme.$block-bg-light; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
.scroll-container { |
||||
flex: 1 1 auto; |
||||
width: 100%; |
||||
border-radius: 4px; |
||||
overflow: hidden; |
||||
.scroll-view { |
||||
white-space: nowrap; |
||||
width: auto; |
||||
width: fit-content; |
||||
block-size: fit-content; |
||||
position: relative; |
||||
> * { |
||||
display: inline-block; |
||||
} |
||||
} |
||||
} |
||||
.scroll-controller { |
||||
flex: 0 0 auto; |
||||
&:last-child { |
||||
margin-right: 0; |
||||
margin-left: var(--gap-size, 0.5rem); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
::v-deep { |
||||
.el-tag--plain { |
||||
background-color: theme.$block-bg-primary; |
||||
color: theme.$text-primary-color; |
||||
border: none; |
||||
.el-tag__close { |
||||
color: theme.$text-primary-color; |
||||
&:hover { |
||||
background-color: theme.$block-bg-dark; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,2 @@
|
||||
import AppMain from "./components/AppMain.vue"; |
||||
export default AppMain; |
@ -0,0 +1,76 @@
|
||||
<script> |
||||
import { defineComponent } from "@vue/composition-api"; |
||||
import SideBar from "./components/SideBar.vue"; |
||||
import AppMain from "./components/AppMain.vue"; |
||||
import Header from "./components/Header.vue"; |
||||
import Tabs from "./components/Tabs.vue"; |
||||
|
||||
export default defineComponent({ |
||||
components: { SideBar, AppMain, Header, Tabs }, |
||||
setup() { |
||||
return; |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="layout-index"> |
||||
<img class="background" :src="require('@/assets/images/auth/mountain.jpg')" /> |
||||
<div class="side-bar"> |
||||
<SideBar></SideBar> |
||||
</div> |
||||
<div class="view-box"> |
||||
<div class="header"> |
||||
<Header></Header> |
||||
</div> |
||||
<div class="tabs"> |
||||
<Tabs></Tabs> |
||||
</div> |
||||
<div class="main"> |
||||
<AppMain class="root-app-main"></AppMain> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
@use "../styles/theme"; |
||||
.layout-index { |
||||
display: flex; |
||||
width: 100%; |
||||
height: 100%; |
||||
.background { |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
width: 100%; |
||||
height: 100%; |
||||
object-fit: cover; |
||||
z-index: -1; |
||||
} |
||||
.side-bar { |
||||
flex: 0 0 auto; |
||||
height: 100%; |
||||
background: theme.$block-bg-dark; |
||||
backdrop-filter: blur(1rem); |
||||
user-select: none; |
||||
} |
||||
.view-box { |
||||
flex: 1 1 auto; |
||||
width: 100%; |
||||
height: 100%; |
||||
background: theme.$block-bg-secondary; |
||||
> * { |
||||
width: 100%; |
||||
} |
||||
.root-app-main { |
||||
width: calc(100% - 1rem); |
||||
padding: 0.5rem; |
||||
margin: 0 0.5rem; |
||||
background: theme.$block-bg-primary; |
||||
border-radius: 0.5rem; |
||||
} |
||||
} |
||||
} |
||||
</style> |
||||
e |
@ -0,0 +1,29 @@
|
||||
import Vue from "vue"; |
||||
import VueCompositionAPI from "@vue/composition-api"; |
||||
import { PiniaVuePlugin } from "pinia"; |
||||
import store from "@/stores"; |
||||
import App from "@/App.vue"; |
||||
// eslint-disable-next-line no-unused-vars
|
||||
import router from "@/routers"; |
||||
import ElementUI from "element-ui"; |
||||
import VueTippy, { TippyComponent } from "vue-tippy"; |
||||
import "modern-normalize/modern-normalize.css"; |
||||
import "@/styles/element.scss"; |
||||
import "@fortawesome/fontawesome-free/css/all.css"; // cSpell:ignore fortawesome
|
||||
import "tippy.js/themes/light.css"; |
||||
|
||||
Vue.config.productionTip = false; |
||||
|
||||
Vue.use(VueCompositionAPI); |
||||
Vue.use(ElementUI); |
||||
Vue.use(VueTippy); |
||||
Vue.use(PiniaVuePlugin); |
||||
Vue.use(store); |
||||
|
||||
Vue.component("tippy", TippyComponent); |
||||
|
||||
new Vue({ |
||||
router, |
||||
pinia: store, |
||||
render: (h) => h(App), |
||||
}).$mount("#app"); |
@ -0,0 +1,65 @@
|
||||
import { ErrorPage, RedirectName, Layout, BaseHome } from "@/routers/constants"; |
||||
// 404 on a page
|
||||
export const ErrorPageRoute = { |
||||
path: "/:path(.*)*", |
||||
name: "ErrorPageParent", |
||||
component: Layout, |
||||
meta: { |
||||
title: "ErrorPage", |
||||
hideBreadcrumb: true, |
||||
hidden: true, |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: "/:path(.*)*", |
||||
name: "ErrorPageChild", |
||||
component: ErrorPage, |
||||
meta: { |
||||
title: "ErrorPage", |
||||
hideBreadcrumb: true, |
||||
}, |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const RedirectRoute = { |
||||
path: "/redirect", |
||||
name: RedirectName + "Root", |
||||
component: Layout, |
||||
meta: { |
||||
title: RedirectName, |
||||
hideBreadcrumb: true, |
||||
hidden: true, |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: "/redirect/:path(.*)", |
||||
name: RedirectName + "Child", |
||||
component: () => import(/* webpackChunkName: "redirect" */ "@/views/redirect/index.vue"), |
||||
meta: { |
||||
title: RedirectName, |
||||
hideBreadcrumb: true, |
||||
}, |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const RootRoute = { |
||||
path: "/", |
||||
name: "Root", |
||||
redirect: BaseHome, |
||||
meta: { |
||||
title: "Root", |
||||
hidden: true, |
||||
}, |
||||
}; |
||||
|
||||
export const LoginRoute = { |
||||
path: "/auth/login", |
||||
name: "Login", |
||||
component: () => import(/* webpackChunkName: "authLogin" */ "@/views/auth/login.vue"), |
||||
meta: { |
||||
title: "登录", |
||||
hidden: true, |
||||
}, |
||||
}; |
@ -0,0 +1,45 @@
|
||||
import { Layout, BaseHomeTitle } from "../constants"; |
||||
|
||||
const routeName = "dashboard"; |
||||
|
||||
/** @constant |
||||
@type {import("vue-router").RouteRecord[]} |
||||
@default |
||||
*/ |
||||
export default [ |
||||
{ |
||||
path: "/dashboard", |
||||
name: "routeName", |
||||
component: Layout, |
||||
meta: { |
||||
title: "主控台", |
||||
icon: "renderIcon(DashboardOutlined)", |
||||
permissions: ["admin"], |
||||
sort: 0, |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: "home", |
||||
name: `${routeName}_home`, |
||||
component: () => import("@/views/dashboard/console.vue"), |
||||
meta: { |
||||
title: BaseHomeTitle, |
||||
icon: "renderIcon(DashboardOutlined)", |
||||
permissions: ["admin"], |
||||
sort: 0, |
||||
}, |
||||
}, |
||||
{ |
||||
path: "console", |
||||
name: `${routeName}_console`, |
||||
component: () => import("@/views/dashboard/console.vue"), |
||||
meta: { |
||||
title: "Console", |
||||
icon: "renderIcon(DashboardOutlined)", |
||||
permissions: ["admin"], |
||||
sort: 0, |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
]; |
@ -0,0 +1,11 @@
|
||||
const asyncFiles = require.context("./", true, /\.js$/); |
||||
|
||||
/** @constant |
||||
@type {import("vue-router").RouteRecord[]} |
||||
@default |
||||
*/ |
||||
export const constantModules = asyncFiles |
||||
.keys() |
||||
.filter((key) => key !== "./index.js") |
||||
.map((key) => asyncFiles(key).default) |
||||
.flat(); |
@ -0,0 +1,11 @@
|
||||
export const RedirectName = "Redirect"; |
||||
|
||||
export const LoginPath = "/auth/login"; |
||||
|
||||
export const BaseHome = "/dashboard/home"; |
||||
export const BaseHomeTitle = "首页"; |
||||
|
||||
export const ErrorPage = () => import("@/views/exception/404.vue"); |
||||
|
||||
export const Layout = () => import("@/layouts/rootLayout.vue"); |
||||
export const ParentLayout = () => import("@/layouts/parentLayout.js"); |
@ -0,0 +1,33 @@
|
||||
import Vue from "vue"; |
||||
import VueRouter from "vue-router"; |
||||
import { baseUrl } from "@/configs"; |
||||
import { RootRoute, LoginRoute, RedirectRoute } from "./base"; |
||||
import { permissionModules } from "./permissionModules"; |
||||
import { constantModules } from "./constantModules"; |
||||
import { createRouterGuards } from "./routerGuards"; |
||||
export { useRouter, useRoute } from "vue2-helpers/vue-router"; |
||||
|
||||
Vue.use(VueRouter); |
||||
|
||||
export const constantRoutes = [LoginRoute, RootRoute, RedirectRoute, ...constantModules]; |
||||
|
||||
export const asyncRoutes = [...permissionModules]; |
||||
|
||||
const createRouter = () => |
||||
new VueRouter({ |
||||
mode: "history", |
||||
scrollBehavior: () => ({ y: 0 }), |
||||
base: baseUrl, |
||||
routes: constantRoutes, |
||||
}); |
||||
|
||||
const router = createRouter(); |
||||
|
||||
new createRouterGuards(router); |
||||
|
||||
export function resetRouter() { |
||||
const newRouter = createRouter(); |
||||
router.matcher = newRouter.matcher; |
||||
} |
||||
|
||||
export default router; |
@ -0,0 +1,11 @@
|
||||
const asyncFiles = require.context("./", true, /\.js$/); |
||||
|
||||
/** @constant |
||||
@type {import("vue-router").RouteRecord[]} |
||||
@default |
||||
*/ |
||||
export const permissionModules = asyncFiles |
||||
.keys() |
||||
.filter((key) => key !== "./index.js") |
||||
.map((key) => asyncFiles(key).default) |
||||
.flat(); |
@ -0,0 +1,93 @@
|
||||
import { Layout, ParentLayout } from "../constants"; |
||||
|
||||
/** @constant |
||||
@type {import("vue-router").RouteRecord[]} |
||||
@default |
||||
*/ |
||||
export default [ |
||||
{ |
||||
path: "/hello", |
||||
name: "hello", |
||||
component: Layout, |
||||
meta: { |
||||
title: "hello", |
||||
icon: require("@/assets/images/trademarks/app-logo.svg"), |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: "index", |
||||
component: ParentLayout, |
||||
meta: { |
||||
title: "hello-world", |
||||
icon: "fab fa-app-store", |
||||
permissions: ["admin"], |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: "1", |
||||
component: () => import("@/components/HelloWorld.vue"), |
||||
meta: { |
||||
title: "1-1", |
||||
icon: "fab fa-app-store", |
||||
permissions: ["admin"], |
||||
|