first commit

This commit is contained in:
zhangchuanlong
2022-01-08 17:20:46 +08:00
commit 8d0158be7c
80 changed files with 5240 additions and 0 deletions

25
src/App.vue Normal file
View File

@@ -0,0 +1,25 @@
<script setup>
import AppProvider from '@/components/AppProvider/index.vue'
</script>
<template>
<router-view>
<template #default="{ Component, route }">
<app-provider>
<keep-alive v-if="route.meta && route.meta.keepAlive">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-else :key="route.fullPath" />
</app-provider>
</template>
</router-view>
</template>
<style lang="scss">
#app {
height: 100%;
.n-config-provider {
height: inherit;
}
}
</style>

16
src/api/auth/index.js Normal file
View File

@@ -0,0 +1,16 @@
import { defAxios } from '@/utils/http'
export const login = (data) => {
return defAxios({
url: '/auth/login',
method: 'post',
data,
})
}
export const refreshToken = () => {
return defAxios({
url: '/auth/refreshToken',
method: 'post',
})
}

38
src/api/user/index.js Normal file
View File

@@ -0,0 +1,38 @@
import { defAxios, mockAxios } from '@/utils/http'
export function getUsers(data = {}) {
return defAxios({
url: '/users',
method: 'get',
data,
})
}
export function getUser(id) {
if (id) {
return defAxios({
url: `/user/${id}`,
method: 'get',
})
}
return defAxios({
url: '/user',
method: 'get',
})
}
export function saveUser(data = {}, id) {
if (id) {
return defAxios({
url: '/user',
method: 'put',
data,
})
}
return defAxios({
url: `/user/${id}`,
method: 'put',
data,
})
}

View File

@@ -0,0 +1,52 @@
<script setup>
import { isNullOrUndef } from '@/utils/is'
import { useDialog } from 'naive-ui'
const NDialog = useDialog()
class Dialog {
success(title, option) {
this.showDialog('success', { title, ...option })
}
warning(title, option) {
this.showDialog('warning', { title, ...option })
}
error(title, option) {
this.showDialog('error', { title, ...option })
}
showDialog(type = 'success', option) {
if (isNullOrUndef(option.title)) {
// ! 没有title的情况
option.showIcon = false
}
NDialog[type]({
positiveText: 'OK',
closable: false,
...option,
})
}
confirm(option = {}) {
this.showDialog(option.type || 'error', {
positiveText: '确定',
negativeText: '取消',
onPositiveClick: option.confirm,
onNegativeClick: option.cancel,
onMaskClick: option.cancel,
...option,
})
}
}
window['$dialog'] = new Dialog()
Object.freeze(window.$dialog)
Object.defineProperty(window, '$dialog', {
configurable: false,
writable: false,
})
</script>
<template></template>

View File

@@ -0,0 +1,10 @@
<script setup>
import { useLoadingBar } from 'naive-ui'
window['$loadingBar'] = useLoadingBar()
Object.defineProperty(window, '$loadingBar', {
configurable: false,
writable: false,
})
</script>
<template></template>

View File

@@ -0,0 +1,73 @@
<script setup>
import { useMessage } from 'naive-ui'
const NMessage = useMessage()
let loadingMessage = null
class Message {
/**
* 规则:
* * 同一Message实例只显示一个loading message如果需要显示多个可以创建多个Message实例
* * 新的message会替换正在显示的loading message
* * 默认已创建一个Message实例$message挂载到window同时也将Message类挂载到了window
*/
removeMessage(message, duration = 2000) {
setTimeout(() => {
if (message) {
message.destroy()
message = null
}
}, duration)
}
showMessage(type, content, option = {}) {
if (this.loadingMessage && this.loadingMessage.type === 'loading') {
// 如果存在则替换正在显示的loading message
this.loadingMessage.type = type
this.loadingMessage.content = content
if (type !== 'loading') {
// 非loading message需设置自动清除
this.removeMessage(this.loadingMessage, option.duration)
}
} else {
// 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来
let message = NMessage[type](content, option)
if (type === 'loading') {
this.loadingMessage = message
}
}
}
loading(content) {
this.showMessage('loading', content, { duration: 0 })
}
success(content, option = {}) {
this.showMessage('success', content, option)
}
error(content, option = {}) {
this.showMessage('error', content, option)
}
info(content, option = {}) {
this.showMessage('info', content, option)
}
warning(content, option = {}) {
this.showMessage('warning', content, option)
}
}
window['$message'] = new Message()
Object.defineProperty(window, '$message', {
configurable: false,
writable: false,
})
</script>
<template></template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { NConfigProvider, NGlobalStyle, NLoadingBarProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
import MessageContent from './MessageContent.vue'
import DialogContent from './DialogContent.vue'
import LoadingBar from './LoadingBar.vue'
import { useAppStore } from '@/store/modules/app'
const appStore = useAppStore()
</script>
<template>
<n-config-provider :theme-overrides="appStore.themeOverrides">
<n-global-style />
<n-loading-bar-provider>
<loading-bar />
<n-dialog-provider>
<dialog-content />
<n-message-provider>
<message-content />
<slot name="default"></slot>
</n-message-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<router-view>
<template #default="{ Component, route }">
<transition name="fade-slide" mode="out-in" appear>
<component :is="Component" :key="route.fullPath"></component>
</transition>
</template>
</router-view>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
import { useRouter } from 'vue-router'
const router = useRouter()
const { currentRoute } = router
function handleBreadClick(path) {
if (path === currentRoute.value.path) return
router.push(path)
}
</script>
<template>
<n-breadcrumb>
<n-breadcrumb-item v-for="item in currentRoute.matched" :key="item.path" @click="handleBreadClick(item.path)">
{{ item.meta.title }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { useUserStore } from '@/store/modules/user'
import { useRouter } from 'vue-router'
import { NDropdown } from 'naive-ui'
const userStore = useUserStore()
const router = useRouter()
const options = [
{
label: '退出登录',
key: 'logout',
},
]
function handleSelect(key) {
if (key === 'logout') {
userStore.logout()
$message.success('已退出登录')
router.push({ path: '/login' })
}
}
</script>
<template>
<n-dropdown :options="options" @select="handleSelect">
<div class="avatar">
<img :src="userStore.avatar" />
<span>{{ userStore.name }}</span>
</div>
</n-dropdown>
</template>
<style lang="scss" scoped>
.avatar {
display: flex;
align-items: center;
cursor: pointer;
img {
width: 100%;
width: 25px;
height: 25px;
border-radius: 50%;
margin-right: 10px;
}
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup>
import BreadCrumb from './BreadCrumb.vue'
import HeaderAction from './HeaderAction.vue'
</script>
<template>
<header class="header">
<bread-crumb />
<header-action />
</header>
</template>
<style lang="scss" scoped>
.header {
padding: 0 35px;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import { NGradientText, NIcon } from 'naive-ui'
import { LastfmSquare } from '@vicons/fa'
const title = import.meta.env.VITE_APP_TITLE
</script>
<template>
<div class="logo">
<n-icon size="36" color="#316c72">
<lastfm-square />
</n-icon>
<router-link to="/">
<n-gradient-text type="primary">{{ title }}</n-gradient-text>
</router-link>
</div>
</template>
<style lang="scss" scoped>
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
a {
margin-left: 5px;
.n-gradient-text {
font-size: 14px;
font-weight: bold;
}
}
}
</style>

View File

@@ -0,0 +1,160 @@
<script setup>
import { NMenu } from 'naive-ui'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { usePermissionStore } from '@/store/modules/permission'
const router = useRouter()
const permissionStore = usePermissionStore()
const { currentRoute } = router
const routes = permissionStore.routes
const menuOptions = computed(() => {
return generateOptions(routes, '')
})
function resolvePath(...pathes) {
return (
'/' +
pathes
.filter((path) => !!path && path !== '/')
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
.join('/')
)
}
function generateOptions(routes, basePath) {
let options = []
routes.forEach((route) => {
if (route.name && !route.isHidden) {
let curOption = {
label: (route.meta && route.meta.title) || route.name,
key: route.name,
path: resolvePath(basePath, route.path),
}
if (route.children && route.children.length) {
curOption.children = generateOptions(route.children, resolvePath(basePath, route.path))
}
if (curOption.children && curOption.children.length <= 1) {
if (curOption.children.length === 1) {
curOption = { ...curOption.children[0] }
}
delete curOption.children
}
options.push(curOption)
}
})
return options
}
function handleMenuSelect(key, item) {
router.push(item.path)
// 通过path重定向
// router.push({
// path: '/redirect',
// query: { redirect: item.path },
// })
}
</script>
<template>
<n-menu
class="side-menu"
:root-indent="20"
:options="menuOptions"
:value="(currentRoute.meta && currentRoute.meta.activeMenu) || currentRoute.name"
@update:value="handleMenuSelect"
/>
</template>
<style lang="scss">
.n-menu {
margin-top: 10px;
padding-left: 10px;
.n-menu-item {
margin-top: 0;
position: relative;
&::before {
left: 0;
right: 0;
border-radius: 0;
background-color: unset !important;
}
&:hover,
&.n-menu-item--selected {
border-radius: 0 !important;
&::before {
border-right: 3px solid $primaryColor;
background-color: #16243a;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba($primaryColor, 0.3) 100%);
}
}
}
.n-menu-item-content-header {
font-size: 14px;
font-weight: bold;
}
.n-submenu-children {
.n-menu-item-content-header {
font-size: 14px;
font-weight: normal;
position: relative;
overflow: visible !important;
&::before {
content: '';
position: absolute;
left: -15px;
top: 0;
bottom: 0;
margin: auto;
width: 5px;
height: 5px;
border-radius: 50%;
border: 1px solid #333;
}
}
}
}
// .side-menu {
// // padding-left: 15px;
// .n-menu-item-content-header {
// color: #fff !important;
// font-weight: bold;
// font-size: 14px;
// }
// .n-submenu {
// .n-menu-item-content-header {
// color: #fff !important;
// font-weight: bold;
// font-size: 14px;
// }
// }
// .n-submenu-children {
// .n-menu-item-content-header {
// color: #fff !important;
// font-weight: normal;
// font-size: 12px;
// }
// }
// .n-menu-item {
// border-top-left-radius: 5px;
// border-bottom-left-radius: 5px;
// &:hover,
// &.n-menu-item--selected::before {
// background-color: #16243a;
// right: 0;
// left: 0;
// border-right: 3px solid $primaryColor;
// background-color: unset !important;
// background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba($primaryColor, 0.3) 100%);
// }
// }
// }
</style>

View File

@@ -0,0 +1,9 @@
<script setup>
import SideLogo from './SideLogo.vue'
import SideMenu from './SideMenu.vue'
</script>
<template>
<side-logo />
<side-menu />
</template>

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

@@ -0,0 +1,30 @@
<script setup>
import { NLayout, NLayoutHeader, NLayoutSider } from 'naive-ui'
import AppHeader from './components/header/index.vue'
import SideMenu from './components/sidebar/index.vue'
import AppMain from './components/AppMain.vue'
</script>
<template>
<div class="layout">
<n-layout has-sider position="absolute">
<n-layout-sider :width="200" :collapsed-width="0" :native-scrollbar="false">
<side-menu />
</n-layout-sider>
<n-layout>
<n-layout-header style="height: 100px; background-color: #f5f6fb">
<app-header />
</n-layout-header>
<n-layout
position="absolute"
content-style="padding: 0 35px 35px"
style="top: 100px; background-color: #f5f6fb"
:native-scrollbar="false"
>
<app-main />
</n-layout>
</n-layout>
</n-layout>
</div>
</template>

19
src/main.js Normal file
View File

@@ -0,0 +1,19 @@
import '@/styles/index.scss'
import 'uno.css'
import { createApp } from 'vue'
import App from './App.vue'
import { setupRouter } from '@/router'
import { setupStore } from '@/store'
async function bootstrap() {
const app = createApp(App)
setupStore(app)
setupRouter(app)
app.mount('#app', true)
}
bootstrap()

View File

@@ -0,0 +1,7 @@
import { createPageLoadingGuard } from './pageLoadingGuard'
import { createPermissionGuard } from './permissionGuard'
export function setupRouterGuard(router) {
createPageLoadingGuard(router)
createPermissionGuard(router)
}

View File

@@ -0,0 +1,15 @@
export function createPageLoadingGuard(router) {
router.beforeEach(() => {
$loadingBar.start()
})
router.afterEach(() => {
setTimeout(() => {
$loadingBar.finish()
}, 200)
})
router.onError(() => {
$loadingBar.error()
})
}

View File

@@ -0,0 +1,44 @@
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes'
import { getToken, refreshAccessToken, removeToken } from '@/utils/token'
const WHITE_LIST = ['/login', '/redirect']
export function createPermissionGuard(router) {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
router.beforeEach(async (to, from, next) => {
const token = getToken()
if (token) {
if (to.path === '/login') {
next({ path: '/' })
} else {
if (userStore.userId) {
// 已经拿到用户信息
refreshAccessToken()
next()
} else {
try {
await userStore.getUserInfo()
const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
router.addRoute(NOT_FOUND_ROUTE)
next({ ...to, replace: true })
} catch (error) {
removeToken()
$message.error(error)
next({ path: '/login', query: { ...to.query, redirect: to.path } })
}
}
}
} else {
if (WHITE_LIST.includes(to.path)) {
next()
} else {
next({ path: '/login', query: { ...to.query, redirect: to.path } })
}
}
})
}

23
src/router/index.js Normal file
View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { basicRoutes } from './routes'
export const router = createRouter({
history: createWebHistory('/'),
routes: basicRoutes,
scrollBehavior: () => ({ left: 0, top: 0 }),
})
export function resetRouter() {
router.getRoutes().forEach((route) => {
const { name } = route
if (name && !WHITE_NAME_LIST.includes(name)) {
router.hasRoute(name) && router.removeRoute(name)
}
})
}
export function setupRouter(app) {
app.use(router)
setupRouterGuard(router)
}

111
src/router/routes/index.js Normal file
View File

@@ -0,0 +1,111 @@
import Layout from '@/layout/index.vue'
import Dashboard from '@/views/dashboard/index.vue'
export const basicRoutes = [
{
name: '404',
path: '/404',
component: () => import('@/views/error-page/404.vue'),
isHidden: true,
},
{
name: '401',
path: '/401',
component: () => import('@/views/error-page/401.vue'),
isHidden: true,
},
{
name: 'REDIRECT',
path: '/redirect',
component: Layout,
isHidden: true,
children: [
{
name: 'REDIRECT_NAME',
path: '',
component: () => import('@/views/redirect/index.vue'),
},
],
},
{
name: 'LOGIN',
path: '/login',
component: () => import('@/views/login/index.vue'),
isHidden: true,
meta: {
title: '登录页',
},
},
{
name: 'HOME',
path: '/',
component: Layout,
redirect: '/dashboard',
meta: {
title: '首页',
},
children: [
{
name: 'DASHBOARD',
path: 'dashboard',
component: Dashboard,
meta: {
title: 'Dashboard',
},
},
],
},
{
name: 'TEST',
path: '/test',
component: Layout,
redirect: '/test/unocss',
meta: {
title: '测试',
},
children: [
{
name: 'UNOCSS',
path: 'unocss',
component: () => import('@/views/test-page/TestUnocss.vue'),
meta: {
title: '测试unocss',
},
},
{
name: 'MESSAGE',
path: 'message',
component: () => import('@/views/test-page/TestMessage.vue'),
meta: {
title: '测试Message',
},
},
{
name: 'DIALOG',
path: 'dialog',
component: () => import('@/views/test-page/TestDialog.vue'),
meta: {
title: '测试Dialog',
},
},
],
},
]
export const NOT_FOUND_ROUTE = {
name: 'NOT_FOUND',
path: '/:pathMatch(.*)*',
redirect: '/404',
isHidden: true,
}
const modules = import.meta.globEager('./modules/*.js')
const asyncRoutes = []
Object.keys(modules).forEach((key) => {
asyncRoutes.push(...modules[key].default)
})
export { asyncRoutes }

View File

@@ -0,0 +1,34 @@
import Layout from '@/layout/index.vue'
export default [
{
name: 'USER_MANAGER',
path: '/user',
component: Layout,
redirect: '/user/management',
meta: {
title: '用户中心',
role: ['admin'],
},
children: [
{
name: 'USER',
path: 'management',
component: () => import('@/views/user/index.vue'),
meta: {
title: '用户管理',
role: ['admin'],
},
},
{
name: 'PERMISSION',
path: 'permission',
component: () => import('@/views/user/UserPermission.vue'),
meta: {
title: '权限管理',
role: ['admin'],
},
},
],
},
]

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

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
export function setupStore(app) {
app.use(createPinia())
}

17
src/store/modules/app.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state() {
return {
themeOverrides: {
common: {
primaryColor: '#316c72',
primaryColorSuppl: '#316c72',
primaryColorHover: '#316c72',
successColorHover: '#316c72',
successColorSuppl: '#316c72',
},
},
}
},
})

View File

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { asyncRoutes, basicRoutes } from '@/router/routes'
import { difference } from 'lodash-es'
function hasPermission(route, role) {
const routeRole = route.meta?.role ? route.meta.role : []
if (!role.length || !routeRole.length) {
return false
}
return difference(routeRole, role).length < routeRole.length
}
function filterAsyncRoutes(routes = [], role) {
const ret = []
routes.forEach((route) => {
if (hasPermission(route, role)) {
const curRoute = {
...route,
children: [],
}
if (route.children && route.children.length) {
curRoute.children = filterAsyncRoutes(route.children, role)
} else {
Reflect.deleteProperty(curRoute, 'children')
}
ret.push(curRoute)
}
})
return ret
}
export const usePermissionStore = defineStore('permission', {
state() {
return {
accessRoutes: [],
}
},
getters: {
routes() {
return basicRoutes.concat(this.accessRoutes)
},
},
actions: {
generateRoutes(role = []) {
const accessRoutes = filterAsyncRoutes(asyncRoutes, role)
this.accessRoutes = accessRoutes
return accessRoutes
},
},
})

46
src/store/modules/user.js Normal file
View File

@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { getUser } from '@/api/user'
import { removeToken } from '@/utils/token'
export const useUserStore = defineStore('user', {
state() {
return {
userInfo: {},
}
},
getters: {
userId() {
return this.userInfo?.id
},
name() {
return this.userInfo?.name
},
avatar() {
return this.userInfo?.avatar
},
role() {
return this.userInfo?.role || []
},
},
actions: {
async getUserInfo() {
try {
const res = await getUser()
if (res.code === 0) {
const { id, name, avatar, role } = res.data
this.userInfo = { id, name, avatar, role }
return Promise.resolve(res.data)
} else {
return Promise.reject(res.message)
}
} catch (error) {
console.error(error)
return Promise.reject(error.message)
}
},
logout() {
removeToken()
this.userInfo = {}
},
},
})

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

@@ -0,0 +1,2 @@
@import './reset.scss';
@import './public.scss';

28
src/styles/public.scss Normal file
View File

@@ -0,0 +1,28 @@
html {
font-size: 4px; // * 1rem = 4px 方便unocss计算在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #f2f2f2;
font-family: 'Encode Sans Condensed', sans-serif;
}
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}

40
src/styles/reset.scss Normal file
View File

@@ -0,0 +1,40 @@
html {
box-sizing: border-box;
}
*,
::before,
::after {
margin: 0;
padding: 0;
box-sizing: inherit;
}
a {
text-decoration: none;
color: #333;
}
a:hover,
a:link,
a:visited,
a:active {
text-decoration: none;
}
ol,
ul {
list-style: none;
}
input,
textarea {
outline: none;
border: none;
resize: none;
}
body {
font-size: 14px;
font-weight: 400;
}

View File

@@ -0,0 +1,5 @@
$primaryColor: #316c72;
:root {
--vh100: 100vh;
}

9
src/utils/cache/index.js vendored Normal file
View File

@@ -0,0 +1,9 @@
import { createWebStorage } from './webStorage'
export const createLocalStorage = function (option = {}) {
return createWebStorage({ prefixKey: option.prefixKey || '', storage: localStorage })
}
export const createSessionStorage = function (option = {}) {
return createWebStorage({ prefixKey: option.prefixKey || '', storage: localStorage })
}

55
src/utils/cache/webStorage.js vendored Normal file
View File

@@ -0,0 +1,55 @@
import { isNullOrUndef } from '@/utils/is'
class WebStorage {
constructor(option) {
this.storage = option.storage
this.prefixKey = option.prefixKey
}
getKey(key) {
return `${this.prefixKey}${key}`.toUpperCase()
}
set(key, value, expire) {
const stringData = JSON.stringify({
value,
time: Date.now(),
expire: !isNullOrUndef(expire) ? new Date().getTime() + expire * 1000 : null,
})
this.storage.setItem(this.getKey(key), stringData)
}
get(key) {
const { value } = this.getItem(key, {})
return value
}
getItem(key, def = null) {
const val = this.storage.getItem(this.getKey(key))
if (!val) return def
try {
const data = JSON.parse(val)
const { value, time, expire } = data
if (isNullOrUndef(expire) || expire > new Date().getTime()) {
return { value, time }
}
this.remove(key)
return def
} catch (error) {
this.remove(key)
return def
}
}
remove(key) {
this.storage.removeItem(this.getKey(key))
}
clear() {
this.storage.clear()
}
}
export function createWebStorage({ prefixKey = '', storage = sessionStorage }) {
return new WebStorage({ prefixKey, storage })
}

13
src/utils/http/help.js Normal file
View File

@@ -0,0 +1,13 @@
import { useUserStore } from '@/store/modules/user'
const WITHOUT_TOKEN_API = [{ url: '/auth/login', method: 'POST' }]
export function isWithoutToken({ url, method = '' }) {
return WITHOUT_TOKEN_API.some((item) => item.url === url && item.method === method.toUpperCase())
}
export function addBaseParams(params) {
if (!params.userId) {
params.userId = useUserStore().userId
}
}

18
src/utils/http/index.js Normal file
View File

@@ -0,0 +1,18 @@
import axios from 'axios'
import { setupInterceptor } from './interceptors'
function createAxios(option = {}) {
const defBaseURL = window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API || import.meta.env.VITE_APP_GLOB_BASE_API
const service = axios.create({
timeout: option.timeout || 120000,
baseURL: option.baseURL || defBaseURL,
})
setupInterceptor(service)
return service
}
export const defAxios = createAxios()
export const mockAxios = createAxios({
baseURL: window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API_TEST || import.meta.env.VITE_APP_GLOB_BASE_API_TEST,
})

View File

@@ -0,0 +1,82 @@
import { router } from '@/router'
import { getToken, removeToken } from '@/utils/token'
import { isWithoutToken } from './help'
export function setupInterceptor(service) {
service.interceptors.request.use(
async (config) => {
// 防止缓存给get请求加上时间戳
if (config.method === 'get') {
config.params = { ...config.params, t: new Date().getTime() }
}
// 处理不需要token的请求
if (isWithoutToken(config)) {
return config
}
const token = getToken()
if (token) {
/**
* * jwt token
* ! 认证方案: Bearer
*/
config.headers.Authorization = 'Bearer ' + token
return config
}
/**
* * 未登录或者token过期的情况下
* * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
*/
const { currentRoute } = router
router.replace({
path: '/login',
query: { ...currentRoute.query, redirect: currentRoute.path },
})
return Promise.reject({ code: '-1', message: '未登录' })
},
(error) => Promise.reject(error)
)
service.interceptors.response.use(
(response) => response?.data,
(error) => {
let { code, message } = error.response?.data
return Promise.reject({ code, message })
/**
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
*/
switch (code) {
case 401:
// 未登录可能是token过期或者无效了
console.error(message)
removeToken()
const { currentRoute } = router
router.replace({
path: '/login',
query: { ...currentRoute.query, redirect: currentRoute.path },
})
break
case 403:
// 没有权限
console.error(message)
break
case 404:
// 资源不存在
console.error(message)
break
default:
break
}
// 已知错误resolve在业务代码中作提醒未知错误reject捕获错误统一提示接口异常9000以上为业务类型错误需要跟后端确定好
if ([401, 403, 404].includes(code) || code >= 9000) {
return Promise.resolve({ code, message })
} else {
console.error('【err】' + error)
return Promise.reject({ message: '接口异常,请稍后重试!' })
}
}
)
}

76
src/utils/index.js Normal file
View File

@@ -0,0 +1,76 @@
import dayjs from 'dayjs'
/**
* @desc 格式化时间
* @param {(Object|string|number)} time
* @param {string} format
* @returns {string | null}
*/
export function formatDateTime(time = undefined, format = 'YYYY-MM-DD HH:mm:ss') {
return dayjs(time).format(format)
}
export function formatDate(date = undefined, format = 'YYYY-MM-DD') {
return formatDateTime(date, format)
}
/**
* @desc 函数节流
* @param {Function} fn
* @param {Number} wait
* @returns {Function}
*/
export function throttle(fn, wait) {
var context, args
var previous = 0
return function () {
var now = +new Date()
context = this
args = arguments
if (now - previous > wait) {
fn.apply(context, args)
previous = now
}
}
}
/**
* @desc 函数防抖
* @param {Function} func
* @param {number} wait
* @param {boolean} immediate
* @return {*}
*/
export function debounce(method, wait, immediate) {
let timeout
return function (...args) {
let context = this
if (timeout) {
clearTimeout(timeout)
}
// 立即执行需要两个条件一是immediate为true二是timeout未被赋值或被置为null
if (immediate) {
/**
* 如果定时器不存在则立即执行并设置一个定时器wait毫秒后将定时器置为null
* 这样确保立即执行后wait毫秒内不会被再次触发
*/
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
if (callNow) {
method.apply(context, args)
}
} else {
// 如果immediate为false则函数wait毫秒后执行
timeout = setTimeout(() => {
/**
* args是一个类数组对象所以使用fn.apply
* 也可写作method.call(context, ...args)
*/
method.apply(context, args)
}, wait)
}
}
}

91
src/utils/is.js Normal file
View File

@@ -0,0 +1,91 @@
const toString = Object.prototype.toString
export function is(val, type) {
return toString.call(val) === `[object ${type}]`
}
export function isDef(val) {
return typeof val !== 'undefined'
}
export function isUndef(val) {
return typeof val === 'undefined'
}
export function isNull(val) {
return val === null
}
export function isObject(val) {
return !isNull(isNull) && is(val, 'Object')
}
export function isArray(val) {
return val && Array.isArray(val)
}
export function isString(val) {
return is(val, 'String')
}
export function isNumber(val) {
return is(val, 'Number')
}
export function isBoolean(val) {
return is(val, 'Boolean')
}
export function isDate(val) {
return is(val, 'Date')
}
export function isRegExp(val) {
return is(val, 'RegExp')
}
export function isFunction(val) {
return typeof val === 'function'
}
export function isPromise(val) {
return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
}
export function isElement(val) {
return isObject(val) && !!val.tagName
}
export function isWindow(val) {
return typeof window !== 'undefined' && isDef(window) && is(val, 'Window')
}
export function isNullOrUndef(val) {
return isNull(val) || isUndef(val)
}
export function isEmpty(val) {
if (isArray(val) || isString(val)) {
return val.length === 0
}
if (val instanceof Map || val instanceof Set) {
return val.size === 0
}
if (isObject(val)) {
return Object.keys(val).length === 0
}
return false
}
export function isUrl(path) {
const reg =
/(((^https?:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/
return reg.test(path)
}
export const isServer = typeof window === 'undefined'
export const isClient = !isServer

37
src/utils/token.js Normal file
View File

@@ -0,0 +1,37 @@
import { createLocalStorage } from './cache'
import { refreshToken } from '@/api/auth'
const TOKEN_CODE = 'access_token'
const DURATION = 6 * 60 * 60
export const lsToken = createLocalStorage()
export function getToken() {
return lsToken.get(TOKEN_CODE)
}
export function setToken(token) {
lsToken.set(TOKEN_CODE, token, DURATION)
}
export function removeToken() {
lsToken.remove(TOKEN_CODE)
}
export async function refreshAccessToken() {
const tokenItem = lsToken.getItem(TOKEN_CODE)
if (!tokenItem) {
return
}
const { time } = tokenItem
if (new Date().getTime() - time > 1000 * 60 * 30) {
try {
const res = await refreshToken()
if (res.code === 0) {
setToken(res.data.token)
}
} catch (error) {
console.error(error)
}
}
}

View File

@@ -0,0 +1,3 @@
<template>
<h1>首页</h1>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<h1>401</h1>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<h1>404</h1>
</template>

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

@@ -0,0 +1,207 @@
<script setup>
import { ref, unref } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '@/api/auth'
import { createLocalStorage } from '@/utils/cache'
import { setToken } from '@/utils/token'
const title = import.meta.env.VITE_APP_TITLE
const router = useRouter()
const query = unref(router.currentRoute).query
const loginInfo = ref({
name: 'admin',
password: 123456,
})
const ls = createLocalStorage({ prefixKey: 'login_' })
const lsLoginInfo = ls.get('loginInfo')
if (lsLoginInfo) {
loginInfo.value.name = lsLoginInfo.name || ''
loginInfo.value.password = lsLoginInfo.password || ''
}
async function handleLogin() {
const { name, password } = loginInfo.value
if (!name || !password) {
$message.warning('请输入用户名和密码')
return
}
try {
$message.loading('正在验证...')
const res = await login({ name, password: password.toString() })
if (res.code === 0) {
$message.success('登录成功')
ls.set('loginInfo', { name, password })
setToken(res.data.token)
if (query.redirect) {
router.push({ path: '/redirect', query })
} else {
router.push('/')
}
} else {
$message.warning(res.message)
}
} catch (error) {
$message.error(error.message)
}
}
</script>
<template>
<div class="login-page">
<div class="form-wrapper">
<h2 class="title">{{ title }}</h2>
<div class="form-item" mt-20>
<input
v-model="loginInfo.name"
autofocus
type="text"
class="input"
placeholder="username"
@keydown.enter="handleLogin"
/>
</div>
<div class="form-item" mt-20>
<input
v-model="loginInfo.password"
type="password"
class="input"
placeholder="password"
@keydown.enter="handleLogin"
/>
</div>
<div class="form-item" mt-20>
<button class="submit-btn" @click="handleLogin">登录</button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@property --perA {
syntax: '<percentage>';
inherits: false;
initial-value: 75%;
}
@property --perB {
syntax: '<percentage>';
inherits: false;
initial-value: 99%;
}
@property --perC {
syntax: '<percentage>';
inherits: false;
initial-value: 15%;
}
@property --perD {
syntax: '<percentage>';
inherits: false;
initial-value: 16%;
}
@property --perE {
syntax: '<percentage>';
inherits: false;
initial-value: 86%;
}
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.login-page {
height: 100%;
background-color: #e1e8ee;
display: flex;
align-items: center;
justify-content: center;
background-image: radial-gradient(
circle at var(--perE) 7%,
rgba(40, 40, 40, 0.04) 0%,
rgba(40, 40, 40, 0.04) 50%,
rgba(200, 200, 200, 0.04) 50%,
rgba(200, 200, 200, 0.04) 100%
),
radial-gradient(
circle at var(--perC) var(--perD),
rgba(99, 99, 99, 0.04) 0%,
rgba(99, 99, 99, 0.04) 50%,
rgba(45, 45, 45, 0.04) 50%,
rgba(45, 45, 45, 0.04) 100%
),
radial-gradient(
circle at var(--perA) var(--perB),
rgba(243, 243, 243, 0.04) 0%,
rgba(243, 243, 243, 0.04) 50%,
rgba(37, 37, 37, 0.04) 50%,
rgba(37, 37, 37, 0.04) 100%
),
linear-gradient(var(--angle), #22deed, #8759d7);
animation: move 30s infinite alternate linear;
@keyframes move {
100% {
--perA: 85%;
--perB: 49%;
--perC: 45%;
--perD: 39%;
--perE: 70%;
--angle: 360deg;
}
}
}
.form-wrapper {
text-align: center;
padding: 40px 50px;
border-radius: 15px;
background-color: rgba(#fff, 0.2);
.title {
font-size: 22px;
color: #f3f3f3;
}
.form-item {
width: 240px;
input {
width: 100%;
height: 40px;
padding: 0 15px;
border-radius: 5px;
font-size: 14px;
color: #333;
transition: 0.3s;
&:focus {
box-shadow: 0 0 5px #8759d7;
}
}
button {
width: 100%;
height: 40px;
color: #fff;
font-size: 14px;
font-weight: bold;
border: none;
border-radius: 5px;
background-color: #6683d2;
cursor: pointer;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup>
import { useRouter } from 'vue-router'
const { currentRoute, replace } = useRouter()
const { query } = currentRoute.value
let { redirect } = query
Reflect.deleteProperty(query, 'redirect')
if (Array.isArray(redirect)) {
redirect = redirect.join('/')
}
if (redirect.startsWith('/redirect')) {
redirect = '/'
}
replace({
path: redirect.startsWith('/') ? redirect : '/' + redirect,
query,
})
</script>

View File

@@ -0,0 +1,18 @@
<script setup>
import { NButton } from 'naive-ui'
const handleDelete = function () {
$dialog.confirm({
content: '确认删除?',
confirm() {
$dialog.success('删除成功', { positiveText: '我知道了' })
},
cancel() {
$dialog.warning('已取消', { closable: true })
},
})
}
</script>
<template>
<n-button @click="handleDelete">删除</n-button>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { NButton } from 'naive-ui'
function handleLogin() {
$message.loading('登陆中...')
setTimeout(() => {
$message.error('登陆失败')
$message.loading('正在尝试重新登陆...')
setTimeout(() => {
$message.success('登陆成功')
}, 2000)
}, 2000)
}
</script>
<template>
<n-button @click="handleLogin">点击登陆</n-button>
</template>

View File

@@ -0,0 +1,56 @@
<template>
<div>
<div class="content-box">
<p text-12>测试12px</p>
<p text-13>测试13px</p>
<p text-14>测试14px</p>
<p text-15>测试15px</p>
<p text-16>测试16px</p>
<p text-17>测试17px</p>
<p text-18>测试18px</p>
<p text-19>测试19px</p>
<p text-20>测试20px</p>
</div>
<div mt-30 class="content-box">
<p text-12>测试12px</p>
<p text-13>测试13px</p>
<p text-14>测试14px</p>
<p text-15>测试15px</p>
<p text-16>测试16px</p>
<p text-17>测试17px</p>
<p text-18>测试18px</p>
<p text-19>测试19px</p>
<p text-20>测试20px</p>
</div>
<div mt-30 class="content-box">
<p text-12>测试12px</p>
<p text-13>测试13px</p>
<p text-14>测试14px</p>
<p text-15>测试15px</p>
<p text-16>测试16px</p>
<p text-17>测试17px</p>
<p text-18>测试18px</p>
<p text-19>测试19px</p>
<p text-20>测试20px</p>
</div>
<div mt-30 class="content-box">
<p text-12>测试12px</p>
<p text-13>测试13px</p>
<p text-14>测试14px</p>
<p text-15>测试15px</p>
<p text-16>测试16px</p>
<p text-17>测试17px</p>
<p text-18>测试18px</p>
<p text-19>测试19px</p>
<p text-20>测试20px</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.content-box {
background-color: #fff;
border-radius: 15px;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<h1>权限管理</h1>
</template>

5
src/views/user/index.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup></script>
<template>
<h1>用户管理</h1>
</template>