first commit
@@ -0,0 +1,8 @@
|
||||
# 资源公共路径,需要以 /开头和结尾
|
||||
VITE_PUBLIC_PATH = '/'
|
||||
|
||||
# 是否启用代理
|
||||
VITE_USE_PROXY = true
|
||||
|
||||
# base api
|
||||
VITE_BASE_API = '/api/v1'
|
||||
@@ -0,0 +1,11 @@
|
||||
# 资源公共路径,需要以 /开头和结尾
|
||||
VITE_PUBLIC_PATH = '/'
|
||||
|
||||
# base api
|
||||
VITE_BASE_API = '/api/v1'
|
||||
|
||||
# 是否启用压缩
|
||||
VITE_USE_COMPRESS = true
|
||||
|
||||
# 压缩类型
|
||||
VITE_COMPRESS_TYPE = gzip
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"globals": {
|
||||
"$loadingBar": true,
|
||||
"$message": true,
|
||||
"defineOptions": true,
|
||||
"$dialog": true,
|
||||
"$notification": true,
|
||||
"EffectScope": true,
|
||||
"computed": true,
|
||||
"createApp": true,
|
||||
"customRef": true,
|
||||
"defineAsyncComponent": true,
|
||||
"defineComponent": true,
|
||||
"effectScope": true,
|
||||
"getCurrentInstance": true,
|
||||
"getCurrentScope": true,
|
||||
"h": true,
|
||||
"inject": true,
|
||||
"isProxy": true,
|
||||
"isReactive": true,
|
||||
"isReadonly": true,
|
||||
"isRef": true,
|
||||
"markRaw": true,
|
||||
"nextTick": true,
|
||||
"onActivated": true,
|
||||
"onBeforeMount": true,
|
||||
"onBeforeUnmount": true,
|
||||
"onBeforeUpdate": true,
|
||||
"onDeactivated": true,
|
||||
"onErrorCaptured": true,
|
||||
"onMounted": true,
|
||||
"onRenderTracked": true,
|
||||
"onRenderTriggered": true,
|
||||
"onScopeDispose": true,
|
||||
"onServerPrefetch": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"provide": true,
|
||||
"reactive": true,
|
||||
"readonly": true,
|
||||
"ref": true,
|
||||
"resolveComponent": true,
|
||||
"shallowReactive": true,
|
||||
"shallowReadonly": true,
|
||||
"shallowRef": true,
|
||||
"toRaw": true,
|
||||
"toRef": true,
|
||||
"toRefs": true,
|
||||
"triggerRef": true,
|
||||
"unref": true,
|
||||
"useAttrs": true,
|
||||
"useCssModule": true,
|
||||
"useCssVars": true,
|
||||
"useRoute": true,
|
||||
"useRouter": true,
|
||||
"useSlots": true,
|
||||
"watch": true,
|
||||
"watchEffect": true,
|
||||
"watchPostEffect": true,
|
||||
"watchSyncEffect": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
package.json
|
||||
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
stats.html
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
/node_modules/**
|
||||
/dist/*
|
||||
/public/*
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
## 快速开始
|
||||
|
||||
进入前端目录
|
||||
|
||||
```sh
|
||||
cd web
|
||||
```
|
||||
|
||||
安装依赖(建议使用pnpm: https://pnpm.io/zh/installation)
|
||||
|
||||
```sh
|
||||
npm i -g pnpm # 已安装可忽略
|
||||
pnpm i # 或者 npm i
|
||||
```
|
||||
|
||||
启动
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
* * 此处定义的是全局常量,启动或打包后将添加到window中
|
||||
* https://vitejs.cn/config/#define
|
||||
*/
|
||||
|
||||
// 项目构建时间
|
||||
const _BUILD_TIME_ = JSON.stringify(dayjs().format('YYYY-MM-DD HH:mm:ss'))
|
||||
|
||||
export const viteDefine = {
|
||||
_BUILD_TIME_,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './define'
|
||||
@@ -0,0 +1,23 @@
|
||||
export const OUTPUT_DIR = 'dist'
|
||||
|
||||
export const PROXY_CONFIG = {
|
||||
// /**
|
||||
// * @desc 替换匹配值
|
||||
// * @请求路径 http://localhost:3100/api/user
|
||||
// * @转发路径 http://localhost:9999/api/v1 +/user
|
||||
// */
|
||||
// '/api': {
|
||||
// target: 'http://localhost:9999/api/v1',
|
||||
// changeOrigin: true,
|
||||
// rewrite: (path) => path.replace(new RegExp('^/api'), ''),
|
||||
// },
|
||||
/**
|
||||
* @desc 不替换匹配值
|
||||
* @请求路径 http://localhost:3100/api/v1/user
|
||||
* @转发路径 http://localhost:9999/api/v1/user
|
||||
*/
|
||||
'/api/v1': {
|
||||
target: 'http://127.0.0.1:9999',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
|
||||
export function configHtmlPlugin(viteEnv, isBuild) {
|
||||
const { VITE_TITLE } = viteEnv
|
||||
|
||||
const htmlPlugin = createHtmlPlugin({
|
||||
minify: isBuild,
|
||||
inject: {
|
||||
data: {
|
||||
title: VITE_TITLE,
|
||||
},
|
||||
},
|
||||
})
|
||||
return htmlPlugin
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
/**
|
||||
* * unocss插件,原子css
|
||||
* https://github.com/antfu/unocss
|
||||
*/
|
||||
import Unocss from 'unocss/vite'
|
||||
|
||||
// rollup打包分析插件
|
||||
import visualizer from 'rollup-plugin-visualizer'
|
||||
// 压缩
|
||||
import viteCompression from 'vite-plugin-compression'
|
||||
|
||||
import { configHtmlPlugin } from './html'
|
||||
import unplugin from './unplugin'
|
||||
|
||||
export function createVitePlugins(viteEnv, isBuild) {
|
||||
const plugins = [vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
|
||||
|
||||
if (viteEnv.VITE_USE_COMPRESS) {
|
||||
plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' }))
|
||||
}
|
||||
|
||||
if (isBuild) {
|
||||
plugins.push(
|
||||
visualizer({
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { resolve } from 'path'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
|
||||
/**
|
||||
* * unplugin-icons插件,自动引入iconify图标
|
||||
* usage: https://github.com/antfu/unplugin-icons
|
||||
* 图标库: https://icones.js.org/
|
||||
*/
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||
|
||||
import { getSrcPath } from '../utils'
|
||||
|
||||
const customIconPath = resolve(getSrcPath(), 'assets/svg')
|
||||
|
||||
export default [
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router'],
|
||||
dts: false,
|
||||
}),
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
customCollections: {
|
||||
custom: FileSystemIconLoader(customIconPath),
|
||||
},
|
||||
scale: 1,
|
||||
defaultClass: 'inline-block',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
NaiveUiResolver(),
|
||||
IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' }),
|
||||
],
|
||||
dts: false,
|
||||
}),
|
||||
createSvgIconsPlugin({
|
||||
iconDirs: [customIconPath],
|
||||
symbolId: 'icon-custom-[dir]-[name]',
|
||||
inject: 'body-last',
|
||||
customDomId: '__CUSTOM_SVG_ICON__',
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { resolve } from 'path'
|
||||
import chalk from 'chalk'
|
||||
import { writeFileSync } from 'fs-extra'
|
||||
import { OUTPUT_DIR } from '../constant'
|
||||
import { getEnvConfig, getRootPath } from '../utils'
|
||||
|
||||
export function runBuildCNAME() {
|
||||
const { VITE_CNAME } = getEnvConfig()
|
||||
if (!VITE_CNAME) return
|
||||
try {
|
||||
writeFileSync(resolve(getRootPath(), `${OUTPUT_DIR}/CNAME`), VITE_CNAME)
|
||||
} catch (error) {
|
||||
console.log(chalk.red('CNAME file failed to package:\n' + error))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import chalk from 'chalk'
|
||||
import { runBuildCNAME } from './build-cname'
|
||||
|
||||
export const runBuild = async () => {
|
||||
try {
|
||||
runBuildCNAME()
|
||||
console.log(`✨ ${chalk.cyan('build successfully!')}`)
|
||||
} catch (error) {
|
||||
console.log(chalk.red('vite build error:\n' + error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
runBuild()
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
/**
|
||||
* * 项目根路径
|
||||
* @descrition 结尾不带/
|
||||
*/
|
||||
export function getRootPath() {
|
||||
return path.resolve(process.cwd())
|
||||
}
|
||||
|
||||
/**
|
||||
* * 项目src路径
|
||||
* @param srcName src目录名称(默认: "src")
|
||||
* @descrition 结尾不带斜杠
|
||||
*/
|
||||
export function getSrcPath(srcName = 'src') {
|
||||
return path.resolve(getRootPath(), srcName)
|
||||
}
|
||||
|
||||
export function convertEnv(envOptions) {
|
||||
const result = {}
|
||||
if (!envOptions) return result
|
||||
|
||||
for (const envKey in envOptions) {
|
||||
let envVal = envOptions[envKey]
|
||||
if (['true', 'false'].includes(envVal)) envVal = envVal === 'true'
|
||||
|
||||
if (['VITE_PORT'].includes(envKey)) envVal = +envVal
|
||||
|
||||
result[envKey] = envVal
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前环境下生效的配置文件名
|
||||
*/
|
||||
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.local', `.env.${mode}`]
|
||||
}
|
||||
return ['.env', '.env.local', '.env.production']
|
||||
}
|
||||
|
||||
export function getEnvConfig(match = 'VITE_', confFiles = getConfFiles()) {
|
||||
let envConfig = {}
|
||||
confFiles.forEach((item) => {
|
||||
try {
|
||||
if (fs.existsSync(path.resolve(process.cwd(), item))) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { lStorage } from '@/utils'
|
||||
|
||||
import messages from './messages'
|
||||
|
||||
const currentLocale = lStorage.get('locale')
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: currentLocale || 'cn',
|
||||
fallbackLocale: 'cn',
|
||||
messages: messages,
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"lang": "中文",
|
||||
"app_name": "Vue FastAPI Admin",
|
||||
"header": {
|
||||
"label_profile": "个人信息",
|
||||
"label_logout": "退出登录",
|
||||
"label_logout_dialog_title": "提示",
|
||||
"text_logout_confirm": "确认退出?",
|
||||
"text_logout_success": "已退出登录"
|
||||
},
|
||||
"views": {
|
||||
"login": {
|
||||
"text_login": "登录",
|
||||
"message_input_username_password": "请输入用户名和密码",
|
||||
"message_verifying": "正在验证...",
|
||||
"message_login_success": "登录成功"
|
||||
},
|
||||
"workbench": {
|
||||
"label_workbench": "工作台",
|
||||
"text_hello": "hello, {username}",
|
||||
"text_welcome": "今天又是元气满满的一天!",
|
||||
"label_number_of_items": "项目数",
|
||||
"label_upcoming": "待办",
|
||||
"label_information": "消息",
|
||||
"label_project": "项目",
|
||||
"label_more": "更多"
|
||||
},
|
||||
"profile": {
|
||||
"label_profile": "个人中心",
|
||||
"label_modify_information": "修改信息",
|
||||
"label_change_password": "修改密码",
|
||||
"label_avatar": "头像",
|
||||
"label_username": "用户姓名",
|
||||
"label_email": "邮箱",
|
||||
"label_old_password": "旧密码",
|
||||
"label_new_password": "新密码",
|
||||
"label_confirm_password": "确认密码",
|
||||
"placeholder_username": "请填写姓名",
|
||||
"placeholder_email": "请填写邮箱",
|
||||
"placeholder_old_password": "请输入旧密码",
|
||||
"placeholder_new_password": "请输入新密码",
|
||||
"placeholder_confirm_password": "请再次输入新密码",
|
||||
"message_username_required": "请输入昵称",
|
||||
"message_old_password_required": "请输入旧密码",
|
||||
"message_new_password_required": "请输入新密码",
|
||||
"message_password_confirmation_required": "请再次输入密码",
|
||||
"message_password_confirmation_diff": "两次密码输入不一致"
|
||||
},
|
||||
"errors": {
|
||||
"label_error": "错误页",
|
||||
"text_back_to_home": "返回首页"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"text": {
|
||||
"update_success": "修改成功"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "修改"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"lang": "English",
|
||||
"app_name": "Vue FastAPI Admin",
|
||||
"header": {
|
||||
"label_profile": "Profile",
|
||||
"label_logout": "Logout",
|
||||
"label_logout_dialog_title": "Hint",
|
||||
"text_logout_confirm": "Logout confirm",
|
||||
"text_logout_success": "Logout success"
|
||||
},
|
||||
"views": {
|
||||
"login": {
|
||||
"text_login": "Login",
|
||||
"message_input_username_password": "Please enter username and password",
|
||||
"message_verifying": "Verifying...",
|
||||
"message_login_success": "Login successful"
|
||||
},
|
||||
"workbench": {
|
||||
"label_workbench": "Workbench",
|
||||
"text_hello": "hello, {username}",
|
||||
"text_welcome": "Today is another day full of energy!",
|
||||
"label_number_of_items": "Number of items",
|
||||
"label_upcoming": "Upcoming",
|
||||
"label_information": "Information",
|
||||
"label_project": "Project",
|
||||
"label_more": "More"
|
||||
},
|
||||
"profile": {
|
||||
"label_profile": "Profile",
|
||||
"label_modify_information": "Modify your information",
|
||||
"label_change_password": "Change password",
|
||||
"label_avatar": "Avatar",
|
||||
"label_username": "Username",
|
||||
"label_email": "Email",
|
||||
"label_old_password": "Old password",
|
||||
"label_new_password": "New password",
|
||||
"label_confirm_password": "Password confirmation",
|
||||
"placeholder_username": "Please fill in your name",
|
||||
"placeholder_email": "Please fill in your email address",
|
||||
"placeholder_old_password": "Please enter the old password",
|
||||
"placeholder_new_password": "Please enter a new password",
|
||||
"placeholder_confirm_password": "Please enter the confirm password",
|
||||
"message_username_required": "Please enter username",
|
||||
"message_old_password_required": "Please enter the old password",
|
||||
"message_new_password_required": "Please enter a new password",
|
||||
"message_password_confirmation_required": "Please enter confirm password",
|
||||
"message_password_confirmation_diff": "Two password inputs are inconsistent"
|
||||
},
|
||||
"errors": {
|
||||
"label_error": "Error",
|
||||
"text_back_to_home": "Back to home"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"text": {
|
||||
"update_success": "Update success"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Update"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as en from './en.json'
|
||||
import * as cn from './cn.json'
|
||||
|
||||
export default {
|
||||
en,
|
||||
cn,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<!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.svg" />
|
||||
<link rel="stylesheet" href="/resource/loading.css" />
|
||||
|
||||
<title><%= title %></title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 白屏时的loading效果 -->
|
||||
<div class="loading-container">
|
||||
<div id="loadingLogo" class="loading-svg"></div>
|
||||
<div class="loading-spin__container">
|
||||
<div class="loading-spin">
|
||||
<div class="left-0 top-0 loading-spin-item"></div>
|
||||
<div class="left-0 bottom-0 loading-spin-item loading-delay-500"></div>
|
||||
<div class="right-0 top-0 loading-spin-item loading-delay-1000"></div>
|
||||
<div class="right-0 bottom-0 loading-spin-item loading-delay-1500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-title"><%= title %></div>
|
||||
</div>
|
||||
<script src="/resource/loading.js"></script>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"~/*": ["./*"],
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"jsx": "preserve",
|
||||
"allowJs": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "vue-fastapi-admin-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .js,.vue .",
|
||||
"lint:fix": "eslint --fix --ext .js,.vue .",
|
||||
"lint:staged": "lint-staged",
|
||||
"prettier": "npx prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/json": "^2.2.228",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@unocss/eslint-config": "^0.55.0",
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@zclzone/eslint-config": "^0.0.4",
|
||||
"axios": "^1.4.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.46.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"naive-ui": "^2.34.4",
|
||||
"pinia": "^2.1.6",
|
||||
"rollup-plugin-visualizer": "^5.9.2",
|
||||
"sass": "^1.65.1",
|
||||
"typescript": "^5.1.6",
|
||||
"unocss": "^0.55.0",
|
||||
"unplugin-auto-import": "^0.16.6",
|
||||
"unplugin-icons": "^0.16.5",
|
||||
"unplugin-vue-components": "^0.25.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"vite": "^4.4.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue}": [
|
||||
"eslint --ext .js,.vue ."
|
||||
]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@zclzone",
|
||||
"@unocss",
|
||||
".eslint-global-variables.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,91 @@
|
||||
.loading-container {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-svg {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.loading-spin__container {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 36px 0;
|
||||
}
|
||||
|
||||
.loading-spin {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
animation: loadingSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
.left-0 {
|
||||
left: 0;
|
||||
}
|
||||
.right-0 {
|
||||
right: 0;
|
||||
}
|
||||
.top-0 {
|
||||
top: 0;
|
||||
}
|
||||
.bottom-0 {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.loading-spin-item {
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 8px;
|
||||
-webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes loadingSpin {
|
||||
from {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-delay-500 {
|
||||
-webkit-animation-delay: 500ms;
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
.loading-delay-1000 {
|
||||
-webkit-animation-delay: 1000ms;
|
||||
animation-delay: 1000ms;
|
||||
}
|
||||
.loading-delay-1500 {
|
||||
-webkit-animation-delay: 1500ms;
|
||||
animation-delay: 1500ms;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #6a6a6a;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './theme.json'
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"header": {
|
||||
"height": 60
|
||||
},
|
||||
"tags": {
|
||||
"visible": true,
|
||||
"height": 50
|
||||
},
|
||||
"naiveThemeOverrides": {
|
||||
"common": {
|
||||
"primaryColor": "#F4511E",
|
||||
"primaryColorHover": "#F4511E",
|
||||
"primaryColorPressed": "#2B4C59FF",
|
||||
"primaryColorSuppl": "#F4511E",
|
||||
|
||||
"infoColor": "#2080F0FF",
|
||||
"infoColorHover": "#4098FCFF",
|
||||
"infoColorPressed": "#1060C9FF",
|
||||
"infoColorSuppl": "#4098FCFF",
|
||||
|
||||
"successColor": "#18A058FF",
|
||||
"successColorHover": "#F4511E",
|
||||
"successColorPressed": "#0C7A43FF",
|
||||
"successColorSuppl": "#F4511E",
|
||||
|
||||
"warningColor": "#F0A020FF",
|
||||
"warningColorHover": "#FCB040FF",
|
||||
"warningColorPressed": "#C97C10FF",
|
||||
"warningColorSuppl": "#FCB040FF",
|
||||
|
||||
"errorColor": "#D03050FF",
|
||||
"errorColorHover": "#DE576DFF",
|
||||
"errorColorPressed": "#AB1F3FFF",
|
||||
"errorColorSuppl": "#DE576DFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<AppProvider>
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</AppProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppProvider from '@/components/common/AppProvider.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { request, parseFilenameFromDisposition } from '@/utils'
|
||||
|
||||
export const parse_finance_data = (data) => {
|
||||
return request.post('/parse_finance_data/parse', data, { timeout: 15*60*1000, responseType: 'blob' }).then((response) => {
|
||||
const filename = parseFilenameFromDisposition(response.headers['content-disposition'])
|
||||
return {
|
||||
blob: new Blob([response.data]),
|
||||
filename,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const get_task_list = (params = {}) => request.get('/parse_finance_data/list', { params })
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
login: (data) => request.post('/base/access_token', data, { noNeedToken: true }),
|
||||
getUserInfo: () => request.get('/base/userinfo'),
|
||||
getUserMenu: () => request.get('/base/usermenu'),
|
||||
getUserApi: () => request.get('/base/userapi'),
|
||||
// profile
|
||||
updatePassword: (data = {}) => request.post('/base/update_password', data),
|
||||
// users
|
||||
getUserList: (params = {}) => request.get('/user/list', { params }),
|
||||
getUserById: (params = {}) => request.get('/user/get', { params }),
|
||||
createUser: (data = {}) => request.post('/user/create', data),
|
||||
updateUser: (data = {}) => request.post('/user/update', data),
|
||||
updateUserOnline: (data = {}) => request.post('/user/update_user_online', data),
|
||||
deleteUser: (params = {}) => request.delete(`/user/delete`, { params }),
|
||||
resetPassword: (data = {}) => request.post(`/user/reset_password`, data),
|
||||
// role
|
||||
getRoleList: (params = {}) => request.get('/role/list', { params }),
|
||||
createRole: (data = {}) => request.post('/role/create', data),
|
||||
updateRole: (data = {}) => request.post('/role/update', data),
|
||||
deleteRole: (params = {}) => request.delete('/role/delete', { params }),
|
||||
updateRoleAuthorized: (data = {}) => request.post('/role/authorized', data),
|
||||
getRoleAuthorized: (params = {}) => request.get('/role/authorized', { params }),
|
||||
// menus
|
||||
getMenus: (params = {}) => request.get('/menu/list', { params }),
|
||||
createMenu: (data = {}) => request.post('/menu/create', data),
|
||||
updateMenu: (data = {}) => request.post('/menu/update', data),
|
||||
deleteMenu: (params = {}) => request.delete('/menu/delete', { params }),
|
||||
// apis
|
||||
getApis: (params = {}) => request.get('/api/list', { params }),
|
||||
createApi: (data = {}) => request.post('/api/create', data),
|
||||
updateApi: (data = {}) => request.post('/api/update', data),
|
||||
deleteApi: (params = {}) => request.delete('/api/delete', { params }),
|
||||
refreshApi: (data = {}) => request.post('/api/refresh', data),
|
||||
// depts
|
||||
getDepts: (params = {}) => request.get('/dept/list', { params }),
|
||||
createDept: (data = {}) => request.post('/dept/create', data),
|
||||
updateDept: (data = {}) => request.post('/dept/update', data),
|
||||
deleteDept: (params = {}) => request.delete('/dept/delete', { params }),
|
||||
// auditlog
|
||||
getAuditLogList: (params = {}) => request.get('/auditlog/list', { params }),
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,230 @@
|
||||
export default [
|
||||
'mdi-air-humidifier-off',
|
||||
'mdi-chili-off',
|
||||
'mdi-cigar-off',
|
||||
'mdi-clock-time-eight',
|
||||
'mdi-clock-time-eight-outline',
|
||||
'mdi-clock-time-eleven',
|
||||
'mdi-clock-time-eleven-outline',
|
||||
'mdi-clock-time-five',
|
||||
'mdi-clock-time-five-outline',
|
||||
'mdi-clock-time-four',
|
||||
'mdi-clock-time-four-outline',
|
||||
'mdi-clock-time-nine',
|
||||
'mdi-clock-time-nine-outline',
|
||||
'mdi-clock-time-one',
|
||||
'mdi-clock-time-one-outline',
|
||||
'mdi-clock-time-seven',
|
||||
'mdi-clock-time-seven-outline',
|
||||
'mdi-clock-time-six',
|
||||
'mdi-clock-time-six-outline',
|
||||
'mdi-clock-time-ten',
|
||||
'mdi-clock-time-ten-outline',
|
||||
'mdi-clock-time-three',
|
||||
'mdi-clock-time-three-outline',
|
||||
'mdi-clock-time-twelve',
|
||||
'mdi-clock-time-twelve-outline',
|
||||
'mdi-clock-time-two',
|
||||
'mdi-clock-time-two-outline',
|
||||
'mdi-cog-refresh',
|
||||
'mdi-cog-refresh-outline',
|
||||
'mdi-cog-sync',
|
||||
'mdi-cog-sync-outline',
|
||||
'mdi-content-save-cog',
|
||||
'mdi-content-save-cog-outline',
|
||||
'mdi-cosine-wave',
|
||||
'mdi-cube-off',
|
||||
'mdi-cube-off-outline',
|
||||
'mdi-dome-light',
|
||||
'mdi-download-box',
|
||||
'mdi-download-box-outline',
|
||||
'mdi-download-circle',
|
||||
'mdi-download-circle-outline',
|
||||
'mdi-fan-alert',
|
||||
'mdi-fan-chevron-down',
|
||||
'mdi-fan-chevron-up',
|
||||
'mdi-fan-minus',
|
||||
'mdi-fan-plus',
|
||||
'mdi-fan-remove',
|
||||
'mdi-fan-speed-1',
|
||||
'mdi-fan-speed-2',
|
||||
'mdi-fan-speed-3',
|
||||
'mdi-food-drumstick',
|
||||
'mdi-food-drumstick-off',
|
||||
'mdi-food-drumstick-off-outline',
|
||||
'mdi-food-drumstick-outline',
|
||||
'mdi-food-steak',
|
||||
'mdi-food-steak-off',
|
||||
'mdi-fuse-alert',
|
||||
'mdi-fuse-off',
|
||||
'mdi-heart-minus',
|
||||
'mdi-heart-minus-outline',
|
||||
'mdi-heart-off-outline',
|
||||
'mdi-heart-plus',
|
||||
'mdi-heart-plus-outline',
|
||||
'mdi-heart-remove',
|
||||
'mdi-heart-remove-outline',
|
||||
'mdi-hours-24',
|
||||
'mdi-incognito-circle',
|
||||
'mdi-incognito-circle-off',
|
||||
'mdi-lingerie',
|
||||
'mdi-microwave-off',
|
||||
'mdi-minus-circle-off',
|
||||
'mdi-minus-circle-off-outline',
|
||||
'mdi-motion-sensor-off',
|
||||
'mdi-pail-minus',
|
||||
'mdi-pail-minus-outline',
|
||||
'mdi-pail-off',
|
||||
'mdi-pail-off-outline',
|
||||
'mdi-pail-outline',
|
||||
'mdi-pail-plus',
|
||||
'mdi-pail-plus-outline',
|
||||
'mdi-pail-remove',
|
||||
'mdi-pail-remove-outline',
|
||||
'mdi-pine-tree-fire',
|
||||
'mdi-power-plug-off-outline',
|
||||
'mdi-power-plug-outline',
|
||||
'mdi-printer-eye',
|
||||
'mdi-printer-search',
|
||||
'mdi-puzzle-check',
|
||||
'mdi-puzzle-check-outline',
|
||||
'mdi-rug',
|
||||
'mdi-sawtooth-wave',
|
||||
'mdi-set-square',
|
||||
'mdi-smoking-pipe-off',
|
||||
'mdi-spoon-sugar',
|
||||
'mdi-square-wave',
|
||||
'mdi-table-split-cell',
|
||||
'mdi-ticket-percent-outline',
|
||||
'mdi-triangle-wave',
|
||||
'mdi-waveform',
|
||||
'mdi-wizard-hat',
|
||||
'mdi-ab-testing',
|
||||
'mdi-abjad-arabic',
|
||||
'mdi-abjad-hebrew',
|
||||
'mdi-abugida-devanagari',
|
||||
'mdi-abugida-thai',
|
||||
'mdi-access-point',
|
||||
'mdi-access-point-network',
|
||||
'mdi-access-point-network-off',
|
||||
'mdi-account',
|
||||
'mdi-account-alert',
|
||||
'mdi-account-alert-outline',
|
||||
'mdi-account-arrow-left',
|
||||
'mdi-account-arrow-left-outline',
|
||||
'mdi-account-arrow-right',
|
||||
'mdi-account-arrow-right-outline',
|
||||
'mdi-account-box',
|
||||
'mdi-account-box-multiple',
|
||||
'mdi-account-box-multiple-outline',
|
||||
'mdi-account-box-outline',
|
||||
'mdi-account-cancel',
|
||||
'mdi-account-cancel-outline',
|
||||
'mdi-account-cash',
|
||||
'mdi-account-cash-outline',
|
||||
'mdi-account-check',
|
||||
'mdi-account-check-outline',
|
||||
'mdi-account-child',
|
||||
'mdi-account-child-circle',
|
||||
'mdi-account-child-outline',
|
||||
'mdi-account-circle',
|
||||
'mdi-account-circle-outline',
|
||||
'mdi-account-clock',
|
||||
'mdi-account-clock-outline',
|
||||
'mdi-account-cog',
|
||||
'mdi-account-cog-outline',
|
||||
'mdi-account-convert',
|
||||
'mdi-account-convert-outline',
|
||||
'mdi-account-cowboy-hat',
|
||||
'mdi-account-details',
|
||||
'mdi-account-details-outline',
|
||||
'mdi-account-edit',
|
||||
'mdi-account-edit-outline',
|
||||
'mdi-account-group',
|
||||
'mdi-account-group-outline',
|
||||
'mdi-account-hard-hat',
|
||||
'mdi-account-heart',
|
||||
'mdi-account-heart-outline',
|
||||
'mdi-account-key',
|
||||
'mdi-account-key-outline',
|
||||
'mdi-account-lock',
|
||||
'mdi-account-lock-outline',
|
||||
'mdi-account-minus',
|
||||
'mdi-account-minus-outline',
|
||||
'mdi-account-multiple',
|
||||
'mdi-account-multiple-check',
|
||||
'mdi-account-multiple-check-outline',
|
||||
'mdi-account-multiple-minus',
|
||||
'mdi-account-multiple-minus-outline',
|
||||
'mdi-account-multiple-outline',
|
||||
'mdi-account-multiple-plus',
|
||||
'mdi-account-multiple-plus-outline',
|
||||
'mdi-account-multiple-remove',
|
||||
'mdi-account-multiple-remove-outline',
|
||||
'mdi-account-music',
|
||||
'mdi-account-music-outline',
|
||||
'mdi-account-network',
|
||||
'mdi-account-network-outline',
|
||||
'mdi-account-off',
|
||||
'mdi-account-off-outline',
|
||||
'mdi-account-outline',
|
||||
'mdi-account-plus',
|
||||
'mdi-account-plus-outline',
|
||||
'mdi-account-question',
|
||||
'mdi-account-question-outline',
|
||||
'mdi-account-remove',
|
||||
'mdi-account-remove-outline',
|
||||
'mdi-account-search',
|
||||
'mdi-account-search-outline',
|
||||
'mdi-account-settings',
|
||||
'mdi-account-settings-outline',
|
||||
'mdi-account-star',
|
||||
'mdi-account-star-outline',
|
||||
'mdi-account-supervisor',
|
||||
'mdi-account-supervisor-circle',
|
||||
'mdi-account-supervisor-outline',
|
||||
'mdi-account-switch',
|
||||
'mdi-account-switch-outline',
|
||||
'mdi-account-tie',
|
||||
'mdi-account-tie-outline',
|
||||
'mdi-account-tie-voice',
|
||||
'mdi-account-tie-voice-off',
|
||||
'mdi-account-tie-voice-off-outline',
|
||||
'mdi-account-tie-voice-outline',
|
||||
'mdi-account-voice',
|
||||
'mdi-adjust',
|
||||
'mdi-adobe',
|
||||
'mdi-adobe-acrobat',
|
||||
'mdi-air-conditioner',
|
||||
'mdi-air-filter',
|
||||
'mdi-air-horn',
|
||||
'mdi-air-humidifier',
|
||||
'mdi-air-purifier',
|
||||
'mdi-airbag',
|
||||
'mdi-airballoon',
|
||||
'mdi-airballoon-outline',
|
||||
'mdi-airplane',
|
||||
'mdi-airplane-landing',
|
||||
'mdi-airplane-off',
|
||||
'mdi-airplane-takeoff',
|
||||
'mdi-airport',
|
||||
'mdi-alarm',
|
||||
'mdi-alarm-bell',
|
||||
'mdi-alarm-check',
|
||||
'mdi-alarm-light',
|
||||
'mdi-alarm-light-outline',
|
||||
'mdi-alarm-multiple',
|
||||
'mdi-alarm-note',
|
||||
'mdi-alarm-note-off',
|
||||
'mdi-alarm-off',
|
||||
'mdi-alarm-plus',
|
||||
'mdi-alarm-snooze',
|
||||
'mdi-album',
|
||||
'mdi-alert',
|
||||
'mdi-alert-box',
|
||||
'mdi-alert-box-outline',
|
||||
'mdi-alert-circle',
|
||||
'mdi-alert-circle-check',
|
||||
'mdi-alert-circle-check-outline',
|
||||
'mdi-alert-circle-outline',
|
||||
]
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<footer f-c-c flex-col text-14 color="#6a6a6a"></footer>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<n-config-provider
|
||||
wh-full
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
:theme="appStore.isDark ? darkTheme : undefined"
|
||||
:theme-overrides="naiveThemeOverrides"
|
||||
>
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-message-provider>
|
||||
<slot></slot>
|
||||
<NaiveProviderContent />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineComponent, h } from 'vue'
|
||||
import {
|
||||
zhCN,
|
||||
dateZhCN,
|
||||
darkTheme,
|
||||
useLoadingBar,
|
||||
useDialog,
|
||||
useMessage,
|
||||
useNotification,
|
||||
} from 'naive-ui'
|
||||
import { useCssVar } from '@vueuse/core'
|
||||
import { kebabCase } from 'lodash-es'
|
||||
import { setupMessage, setupDialog } from '@/utils'
|
||||
import { naiveThemeOverrides } from '~/settings'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
function setupCssVar() {
|
||||
const common = naiveThemeOverrides.common
|
||||
for (const key in common) {
|
||||
useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
|
||||
if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载naive组件的方法至window, 以便在全局使用
|
||||
function setupNaiveTools() {
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$notification = useNotification()
|
||||
|
||||
window.$message = setupMessage(useMessage())
|
||||
window.$dialog = setupDialog(useDialog())
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
setup() {
|
||||
setupCssVar()
|
||||
setupNaiveTools()
|
||||
},
|
||||
render() {
|
||||
return h('div')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div v-if="reloadFlag" class="relative">
|
||||
<slot></slot>
|
||||
<div v-show="showPlaceholder" class="absolute-lt h-full w-full" :class="placeholderClass">
|
||||
<div v-show="loading" class="absolute-center">
|
||||
<n-spin :show="true" :size="loadingSize" />
|
||||
</div>
|
||||
<div v-show="isEmpty" class="absolute-center">
|
||||
<div class="relative">
|
||||
<icon-custom-no-data :class="iconClass" />
|
||||
<p class="absolute-lb w-full text-center" :class="descClass">{{ emptyDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!network" class="absolute-center">
|
||||
<div
|
||||
class="relative"
|
||||
:class="{ 'cursor-pointer': showNetworkReload }"
|
||||
@click="handleReload"
|
||||
>
|
||||
<icon-custom-network-error :class="iconClass" />
|
||||
<p class="absolute-lb w-full text-center" :class="descClass">{{ networkErrorDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch, onUnmounted } from 'vue'
|
||||
|
||||
defineOptions({ name: 'LoadingEmptyWrapper' })
|
||||
|
||||
const NETWORK_ERROR_MSG = '网络似乎开了小差~'
|
||||
|
||||
const props = {
|
||||
loading: false,
|
||||
empty: false,
|
||||
loadingSize: 'medium',
|
||||
placeholderClass: 'bg-white dark:bg-dark transition-background-color duration-300 ease-in-out',
|
||||
emptyDesc: '暂无数据',
|
||||
iconClass: 'text-320px text-primary',
|
||||
descClass: 'text-16px text-#666',
|
||||
showNetworkReload: false,
|
||||
}
|
||||
|
||||
// 网络状态
|
||||
const network = ref(window.navigator.onLine)
|
||||
const reloadFlag = ref(true)
|
||||
|
||||
// 数据是否为空
|
||||
const isEmpty = computed(() => props.empty && !props.loading && network.value)
|
||||
|
||||
const showPlaceholder = computed(() => props.loading || isEmpty.value || !network.value)
|
||||
|
||||
const networkErrorDesc = computed(() =>
|
||||
props.showNetworkReload ? `${NETWORK_ERROR_MSG}, 点击重试` : NETWORK_ERROR_MSG,
|
||||
)
|
||||
|
||||
function handleReload() {
|
||||
if (!props.showNetworkReload) return
|
||||
reloadFlag.value = false
|
||||
nextTick(() => {
|
||||
reloadFlag.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => props.loading,
|
||||
(newValue) => {
|
||||
// 结束加载判断一下网络状态
|
||||
if (!newValue) {
|
||||
network.value = window.navigator.onLine
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="wrapper" @mousewheel.prevent="handleMouseWheel">
|
||||
<template v-if="showArrow && isOverflow">
|
||||
<div class="left" @click="handleMouseWheel({ wheelDelta: 120 })">
|
||||
<icon-ic:baseline-keyboard-arrow-left />
|
||||
</div>
|
||||
<div class="right" @click="handleMouseWheel({ wheelDelta: -120 })">
|
||||
<icon-ic:baseline-keyboard-arrow-right />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
ref="content"
|
||||
v-resize="refreshIsOverflow"
|
||||
class="content"
|
||||
:class="{ overflow: isOverflow && showArrow }"
|
||||
:style="{
|
||||
transform: `translateX(${translateX}px)`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { debounce, useResize } from '@/utils'
|
||||
|
||||
defineProps({
|
||||
showArrow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const translateX = ref(0)
|
||||
const content = ref(null)
|
||||
const wrapper = ref(null)
|
||||
const isOverflow = ref(false)
|
||||
|
||||
const refreshIsOverflow = debounce(() => {
|
||||
const wrapperWidth = wrapper.value?.offsetWidth
|
||||
const contentWidth = content.value?.offsetWidth
|
||||
isOverflow.value = contentWidth > wrapperWidth
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}, 200)
|
||||
|
||||
function handleMouseWheel(e) {
|
||||
const { wheelDelta } = e
|
||||
const wrapperWidth = wrapper.value?.offsetWidth
|
||||
const contentWidth = content.value?.offsetWidth
|
||||
/**
|
||||
* @wheelDelta 平行滚动的值 >0: 右移 <0: 左移
|
||||
* @translateX 内容translateX的值
|
||||
* @wrapperWidth 容器的宽度
|
||||
* @contentWidth 内容的宽度
|
||||
*/
|
||||
if (wheelDelta < 0) {
|
||||
if (wrapperWidth > contentWidth && translateX.value < -10) return
|
||||
if (wrapperWidth <= contentWidth && contentWidth + translateX.value - wrapperWidth < -10) return
|
||||
}
|
||||
if (wheelDelta > 0 && translateX.value > 10) {
|
||||
return
|
||||
}
|
||||
|
||||
translateX.value += wheelDelta
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}
|
||||
|
||||
const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
|
||||
if (!isOverflow.value) {
|
||||
translateX.value = 0
|
||||
} else if (-translateX.value > contentWidth - wrapperWidth) {
|
||||
translateX.value = wrapperWidth - contentWidth
|
||||
} else if (translateX.value > 0) {
|
||||
translateX.value = 0
|
||||
}
|
||||
}, 200)
|
||||
|
||||
const observer = ref(null)
|
||||
onMounted(() => {
|
||||
refreshIsOverflow()
|
||||
|
||||
observer.value = useResize(document.body, refreshIsOverflow)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
observer.value?.disconnect()
|
||||
})
|
||||
|
||||
function handleScroll(x, width) {
|
||||
const wrapperWidth = wrapper.value?.offsetWidth
|
||||
const contentWidth = content.value?.offsetWidth
|
||||
if (contentWidth <= wrapperWidth) return
|
||||
|
||||
// 当 x 小于可视范围的最小值时
|
||||
if (x < -translateX.value + 150) {
|
||||
translateX.value = -(x - 150)
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}
|
||||
|
||||
// 当 x 大于可视范围的最大值时
|
||||
if (x + width > -translateX.value + wrapperWidth) {
|
||||
translateX.value = wrapperWidth - (x + width)
|
||||
resetTranslateX(wrapperWidth, contentWidth)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
handleScroll,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
|
||||
z-index: 9;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.content {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
transition: transform 0.5s;
|
||||
&.overflow {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
background-color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
|
||||
width: 20px;
|
||||
height: 35px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 18px;
|
||||
border: 1px solid #e0e0e6;
|
||||
border-radius: 2px;
|
||||
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
.left {
|
||||
left: 0;
|
||||
}
|
||||
.right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
/** 自定义图标 */
|
||||
const props = defineProps({
|
||||
/** 图标名称(assets/svg下的文件名) */
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 14,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TheIcon type="custom" v-bind="props" />
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { NInput, NPopover } from 'naive-ui'
|
||||
|
||||
import TheIcon from './TheIcon.vue'
|
||||
import iconData from '@/assets/js/icons'
|
||||
|
||||
const props = defineProps({ value: String })
|
||||
const emit = defineEmits(['update:value'])
|
||||
|
||||
const choosed = ref(props.value) // 选中值
|
||||
const icons = ref(iconData)
|
||||
// const icons = ref(iconData.filter((icon) => icon.includes(choosed.value))) // 可选图标列表
|
||||
|
||||
function filterIcons() {
|
||||
icons.value = iconData.filter((item) => item.includes(choosed.value))
|
||||
}
|
||||
|
||||
function selectIcon(icon) {
|
||||
choosed.value = icon
|
||||
emit('update:value', choosed.value)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
choosed,
|
||||
() => {
|
||||
filterIcons()
|
||||
emit('update:value', choosed.value)
|
||||
},
|
||||
{ debounce: 200 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<NPopover trigger="click" placement="bottom-start">
|
||||
<template #trigger>
|
||||
<NInput v-model:value="choosed" placeholder="请输入图标名称" @update:value="filterIcons">
|
||||
<template #prefix>
|
||||
<span class="i-mdi:magnify text-18" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<TheIcon :icon="choosed" :size="18" />
|
||||
</template>
|
||||
</NInput>
|
||||
</template>
|
||||
<template #footer>
|
||||
更多图标去
|
||||
<a class="text-blue" target="_blank" href="https://icones.js.org/collection/all">
|
||||
Icones
|
||||
</a>
|
||||
查看
|
||||
</template>
|
||||
<ul v-if="icons.length" class="h-150 w-300 overflow-y-scroll">
|
||||
<li
|
||||
v-for="(icon, index) in icons"
|
||||
:key="index"
|
||||
class="mx-5 inline-block cursor-pointer hover:text-cyan"
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<TheIcon :icon="icon" :size="18" />
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else>
|
||||
<TheIcon :icon="choosed" :size="18" />
|
||||
</div>
|
||||
</NPopover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon-custom',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'currentColor',
|
||||
},
|
||||
})
|
||||
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.icon}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg aria-hidden="true" width="1em" height="1em">
|
||||
<use :xlink:href="symbolId" :fill="color" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { renderIcon } from '@/utils'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 14,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="renderIcon(icon, { size, color })" />
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<transition name="fade-slide" mode="out-in" appear>
|
||||
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212">
|
||||
<slot />
|
||||
<AppFooter v-if="showFooter" mt-15 />
|
||||
<n-back-top :bottom="20" />
|
||||
</section>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<AppPage :show-footer="showFooter">
|
||||
<header v-if="showHeader" mb-15 min-h-45 flex items-center justify-between px-15>
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<template v-else>
|
||||
<h2 text-22 font-normal text-hex-333 dark:text-hex-ccc>{{ title || route.meta?.title }}</h2>
|
||||
<slot name="action" />
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<n-card flex-1 rounded-10>
|
||||
<slot />
|
||||
</n-card>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
const route = useRoute()
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
bg="#fafafc"
|
||||
min-h-60
|
||||
flex
|
||||
items-start
|
||||
justify-between
|
||||
b-1
|
||||
rounded-8
|
||||
p-15
|
||||
bc-ccc
|
||||
dark:bg-black
|
||||
>
|
||||
<n-space wrap :size="[35, 15]">
|
||||
<slot />
|
||||
<div>
|
||||
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
|
||||
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
|
||||
</div>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(['search', 'reset'])
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div flex items-center>
|
||||
<label
|
||||
v-if="!isNullOrWhitespace(label)"
|
||||
w-80
|
||||
flex-shrink-0
|
||||
:style="{ width: labelWidth + 'px' }"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { isNullOrWhitespace } from '@/utils'
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
labelWidth: {
|
||||
type: Number,
|
||||
default: 80,
|
||||
},
|
||||
contentWidth: {
|
||||
type: Number,
|
||||
default: 220,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
:style="{ width }"
|
||||
preset="card"
|
||||
:title="title"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<slot />
|
||||
<template v-if="showFooter" #footer>
|
||||
<footer flex justify-end>
|
||||
<slot name="footer">
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button :loading="loading" ml-20 type="primary" @click="emit('save')">保存</n-button>
|
||||
</slot>
|
||||
</footer>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: String,
|
||||
default: '600px',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
top: {
|
||||
type: String,
|
||||
default: '30%',
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'onSave'])
|
||||
const show = computed({
|
||||
get() {
|
||||
return props.visible
|
||||
},
|
||||
set(v) {
|
||||
emit('update:visible', v)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
|
||||
<slot name="queryBar" />
|
||||
</QueryBar>
|
||||
|
||||
<n-data-table
|
||||
:remote="remote"
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:scroll-x="scrollX"
|
||||
:row-key="(row) => row[rowKey]"
|
||||
:pagination="isPagination ? pagination : false"
|
||||
@update:checked-row-keys="onChecked"
|
||||
@update:page="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
/**
|
||||
* @remote true: 后端分页 false: 前端分页
|
||||
*/
|
||||
remote: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* @remote 是否分页
|
||||
*/
|
||||
isPagination: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
scrollX: {
|
||||
type: Number,
|
||||
default: 450,
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
/** queryBar中的参数 */
|
||||
queryItems: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
/** 补充参数(可选) */
|
||||
extraParams: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
/**
|
||||
* ! 约定接口入参出参
|
||||
* * 分页模式需约定分页接口入参
|
||||
* @page_size 分页参数:一页展示多少条,默认10
|
||||
* @page 分页参数:页码,默认1
|
||||
*/
|
||||
getData: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:queryItems', 'onChecked', 'onDataChange'])
|
||||
const loading = ref(false)
|
||||
const initQuery = { ...props.queryItems }
|
||||
const tableData = ref([])
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
prefix({ itemCount }) {
|
||||
return `共 ${itemCount} 条`
|
||||
},
|
||||
onChange: (page) => {
|
||||
pagination.page = page
|
||||
},
|
||||
onUpdatePageSize: (pageSize) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
handleQuery()
|
||||
},
|
||||
})
|
||||
|
||||
async function handleQuery() {
|
||||
try {
|
||||
loading.value = true
|
||||
let paginationParams = {}
|
||||
// 如果非分页模式或者使用前端分页,则无需传分页参数
|
||||
if (props.isPagination && props.remote) {
|
||||
paginationParams = { page: pagination.page, page_size: pagination.page_size }
|
||||
}
|
||||
const { data, total } = await props.getData({
|
||||
...props.queryItems,
|
||||
...props.extraParams,
|
||||
...paginationParams,
|
||||
})
|
||||
tableData.value = data
|
||||
pagination.itemCount = total || 0
|
||||
} catch (error) {
|
||||
tableData.value = []
|
||||
pagination.itemCount = 0
|
||||
} finally {
|
||||
emit('onDataChange', tableData.value)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
handleQuery()
|
||||
}
|
||||
async function handleReset() {
|
||||
const queryItems = { ...props.queryItems }
|
||||
for (const key in queryItems) {
|
||||
queryItems[key] = null
|
||||
}
|
||||
emit('update:queryItems', { ...queryItems, ...initQuery })
|
||||
await nextTick()
|
||||
pagination.page = 1
|
||||
handleQuery()
|
||||
}
|
||||
function onPageChange(currentPage) {
|
||||
pagination.page = currentPage
|
||||
if (props.remote) {
|
||||
handleQuery()
|
||||
}
|
||||
}
|
||||
function onChecked(rowKeys) {
|
||||
if (props.columns.some((item) => item.type === 'selection')) {
|
||||
emit('onChecked', rowKeys)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
handleSearch,
|
||||
handleReset,
|
||||
tableData,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useCRUD } from './useCRUD'
|
||||
@@ -0,0 +1,104 @@
|
||||
import { isNullOrWhitespace } from '@/utils'
|
||||
|
||||
const ACTIONS = {
|
||||
view: '查看',
|
||||
edit: '编辑',
|
||||
add: '新增',
|
||||
}
|
||||
|
||||
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
|
||||
const modalVisible = ref(false)
|
||||
const modalAction = ref('')
|
||||
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
|
||||
const modalLoading = ref(false)
|
||||
const modalFormRef = ref(null)
|
||||
const modalForm = ref({ ...initForm })
|
||||
|
||||
/** 新增 */
|
||||
function handleAdd() {
|
||||
modalAction.value = 'add'
|
||||
modalVisible.value = true
|
||||
modalForm.value = { ...initForm }
|
||||
}
|
||||
|
||||
/** 修改 */
|
||||
function handleEdit(row) {
|
||||
modalAction.value = 'edit'
|
||||
modalVisible.value = true
|
||||
modalForm.value = { ...row }
|
||||
}
|
||||
|
||||
/** 查看 */
|
||||
function handleView(row) {
|
||||
modalAction.value = 'view'
|
||||
modalVisible.value = true
|
||||
modalForm.value = { ...row }
|
||||
}
|
||||
|
||||
/** 保存 */
|
||||
function handleSave(...callbacks) {
|
||||
if (!['edit', 'add'].includes(modalAction.value)) {
|
||||
modalVisible.value = false
|
||||
return
|
||||
}
|
||||
modalFormRef.value?.validate(async (err) => {
|
||||
if (err) return
|
||||
const actions = {
|
||||
add: {
|
||||
api: () => doCreate(modalForm.value),
|
||||
cb: () => {
|
||||
callbacks.forEach((callback) => callback && callback())
|
||||
},
|
||||
msg: () => $message.success('新增成功'),
|
||||
},
|
||||
edit: {
|
||||
api: () => doUpdate(modalForm.value),
|
||||
cb: () => {
|
||||
callbacks.forEach((callback) => callback && callback())
|
||||
},
|
||||
msg: () => $message.success('编辑成功'),
|
||||
},
|
||||
}
|
||||
const action = actions[modalAction.value]
|
||||
|
||||
try {
|
||||
modalLoading.value = true
|
||||
const data = await action.api()
|
||||
action.cb()
|
||||
action.msg()
|
||||
modalLoading.value = modalVisible.value = false
|
||||
data && refresh(data)
|
||||
} catch (error) {
|
||||
modalLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除 */
|
||||
async function handleDelete(params = {}) {
|
||||
if (isNullOrWhitespace(params)) return
|
||||
try {
|
||||
modalLoading.value = true
|
||||
const data = await doDelete(params)
|
||||
$message.success('删除成功')
|
||||
modalLoading.value = false
|
||||
refresh(data)
|
||||
} catch (error) {
|
||||
modalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modalVisible,
|
||||
modalAction,
|
||||
modalTitle,
|
||||
modalLoading,
|
||||
handleAdd,
|
||||
handleDelete,
|
||||
handleEdit,
|
||||
handleView,
|
||||
handleSave,
|
||||
modalForm,
|
||||
modalFormRef,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import setupPermissionDirective from './permission'
|
||||
|
||||
/** setup custom vue directives. - [安装自定义的vue指令] */
|
||||
export function setupDirectives(app) {
|
||||
setupPermissionDirective(app)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useUserStore, usePermissionStore } from '@/store'
|
||||
|
||||
function hasPermission(permission) {
|
||||
const userStore = useUserStore()
|
||||
const userPermissionStore = usePermissionStore()
|
||||
|
||||
const accessApis = userPermissionStore.apis
|
||||
if (userStore.isSuperUser) {
|
||||
return true
|
||||
}
|
||||
return accessApis.includes(permission)
|
||||
}
|
||||
|
||||
export default function setupPermissionDirective(app) {
|
||||
function updateElVisible(el, permission) {
|
||||
if (!permission) {
|
||||
throw new Error(`need roles: like v-permission="get/api/v1/user/list"`)
|
||||
}
|
||||
if (!hasPermission(permission)) {
|
||||
el.parentElement?.removeChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
const permissionDirective = {
|
||||
mounted(el, binding) {
|
||||
updateElVisible(el, binding.value)
|
||||
},
|
||||
beforeUpdate(el, binding) {
|
||||
updateElVisible(el, binding.value)
|
||||
},
|
||||
}
|
||||
|
||||
app.directive('permission', permissionDirective)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<KeepAlive :include="keepAliveRouteNames">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="appStore.reloadFlag"
|
||||
:key="appStore.aliveKeys[route.name] || route.fullPath"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const allRoutes = router.getRoutes()
|
||||
const keepAliveRouteNames = computed(() => {
|
||||
return allRoutes.filter((route) => route.meta?.keepAlive).map((route) => route.name)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item
|
||||
v-for="item in route.matched.filter((item) => !!item.meta?.title)"
|
||||
:key="item.path"
|
||||
@click="handleBreadClick(item.path)"
|
||||
>
|
||||
<component :is="getIcon(item.meta)" />
|
||||
{{ item.meta.title }}
|
||||
</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { renderCustomIcon, renderIcon } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
function handleBreadClick(path) {
|
||||
if (path === route.path) return
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function getIcon(meta) {
|
||||
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
|
||||
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<n-icon v-if="appStore.fullScreen" mr20 size="18" style="cursor: pointer" @click="toggle">
|
||||
<icon-ant-design:fullscreen-exit-outlined v-if="isFullscreen" />
|
||||
<icon-ant-design:fullscreen-outlined v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { isFullscreen, toggle } = useFullscreen()
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
|
||||
<icon-mdi:github />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function handleLinkClick() {
|
||||
window.open('https://github.com/mizhexiaoxiao/vue-fastapi-admin')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<n-dropdown :options="options" @select="handleChangeLocale">
|
||||
<n-icon mr-20 size="18" style="cursor: pointer">
|
||||
<icon-mdi:globe />
|
||||
</n-icon>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { router } from '~/src/router'
|
||||
|
||||
const store = useAppStore()
|
||||
const { availableLocales, t } = useI18n()
|
||||
|
||||
const options = computed(() => {
|
||||
let select = []
|
||||
availableLocales.forEach((locale) => {
|
||||
select.push({
|
||||
label: t('lang', 1, { locale: locale }),
|
||||
key: locale,
|
||||
})
|
||||
})
|
||||
return select
|
||||
})
|
||||
|
||||
const handleChangeLocale = (value) => {
|
||||
store.setLocale(value)
|
||||
// reload page
|
||||
router.go()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<n-icon size="20" cursor-pointer @click="appStore.switchCollapsed">
|
||||
<icon-mdi:format-indent-increase v-if="appStore.collapsed" />
|
||||
<icon-mdi:format-indent-decrease v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const isDark = useDark()
|
||||
const toggleDark = () => {
|
||||
appStore.toggleDark()
|
||||
useToggle(isDark)()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-icon mr-20 cursor-pointer size="18" @click="toggleDark">
|
||||
<icon-mdi-moon-waning-crescent v-if="isDark" />
|
||||
<icon-mdi-white-balance-sunny v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<n-dropdown :options="options" @select="handleSelect">
|
||||
<div flex cursor-pointer items-center>
|
||||
<img :src="userStore.avatar" mr10 h-35 w-35 rounded-full />
|
||||
<span>{{ userStore.name }}</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: t('header.label_profile'),
|
||||
key: 'profile',
|
||||
icon: renderIcon('mdi-account-arrow-right-outline', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: t('header.label_logout'),
|
||||
key: 'logout',
|
||||
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
|
||||
},
|
||||
]
|
||||
|
||||
function handleSelect(key) {
|
||||
if (key === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (key === 'logout') {
|
||||
$dialog.confirm({
|
||||
title: t('header.label_logout_dialog_title'),
|
||||
type: 'warning',
|
||||
content: t('header.text_logout_confirm'),
|
||||
confirm() {
|
||||
userStore.logout()
|
||||
$message.success(t('header.text_logout_success'))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div flex items-center>
|
||||
<MenuCollapse />
|
||||
<BreadCrumb ml-15 hidden sm:block />
|
||||
</div>
|
||||
<div ml-auto flex items-center>
|
||||
<Languages />
|
||||
<ThemeMode />
|
||||
<GithubSite />
|
||||
<FullScreen />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BreadCrumb from './components/BreadCrumb.vue'
|
||||
import MenuCollapse from './components/MenuCollapse.vue'
|
||||
import FullScreen from './components/FullScreen.vue'
|
||||
import UserAvatar from './components/UserAvatar.vue'
|
||||
import GithubSite from './components/GithubSite.vue'
|
||||
import ThemeMode from './components/ThemeMode.vue'
|
||||
import Languages from './components/Languages.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<router-link h-60 f-c-c to="/">
|
||||
<icon-custom-logo text-36 color-primary></icon-custom-logo>
|
||||
<h2
|
||||
v-show="!appStore.collapsed"
|
||||
ml-2
|
||||
mr-8
|
||||
max-w-150
|
||||
flex-shrink-0
|
||||
text-16
|
||||
font-bold
|
||||
color-primary
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store'
|
||||
const title = import.meta.env.VITE_TITLE
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<n-menu
|
||||
ref="menu"
|
||||
class="side-menu"
|
||||
accordion
|
||||
:indent="18"
|
||||
:collapsed-icon-size="22"
|
||||
:collapsed-width="64"
|
||||
:options="menuOptions"
|
||||
:value="activeKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePermissionStore, useAppStore } from '@/store'
|
||||
import { renderCustomIcon, renderIcon, isExternal } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const curRoute = useRoute()
|
||||
const permissionStore = usePermissionStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activeKey = computed(() => curRoute.meta?.activeMenu || curRoute.name)
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
|
||||
})
|
||||
|
||||
const menu = ref(null)
|
||||
watch(curRoute, async () => {
|
||||
await nextTick()
|
||||
menu.value?.showOption()
|
||||
})
|
||||
|
||||
function resolvePath(basePath, path) {
|
||||
if (isExternal(path)) return path
|
||||
return (
|
||||
'/' +
|
||||
[basePath, path]
|
||||
.filter((path) => !!path && path !== '/')
|
||||
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
|
||||
function getMenuItem(route, basePath = '') {
|
||||
let menuItem = {
|
||||
label: (route.meta && route.meta.title) || route.name,
|
||||
key: route.name,
|
||||
path: resolvePath(basePath, route.path),
|
||||
icon: getIcon(route.meta),
|
||||
order: route.meta?.order || 0,
|
||||
}
|
||||
|
||||
const visibleChildren = route.children
|
||||
? route.children.filter((item) => item.name && !item.isHidden)
|
||||
: []
|
||||
|
||||
if (!visibleChildren.length) return menuItem
|
||||
|
||||
if (visibleChildren.length === 1) {
|
||||
// 单个子路由处理
|
||||
const singleRoute = visibleChildren[0]
|
||||
menuItem = {
|
||||
...menuItem,
|
||||
label: singleRoute.meta?.title || singleRoute.name,
|
||||
key: singleRoute.name,
|
||||
path: resolvePath(menuItem.path, singleRoute.path),
|
||||
icon: getIcon(singleRoute.meta),
|
||||
}
|
||||
const visibleItems = singleRoute.children
|
||||
? singleRoute.children.filter((item) => item.name && !item.isHidden)
|
||||
: []
|
||||
|
||||
if (visibleItems.length === 1) {
|
||||
menuItem = getMenuItem(visibleItems[0], menuItem.path)
|
||||
} else if (visibleItems.length > 1) {
|
||||
menuItem.children = visibleItems
|
||||
.map((item) => getMenuItem(item, menuItem.path))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
} else {
|
||||
menuItem.children = visibleChildren
|
||||
.map((item) => getMenuItem(item, menuItem.path))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return menuItem
|
||||
}
|
||||
|
||||
function getIcon(meta) {
|
||||
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
|
||||
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
|
||||
return null
|
||||
}
|
||||
|
||||
function handleMenuSelect(key, item) {
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path)
|
||||
} else {
|
||||
if (item.path === curRoute.path) {
|
||||
appStore.reloadPage()
|
||||
} else {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.side-menu:not(.n-menu--collapsed) {
|
||||
.n-menu-item-content {
|
||||
&::before {
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
&.n-menu-item-content--selected,
|
||||
&:hover {
|
||||
&::before {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import SideLogo from './components/SideLogo.vue'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SideLogo />
|
||||
<SideMenu />
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<n-dropdown
|
||||
:show="show"
|
||||
:options="options"
|
||||
:x="x"
|
||||
:y="y"
|
||||
placement="bottom-start"
|
||||
@clickoutside="handleHideDropdown"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useTagsStore, useAppStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const tagsStore = useTagsStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const options = computed(() => [
|
||||
{
|
||||
label: '重新加载',
|
||||
key: 'reload',
|
||||
disabled: props.currentPath !== tagsStore.activeTag,
|
||||
icon: renderIcon('mdi:refresh', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭',
|
||||
key: 'close',
|
||||
disabled: tagsStore.tags.length <= 1,
|
||||
icon: renderIcon('mdi:close', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭其他',
|
||||
key: 'close-other',
|
||||
disabled: tagsStore.tags.length <= 1,
|
||||
icon: renderIcon('mdi:arrow-expand-horizontal', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭左侧',
|
||||
key: 'close-left',
|
||||
disabled: tagsStore.tags.length <= 1 || props.currentPath === tagsStore.tags[0].path,
|
||||
icon: renderIcon('mdi:arrow-expand-left', { size: '14px' }),
|
||||
},
|
||||
{
|
||||
label: '关闭右侧',
|
||||
key: 'close-right',
|
||||
disabled:
|
||||
tagsStore.tags.length <= 1 ||
|
||||
props.currentPath === tagsStore.tags[tagsStore.tags.length - 1].path,
|
||||
icon: renderIcon('mdi:arrow-expand-right', { size: '14px' }),
|
||||
},
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
const actionMap = new Map([
|
||||
[
|
||||
'reload',
|
||||
() => {
|
||||
if (route.meta?.keepAlive) {
|
||||
// 重置keepAlive
|
||||
appStore.setAliveKeys(route.name, +new Date())
|
||||
}
|
||||
appStore.reloadPage()
|
||||
},
|
||||
],
|
||||
[
|
||||
'close',
|
||||
() => {
|
||||
tagsStore.removeTag(props.currentPath)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close-other',
|
||||
() => {
|
||||
tagsStore.removeOther(props.currentPath)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close-left',
|
||||
() => {
|
||||
tagsStore.removeLeft(props.currentPath)
|
||||
},
|
||||
],
|
||||
[
|
||||
'close-right',
|
||||
() => {
|
||||
tagsStore.removeRight(props.currentPath)
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
function handleHideDropdown() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function handleSelect(key) {
|
||||
const actionFn = actionMap.get(key)
|
||||
actionFn && actionFn()
|
||||
handleHideDropdown()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<ScrollX ref="scrollXRef" class="bg-white dark:bg-dark!">
|
||||
<n-tag
|
||||
v-for="tag in tagsStore.tags"
|
||||
ref="tabRefs"
|
||||
:key="tag.path"
|
||||
class="mx-5 cursor-pointer rounded-4 px-15 hover:color-primary"
|
||||
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
|
||||
:closable="tagsStore.tags.length > 1"
|
||||
@click="handleTagClick(tag.path)"
|
||||
@close.stop="tagsStore.removeTag(tag.path)"
|
||||
@contextmenu.prevent="handleContextMenu($event, tag)"
|
||||
>
|
||||
{{ tag.title }}
|
||||
</n-tag>
|
||||
<ContextMenu
|
||||
v-if="contextMenuOption.show"
|
||||
v-model:show="contextMenuOption.show"
|
||||
:current-path="contextMenuOption.currentPath"
|
||||
:x="contextMenuOption.x"
|
||||
:y="contextMenuOption.y"
|
||||
/>
|
||||
</ScrollX>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import { useTagsStore } from '@/store'
|
||||
import ScrollX from '@/components/common/ScrollX.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsStore = useTagsStore()
|
||||
const tabRefs = ref([])
|
||||
const scrollXRef = ref(null)
|
||||
|
||||
const contextMenuOption = reactive({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
currentPath: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
const { name, fullPath: path } = route
|
||||
const title = route.meta?.title
|
||||
tagsStore.addTag({ name, path, title })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => tagsStore.activeIndex,
|
||||
async (activeIndex) => {
|
||||
await nextTick()
|
||||
const activeTabElement = tabRefs.value[activeIndex]?.$el
|
||||
if (!activeTabElement) return
|
||||
const { offsetLeft: x, offsetWidth: width } = activeTabElement
|
||||
scrollXRef.value?.handleScroll(x + width, width)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const handleTagClick = (path) => {
|
||||
tagsStore.setActiveTag(path)
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function showContextMenu() {
|
||||
contextMenuOption.show = true
|
||||
}
|
||||
function hideContextMenu() {
|
||||
contextMenuOption.show = false
|
||||
}
|
||||
function setContextMenu(x, y, currentPath) {
|
||||
Object.assign(contextMenuOption, { x, y, currentPath })
|
||||
}
|
||||
|
||||
// 右击菜单
|
||||
async function handleContextMenu(e, tagItem) {
|
||||
const { clientX, clientY } = e
|
||||
hideContextMenu()
|
||||
setContextMenu(clientX, clientY, tagItem.path)
|
||||
await nextTick()
|
||||
showContextMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.n-tag__close {
|
||||
box-sizing: content-box;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
transform: scale(0.9);
|
||||
transform: translateX(5px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<n-layout has-sider wh-full>
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="220"
|
||||
:native-scrollbar="false"
|
||||
:collapsed="appStore.collapsed"
|
||||
>
|
||||
<SideBar />
|
||||
</n-layout-sider>
|
||||
|
||||
<article flex-col flex-1 overflow-hidden>
|
||||
<header
|
||||
class="flex items-center border-b bg-white px-15 bc-eee"
|
||||
dark="bg-dark border-0"
|
||||
:style="`height: ${header.height}px`"
|
||||
>
|
||||
<AppHeader />
|
||||
</header>
|
||||
<section v-if="tags.visible" hidden border-b bc-eee sm:block dark:border-0>
|
||||
<AppTags :style="{ height: `${tags.height}px` }" />
|
||||
</section>
|
||||
<section flex-1 overflow-hidden bg-hex-f5f6fb dark:bg-hex-101014>
|
||||
<AppMain />
|
||||
</section>
|
||||
</article>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from './components/header/index.vue'
|
||||
import SideBar from './components/sidebar/index.vue'
|
||||
import AppMain from './components/AppMain.vue'
|
||||
import AppTags from './components/tags/index.vue'
|
||||
import { useAppStore } from '@/store'
|
||||
import { header, tags } from '~/settings'
|
||||
|
||||
// 移动端适配
|
||||
import { useBreakpoints } from '@vueuse/core'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const breakpointsEnum = {
|
||||
xl: 1600,
|
||||
lg: 1199,
|
||||
md: 991,
|
||||
sm: 666,
|
||||
xs: 575,
|
||||
}
|
||||
const breakpoints = reactive(useBreakpoints(breakpointsEnum))
|
||||
const isMobile = breakpoints.smaller('sm')
|
||||
const isPad = breakpoints.between('sm', 'md')
|
||||
const isPC = breakpoints.greater('md')
|
||||
watchEffect(() => {
|
||||
if (isMobile.value) {
|
||||
// Mobile
|
||||
appStore.setCollapsed(true)
|
||||
appStore.setFullScreen(false)
|
||||
}
|
||||
|
||||
if (isPad.value) {
|
||||
// IPad
|
||||
appStore.setCollapsed(true)
|
||||
appStore.setFullScreen(false)
|
||||
}
|
||||
|
||||
if (isPC.value) {
|
||||
// PC
|
||||
appStore.setCollapsed(false)
|
||||
appStore.setFullScreen(true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 重置样式 */
|
||||
import '@/styles/reset.css'
|
||||
import 'uno.css'
|
||||
import '@/styles/global.scss'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { setupRouter } from '@/router'
|
||||
import { setupStore } from '@/store'
|
||||
import App from './App.vue'
|
||||
import { setupDirectives } from './directives'
|
||||
import { useResize } from '@/utils'
|
||||
import i18n from '~/i18n'
|
||||
|
||||
async function setupApp() {
|
||||
const app = createApp(App)
|
||||
|
||||
setupStore(app)
|
||||
|
||||
await setupRouter(app)
|
||||
setupDirectives(app)
|
||||
app.use(useResize)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
setupApp()
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getToken, isNullOrWhitespace } from '@/utils'
|
||||
|
||||
const WHITE_LIST = ['/login', '/404']
|
||||
export function createAuthGuard(router) {
|
||||
router.beforeEach(async (to) => {
|
||||
const token = getToken()
|
||||
|
||||
/** 没有token的情况 */
|
||||
if (isNullOrWhitespace(token)) {
|
||||
if (WHITE_LIST.includes(to.path)) return true
|
||||
return { path: 'login', query: { ...to.query, redirect: to.path } }
|
||||
}
|
||||
|
||||
/** 有token的情况 */
|
||||
if (to.path === '/login') return { path: '/' }
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createPageLoadingGuard } from './page-loading-guard'
|
||||
import { createPageTitleGuard } from './page-title-guard'
|
||||
import { createAuthGuard } from './auth-guard'
|
||||
|
||||
export function setupRouterGuard(router) {
|
||||
createPageLoadingGuard(router)
|
||||
createAuthGuard(router)
|
||||
createPageTitleGuard(router)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function createPageLoadingGuard(router) {
|
||||
router.beforeEach(() => {
|
||||
window.$loadingBar?.start()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
setTimeout(() => {
|
||||
window.$loadingBar?.finish()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
window.$loadingBar?.error()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const baseTitle = import.meta.env.VITE_TITLE
|
||||
|
||||
export function createPageTitleGuard(router) {
|
||||
router.afterEach((to) => {
|
||||
const pageTitle = to.meta?.title
|
||||
if (pageTitle) {
|
||||
document.title = `${pageTitle} | ${baseTitle}`
|
||||
} else {
|
||||
document.title = baseTitle
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import { setupRouterGuard } from './guard'
|
||||
import { basicRoutes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes'
|
||||
import { getToken, isNullOrWhitespace } from '@/utils'
|
||||
import { useUserStore, usePermissionStore } from '@/store'
|
||||
|
||||
const isHash = import.meta.env.VITE_USE_HASH === 'true'
|
||||
export const router = createRouter({
|
||||
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
|
||||
routes: basicRoutes,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
})
|
||||
|
||||
export async function setupRouter(app) {
|
||||
await addDynamicRoutes()
|
||||
setupRouterGuard(router)
|
||||
app.use(router)
|
||||
}
|
||||
|
||||
export async function resetRouter() {
|
||||
const basicRouteNames = getRouteNames(basicRoutes)
|
||||
router.getRoutes().forEach((route) => {
|
||||
const name = route.name
|
||||
if (!basicRouteNames.includes(name)) {
|
||||
router.removeRoute(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function addDynamicRoutes() {
|
||||
const token = getToken()
|
||||
|
||||
// 没有token情况
|
||||
if (isNullOrWhitespace(token)) {
|
||||
router.addRoute(EMPTY_ROUTE)
|
||||
return
|
||||
}
|
||||
// 有token的情况
|
||||
const userStore = useUserStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
!userStore.userId && (await userStore.getUserInfo())
|
||||
try {
|
||||
const accessRoutes = await permissionStore.generateRoutes()
|
||||
await permissionStore.getAccessApis()
|
||||
accessRoutes.forEach((route) => {
|
||||
!router.hasRoute(route.name) && router.addRoute(route)
|
||||
})
|
||||
router.hasRoute(EMPTY_ROUTE.name) && router.removeRoute(EMPTY_ROUTE.name)
|
||||
router.addRoute(NOT_FOUND_ROUTE)
|
||||
} catch (error) {
|
||||
console.error('error', error)
|
||||
const userStore = useUserStore()
|
||||
await userStore.logout()
|
||||
}
|
||||
}
|
||||
|
||||
export function getRouteNames(routes) {
|
||||
return routes.map((route) => getRouteName(route)).flat(1)
|
||||
}
|
||||
|
||||
function getRouteName(route) {
|
||||
const names = [route.name]
|
||||
if (route.children && route.children.length) {
|
||||
names.push(...route.children.map((item) => getRouteName(item)).flat(1))
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import i18n from '~/i18n'
|
||||
const { t } = i18n.global
|
||||
|
||||
const Layout = () => import('@/layout/index.vue')
|
||||
|
||||
export const basicRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/workbench', // 默认跳转到首页
|
||||
meta: { order: 0 },
|
||||
},
|
||||
{
|
||||
name: t('views.workbench.label_workbench'),
|
||||
path: '/workbench',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/workbench/index.vue'),
|
||||
name: `${t('views.workbench.label_workbench')}Default`,
|
||||
meta: {
|
||||
title: t('views.workbench.label_workbench'),
|
||||
icon: 'icon-park-outline:workbench',
|
||||
affix: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { order: 1 },
|
||||
},
|
||||
{
|
||||
name: t('views.profile.label_profile'),
|
||||
path: '/profile',
|
||||
component: Layout,
|
||||
isHidden: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/profile/index.vue'),
|
||||
name: `${t('views.profile.label_profile')}Default`,
|
||||
meta: {
|
||||
title: t('views.profile.label_profile'),
|
||||
icon: 'user',
|
||||
affix: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { order: 99 },
|
||||
},
|
||||
{
|
||||
name: 'ErrorPage',
|
||||
path: '/error-page',
|
||||
component: Layout,
|
||||
redirect: '/error-page/404',
|
||||
isHidden: true,
|
||||
meta: {
|
||||
title: t('views.errors.label_error'),
|
||||
icon: 'mdi:alert-circle-outline',
|
||||
order: 99,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'ERROR-401',
|
||||
path: '401',
|
||||
component: () => import('@/views/error-page/401.vue'),
|
||||
meta: {
|
||||
title: '401',
|
||||
icon: 'material-symbols:authenticator',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ERROR-403',
|
||||
path: '403',
|
||||
component: () => import('@/views/error-page/403.vue'),
|
||||
meta: {
|
||||
title: '403',
|
||||
icon: 'solar:forbidden-circle-line-duotone',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ERROR-404',
|
||||
path: '404',
|
||||
component: () => import('@/views/error-page/404.vue'),
|
||||
meta: {
|
||||
title: '404',
|
||||
icon: 'tabler:error-404',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ERROR-500',
|
||||
path: '500',
|
||||
component: () => import('@/views/error-page/500.vue'),
|
||||
meta: {
|
||||
title: '500',
|
||||
icon: 'clarity:rack-server-outline-alerted',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '403',
|
||||
path: '/403',
|
||||
component: () => import('@/views/error-page/403.vue'),
|
||||
isHidden: true,
|
||||
},
|
||||
{
|
||||
name: '404',
|
||||
path: '/404',
|
||||
component: () => import('@/views/error-page/404.vue'),
|
||||
isHidden: true,
|
||||
},
|
||||
{
|
||||
name: 'Login',
|
||||
path: '/login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
isHidden: true,
|
||||
meta: {
|
||||
title: '登录页',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const NOT_FOUND_ROUTE = {
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404',
|
||||
isHidden: true,
|
||||
}
|
||||
|
||||
export const EMPTY_ROUTE = {
|
||||
name: 'Empty',
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: null,
|
||||
}
|
||||
|
||||
const modules = import.meta.glob('@/views/**/route.js', { eager: true })
|
||||
const asyncRoutes = []
|
||||
Object.keys(modules).forEach((key) => {
|
||||
asyncRoutes.push(modules[key].default)
|
||||
})
|
||||
|
||||
// 加载 views 下每个模块的 index.vue 文件
|
||||
const vueModules = import.meta.glob('@/views/**/index.vue')
|
||||
|
||||
export { asyncRoutes, vueModules }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export function setupStore(app) {
|
||||
app.use(createPinia())
|
||||
}
|
||||
|
||||
export * from './modules'
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { lStorage } from '@/utils'
|
||||
import i18n from '~/i18n'
|
||||
|
||||
const currentLocale = lStorage.get('locale')
|
||||
const { locale } = i18n.global
|
||||
|
||||
const isDark = useDark()
|
||||
export const useAppStore = defineStore('app', {
|
||||
state() {
|
||||
return {
|
||||
reloadFlag: true,
|
||||
collapsed: false,
|
||||
fullScreen: true,
|
||||
/** keepAlive路由的key,重新赋值可重置keepAlive */
|
||||
aliveKeys: {},
|
||||
isDark,
|
||||
locale: currentLocale || 'en',
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async reloadPage() {
|
||||
$loadingBar.start()
|
||||
this.reloadFlag = false
|
||||
await nextTick()
|
||||
this.reloadFlag = true
|
||||
|
||||
setTimeout(() => {
|
||||
document.documentElement.scrollTo({ left: 0, top: 0 })
|
||||
$loadingBar.finish()
|
||||
}, 100)
|
||||
},
|
||||
switchCollapsed() {
|
||||
this.collapsed = !this.collapsed
|
||||
},
|
||||
setCollapsed(collapsed) {
|
||||
this.collapsed = collapsed
|
||||
},
|
||||
setFullScreen(fullScreen) {
|
||||
this.fullScreen = fullScreen
|
||||
},
|
||||
setAliveKeys(key, val) {
|
||||
this.aliveKeys[key] = val
|
||||
},
|
||||
/** 设置暗黑模式 */
|
||||
setDark(isDark) {
|
||||
this.isDark = isDark
|
||||
},
|
||||
/** 切换/关闭 暗黑模式 */
|
||||
toggleDark() {
|
||||
this.isDark = !this.isDark
|
||||
},
|
||||
setLocale(newLocale) {
|
||||
this.locale = newLocale
|
||||
locale.value = newLocale
|
||||
lStorage.set('locale', newLocale)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './app'
|
||||
export * from './permission'
|
||||
export * from './tags'
|
||||
export * from './user'
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { basicRoutes, vueModules } from '@/router/routes'
|
||||
import Layout from '@/layout/index.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// * 后端路由相关函数
|
||||
// 根据后端传来数据构建出前端路由
|
||||
|
||||
function buildRoutes(routes = []) {
|
||||
return routes.map((e) => {
|
||||
const route = {
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
component: shallowRef(Layout),
|
||||
isHidden: e.is_hidden,
|
||||
redirect: e.redirect,
|
||||
meta: {
|
||||
title: e.name,
|
||||
icon: e.icon,
|
||||
order: e.order,
|
||||
keepAlive: e.keepalive,
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
|
||||
if (e.children && e.children.length > 0) {
|
||||
// 有子菜单
|
||||
route.children = e.children.map((e_child) => ({
|
||||
name: e_child.name,
|
||||
path: e_child.path,
|
||||
component: vueModules[`/src/views${e_child.component}/index.vue`],
|
||||
isHidden: e_child.is_hidden,
|
||||
meta: {
|
||||
title: e_child.name,
|
||||
icon: e_child.icon,
|
||||
order: e_child.order,
|
||||
keepAlive: e_child.keepalive,
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
// 没有子菜单,创建一个默认的子路由
|
||||
route.children.push({
|
||||
name: `${e.name}Default`,
|
||||
path: '',
|
||||
component: vueModules[`/src/views${e.component}/index.vue`],
|
||||
isHidden: true,
|
||||
meta: {
|
||||
title: e.name,
|
||||
icon: e.icon,
|
||||
order: e.order,
|
||||
keepAlive: e.keepalive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return route
|
||||
})
|
||||
}
|
||||
|
||||
export const usePermissionStore = defineStore('permission', {
|
||||
state() {
|
||||
return {
|
||||
accessRoutes: [],
|
||||
accessApis: [],
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
routes() {
|
||||
return basicRoutes.concat(this.accessRoutes)
|
||||
},
|
||||
menus() {
|
||||
return this.routes.filter((route) => route.name && !route.isHidden)
|
||||
},
|
||||
apis() {
|
||||
return this.accessApis
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async generateRoutes() {
|
||||
const res = await api.getUserMenu() // 调用接口获取后端传来的菜单路由
|
||||
this.accessRoutes = buildRoutes(res.data) // 处理成前端路由格式
|
||||
return this.accessRoutes
|
||||
},
|
||||
async getAccessApis() {
|
||||
const res = await api.getUserApi()
|
||||
this.accessApis = res.data
|
||||
return this.accessApis
|
||||
},
|
||||
resetPermission() {
|
||||
this.$reset()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { lStorage } from '@/utils'
|
||||
|
||||
export const activeTag = lStorage.get('activeTag')
|
||||
export const tags = lStorage.get('tags')
|
||||
|
||||
export const WITHOUT_TAG_PATHS = ['/404', '/login']
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { activeTag, tags, WITHOUT_TAG_PATHS } from './helpers'
|
||||
import { router } from '@/router'
|
||||
import { lStorage } from '@/utils'
|
||||
|
||||
export const useTagsStore = defineStore('tag', {
|
||||
state() {
|
||||
return {
|
||||
tags: tags || [],
|
||||
activeTag: activeTag || '',
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
activeIndex() {
|
||||
return this.tags.findIndex((item) => item.path === this.activeTag)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setActiveTag(path) {
|
||||
this.activeTag = path
|
||||
lStorage.set('activeTag', path)
|
||||
},
|
||||
setTags(tags) {
|
||||
this.tags = tags
|
||||
lStorage.set('tags', tags)
|
||||
},
|
||||
addTag(tag = {}) {
|
||||
this.setActiveTag(tag.path)
|
||||
if (WITHOUT_TAG_PATHS.includes(tag.path) || this.tags.some((item) => item.path === tag.path))
|
||||
return
|
||||
this.setTags([...this.tags, tag])
|
||||
},
|
||||
removeTag(path) {
|
||||
if (path === this.activeTag) {
|
||||
if (this.activeIndex > 0) {
|
||||
router.push(this.tags[this.activeIndex - 1].path)
|
||||
} else {
|
||||
router.push(this.tags[this.activeIndex + 1].path)
|
||||
}
|
||||
}
|
||||
this.setTags(this.tags.filter((tag) => tag.path !== path))
|
||||
},
|
||||
removeOther(curPath = this.activeTag) {
|
||||
this.setTags(this.tags.filter((tag) => tag.path === curPath))
|
||||
if (curPath !== this.activeTag) {
|
||||
router.push(this.tags[this.tags.length - 1].path)
|
||||
}
|
||||
},
|
||||
removeLeft(curPath) {
|
||||
const curIndex = this.tags.findIndex((item) => item.path === curPath)
|
||||
const filterTags = this.tags.filter((item, index) => index >= curIndex)
|
||||
this.setTags(filterTags)
|
||||
if (!filterTags.find((item) => item.path === this.activeTag)) {
|
||||
router.push(filterTags[filterTags.length - 1].path)
|
||||
}
|
||||
},
|
||||
removeRight(curPath) {
|
||||
const curIndex = this.tags.findIndex((item) => item.path === curPath)
|
||||
const filterTags = this.tags.filter((item, index) => index <= curIndex)
|
||||
this.setTags(filterTags)
|
||||
if (!filterTags.find((item) => item.path === this.activeTag)) {
|
||||
router.push(filterTags[filterTags.length - 1].path)
|
||||
}
|
||||
},
|
||||
resetTags() {
|
||||
this.setTags([])
|
||||
this.setActiveTag('')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { resetRouter } from '@/router'
|
||||
import { useTagsStore, usePermissionStore } from '@/store'
|
||||
import { removeToken, toLogin } from '@/utils'
|
||||
import api from '@/api'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state() {
|
||||
return {
|
||||
userInfo: {},
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
userId() {
|
||||
return this.userInfo?.id
|
||||
},
|
||||
name() {
|
||||
return this.userInfo?.username
|
||||
},
|
||||
email() {
|
||||
return this.userInfo?.email
|
||||
},
|
||||
avatar() {
|
||||
return this.userInfo?.avatar
|
||||
},
|
||||
role() {
|
||||
return this.userInfo?.roles || []
|
||||
},
|
||||
isSuperUser() {
|
||||
return this.userInfo?.is_superuser
|
||||
},
|
||||
isActive() {
|
||||
return this.userInfo?.is_active
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const res = await api.getUserInfo()
|
||||
if (res.code === 401) {
|
||||
this.logout()
|
||||
return
|
||||
}
|
||||
const { id, username, email, avatar, roles, is_superuser, is_active } = res.data
|
||||
this.userInfo = { id, username, email, avatar, roles, is_superuser, is_active }
|
||||
return res.data
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
const { resetTags } = useTagsStore()
|
||||
const { resetPermission } = usePermissionStore()
|
||||
removeToken()
|
||||
resetTags()
|
||||
resetPermission()
|
||||
resetRouter()
|
||||
this.$reset()
|
||||
toLogin()
|
||||
},
|
||||
setUserInfo(userInfo = {}) {
|
||||
this.userInfo = { ...this.userInfo, ...userInfo }
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 4px; // * 1rem = 4px 方便unocss计算:在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.cus-scroll {
|
||||
overflow: auto;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
.cus-scroll-x {
|
||||
overflow-x: auto;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
.cus-scroll-y {
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
.cus-scroll,
|
||||
.cus-scroll-x,
|
||||
.cus-scroll-y {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:link,
|
||||
a:visited,
|
||||
a:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
outline: none;
|
||||
border: none;
|
||||
resize: none;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { router } from '@/router'
|
||||
|
||||
export function toLogin() {
|
||||
const currentRoute = unref(router.currentRoute)
|
||||
const needRedirect =
|
||||
!currentRoute.meta.requireAuth && !['/404', '/login'].includes(router.currentRoute.value.path)
|
||||
router.replace({
|
||||
path: '/login',
|
||||
query: needRedirect ? { ...currentRoute.query, redirect: currentRoute.path } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function toFourZeroFour() {
|
||||
router.replace({
|
||||
path: '/404',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './auth'
|
||||
export * from './token'
|
||||
@@ -0,0 +1,31 @@
|
||||
import { lStorage } from '@/utils'
|
||||
|
||||
const TOKEN_CODE = 'access_token'
|
||||
|
||||
export function getToken() {
|
||||
return lStorage.get(TOKEN_CODE)
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
lStorage.set(TOKEN_CODE, token)
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
lStorage.remove(TOKEN_CODE)
|
||||
}
|
||||
|
||||
// export async function refreshAccessToken() {
|
||||
// const tokenItem = lStorage.getItem(TOKEN_CODE)
|
||||
// if (!tokenItem) {
|
||||
// return
|
||||
// }
|
||||
// const { time } = tokenItem
|
||||
// // token生成或者刷新后30分钟内不执行刷新
|
||||
// if (new Date().getTime() - time <= 1000 * 60 * 30) return
|
||||
// try {
|
||||
// const res = await api.refreshToken()
|
||||
// setToken(res.data.token)
|
||||
// } catch (error) {
|
||||
// console.error(error)
|
||||
// }
|
||||
// }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { h } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import SvgIcon from '@/components/icon/SvgIcon.vue'
|
||||
|
||||
export function renderIcon(icon, props = { size: 12 }) {
|
||||
return () => h(NIcon, props, { default: () => h(Icon, { icon }) })
|
||||
}
|
||||
|
||||
export function renderCustomIcon(icon, props = { size: 12 }) {
|
||||
return () => h(NIcon, props, { default: () => h(SvgIcon, { icon }) })
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './common'
|
||||
export * from './is'
|
||||
export * from './icon'
|
||||
export * from './naiveTools'
|
||||
export * from './useResize'
|
||||