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

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
root = true
[*]
charset = utf-8
end_of_line = lf

3
.env Normal file
View File

@ -0,0 +1,3 @@
VITE_APP_TITLE = 'Vue Naive Admin'
VITE_PORT = 3100

18
.env.development Normal file
View File

@ -0,0 +1,18 @@
NODE_ENV = 'development'
ENV = 'development'
VITE_PUBLIC_PATH = '/'
# 是否使用MOCK
VITE_APP_USE_MOCK = true
# proxy
VITE_PROXY = [["/api","http://localhost:8080"],["/api-test","localhost:8080"]]
# base api
# VITE_APP_GLOB_BASE_API = '/api'
VITE_APP_GLOB_BASE_API = '/api-mock'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'

14
.env.production Normal file
View File

@ -0,0 +1,14 @@
NODE_ENV = 'production'
ENV = 'production'
VITE_PUBLIC_PATH = '/'
# 是否使用MOCK
VITE_APP_USE_MOCK = true
# base api
VITE_APP_GLOB_BASE_API = '/api-mock'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'

14
.env.staging Normal file
View File

@ -0,0 +1,14 @@
NODE_ENV = 'production'
ENV = 'staging'
VITE_PUBLIC_PATH = '/'
# 是否使用MOCK
VITE_APP_USE_MOCK = false
# base api
VITE_APP_GLOB_BASE_API = '/api-mock'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'

14
.env.test Normal file
View File

@ -0,0 +1,14 @@
NODE_ENV = 'production'
ENV = 'test'
VITE_PUBLIC_PATH = '/'
# 是否使用MOCK
VITE_APP_USE_MOCK = false
# base api
VITE_APP_GLOB_BASE_API = '/api-mock'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
public

26
.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
// * https://zhuanlan.zhihu.com/p/388703150
module.exports = {
root: true,
env: {
browser: true, // browser global variables
node: true,
es2021: true, // adds all ECMAScript 2021 globals and automatically sets the ecmaVersion parser option to 12.
},
parserOptions: {
ecmaVersion: 2020,
},
parser: 'vue-eslint-parser',
extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'],
plugins: ['prettier'],
rules: {
'prettier/prettier': 'error',
'vue/valid-template-root': 'off',
'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': [
'error',
{
ignores: ['index', '401', '404'],
},
],
},
}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
*.local

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
/node_modules/**
/dist/*
/public/*

26
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"files.eol": "\n",
"path-intellisense.mappings": {
"@/": "${workspaceRoot}/src"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": ["javascript", "javascriptreact", "typescript"]
}

1
README.md Normal file
View File

@ -0,0 +1 @@
# VUE NAIVE ADMIN

3
build/constant.js Normal file
View File

@ -0,0 +1,3 @@
export const GLOB_CONFIG_FILE_NAME = 'app.config.js'
export const GLOB_CONFIG_NAME = '__APP__GLOB__CONF__'
export const OUTPUT_DIR = 'dist'

View File

@ -0,0 +1,29 @@
import { GLOB_CONFIG_FILE_NAME, GLOB_CONFIG_NAME, OUTPUT_DIR } from '../constant'
import fs, { writeFileSync } from 'fs-extra'
import chalk from 'chalk'
import { getEnvConfig, getRootPath } from '../utils'
function createConfig(option) {
const { config, configName, configFileName } = option
try {
const windowConf = `window.${configName}`
const configStr = `${windowConf}=${JSON.stringify(config)};
Object.freeze(${windowConf});
Object.defineProperty(window, "${configName}", {
configurable: false,
writable: false,
});
`.replace(/\s/g, '')
fs.mkdirp(getRootPath(OUTPUT_DIR))
writeFileSync(getRootPath(`${OUTPUT_DIR}/${configFileName}`), configStr)
} catch (error) {
console.log(chalk.red('configuration file configuration file failed to package:\n' + error))
}
}
export function runBuildConfig() {
const config = getEnvConfig()
const configName = GLOB_CONFIG_NAME
const configFileName = GLOB_CONFIG_FILE_NAME
createConfig({ config, configName, configFileName })
}

14
build/script/index.js Normal file
View File

@ -0,0 +1,14 @@
import chalk from 'chalk'
import { runBuildConfig } from './build-config'
export const runBuild = async () => {
try {
runBuildConfig()
console.log(`${chalk.cyan('build successfully!')}`)
} catch (error) {
console.log(chalk.red('vite build error:\n' + error))
process.exit(1)
}
}
runBuild()

69
build/utils.js Normal file
View File

@ -0,0 +1,69 @@
import fs from 'fs'
import path from 'path'
import dotenv from 'dotenv'
export function wrapperEnv(envOptions) {
if (!envOptions) return {}
const ret = {}
for (const key in envOptions) {
let val = envOptions[key]
if (['true', 'false'].includes(val)) {
val = val === 'true'
}
if (['VITE_PORT'].includes(key)) {
val = +val
}
if (key === 'VITE_PROXY' && val) {
try {
val = JSON.parse(val.replace(/'/g, '"'))
} catch (error) {
val = ''
}
}
ret[key] = val
if (typeof key === 'string') {
process.env[key] = val
} else if (typeof key === 'object') {
process.env[key] = JSON.stringify(val)
}
}
return ret
}
/**
* 获取当前环境下生效的配置文件名
*/
function getConfFiles() {
const script = process.env.npm_lifecycle_script
const reg = new RegExp('--mode ([a-z_\\d]+)')
const result = reg.exec(script)
if (result) {
const mode = result[1]
return ['.env', `.env.${mode}`]
}
return ['.env', '.env.production']
}
export function getEnvConfig(match = 'VITE_APP_GLOB_', confFiles = getConfFiles()) {
let envConfig = {}
confFiles.forEach((item) => {
try {
const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item)))
envConfig = { ...envConfig, ...env }
} catch (e) {
console.error(`Error in parsing ${item}`, e)
}
})
const reg = new RegExp(`^(${match})`)
Object.keys(envConfig).forEach((key) => {
if (!reg.test(key)) {
Reflect.deleteProperty(envConfig, key)
}
})
return envConfig
}
export function getRootPath(...dir) {
return path.resolve(process.cwd(), ...dir)
}

32
build/vite/plugin/html.js Normal file
View File

@ -0,0 +1,32 @@
import html from 'vite-plugin-html'
import { version } from '../../../package.json'
import { GLOB_CONFIG_FILE_NAME } from '../../constant'
export function configHtmlPlugin(viteEnv, isBuild) {
const { VITE_APP_TITLE, VITE_PUBLIC_PATH } = viteEnv
const path = VITE_PUBLIC_PATH.endsWith('/') ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`
const getAppConfigSrc = () => {
return `${path}${GLOB_CONFIG_FILE_NAME}?v=${version}-${new Date().getTime()}`
}
const htmlPlugin = html({
minify: isBuild,
inject: {
data: {
title: VITE_APP_TITLE,
},
tags: isBuild
? [
{
tag: 'script',
attrs: {
src: getAppConfigSrc(),
},
},
]
: [],
},
})
return htmlPlugin
}

View File

@ -0,0 +1,12 @@
import vue from '@vitejs/plugin-vue'
import { configHtmlPlugin } from './html'
import { configMockPlugin } from './mock'
import { unocss } from './unocss'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [vue(), unocss(), configHtmlPlugin(viteEnv, isBuild)]
viteEnv?.VITE_APP_USE_MOCK && plugins.push(configMockPlugin(isBuild))
return plugins
}

14
build/vite/plugin/mock.js Normal file
View File

@ -0,0 +1,14 @@
import { viteMockServe } from 'vite-plugin-mock'
export function configMockPlugin(isBuild) {
return viteMockServe({
ignore: /^\_/,
mockPath: 'mock',
localEnabled: !isBuild,
prodEnabled: isBuild,
injectCode: `
import { setupProdMockServer } from '../mock/_createProdServer';
setupProdMockServer();
`,
})
}

View File

@ -0,0 +1,9 @@
import Unocss from 'unocss/vite'
import { presetUno, presetAttributify, presetIcons } from 'unocss'
// https://github.com/antfu/unocss
export function unocss() {
return Unocss({
presets: [presetUno(), presetAttributify(), presetIcons()],
})
}

18
build/vite/proxy.js Normal file
View File

@ -0,0 +1,18 @@
const httpsRE = /^https:\/\//
export function createProxy(list = []) {
const ret = {}
for (const [prefix, target] of list) {
const isHttps = httpsRE.test(target)
// https://github.com/http-party/node-http-proxy#options
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// https is require secure=false
...(isHttps ? { secure: false } : {}),
}
}
return ret
}

22
index.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-control" content="no-cache" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>
<%= title %>
</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

9
jsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

14
mock/_createProdServer.js Normal file
View File

@ -0,0 +1,14 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const modules = import.meta.globEager('./**/*.js')
const mockModules = []
Object.keys(modules).forEach((key) => {
if (key.includes('/_')) {
return
}
mockModules.push(...modules[key].default)
})
export function setupProdMockServer() {
createProdMockServer(mockModules)
}

12
mock/_utils.js Normal file
View File

@ -0,0 +1,12 @@
export function resolveToken(authorization) {
/**
* * jwt token
* * Bearer + token
* ! 认证方案: Bearer
*/
const reqTokenSplit = authorization.split(' ')
if (reqTokenSplit.length === 2) {
return reqTokenSplit[1]
}
return ''
}

40
mock/auth/index.js Normal file
View File

@ -0,0 +1,40 @@
import { resolveToken } from '../_utils'
const token = {
admin: 'admin',
editor: 'editor',
}
export default [
{
url: '/api-mock/auth/login',
method: 'post',
response: ({ body }) => {
if (['admin', 'editor'].includes(body?.name)) {
return {
code: 0,
data: {
token: token[body.name],
},
}
} else {
return {
code: -1,
message: '没有此用户',
}
}
},
},
{
url: '/api-mock/auth/refreshToken',
method: 'post',
response: ({ headers }) => {
return {
code: 0,
data: {
token: resolveToken(headers?.authorization),
},
}
},
},
]

39
mock/user/index.js Normal file
View File

@ -0,0 +1,39 @@
import { resolveToken } from '../_utils'
const users = {
admin: {
id: 1,
name: '大脸怪(admin)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
email: 'Ronnie@123.com',
role: ['admin'],
},
editor: {
id: 2,
name: '大脸怪(editor)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
email: 'Ronnie@123.com',
role: ['editor'],
},
guest: {
id: 3,
name: '访客(guest)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
role: [],
},
}
export default [
{
url: '/api-mock/user',
method: 'get',
response: ({ headers }) => {
const token = resolveToken(headers?.authorization)
return {
code: 0,
data: {
...(users[token] || users.guest),
},
}
},
},
]

2945
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "vue-naive-admin",
"version": "0.0.1",
"scripts": {
"dev": "vite",
"lint": "eslint . --fix",
"lint:fix": "eslint . --fix",
"build": "vite build && esno ./build/script",
"build:test": "vite build --mode test && esno ./build/script",
"build:staging": "vite build --mode staging && esno ./build/script",
"preview": "vite preview"
},
"dependencies": {
"@vicons/fa": "^0.11.0",
"axios": "^0.21.4",
"dayjs": "^1.10.7",
"js-cookie": "^3.0.1",
"lodash-es": "^4.17.21",
"mockjs": "^1.1.0",
"pinia": "^2.0.9",
"vue": "^3.2.6",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@unocss/preset-attributify": "^0.16.3",
"@unocss/preset-icons": "^0.16.3",
"@unocss/preset-uno": "^0.16.3",
"@vitejs/plugin-vue": "^1.6.0",
"@vue/compiler-sfc": "^3.0.5",
"chalk": "^5.0.0",
"dotenv": "^10.0.0",
"eslint": "^8.6.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.2.0",
"esno": "^0.13.0",
"fs-extra": "^10.0.0",
"naive-ui": "^2.19.1",
"prettier": "^2.5.1",
"sass": "^1.38.1",
"unocss": "^0.16.3",
"vite": "^2.7.6",
"vite-plugin-html": "^2.1.1",
"vite-plugin-mock": "^2.9.6"
}
}

6
prettier.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
printWidth: 120,
singleQuote: true,
semi: false,
endOfLine: 'lf',
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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 {
// loadingmessage,messageloading messagemessage
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>

46
vite.config.js Normal file
View File

@ -0,0 +1,46 @@
import { defineConfig, loadEnv } from 'vite'
import path from 'path'
import { wrapperEnv } from './build/utils'
import { createVitePlugins } from './build/vite/plugin'
import { createProxy } from './build/vite/proxy'
import { OUTPUT_DIR } from './build/constant'
export default defineConfig(({ command, mode }) => {
const root = process.cwd()
const isBuild = command === 'build'
const env = loadEnv(mode, process.cwd())
const viteEnv = wrapperEnv(env)
const { VITE_PORT, VITE_PUBLIC_PATH, VITE_PROXY } = viteEnv
return {
root,
base: VITE_PUBLIC_PATH || '/',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
plugins: createVitePlugins(viteEnv, isBuild),
css: {
preprocessorOptions: {
//define global scss variable
scss: {
additionalData: `@import '@/styles/variables.scss';`,
},
},
},
server: {
host: '0.0.0.0',
port: VITE_PORT,
proxy: createProxy(VITE_PROXY),
},
build: {
target: 'es2015',
outDir: OUTPUT_DIR,
brotliSize: false,
chunkSizeWarningLimit: 2000,
},
}
})