master
parent
dd6f59222f
commit
841a0dabcb
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_BASE_API = 'http://localhost:8081/'
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_BASE_API = 'http://localhost:8081/'
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
.vite-ssg-temp
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# lock
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tansci Boot</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "tansci-boot-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"generate": "vite-ssg build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.3.4",
|
||||||
|
"element-plus": "^2.2.13",
|
||||||
|
"vue": "^3.2.36",
|
||||||
|
"vue-router": "^4.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/ep": "^1.1.4",
|
||||||
|
"@types/node": "^18.14.0",
|
||||||
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
|
"sass": "^1.52.1",
|
||||||
|
"typescript": "^4.7.2",
|
||||||
|
"unocss": "^0.49.7",
|
||||||
|
"unplugin-vue-components": "^0.24.0",
|
||||||
|
"vite": "^4.1.2",
|
||||||
|
"vite-ssg": "^0.22.1",
|
||||||
|
"vue-tsc": "^1.1.3"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<el-config-provider namespace="el">
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// token key
|
||||||
|
const tokenKey:string = 'tansci_boot_token'
|
||||||
|
|
||||||
|
// 获取token
|
||||||
|
export function getToken() {
|
||||||
|
return sessionStorage.getItem(tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储token
|
||||||
|
export function setToken(token:string) {
|
||||||
|
sessionStorage.setItem(tokenKey, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除token
|
||||||
|
export function removeToken() {
|
||||||
|
sessionStorage.removeItem(tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
export function login(data:any){
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request({
|
||||||
|
url: '/system/security/login',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
code: data.code,
|
||||||
|
uuid: data.uuid
|
||||||
|
}
|
||||||
|
}).then((res:any) => {
|
||||||
|
var token = res.data
|
||||||
|
setToken(token)
|
||||||
|
resolve(token)
|
||||||
|
}).catch((e:any) => {
|
||||||
|
reject(e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
export function logout(){
|
||||||
|
request({
|
||||||
|
url: '/system/security/logout',
|
||||||
|
method: 'get'
|
||||||
|
}).then(() => {
|
||||||
|
removeToken()
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {defineProps} from 'vue'
|
||||||
|
const props = defineProps({
|
||||||
|
data: Array
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<el-sub-menu :index="data.path">
|
||||||
|
<template #title>
|
||||||
|
<el-icon v-if="data.icon" style="vertical-align: middle;">
|
||||||
|
<component :is="data.icon"></component>
|
||||||
|
</el-icon>
|
||||||
|
<span style="vertical-align: middle;">{{data.meta.title}}</span>
|
||||||
|
</template>
|
||||||
|
<template v-for="item in data.children" :key="item">
|
||||||
|
<el-menu-item v-if="!item.children || item.children.length <= 1" :index="item.path">
|
||||||
|
<el-icon v-if="item.icon" style="vertical-align: middle;">
|
||||||
|
<component :is="item.icon"></component>
|
||||||
|
</el-icon>
|
||||||
|
<span style="vertical-align: middle;">{{item.meta.title}}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<Submenu v-else :data='item'></Submenu>
|
||||||
|
</template>
|
||||||
|
</el-sub-menu>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { watch, reactive, toRefs } from "vue"
|
||||||
|
import { TabsPaneContext } from "element-plus"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { HOME_URL, TABS_BLACK_LIST } from "@/config/config"
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
tabsMenuValue: HOME_URL,
|
||||||
|
tabsMenuList:[
|
||||||
|
{title:'首页', path: HOME_URL, icon:'HomeFilled', close: false}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const {tabsMenuValue, tabsMenuList} = toRefs(state)
|
||||||
|
|
||||||
|
// 监听路由的变化(防止浏览器后退/前进不变化 tabsMenuValue)
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
let params = {
|
||||||
|
title: route.meta.title as string,
|
||||||
|
path: route.path,
|
||||||
|
close: true
|
||||||
|
};
|
||||||
|
onAddTabManu(params);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function onAddTabManu(tabItem: any){
|
||||||
|
if (TABS_BLACK_LIST.includes(tabItem.path)) return;
|
||||||
|
|
||||||
|
if (state.tabsMenuList.every(item => item.path !== tabItem.path)) {
|
||||||
|
state.tabsMenuList.push(tabItem);
|
||||||
|
}
|
||||||
|
state.tabsMenuValue = tabItem.path;
|
||||||
|
router.push(tabItem.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTabMenuClick = (tabItem: TabsPaneContext) =>{
|
||||||
|
let path = tabItem.props.name as string;
|
||||||
|
router.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTabMenuRemove = (tabItem: String) =>{
|
||||||
|
let _tabsMenuValue = state.tabsMenuValue;
|
||||||
|
let _tabsMenuList = state.tabsMenuList;
|
||||||
|
if (_tabsMenuValue === tabItem) {
|
||||||
|
_tabsMenuList.forEach((item, index) => {
|
||||||
|
if (item.path !== tabItem) return;
|
||||||
|
|
||||||
|
let nextTab = _tabsMenuList[index + 1] || _tabsMenuList[index - 1];
|
||||||
|
if (!nextTab) return;
|
||||||
|
|
||||||
|
_tabsMenuValue = nextTab.path;
|
||||||
|
router.push(nextTab.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.tabsMenuValue = _tabsMenuValue;
|
||||||
|
state.tabsMenuList = _tabsMenuList.filter(item => item.path !== tabItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseCurrentTab = () =>{
|
||||||
|
if (state.tabsMenuValue === HOME_URL) return;
|
||||||
|
onTabMenuRemove(state.tabsMenuValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseOtherTab = () =>{
|
||||||
|
state.tabsMenuList = state.tabsMenuList.filter(item => {
|
||||||
|
return item.path === state.tabsMenuValue || item.path === HOME_URL;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseAllTab = () =>{
|
||||||
|
state.tabsMenuList = state.tabsMenuList.filter(item => {
|
||||||
|
return item.path === HOME_URL;
|
||||||
|
});
|
||||||
|
router.push(HOME_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tabs-menu">
|
||||||
|
<el-tabs v-model="tabsMenuValue" type="card" @tab-click="onTabMenuClick" @tab-remove="onTabMenuRemove">
|
||||||
|
<el-tab-pane v-for="item in tabsMenuList"
|
||||||
|
:key="item.path"
|
||||||
|
:path="item.path"
|
||||||
|
:label="item.title"
|
||||||
|
:name="item.path"
|
||||||
|
:closable="item.close">
|
||||||
|
<template #label>
|
||||||
|
<el-icon v-if="item.icon" style="vertical-align: middle; padding-right: 0.2rem;">
|
||||||
|
<component :is="item.icon"></component>
|
||||||
|
</el-icon>
|
||||||
|
<span style="vertical-align: middle">{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
<el-dropdown trigger="hover">
|
||||||
|
<el-button size="small" type="primary">
|
||||||
|
<span>更多</span>
|
||||||
|
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item icon="CircleCloseFilled" @click="onCloseCurrentTab">关闭当前</el-dropdown-item>
|
||||||
|
<el-dropdown-item icon="CircleClose" @click="onCloseOtherTab">关闭其他</el-dropdown-item>
|
||||||
|
<el-dropdown-item icon="CloseBold" @click="onCloseAllTab">关闭所有</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" >
|
||||||
|
.tabs-menu{
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
// border-top: 1px transparent solid;
|
||||||
|
// border-image: linear-gradient(to right, var(--bg1),#DCDFE6, var(--bg1)) 1 10;
|
||||||
|
// box-shadow: rgba(27, 31, 35, 0.04) 0px 1px 0px, rgba(255, 255, 255, 0.25) 0px 1px 0px inset;
|
||||||
|
// margin-bottom: 0.2rem;
|
||||||
|
|
||||||
|
.el-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tabs__nav-wrap {
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - 120px);
|
||||||
|
}
|
||||||
|
.el-tabs--card > .el-tabs__header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.el-tabs--card > .el-tabs__header .el-tabs__nav {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.el-tabs--card > .el-tabs__header .el-tabs__item {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.el-tabs--card > .el-tabs__header .el-tabs__item.is-active {
|
||||||
|
color: var(--theme);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.el-tabs__item .is-icon-close svg {
|
||||||
|
margin-top: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, onMounted, onBeforeMount, onDeactivated } from "vue"
|
||||||
|
import { isDark, toggleDark } from '@/composables'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Submenu from "@/components/Submenu.vue"
|
||||||
|
import TabsMenu from "@/components/TabsMenu.vue"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const logo = new URL('../../assets/image/logo.png', import.meta.url).href
|
||||||
|
const state = reactive({
|
||||||
|
headerHeight: '52px',
|
||||||
|
asideWidth: '180px',
|
||||||
|
routers: [],
|
||||||
|
defaultHeight: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
state.defaultHeight = (document.body.clientHeight || document.documentElement.clientHeight);
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(()=>{
|
||||||
|
// 获取菜单
|
||||||
|
let routers:any = [];
|
||||||
|
let _routes = router.options.routes;
|
||||||
|
_routes.forEach((item:any)=>{
|
||||||
|
if(item.children && item.type == 0){
|
||||||
|
routers.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
state.routers = routers
|
||||||
|
|
||||||
|
window.addEventListener('resize', onDefaultHeight);
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(()=>{
|
||||||
|
window.removeEventListener('resize', onDefaultHeight, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onDefaultHeight(){
|
||||||
|
state.defaultHeight = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="layout-container">
|
||||||
|
<el-container>
|
||||||
|
<el-header :height="state.headerHeight">
|
||||||
|
<div class="header-logo">
|
||||||
|
<el-image :src="logo"></el-image>
|
||||||
|
</div>
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="header-dark">
|
||||||
|
<el-button @click="toggleDark()" link type="primary">
|
||||||
|
<template #icon>
|
||||||
|
<el-icon><Sunny v-show="!isDark"/></el-icon>
|
||||||
|
<el-icon><Moon v-show="isDark"/></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="header-login">
|
||||||
|
<el-button circle>
|
||||||
|
<template #icon>
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-container>
|
||||||
|
<el-aside :width="state.asideWidth" :style="{height: state.defaultHeight+'px'}">
|
||||||
|
<el-menu router :default-active="$route.path">
|
||||||
|
<template v-for="item in state.routers" :key="item">
|
||||||
|
<el-menu-item v-if="!item.children || item.children.length <= 1" :index="item.path">
|
||||||
|
<el-icon v-if="item.icon" style="vertical-align: middle;">
|
||||||
|
<component :is="item.icon"></component>
|
||||||
|
</el-icon>
|
||||||
|
<span style="vertical-align: middle;">{{item.meta.title}}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<Submenu v-else :data="item"></Submenu>
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
<el-main>
|
||||||
|
<TabsMenu></TabsMenu>
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.layout-container{
|
||||||
|
.el-header{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
line-height: 52px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
.header-logo{
|
||||||
|
display: flex;
|
||||||
|
height: 52px;
|
||||||
|
line-height: 52px;
|
||||||
|
padding-left: 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.header-content{
|
||||||
|
display: flex;
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-aside{
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
::v-deep .el-menu{
|
||||||
|
margin: 0 0.6rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-right: none;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
.el-menu-item, .el-sub-menu__title {
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.el-menu-item, .el-menu-item-group__title, .el-sub-menu, .el-sub-menu__title{
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.el-sub-menu__title:hover{
|
||||||
|
background: #fff !important;
|
||||||
|
color: var(--theme) !important;
|
||||||
|
}
|
||||||
|
.el-menu-item:hover{
|
||||||
|
background: #fff !important;
|
||||||
|
color: var(--theme) !important;
|
||||||
|
}
|
||||||
|
.el-menu-item.is-active {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-aside::-webkit-scrollbar{
|
||||||
|
width: 0px;
|
||||||
|
}
|
||||||
|
.el-main{
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.el-main::-webkit-scrollbar{
|
||||||
|
width: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { useDark, useToggle } from '@vueuse/core'
|
||||||
|
|
||||||
|
export const isDark = useDark()
|
||||||
|
export const toggleDark = useToggle(isDark)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './dark'
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
// 全局不动配置项 只做导出不做修改
|
||||||
|
|
||||||
|
// 首页地址(默认)
|
||||||
|
export const HOME_URL: string = "/index";
|
||||||
|
|
||||||
|
// Tabs(黑名单地址,不需要添加到 tabs 的路由地址)
|
||||||
|
export const TABS_BLACK_LIST: string[] = ["/404", "/500", "/login"];
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import { DefineComponent } from "vue";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './styles/index.scss'
|
||||||
|
import * as ElIcons from '@element-plus/icons-vue'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import 'uno.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus,{
|
||||||
|
locale: zhCn,
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
for (const icon in ElIcons) {
|
||||||
|
app.component(icon, (ElIcons as any)[icon])
|
||||||
|
}
|
||||||
|
app.mount('#app');
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
export default[
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: 'login',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
meta: {title: "登录"},
|
||||||
|
component: () => import("@/views/common/Login.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/404',
|
||||||
|
name: '404',
|
||||||
|
meta: {title: "404"},
|
||||||
|
component: () => import('@/views/common/404.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/500',
|
||||||
|
name: '500',
|
||||||
|
meta: {title: "500"},
|
||||||
|
component: () => import('@/views/common/500.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
|
|
||||||
|
import routers from './routers'
|
||||||
|
|
||||||
|
import common from './common'
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
...common,
|
||||||
|
...routers
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import Layout from '@/components/layout/Index.vue'
|
||||||
|
export default[
|
||||||
|
{
|
||||||
|
path: '/index',
|
||||||
|
name: 'index',
|
||||||
|
type: 0,
|
||||||
|
icon: 'HomeFilled',
|
||||||
|
meta: { title: "首页" },
|
||||||
|
component: () => Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/index',
|
||||||
|
name: 'index',
|
||||||
|
type: 0,
|
||||||
|
icon: 'HomeFilled',
|
||||||
|
meta: { title: "首页" },
|
||||||
|
component: () => import('@/views/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system',
|
||||||
|
name: 'system',
|
||||||
|
type: 0,
|
||||||
|
icon: 'Grid',
|
||||||
|
meta: { title: "系统管理" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/menu',
|
||||||
|
name: 'menu',
|
||||||
|
type: 0,
|
||||||
|
icon: 'Grid',
|
||||||
|
meta: { title: "菜单管理" },
|
||||||
|
component: () => Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/menu',
|
||||||
|
name: 'menu',
|
||||||
|
type: 0,
|
||||||
|
icon: 'Grid',
|
||||||
|
meta: { title: "菜单管理" },
|
||||||
|
component: () => import('@/views/system/Manu.vue')
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/org',
|
||||||
|
name: 'org',
|
||||||
|
type: 0,
|
||||||
|
icon: 'QuestionFilled',
|
||||||
|
meta: { title: "组织管理" },
|
||||||
|
component: () => Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/org',
|
||||||
|
name: 'org',
|
||||||
|
type: 0,
|
||||||
|
icon: 'QuestionFilled',
|
||||||
|
meta: { title: "组织管理" },
|
||||||
|
component: () => import('@/views/system/Org.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user',
|
||||||
|
name: 'user',
|
||||||
|
type: 0,
|
||||||
|
icon: 'QuestionFilled',
|
||||||
|
meta: { title: "用户管理" },
|
||||||
|
component: () => Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/user',
|
||||||
|
name: 'user',
|
||||||
|
type: 0,
|
||||||
|
icon: 'QuestionFilled',
|
||||||
|
meta: { title: "用户管理" },
|
||||||
|
component: () => import('@/views/system/User.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
$--colors: (
|
||||||
|
"primary": (
|
||||||
|
"base": #2F9688,
|
||||||
|
),
|
||||||
|
"success": (
|
||||||
|
"base": #21ba45,
|
||||||
|
),
|
||||||
|
"warning": (
|
||||||
|
"base": #f2711c,
|
||||||
|
),
|
||||||
|
"danger": (
|
||||||
|
"base": #db2828,
|
||||||
|
),
|
||||||
|
"error": (
|
||||||
|
"base": #db2828,
|
||||||
|
),
|
||||||
|
"info": (
|
||||||
|
"base": #42b8dd,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
@forward "element-plus/theme-chalk/src/dark/var.scss" with (
|
||||||
|
$colors: $--colors
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
$--colors: (
|
||||||
|
"primary": (
|
||||||
|
"base": #2F9688,
|
||||||
|
),
|
||||||
|
"success": (
|
||||||
|
"base": #21ba45,
|
||||||
|
),
|
||||||
|
"warning": (
|
||||||
|
"base": #f2711c,
|
||||||
|
),
|
||||||
|
"danger": (
|
||||||
|
"base": #db2828,
|
||||||
|
),
|
||||||
|
"error": (
|
||||||
|
"base": #db2828,
|
||||||
|
),
|
||||||
|
"info": (
|
||||||
|
"base": #42b8dd,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
@forward "element-plus/theme-chalk/src/mixins/config.scss" with (
|
||||||
|
$namespace: "el"
|
||||||
|
);
|
||||||
|
|
||||||
|
@forward "element-plus/theme-chalk/src/common/var.scss" with (
|
||||||
|
$colors: $--colors,
|
||||||
|
$button-padding-horizontal: ("default": 50px)
|
||||||
|
);
|
||||||
|
|
||||||
|
@use "element-plus/theme-chalk/src/index.scss" as *;
|
||||||
|
|
||||||
|
@use "./dark.scss";
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// import dark theme
|
||||||
|
@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// 主题
|
||||||
|
--theme: #2F9688;
|
||||||
|
|
||||||
|
// 局部背景
|
||||||
|
--el-bg-color: #eff4f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB","Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-table 滚动条样式
|
||||||
|
*/
|
||||||
|
.el-table__body-wrapper::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.el-table__body-wrapper::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #909399;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.el-table__body-wrapper::-webkit-scrollbar-track {
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #ededed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动条样式
|
||||||
|
*/
|
||||||
|
.scroll-div::-webkit-scrollbar{
|
||||||
|
width: 5px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.scroll-div::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #909399;
|
||||||
|
}
|
||||||
|
.scroll-div::-webkit-scrollbar-track {
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #ededed;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
|
import { showMessage } from './status'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { logout, getToken } from '@/api/auth'
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
// 根据环境获得不同的代理模式
|
||||||
|
const baseURL = import.meta.env.VITE_BASE_URL as string;
|
||||||
|
const axiosInstance: AxiosInstance = axios.create({
|
||||||
|
baseURL: baseURL,
|
||||||
|
timeout: 30 * 1000, // 超时时间
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// axios实例拦截请求
|
||||||
|
axiosInstance.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||||
|
// 设置token
|
||||||
|
if (getToken()) {
|
||||||
|
config.headers.Authorization = `Bearer ${getToken()}`
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: any) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// axios实例拦截响应
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
if (response.status === 200 && response.data.code == 200) {
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(showMessage(response.status));
|
||||||
|
if (response.data.code === 402) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.code == 403 || response.data.code == 401){
|
||||||
|
sessionStorage.clear();
|
||||||
|
router.push({path: 'login'});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 请求失败
|
||||||
|
(error: any) => {
|
||||||
|
const {response} = error;
|
||||||
|
if (response) {
|
||||||
|
// 请求已发出,但是不在2xx的范围
|
||||||
|
ElMessage.warning(showMessage(response.status));
|
||||||
|
return Promise.reject(response.data);
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('网络连接异常,请稍后再试!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default axiosInstance
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
export const showMessage = (status:number|string) : string => {
|
||||||
|
let message:string = "";
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
message = "请求错误(400)";
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
// message = "未授权,请重新登录(401)";
|
||||||
|
message = "用户名或密码错误";
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
message = "拒绝访问(403)";
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
message = "请求出错(404)";
|
||||||
|
break;
|
||||||
|
case 408:
|
||||||
|
message = "请求超时(408)";
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
message = "服务器错误(500)";
|
||||||
|
break;
|
||||||
|
case 501:
|
||||||
|
message = "服务未实现(501)";
|
||||||
|
break;
|
||||||
|
case 502:
|
||||||
|
message = "网络错误(502)";
|
||||||
|
break;
|
||||||
|
case 503:
|
||||||
|
message = "服务不可用(503)";
|
||||||
|
break;
|
||||||
|
case 504:
|
||||||
|
message = "网络超时(504)";
|
||||||
|
break;
|
||||||
|
case 505:
|
||||||
|
message = "HTTP版本不受支持(505)";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = `未授权,请重新登录!`;
|
||||||
|
}
|
||||||
|
return `${message}`;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, onMounted } from "vue"
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
shadow: 'always',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(()=>{
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<el-card class="home-container" :shadow="state.shadow">
|
||||||
|
首页
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.home-container{
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
500
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {onBeforeMount,reactive,ref,toRefs} from "vue"
|
||||||
|
import type {FormInstance} from 'element-plus'
|
||||||
|
import {useRouter} from 'vue-router'
|
||||||
|
import {login} from '@/api/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loginFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const logo = new URL('../../assets/image/logo.png', import.meta.url).href
|
||||||
|
const loginLogo = new URL('../../assets/image/login-icon.png', import.meta.url).href
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: false,
|
||||||
|
loginStyle: {
|
||||||
|
height: '',
|
||||||
|
},
|
||||||
|
loginForm: {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
state.loginStyle.height = (document.body.clientHeight || document.documentElement.clientHeight) + "px"
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyYear(){
|
||||||
|
let date = new Date();
|
||||||
|
return date.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(formEl: FormInstance | undefined) {
|
||||||
|
if (!formEl) return;
|
||||||
|
await formEl.validate((valid)=>{
|
||||||
|
if(valid){
|
||||||
|
// 登录成功后设置token到缓存
|
||||||
|
let param:any = {
|
||||||
|
username: state.loginForm.username,
|
||||||
|
password: state.loginForm.password
|
||||||
|
}
|
||||||
|
state.loading = true;
|
||||||
|
login(param).then((res:any) =>{
|
||||||
|
if(res){
|
||||||
|
state.loading = false;
|
||||||
|
router.push({path: 'index'});
|
||||||
|
}
|
||||||
|
}).catch(()=>{
|
||||||
|
state.loading = false;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="login-container" :style="state.loginStyle">
|
||||||
|
<div class="login-header">
|
||||||
|
<div>
|
||||||
|
<el-image :src="logo" style="width: 100%; height: 100%;"></el-image>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="title">Tansci Boot</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-main">
|
||||||
|
<div class="main-title">帐号登录</div>
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="logo">
|
||||||
|
<el-image :src="loginLogo" style="width: 100%; height: 100%;"></el-image>
|
||||||
|
</div>
|
||||||
|
<div class="form">
|
||||||
|
<el-form :model="state.loginForm" :rules="rules" size="large" ref="loginFormRef">
|
||||||
|
<el-form-item prop="username" :rules="[
|
||||||
|
{required: true,message: '请输入账号',trigger: 'blur'},
|
||||||
|
{pattern: /^[a-zA-Z]\w{4,17}$/,message: '账号有误,请重新输入',trigger: 'blur'}]">
|
||||||
|
<el-input v-model="state.loginForm.username" prefix-icon="Avatar" placeholder="请输入账号" style="width:100%"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password" :rules="[
|
||||||
|
{required: true,message: '请输入密码',trigger: 'blur'},
|
||||||
|
{pattern: /^[a-zA-Z]\w{5,17}$/,message: '密码有误,请重新输入',trigger: 'blur'}]">
|
||||||
|
<el-input type="password" v-model="state.loginForm.password" prefix-icon="Lock" show-password placeholder="请输入密码" style="width:100%"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="onSubmit(loginFormRef)" :loading="loading" style="width:100%">登录</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-footer">
|
||||||
|
<div>
|
||||||
|
<el-link href="https://typ1805.gitee.io" target="_blank">关于作者</el-link>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<el-link href="https://gitee.com/typ1805/tansci-boot" target="_blank">源码地址 Gitee & GitHub</el-link>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<el-link href="https://typ1805.gitee.io" target="_blank">联系作者</el-link>
|
||||||
|
</div>
|
||||||
|
<div class="copy">
|
||||||
|
© {{copyYear()}} Tansci Boot 版权所有
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style lang="scss" scoped="scoped">
|
||||||
|
.login-container {
|
||||||
|
background-image: radial-gradient( white 0%, #FAFDFE 10%, #ddf8e7 50%, #FAFDFE 90%, white 100%);
|
||||||
|
// background-image: radial-gradient(#ddf8e7 00%, #FAFDFE 80%, white 100%);
|
||||||
|
.login-header{
|
||||||
|
width: 100%;
|
||||||
|
height: 5rem;
|
||||||
|
line-height: 5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 20%;
|
||||||
|
.title{
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: var(--t9);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.login-main{
|
||||||
|
height: 80%;
|
||||||
|
.main-title{
|
||||||
|
font-size: 32px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6rem 0;
|
||||||
|
}
|
||||||
|
.main-container{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
.logo{
|
||||||
|
width: 36rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
// transition: all .2s;
|
||||||
|
}
|
||||||
|
// .logo:hover{
|
||||||
|
// transform: scaleY(1.1) scaleX(1.1) translateZ(0);
|
||||||
|
// }
|
||||||
|
.form{
|
||||||
|
width: 26rem;
|
||||||
|
padding-left: 4rem;
|
||||||
|
border-left: 1px solid #c7f6da;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.login-footer{
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
color: #606266;
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
.copy{
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
App
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
help
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
help
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"typeRoots": [
|
||||||
|
"node_modules/@types", // 默认值
|
||||||
|
"src/types"
|
||||||
|
],
|
||||||
|
"baseUrl": "./",
|
||||||
|
"target": "esnext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["esnext", "dom"],
|
||||||
|
"paths": {
|
||||||
|
"@": ["src"],
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"src/**/**/*.vue"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import Unocss from 'unocss/vite'
|
||||||
|
import {
|
||||||
|
presetAttributify,
|
||||||
|
presetIcons,
|
||||||
|
presetUno,
|
||||||
|
transformerDirectives,
|
||||||
|
transformerVariantGroup,
|
||||||
|
} from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: `@use "@/styles/element/index.scss" as *;`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Unocss({
|
||||||
|
presets: [
|
||||||
|
presetUno(),
|
||||||
|
presetAttributify(),
|
||||||
|
presetIcons({
|
||||||
|
scale: 1.2,
|
||||||
|
warn: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
transformers: [
|
||||||
|
transformerDirectives(),
|
||||||
|
transformerVariantGroup(),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue