Compare commits

...
4 Commits
Author SHA1 Message Date
hooray cc54fc07e4 chore: release v2.0.1 2022-09-26 00:31:14 +08:00
hooray 5a75e566e7 fix: 修改 mock 文件引入后缀 2022-09-26 00:30:00 +08:00
hooray 47461cdfa5 chore: release v2.0.0 2022-09-26 00:23:29 +08:00
hooray f17fd5ec8c chore!: typeScript 支持 2022-09-26 00:21:50 +08:00
179 changed files with 8969 additions and 7121 deletions
+69
View File
@@ -0,0 +1,69 @@
// .commitlintrc.js
/** @type {import('cz-git').UserConfig} */
module.exports = {
rules: {
// @see: https://commitlint.js.org/#/reference-rules
},
prompt: {
alias: { fd: 'docs: fix typos' },
messages: {
type: '选择你要提交的类型 :',
scope: '选择一个提交范围(可选):',
customScope: '请输入自定义的提交范围 :',
subject: '填写简短精炼的变更描述 :\n',
body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
footerPrefixsSelect: '选择关联issue前缀(可选):',
customFooterPrefixs: '输入自定义issue前缀 :',
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
confirmCommit: '是否提交或修改commit ?'
},
types: [
{ value: 'feat', name: 'feat: ✨ 新增功能 | A new feature', emoji: ':sparkles:' },
{ value: 'fix', name: 'fix: 🐛 修复缺陷 | A bug fix', emoji: ':bug:' },
{ value: 'docs', name: 'docs: 📝 文档更新 | Documentation only changes', emoji: ':memo:' },
{ value: 'style', name: 'style: 💄 代码格式 | Changes that do not affect the meaning of the code', emoji: ':lipstick:' },
{ value: 'refactor', name: 'refactor: ♻️ 代码重构 | A code change that neither fixes a bug nor adds a feature', emoji: ':recycle:' },
{ value: 'perf', name: 'perf: ⚡️ 性能提升 | A code change that improves performance', emoji: ':zap:' },
{ value: 'test', name: 'test: ✅ 测试相关 | Adding missing tests or correcting existing tests', emoji: ':white_check_mark:' },
{ value: 'build', name: 'build: 📦️ 构建相关 | Changes that affect the build system or external dependencies', emoji: ':package:' },
{ value: 'ci', name: 'ci: 🎡 持续集成 | Changes to our CI configuration files and scripts', emoji: ':ferris_wheel:' },
{ value: 'revert', name: 'revert: ⏪️ 回退代码 | Revert to a commit', emoji: ':rewind:' },
{ value: 'chore', name: 'chore: 🔨 其他修改 | Other changes that do not modify src or test files', emoji: ':hammer:' },
],
useEmoji: false,
emojiAlign: 'center',
themeColorCode: '',
scopes: [],
allowCustomScopes: true,
allowEmptyScopes: true,
customScopesAlign: 'bottom',
customScopesAlias: 'custom',
emptyScopesAlias: 'empty',
upperCaseSubject: false,
markBreakingChangeMode: true,
allowBreakingChanges: ['feat', 'fix'],
breaklineNumber: 100,
breaklineChar: '|',
skipQuestions: [],
issuePrefixs: [
// 如果使用 gitee 作为开发管理
{ value: 'link', name: 'link: 链接 ISSUES 进行中' },
{ value: 'closed', name: 'closed: 标记 ISSUES 已完成' }
],
customIssuePrefixsAlign: 'top',
emptyIssuePrefixsAlias: 'skip',
customIssuePrefixsAlias: 'custom',
allowCustomIssuePrefixs: true,
allowEmptyIssuePrefixs: true,
confirmColorize: true,
maxHeaderLength: Infinity,
maxSubjectLength: Infinity,
minSubjectLength: 0,
scopeOverrides: undefined,
defaultBody: '',
defaultIssues: '',
defaultScope: '',
defaultSubject: ''
}
}
+1 -4
View File
@@ -3,10 +3,7 @@ root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+1 -3
View File
@@ -1,7 +1,7 @@
NODE_ENV = production
# 页面标题
VITE_APP_TITLE = Fantastic-admin 基础版
VITE_APP_TITLE = 页面标题
# 接口请求地址,会设置到 axios 的 baseURL 参数上
VITE_APP_API_BASEURL = /
# 调试工具,可设置 eruda 或 vconsole,如果不需要开启则留空
@@ -11,7 +11,5 @@ VITE_APP_DEBUG_TOOL =
VITE_BUILD_MOCK = false
# 是否在打包时生成 sourcemap
VITE_BUILD_SOURCEMAP = false
# 是否在打包时删除 console 代码
VITE_BUILD_DROP_CONSOLE = true
# 是否在打包时开启压缩,支持 gzip 和 brotli
VITE_BUILD_COMPRESS = gzip,brotli
+2 -4
View File
@@ -1,7 +1,7 @@
NODE_ENV = production
# 页面标题
VITE_APP_TITLE = Fantastic-admin 专业版
VITE_APP_TITLE = 页面标题(test)
# 接口请求地址,会设置到 axios 的 baseURL 参数上
VITE_APP_API_BASEURL = /
# 调试工具,可设置 eruda 或 vconsole,如果不需要开启则留空
@@ -11,7 +11,5 @@ VITE_APP_DEBUG_TOOL =
VITE_BUILD_MOCK = true
# 是否在打包时生成 sourcemap
VITE_BUILD_SOURCEMAP = true
# 是否在打包时删除 console 代码
VITE_BUILD_DROP_CONSOLE = true
# 是否在打包时开启压缩,支持 gzip 和 brotli
VITE_BUILD_COMPRESS = gzip,brotli
VITE_BUILD_COMPRESS =
-2
View File
@@ -1,4 +1,2 @@
dist/
node_modules/
babel.config.js
.eslintrc.js
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@antfu"
}
-101
View File
@@ -1,101 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es6: true
},
globals: {
__dirname: true,
process: true,
require: true,
module: true,
// element-plus
ElMessage: true,
ElMessageBox: true,
ElNotification: true,
ElLoading: true
},
extends: [
'plugin:vue/vue3-recommended',
'plugin:vue/vue3-strongly-recommended',
'eslint:recommended',
'./.eslintrc-auto-import.json'
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: '2020',
ecmaFeatures: {
jsx: true
}
},
rules: {
// 代码风格
'block-spacing': [2, 'always'],
'brace-style': [2, '1tbs', {
'allowSingleLine': true
}],
'comma-spacing': [2, {
'before': false,
'after': true
}],
'comma-dangle': [2, 'never'],
'comma-style': [2, 'last'],
'computed-property-spacing': [2, 'never'],
'indent': [2, 4, {
'SwitchCase': 1
}],
'key-spacing': [2, {
'beforeColon': false,
'afterColon': true
}],
'keyword-spacing': [2, {
'before': true,
'after': true
}],
'linebreak-style': 0,
'multiline-ternary': [2, 'always-multiline'],
'no-multiple-empty-lines': [2, {
'max': 1
}],
'no-unneeded-ternary': [2, {
'defaultAssignment': false
}],
'quotes': [2, 'single'],
'semi': [2, 'never'],
'space-before-blocks': [2, 'always'],
'space-before-function-paren': [2, 'never'],
'space-in-parens': [2, 'never'],
'space-infix-ops': 2,
'space-unary-ops': [2, {
'words': true,
'nonwords': false
}],
'spaced-comment': [2, 'always', {
'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ',']
}],
'switch-colon-spacing': [2, {
'after': true,
'before': false
}],
'object-curly-spacing': [2, 'always'],
// ES6
'arrow-parens': [2, 'as-needed'],
'arrow-spacing': [2, {
'before': true,
'after': true
}],
// Vue - https://github.com/vuejs/eslint-plugin-vue
'vue/multi-word-component-names': 0,
'vue/html-indent': [2, 4],
'vue/no-v-html': 0,
'vue/max-attributes-per-line': 0,
'vue/require-default-prop': 0,
'vue/singleline-html-element-content-newline': 0,
'vue/require-explicit-emits': [2, {
'allowProps': true
}],
'vue/script-indent': [2, 4, {
'switchCase': 1
}]
}
};
+22
View File
@@ -0,0 +1,22 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 16.x
- run: npx changelogithub # or changelogithub@0.12 if ensure the stable result
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
-2
View File
@@ -3,7 +3,5 @@ node_modules
dist*
dist-ssr
*.local
.eslintrc-auto-import.json
public/icons
src/assets/sprites/*.*
!src/assets/sprites/.gitkeep
+3 -3
View File
@@ -1,5 +1,5 @@
{
"src/**/*.{js,jsx}": "eslint --fix",
"src/**/*.{css,scss}": "stylelint --fix --allow-empty-input",
"src/**/*.vue": ["eslint --fix", "stylelint --fix --allow-empty-input"]
"src/**/*.{js,jsx,ts,tsx}": "eslint --fix",
"src/**/*.{css,scss}": "stylelint --fix --allow-empty-input",
"src/**/*.vue": ["eslint --fix", "stylelint --fix --allow-empty-input"]
}
+2
View File
@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false
+20 -45
View File
@@ -1,48 +1,23 @@
{
"extends": [
"stylelint-config-standard",
"stylelint-config-recommended-vue/scss"
],
"overrides": [
{
"files": ["**/*.scss"],
"customSyntax": "postcss-scss"
}
],
"plugins": [
"stylelint-scss"
],
"rules": {
"indentation": 4,
"rule-empty-line-before": "never",
"at-rule-empty-line-before": "never",
"at-rule-no-unknown": null,
"no-descending-specificity": null,
"selector-pseudo-class-no-unknown": null,
"property-no-unknown": null,
"font-family-no-missing-generic-family-keyword": null,
"selector-id-pattern": null,
"selector-class-pattern": null,
"scss/no-global-function-names": null,
"scss/at-import-partial-extension": null,
"function-no-unknown": [
true,
{
"ignoreFunctions": [
"v-bind",
"lighten",
"darken"
]
}
],
"property-no-vendor-prefix": [
true,
{
"ignoreProperties": [
"mask-image"
]
}
],
"max-line-length": null
"extends": [
"stylelint-config-standard",
"stylelint-config-standard-vue/scss"
],
"overrides": [
{
"files": ["**/*.scss"],
"customSyntax": "postcss-scss"
}
],
"plugins": [
"stylelint-scss"
],
"rules": {
"at-rule-no-unknown": null,
"no-descending-specificity": null,
"property-no-unknown": null,
"font-family-no-missing-generic-family-keyword": null,
"selector-class-pattern": null,
"max-line-length": null
}
}
+7 -7
View File
@@ -1,9 +1,9 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"mikestead.dotenv",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"vue.volar"
]
"recommendations": [
"EditorConfig.EditorConfig",
"mikestead.dotenv",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"vue.volar"
]
}
+5 -5
View File
@@ -1,7 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
},
"stylelint.validate": ["css", "scss", "vue"]
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
},
"stylelint.validate": ["css", "scss", "vue"]
}
+36 -58
View File
@@ -1,62 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title>
<%- copyrightScript %>
</head>
<body>
<div id="app">
<div class="fantastic-admin-home">
<div class="loading">
<div class="square"></div>
<div class="square"></div>
<div class="square"></div>
<div class="square"></div>
</div>
<div class="text"><%= title %>载入中…</div>
</div>
<div id="browser-upgrade">
<div class="title">为了您的体验,推荐使用以下浏览器</div>
<div class="browsers">
<a href="https://www.microsoft.com/edge" target="_blank" class="browser">
<img class="browser-icon" src="/browser_upgrade/edge.png" />
<div class="browser-name">Mircosoft Edge</div>
</a>
<a href="https://www.google.cn/chrome/" target="_blank" class="browser">
<img class="browser-icon" src="/browser_upgrade/chrome.png" />
<div class="browser-name">Google Chrome</div>
</a>
</div>
</div>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/browser_upgrade/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title>
<%- copyrightScript %>
</head>
<body>
<div id="app">
<div class="fantastic-admin-home">
<div class="loading">
<div class="square"></div>
<div class="square"></div>
<div class="square"></div>
<div class="square"></div>
</div>
<script>
if(!!window.ActiveXObject || "ActiveXObject" in window) {
document.getElementById('browser-upgrade').style.display = 'block'
} else {
var Loading = document.querySelector('.loading')
Loading.classList.add('animate')
Loading.addEventListener('animationend', function() {
setTimeout(function() {
Loading.classList.remove('animate')
}, 600)
setTimeout(function() {
Loading.classList.add('animate')
}, 1000)
})
}
</script>
<script type="module" src="/src/main.js"></script>
<% if (debugTool == 'eruda') { %>
<script src="//unpkg.com/eruda/eruda.js"></script>
<script>eruda.init();</script>
<% } %>
<% if (debugTool == 'vconsole') { %>
<script src="//unpkg.com/vconsole/dist/vconsole.min.js"></script>
<script>new VConsole();</script>
<% } %>
</body>
<div class="text"><%= title %>载入中…</div>
</div>
<div id="browser-upgrade">
<div class="title">为了您的体验,推荐使用以下浏览器</div>
<div class="browsers">
<a href="https://www.microsoft.com/edge" target="_blank" class="browser">
<img class="browser-icon" src="/browser_upgrade/edge.png" />
<div class="browser-name">Mircosoft Edge</div>
</a>
<a href="https://www.google.cn/chrome/" target="_blank" class="browser">
<img class="browser-icon" src="/browser_upgrade/chrome.png" />
<div class="browser-name">Google Chrome</div>
</a>
</div>
</div>
</div>
<%- loadingScript %>
<script type="module" src="/src/main.ts"></script>
<%- devtoolScript %>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "es2017",
"allowSyntheticDefaultImports": false,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"jsx": "preserve"
},
"exclude": ["node_modules", "dist", "dist-test"]
}
+85 -73
View File
@@ -1,76 +1,88 @@
{
"name": "fantastic-admin",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build:test": "vite build --mode test",
"build": "vite build",
"serve:test": "http-server ./dist-test -o",
"serve": "http-server ./dist -o",
"svgo": "svgo -f src/assets/icons",
"new": "plop",
"lint:eslint": "eslint src/**/*.{js,vue} --fix",
"lint:stylelint": "stylelint src/**/*.{css,scss,vue} --fix",
"prepare": "husky install",
"postinstall": "esno scripts/prepare.js",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"@tinymce/tinymce-vue": "^5.0.0",
"@vueuse/core": "^9.2.0",
"@vueuse/integrations": "^9.2.0",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
"element-plus": "^2.2.16",
"hotkeys-js": "^3.10.0",
"js-cookie": "^3.0.1",
"mitt": "^3.0.0",
"mockjs": "^1.1.0",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^6.2.1",
"pinia": "^2.0.22",
"qs": "^6.11.0",
"tinymce": "^6.2.0",
"vue": "^3.2.39",
"vue-router": "^4.1.3"
},
"devDependencies": {
"@iconify-json/ep": "^1.1.7",
"@iconify/vue": "^4.0.0",
"@vitejs/plugin-vue": "^3.1.0",
"@vitejs/plugin-vue-jsx": "^2.0.1",
"@vue/compiler-sfc": "^3.2.39",
"eslint": "^8.23.1",
"eslint-plugin-vue": "^9.3.0",
"esno": "^0.16.3",
"fs-extra": "^10.1.0",
"http-server": "^14.1.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"plop": "^3.1.1",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.5",
"sass": "^1.54.9",
"stylelint": "^14.10.0",
"stylelint-config-recommended-scss": "^7.0.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^28.0.0",
"stylelint-scss": "^4.3.0",
"svgo": "^2.8.0",
"unplugin-auto-import": "^0.11.2",
"unplugin-vue-components": "^0.22.7",
"vite": "^3.1.1",
"vite-plugin-banner": "^0.5.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "^2.9.6",
"vite-plugin-pages": "^0.26.0",
"vite-plugin-restart": "^0.2.0",
"vite-plugin-spritesmith": "^0.1.1",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-layouts": "^0.6.0",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue-eslint-parser": "^9.1.0"
"version": "2.0.1",
"scripts": {
"dev": "vite",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build": "vue-tsc --noEmit && vite build",
"serve:test": "http-server ./dist-test -o",
"serve": "http-server ./dist -o",
"svgo": "svgo -f src/assets/icons",
"new": "plop",
"lint:eslint": "eslint src/**/*.{ts,tsx,vue} --fix",
"lint:stylelint": "stylelint src/**/*.{css,scss,vue} --fix",
"prepare": "husky install",
"postinstall": "esno scripts/prepare.js",
"preinstall": "npx only-allow pnpm",
"commit": "git cz",
"release": "bumpp"
},
"dependencies": {
"@tinymce/tinymce-vue": "^5.0.0",
"@vueuse/core": "^9.2.0",
"@vueuse/integrations": "^9.2.0",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
"element-plus": "^2.2.17",
"hotkeys-js": "^3.10.0",
"lodash-es": "^4.17.21",
"mitt": "^3.0.0",
"mockjs": "^1.1.0",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^6.2.1",
"pinia": "^2.0.22",
"qs": "^6.11.0",
"tinymce": "^6.2.0",
"vue": "^3.2.39",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@antfu/eslint-config": "^0.27.0",
"@iconify-json/ep": "^1.1.7",
"@iconify/vue": "^4.0.0",
"@types/nprogress": "^0.2.0",
"@types/path-browserify": "^1.0.0",
"@types/qs": "^6.9.7",
"@vitejs/plugin-vue": "^3.1.0",
"@vitejs/plugin-vue-jsx": "^2.0.1",
"autoprefixer": "^10.4.12",
"bumpp": "^8.2.1",
"cz-git": "^1.3.11",
"eslint": "^8.24.0",
"esno": "^0.16.3",
"fs-extra": "^10.1.0",
"http-server": "^14.1.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"plop": "^3.1.1",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.5",
"sass": "^1.55.0",
"stylelint": "^14.12.1",
"stylelint-config-standard": "^28.0.0",
"stylelint-config-standard-scss": "^5.0.0",
"stylelint-config-standard-vue": "^1.0.0",
"stylelint-scss": "^4.3.0",
"svgo": "^2.8.0",
"typescript": "^4.8.3",
"unplugin-auto-import": "^0.11.2",
"unplugin-vue-components": "^0.22.7",
"vite": "^3.1.3",
"vite-plugin-banner": "^0.5.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "^2.9.6",
"vite-plugin-pages": "^0.26.0",
"vite-plugin-restart": "^0.2.0",
"vite-plugin-spritesmith": "^0.1.1",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-layouts": "^0.6.0",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue-tsc": "^0.40.13"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git"
}
}
}
+5 -6
View File
@@ -1,12 +1,11 @@
<script setup{{#if isGlobal}} name="{{ properCase name }}"{{/if}}>
// const router = useRouter()
// const route = useRoute()
<script lang="ts" setup{{#if isGlobal}} name="{{ properCase name }}"{{/if}}>
// const proxy = useCurrentInstance()
</script>
<template>
<div>
<!-- 内容区 -->
</div>
<div>
<!-- 内容区 -->
</div>
</template>
<style lang="scss" scoped>
+57 -56
View File
@@ -1,63 +1,64 @@
const fs = require('fs')
function getFolder(path) {
let components = []
const files = fs.readdirSync(path)
files.forEach(function(item) {
let stat = fs.lstatSync(path + '/' + item)
if (stat.isDirectory() === true && item != 'components') {
components.push(path + '/' + item)
components.push.apply(components, getFolder(path + '/' + item))
}
})
return components
const components = []
const files = fs.readdirSync(path)
files.forEach((item) => {
const stat = fs.lstatSync(`${path}/${item}`)
if (stat.isDirectory() === true && item !== 'components') {
components.push(`${path}/${item}`)
components.push(...getFolder(`${path}/${item}`))
}
})
return components
}
module.exports = {
description: '创建组件',
prompts: [
{
type: 'confirm',
name: 'isGlobal',
message: '是否为全局组件',
default: false
},
{
type: 'list',
name: 'path',
message: '请选择组件创建目录',
choices: getFolder('src/views'),
when: answers => {
return !answers.isGlobal
}
},
{
type: 'input',
name: 'name',
message: '请输入组件名称',
validate: v => {
if (!v || v.trim === '') {
return '组件名称不能为空'
} else {
return true
}
}
}
],
actions: data => {
let path = ''
if (data.isGlobal) {
path = 'src/components/{{properCase name}}/index.vue'
} else {
path = `${data.path}/components/{{properCase name}}/index.vue`
}
const actions = [
{
type: 'add',
path: path,
templateFile: 'plop-templates/component/index.hbs'
}
]
return actions
}
description: '创建组件',
prompts: [
{
type: 'confirm',
name: 'isGlobal',
message: '是否为全局组件',
default: false,
},
{
type: 'list',
name: 'path',
message: '请选择组件创建目录',
choices: getFolder('src/views'),
when: (answers) => {
return !answers.isGlobal
},
},
{
type: 'input',
name: 'name',
message: '请输入组件名称',
validate: (v) => {
if (!v || v.trim === '')
return '组件名称不能为空'
else
return true
},
},
],
actions: (data) => {
let path = ''
if (data.isGlobal)
path = 'src/components/{{properCase name}}/index.vue'
else
path = `${data.path}/components/{{properCase name}}/index.vue`
const actions = [
{
type: 'add',
path,
templateFile: 'plop-templates/component/index.hbs',
},
]
return actions
},
}
-80
View File
@@ -1,80 +0,0 @@
import Mock from 'mockjs'
const AllList = []
for (let i = 0; i < 50; i++) {
AllList.push(Mock.mock({
id: '@id',
title: '@ctitle(10, 20)'
}))
}
export default [
{
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/list',
method: 'get',
response: option => {
let { title, from, limit } = option.query
from = ~~from
limit = ~~limit
let list = AllList.filter(item => {
return title ? item.title.includes(title) : true
})
let pageList = list.filter((item, index) => {
return index >= from && index < (from + limit)
})
return {
error: '',
status: 1,
data: {
list: pageList,
total: list.length
}
}
}
},
{
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/detail',
method: 'get',
response: option => {
let info = AllList.filter(item => item.id == option.query.id)
return {
error: '',
status: 1,
data: info[0]
}
}
},
{
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/create',
method: 'post',
response: {
error: '',
status: 1,
data: {
isSuccess: true
}
}
},
{
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/edit',
method: 'post',
response: {
error: '',
status: 1,
data: {
isSuccess: true
}
}
},
{
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/delete',
method: 'post',
response: {
error: '',
status: 1,
data: {
isSuccess: true
}
}
}
]
-43
View File
@@ -1,43 +0,0 @@
const path = require('path')
const fs = require('fs')
function getFolder(path) {
let components = []
const files = fs.readdirSync(path)
files.forEach(function(item) {
let stat = fs.lstatSync(path + '/' + item)
if (stat.isDirectory() === true && item != 'components') {
components.push(path + '/' + item)
components.push.apply(components, getFolder(path + '/' + item))
}
})
return components
}
module.exports = {
description: '创建标准模块 Mock',
prompts: [
{
type: 'list',
name: 'path',
message: '请选择模块目录',
choices: getFolder('src/views')
}
],
actions: data => {
let pathArr = path.relative('src/views', data.path).split('\\')
let moduleName = pathArr.pop()
let relativePath = pathArr.join('/')
const actions = []
actions.push({
type: 'add',
path: pathArr.length == 0 ? 'src/mock/{{moduleName}}.js' : `src/mock/${pathArr.join('.')}.{{moduleName}}.js`,
templateFile: 'plop-templates/mock/mock.hbs',
data: {
relativePath,
moduleName
}
})
return actions
}
}
+5 -7
View File
@@ -1,15 +1,13 @@
<script setup name="{{ properCase componentName }}">
<script lang="ts" setup name="{{ properCase componentName }}">
// const proxy = useCurrentInstance()
// const router = useRouter()
// const route = useRoute()
</script>
<template>
<div>
<page-header title="{{ cname }}" />
<page-main>
页面内容区域
</page-main>
</div>
<div>
页面内容区域
</div>
</template>
<style lang="scss" scoped>
+44 -52
View File
@@ -2,60 +2,52 @@ const path = require('path')
const fs = require('fs')
function getFolder(path) {
let components = []
const files = fs.readdirSync(path)
files.forEach(function(item) {
let stat = fs.lstatSync(path + '/' + item)
if (stat.isDirectory() === true && item != 'components') {
components.push(path + '/' + item)
components.push.apply(components, getFolder(path + '/' + item))
}
})
return components
const components = []
const files = fs.readdirSync(path)
files.forEach((item) => {
const stat = fs.lstatSync(`${path}/${item}`)
if (stat.isDirectory() === true && item !== 'components') {
components.push(`${path}/${item}`)
components.push(...getFolder(`${path}/${item}`))
}
})
return components
}
module.exports = {
description: '创建页面',
prompts: [
{
type: 'list',
name: 'path',
message: '请选择页面创建目录',
choices: getFolder('src/views')
description: '创建页面',
prompts: [
{
type: 'list',
name: 'path',
message: '请选择页面创建目录',
choices: getFolder('src/views'),
},
{
type: 'input',
name: 'name',
message: '请输入文件名',
validate: (v) => {
if (!v || v.trim === '')
return '文件名不能为空'
else
return true
},
},
],
actions: (data) => {
const relativePath = path.relative('src/views', data.path)
const actions = [
{
type: 'add',
path: `${data.path}/{{dotCase name}}.vue`,
templateFile: 'plop-templates/page/index.hbs',
data: {
componentName: `${relativePath} ${data.name}`,
},
{
type: 'input',
name: 'name',
message: '请输入文件名',
validate: v => {
if (!v || v.trim === '') {
return '文件名不能为空'
} else if (v == 'list' || v == 'detail') {
return 'list 和 detail 为保留关键字,无法直接创建,请通过创建标准模块进行生成'
} else {
return true
}
}
},
{
type: 'input',
name: 'cname',
message: '请输入页面中文名称',
default: '默认页面'
}
],
actions: data => {
let relativePath = path.relative('src/views', data.path)
const actions = [
{
type: 'add',
path: `${data.path}/{{dotCase name}}.vue`,
templateFile: 'plop-templates/page/index.hbs',
data: {
componentName: `${relativePath} ${data.name}`
}
}
]
return actions
}
},
]
return actions
},
}
+7 -7
View File
@@ -1,11 +1,11 @@
const use{{ properCase name }}Store = defineStore(
// 唯一ID
'{{ camelCase name }}',
{
state: () => ({}),
getters: {},
actions: {}
}
// 唯一ID
'{{ camelCase name }}',
{
state: () => ({}),
getters: {},
actions: {}
}
)
export default use{{ properCase name }}Store
+25 -25
View File
@@ -1,27 +1,27 @@
module.exports = {
description: '创建全局状态',
prompts: [
{
type: 'input',
name: 'name',
message: '请输入模块名称',
validate: v => {
if (!v || v.trim === '') {
return '模块名称不能为空'
} else {
return true
}
}
}
],
actions: () => {
const actions = [
{
type: 'add',
path: 'src/store/modules/{{camelCase name}}.js',
templateFile: 'plop-templates/store/index.hbs'
}
]
return actions
}
description: '创建全局状态',
prompts: [
{
type: 'input',
name: 'name',
message: '请输入模块名称',
validate: (v) => {
if (!v || v.trim === '')
return '模块名称不能为空'
else
return true
},
},
],
actions: () => {
const actions = [
{
type: 'add',
path: 'src/store/modules/{{camelCase name}}.js',
templateFile: 'plop-templates/store/index.hbs',
},
]
return actions
},
}
+5 -6
View File
@@ -1,7 +1,6 @@
module.exports = function(plop) {
plop.setWelcomeMessage('请选择需要创建的模式:')
plop.setGenerator('page', require('./plop-templates/page/prompt'))
plop.setGenerator('component', require('./plop-templates/component/prompt'))
plop.setGenerator('store', require('./plop-templates/store/prompt'))
plop.setGenerator('mock', require('./plop-templates/mock/prompt'))
module.exports = function (plop) {
plop.setWelcomeMessage('请选择需要创建的模式:')
plop.setGenerator('page', require('./plop-templates/page/prompt'))
plop.setGenerator('component', require('./plop-templates/component/prompt'))
plop.setGenerator('store', require('./plop-templates/store/prompt'))
}
+1331 -506
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -1,15 +1,15 @@
import path from 'path'
import path from 'node:path'
import fs from 'fs-extra'
import { icons } from '@iconify-json/ep'
async function prepare() {
const outputDir = path.resolve(process.cwd(), 'public/icons')
await fs.ensureDir(outputDir)
await fs.emptyDir(outputDir)
await fs.writeJSON(
path.resolve(outputDir, 'icons.json'),
icons
)
const outputDir = path.resolve(process.cwd(), 'public/icons')
await fs.ensureDir(outputDir)
await fs.emptyDir(outputDir)
await fs.writeJSON(
path.resolve(outputDir, 'icons.json'),
icons,
)
}
prepare()
+22 -22
View File
@@ -1,7 +1,7 @@
{
// Default options
'functions': true,
'variableNameTransforms': ['dasherize']
// Default options
'functions': true,
'variableNameTransforms': ['dasherize']
}
{{#block "sprites"}}
@@ -19,35 +19,35 @@ ${{spritesheet_info.strings.name}}-sprites: (
{{#block "sprite-functions"}}
{{#if options.functions}}
@mixin {{spritesheet_info.strings.name}}-sprite-width($sprite) {
width: nth($sprite, 5);
width: nth($sprite, 5);
}
@mixin {{spritesheet_info.strings.name}}-sprite-height($sprite) {
height: nth($sprite, 6);
height: nth($sprite, 6);
}
@mixin {{spritesheet_info.strings.name}}-sprite-position($sprite) {
$sprite-offset-x: nth($sprite, 3);
$sprite-offset-y: nth($sprite, 4);
background-position: $sprite-offset-x $sprite-offset-y;
$sprite-offset-x: nth($sprite, 3);
$sprite-offset-y: nth($sprite, 4);
background-position: $sprite-offset-x $sprite-offset-y;
}
@mixin {{spritesheet_info.strings.name}}-sprite-size($sprite) {
background-size: nth($sprite, 7) nth($sprite, 8);
background-size: nth($sprite, 7) nth($sprite, 8);
}
@mixin {{spritesheet_info.strings.name}}-sprite-image($sprite) {
$sprite-image: nth($sprite, 9);
background-image: url(#{$sprite-image});
$sprite-image: nth($sprite, 9);
background-image: url(#{$sprite-image});
}
@mixin {{spritesheet_info.strings.name}}-sprite($name) {
@include {{spritesheet_info.strings.name}}-sprite-image(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-position(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-size(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-width(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-height(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
background-repeat: no-repeat;
@include {{spritesheet_info.strings.name}}-sprite-image(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-position(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-size(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-width(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
@include {{spritesheet_info.strings.name}}-sprite-height(map-get(${{spritesheet_info.strings.name}}-sprites, #{$name}));
background-repeat: no-repeat;
}
{{/if}}
{{/block}}
@@ -55,12 +55,12 @@ ${{spritesheet_info.strings.name}}-sprites: (
{{#block "spritesheet-functions"}}
{{#if options.functions}}
@mixin all-{{spritesheet_info.strings.name}}-sprites() {
@each $key, $val in ${{spritesheet_info.strings.name}}-sprites {
$sprite-name: nth($val, 10);
.{{spritesheet_info.strings.name}}-#{$sprite-name}-sprites {
@include {{spritesheet_info.strings.name}}-sprite($key);
}
@each $key, $val in ${{spritesheet_info.strings.name}}-sprites {
$sprite-name: nth($val, 10);
.{{spritesheet_info.strings.name}}-#{$sprite-name}-sprites {
@include {{spritesheet_info.strings.name}}-sprite($key);
}
}
}
{{/if}}
{{/block}}
+64 -53
View File
@@ -1,89 +1,100 @@
<script setup>
<script lang="ts" setup>
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import hotkeys from 'hotkeys-js'
import eventBus from './utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
const settingsStore = useSettingsStore()
const buttonConfig = ref({
autoInsertSpace: true,
})
// 侧边栏主导航当前实际宽度
const mainSidebarActualWidth = computed(() => {
let actualWidth = getComputedStyle(document.documentElement).getPropertyValue('--g-main-sidebar-width')
actualWidth = parseInt(actualWidth)
if (['head', 'single'].includes(settingsStore.menu.menuMode)) {
actualWidth = 0
}
return `${actualWidth}px`
let actualWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-main-sidebar-width'))
if (['head', 'single'].includes(settingsStore.menu.menuMode))
actualWidth = 0
return `${actualWidth}px`
})
// 侧边栏次导航当前实际宽度
const subSidebarActualWidth = computed(() => {
let actualWidth = getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-width')
actualWidth = parseInt(actualWidth)
if (settingsStore.menu.subMenuCollapse) {
actualWidth = 64
}
return `${actualWidth}px`
let actualWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-width'))
if (settingsStore.menu.subMenuCollapse)
actualWidth = 64
return `${actualWidth}px`
})
watch(() => settingsStore.app.colorScheme, () => {
if (settingsStore.app.colorScheme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
if (settingsStore.app.colorScheme === 'dark')
document.documentElement.classList.add('dark')
else
document.documentElement.classList.remove('dark')
}, {
immediate: true
immediate: true,
})
watch(() => settingsStore.mode, () => {
if (settingsStore.mode === 'pc') {
settingsStore.$patch(state => {
state.menu.subMenuCollapse = settingsStore.subMenuCollapseLastStatus
})
} else if (settingsStore.mode === 'mobile') {
settingsStore.$patch(state => {
state.menu.subMenuCollapse = true
})
}
document.body.setAttribute('data-mode', settingsStore.mode)
if (settingsStore.mode === 'pc') {
settingsStore.$patch((state) => {
state.menu.subMenuCollapse = settingsStore.subMenuCollapseLastStatus
})
}
else if (settingsStore.mode === 'mobile') {
settingsStore.$patch((state) => {
state.menu.subMenuCollapse = true
})
}
document.body.setAttribute('data-mode', settingsStore.mode)
}, {
immediate: true
immediate: true,
})
watch(() => settingsStore.menu.menuMode, () => {
document.body.setAttribute('data-menu-mode', settingsStore.menu.menuMode)
document.body.setAttribute('data-menu-mode', settingsStore.menu.menuMode)
}, {
immediate: true
immediate: true,
})
watch([
() => settingsStore.app.enableDynamicTitle,
() => settingsStore.title
() => settingsStore.app.enableDynamicTitle,
() => settingsStore.title,
], () => {
if (settingsStore.app.enableDynamicTitle && settingsStore.title) {
let title = settingsStore.title
document.title = `${title} - ${import.meta.env.VITE_APP_TITLE}`
} else {
document.title = import.meta.env.VITE_APP_TITLE
}
if (settingsStore.app.enableDynamicTitle && settingsStore.title) {
const title = settingsStore.title
document.title = `${title} - ${import.meta.env.VITE_APP_TITLE}`
}
else {
document.title = import.meta.env.VITE_APP_TITLE
}
}, {
immediate: true
immediate: true,
})
onMounted(() => {
window.onresize = () => {
settingsStore.setMode(document.documentElement.clientWidth)
}
window.onresize()
settingsStore.setMode(document.documentElement.clientWidth)
window.onresize = () => {
settingsStore.setMode(document.documentElement.clientWidth)
}
hotkeys('alt+i', () => {
eventBus.emit('global-system-info-toggle')
})
})
</script>
<template>
<el-config-provider :locale="zhCn" :size="settingsStore.app.elementSize" :button="{autoInsertSpace: true}">
<RouterView
:style="{
'--g-main-sidebar-actual-width': mainSidebarActualWidth,
'--g-sub-sidebar-actual-width': subSidebarActualWidth
}"
/>
</el-config-provider>
<el-config-provider :locale="zhCn" :size="settingsStore.app.elementSize" :button="buttonConfig">
<RouterView
:style="{
'--g-main-sidebar-actual-width': mainSidebarActualWidth,
'--g-sub-sidebar-actual-width': subSidebarActualWidth,
}"
/>
<system-info />
</el-config-provider>
</template>
-82
View File
@@ -1,82 +0,0 @@
import axios from 'axios'
// import qs from 'qs'
import router from '@/router/index'
import { ElMessage } from 'element-plus'
import useUserStore from '@/store/modules/user'
const toLogin = () => {
useUserStore().logout().then(() => {
router.push({
name: 'login',
query: {
redirect: router.currentRoute.value.path !== '/login' ? router.currentRoute.value.fullPath : undefined
}
})
})
}
const api = axios.create({
baseURL: import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY === 'true' ? '/proxy/' : import.meta.env.VITE_APP_API_BASEURL,
timeout: 10000,
responseType: 'json'
})
api.interceptors.request.use(
request => {
const userStore = useUserStore()
/**
* 全局拦截请求发送前提交的参数
* 以下代码为示例,在请求头里带上 token 信息
*/
if (userStore.isLogin) {
request.headers['Token'] = userStore.token
}
// 是否将 POST 请求参数进行字符串化处理
if (request.method === 'post') {
// request.data = qs.stringify(request.data, {
// arrayFormat: 'brackets'
// })
}
return request
}
)
api.interceptors.response.use(
response => {
/**
* 全局拦截请求发送后返回的数据,如果数据有报错则在这做全局的错误提示
* 假设返回数据格式为:{ status: 1, error: '', data: '' }
* 规则是当 status 为 1 时表示请求成功,为 0 时表示接口需要登录或者登录状态失效,需要重新登录
* 请求出错时 error 会返回错误信息
*/
if (response.data.status === 1) {
if (response.data.error === '') {
// 请求成功并且没有报错
return Promise.resolve(response.data)
} else {
// 这里做错误提示
// ElMessage.error(options)
return Promise.reject(response.data)
}
} else {
toLogin()
}
},
error => {
let message = error.message
if (message == 'Network Error') {
message = '后端网络故障'
} else if (message.includes('timeout')) {
message = '接口请求超时'
} else if (message.includes('Request failed with status code')) {
message = '接口' + message.substr(message.length - 3) + '异常'
}
ElMessage({
message,
type: 'error'
})
return Promise.reject(error)
}
)
export default api
+83
View File
@@ -0,0 +1,83 @@
import axios from 'axios'
// import qs from 'qs'
import router from '@/router/index'
import useUserStore from '@/store/modules/user'
const toLogin = () => {
router.push({
path: '/login',
query: {
redirect: router.currentRoute.value.path !== '/login' ? router.currentRoute.value.fullPath : undefined,
},
})
}
const api = axios.create({
baseURL: import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY === 'true' ? '/proxy/' : import.meta.env.VITE_APP_API_BASEURL,
timeout: 10000,
responseType: 'json',
})
api.interceptors.request.use(
(request) => {
const userStore = useUserStore()
/**
* 全局拦截请求发送前提交的参数
* 以下代码为示例,在请求头里带上 token 信息
*/
if (userStore.isLogin && request.headers)
request.headers.Token = userStore.token
// 是否将 POST 请求参数进行字符串化处理
if (request.method === 'post') {
// request.data = qs.stringify(request.data, {
// arrayFormat: 'brackets',
// })
}
return request
},
)
api.interceptors.response.use(
(response) => {
/**
* 全局拦截请求发送后返回的数据,如果数据有报错则在这做全局的错误提示
* 假设返回数据格式为:{ status: 1, error: '', data: '' }
* 规则是当 status 为 1 时表示请求成功,为 0 时表示接口需要登录或者登录状态失效,需要重新登录
* 请求出错时 error 会返回错误信息
*/
if (response.data.status === 1) {
if (response.data.error === '') {
// 请求成功并且没有报错
return Promise.resolve(response.data)
}
else {
// 这里做错误提示,如果使用了 element plus 则可以使用 Message 进行提示
// ElMessage.error(options)
return Promise.reject(response.data)
}
}
else {
toLogin()
}
},
(error) => {
let message = error.message
if (message === 'Network Error')
message = '后端网络故障'
else if (message.includes('timeout'))
message = '接口请求超时'
else if (message.includes('Request failed with status code'))
message = `接口${message.substr(message.length - 3)}异常`
ElMessage({
message,
type: 'error',
})
return Promise.reject(error)
},
)
export default api
-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1623578868505" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5778" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M972.4928 217.4976a73.4208 73.4208 0 0 0-58.5728-64.0512 1415.7312 1415.7312 0 0 1-189.184-42.9568A706.1504 706.1504 0 0 1 555.52 14.4384a77.6704 77.6704 0 0 0-90.112 0 445.8496 445.8496 0 0 1-170.496 96.0512 639.0272 639.0272 0 0 1-183.8592 44.8512A67.8912 67.8912 0 0 0 53.1968 219.392S51.2 379.2384 51.2 520.448C51.2 776.4992 358.1952 1024 511.8464 1024c153.6 0 413.5936-174.592 455.0144-499.712 6.5536-101.888 7.68-204.0832 3.328-306.176H972.8l-0.3072-0.6144z m-167.168 197.9392l-320.512 297.216a43.6736 43.6736 0 0 1-53.248 5.12l-8.5504-7.0144L246.272 529.408a42.752 42.752 0 0 1 2.304-61.184 43.776 43.776 0 0 1 62.1568 2.56l145.7664 150.2208 288.8704-269.6704a44.4416 44.4416 0 0 1 62.5152 2.56c16.5376 17.7664 15.36 45.1072-2.6112 61.44z" p-id="5779"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="128" height="128"><path d="M512 128q69.675 0 135.51 21.163t115.498 54.997 93.483 74.837 73.685 82.006 51.67 74.837 32.17 54.827L1024 512q-2.347 4.992-6.315 13.483T998.87 560.17t-31.658 51.669-44.331 59.99-56.832 64.34-69.504 60.16-82.347 51.5-94.848 34.687T512 896q-69.675 0-135.51-21.163t-115.498-54.826-93.483-74.326-73.685-81.493-51.67-74.496-32.17-54.997L0 513.707q2.347-4.992 6.315-13.483t18.816-34.816 31.658-51.84 44.331-60.33 56.832-64.683 69.504-60.331 82.347-51.84 94.848-34.816T512 128.085zm0 85.333q-46.677 0-91.648 12.331t-81.152 31.83-70.656 47.146-59.648 54.485-48.853 57.686-37.675 52.821-26.325 43.99q12.33 21.674 26.325 43.52t37.675 52.351 48.853 57.003 59.648 53.845T339.2 767.02t81.152 31.488T512 810.667t91.648-12.331 81.152-31.659 70.656-46.848 59.648-54.186 48.853-57.344 37.675-52.651T927.957 512q-12.33-21.675-26.325-43.648t-37.675-52.65-48.853-57.345-59.648-54.186-70.656-46.848-81.152-31.659T512 213.334zm0 128q70.656 0 120.661 50.006T682.667 512 632.66 632.661 512 682.667 391.339 632.66 341.333 512t50.006-120.661T512 341.333zm0 85.334q-35.328 0-60.33 25.002T426.666 512t25.002 60.33T512 597.334t60.33-25.002T597.334 512t-25.002-60.33T512 426.666z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-1
View File
@@ -1 +0,0 @@
<svg width="128" height="64" xmlns="http://www.w3.org/2000/svg"><path d="M127.072 7.994c1.37-2.208.914-5.152-.914-6.87-2.056-1.717-4.797-1.226-6.396.982-.229.245-25.586 32.382-55.74 32.382-29.24 0-55.74-32.382-55.968-32.627-1.6-1.963-4.57-2.208-6.397-.49C-.17 3.086-.399 6.275 1.2 8.238c.457.736 5.94 7.36 14.62 14.72L4.17 35.96c-1.828 1.963-1.6 5.152.228 6.87.457.98 1.6 1.471 2.742 1.471s2.284-.49 3.198-1.472l12.564-13.983c5.94 4.416 13.021 8.587 20.788 11.53l-4.797 17.418c-.685 2.699.686 5.397 3.198 6.133h1.37c2.057 0 3.884-1.472 4.341-3.68L52.6 42.83c3.655.736 7.538 1.227 11.422 1.227 3.883 0 7.767-.49 11.422-1.227l4.797 17.173c.457 2.208 2.513 3.68 4.34 3.68.457 0 .914 0 1.143-.246 2.513-.736 3.883-3.434 3.198-6.133l-4.797-17.172c7.767-2.944 14.848-7.114 20.788-11.53l12.336 13.738c.913.981 2.056 1.472 3.198 1.472s2.284-.49 3.198-1.472c1.828-1.963 1.828-4.906.228-6.87l-11.65-13.001c9.366-7.36 14.849-14.474 14.849-14.474z"/></svg>

Before

Width:  |  Height:  |  Size: 944 B

-1
View File
@@ -1 +0,0 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M108.8 44.322H89.6v-5.36c0-9.04-3.308-24.163-25.6-24.163-23.145 0-25.6 16.881-25.6 24.162v5.361H19.2v-5.36C19.2 15.281 36.798 0 64 0c27.202 0 44.8 15.281 44.8 38.961v5.361zm-32 39.356c0-5.44-5.763-9.832-12.8-9.832-7.037 0-12.8 4.392-12.8 9.832 0 3.682 2.567 6.808 6.407 8.477v11.205c0 2.718 2.875 4.962 6.4 4.962 3.524 0 6.4-2.244 6.4-4.962V92.155c3.833-1.669 6.393-4.795 6.393-8.477zM128 64v49.201c0 8.158-8.645 14.799-19.2 14.799H19.2C8.651 128 0 121.359 0 113.201V64c0-8.153 8.645-14.799 19.2-14.799h89.6c10.555 0 19.2 6.646 19.2 14.799z"/></svg>

Before

Width:  |  Height:  |  Size: 623 B

-1
View File
@@ -1 +0,0 @@
<svg width="130" height="130" xmlns="http://www.w3.org/2000/svg"><path d="M63.444 64.996c20.633 0 37.359-14.308 37.359-31.953 0-17.649-16.726-31.952-37.359-31.952-20.631 0-37.36 14.303-37.358 31.952 0 17.645 16.727 31.953 37.359 31.953zM80.57 75.65H49.434c-26.652 0-48.26 18.477-48.26 41.27v2.664c0 9.316 21.608 9.325 48.26 9.325H80.57c26.649 0 48.256-.344 48.256-9.325v-2.663c0-22.794-21.605-41.271-48.256-41.271z" stroke="#979797"/></svg>

Before

Width:  |  Height:  |  Size: 440 B

+6 -5
View File
@@ -1,9 +1,10 @@
// 暗黑模式
@forward "element-plus/theme-chalk/src/dark/var.scss" with (
$bg-color: (
"page": #0a0a0a,
"": #141414,
"overlay": #1d1e1f,
)
$bg-color: (
"page": #0a0a0a,
"": #141414,
"overlay": #1d1e1f,
)
);
@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *;
+5 -4
View File
@@ -1,9 +1,10 @@
// @forward "element-plus/theme-chalk/src/common/var.scss" with (
// $colors: (
// "primary": (
// "base": green
// )
// $colors: (
// "primary": (
// "base": green
// )
// )
// );
@use "element-plus/theme-chalk/src/index.scss" as *;
@use "./dark.scss";
+51 -42
View File
@@ -3,73 +3,82 @@
// 全局变量
:root {
// 头部宽度(默认自适应宽度,可固定宽度,固定宽度后为居中显示)
--g-header-width: 100%;
// 头部高度
--g-header-height: 70px;
// 侧边栏宽度
--g-main-sidebar-width: 70px;
--g-sub-sidebar-width: 220px;
// 侧边栏Logo高度
--g-sidebar-logo-height: 50px;
// 顶栏高度
--g-topbar-height: 50px;
// 头部宽度(默认自适应宽度,可固定宽度,固定宽度后为居中显示)
--g-header-width: 100%;
// 头部高度
--g-header-height: 70px;
// 侧边栏宽度
--g-main-sidebar-width: 70px;
--g-sub-sidebar-width: 220px;
// 侧边栏Logo高度
--g-sidebar-logo-height: 50px;
// 顶栏高度
--g-topbar-height: 50px;
}
// 全局样式
::-webkit-scrollbar {
width: 12px;
height: 12px;
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
background-color: rgb(0 0 0 / 40%);
background-clip: padding-box;
border: 3px solid transparent;
border-radius: 6px;
background-color: rgb(0 0 0 / 40%);
background-clip: padding-box;
border: 3px solid transparent;
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgb(0 0 0 / 50%);
background-color: rgb(0 0 0 / 50%);
}
::-webkit-scrollbar-track {
background-color: transparent;
background-color: transparent;
}
html,
body {
height: 100%;
height: 100%;
}
body {
margin: 0;
box-sizing: border-box;
font-family: Lato, "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-tap-highlight-color: transparent;
&.hidden {
overflow: hidden;
}
margin: 0;
box-sizing: border-box;
font-family: Lato, "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-tap-highlight-color: transparent;
&.hidden {
overflow: hidden;
}
}
* {
box-sizing: inherit;
box-sizing: inherit;
}
// 右侧内容区针对fixed元素,有横向铺满的需求,可在fixed元素上设置 [data-fixed-calc-width]
[data-fixed-calc-width] {
position: fixed;
left: 50%;
right: 0;
width: calc(100% - var(--g-main-sidebar-actual-width) - var(--g-sub-sidebar-actual-width));
transform: translateX(-50%) translateX(calc(var(--g-main-sidebar-actual-width) / 2)) translateX(calc(var(--g-sub-sidebar-actual-width) / 2));
position: fixed;
left: 50%;
right: 0;
width: calc(100% - var(--g-main-sidebar-actual-width) - var(--g-sub-sidebar-actual-width));
transform: translateX(-50%) translateX(calc(var(--g-main-sidebar-actual-width) / 2)) translateX(calc(var(--g-sub-sidebar-actual-width) / 2));
}
[data-mode="mobile"] {
[data-fixed-calc-width] {
width: 100% !important;
transform: translateX(-50%) !important;
}
[data-fixed-calc-width] {
width: 100% !important;
transform: translateX(-50%) !important;
}
}
// textarea 字体跟随系统
textarea {
font-family: inherit;
font-family: inherit;
}
// 列表页全局样式
.el-table.list-table {
margin: 20px 0;
.el-button + .el-dropdown {
margin-left: 10px;
}
margin: 20px 0;
.el-button + .el-dropdown {
margin-left: 10px;
}
}
+54 -46
View File
@@ -1,55 +1,63 @@
#nprogress {
pointer-events: none;
.bar {
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--g-nprogress-color);
}
.peg {
display: block;
position: absolute;
right: 0;
width: 100px;
height: 100%;
opacity: 1;
transform: rotate(3deg) translate(0, -4px);
box-shadow: 0 0 10px var(--g-nprogress-color), 0 0 5px var(--g-nprogress-color);
}
.spinner {
display: block;
position: fixed;
z-index: 1031;
top: 11px;
right: 14px;
.spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-radius: 50%;
animation: nprogress-spinner 400ms linear infinite;
border-top-color: var(--g-nprogress-color);
border-left-color: var(--g-nprogress-color);
}
pointer-events: none;
.bar {
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--g-nprogress-color);
}
.peg {
display: block;
position: absolute;
right: 0;
width: 100px;
height: 100%;
opacity: 1;
transform: rotate(3deg) translate(0, -4px);
box-shadow: 0 0 10px var(--g-nprogress-color), 0 0 5px var(--g-nprogress-color);
}
.spinner {
display: block;
position: fixed;
z-index: 1031;
top: 11px;
right: 14px;
.spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-radius: 50%;
animation: nprogress-spinner 400ms linear infinite;
border-top-color: var(--g-nprogress-color);
border-left-color: var(--g-nprogress-color);
}
}
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
#nprogress .spinner,
#nprogress .bar {
position: absolute;
}
overflow: hidden;
position: relative;
#nprogress .spinner,
#nprogress .bar {
position: absolute;
}
}
@keyframes nprogress-spinner {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes nprogress-spinner {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
+40 -35
View File
@@ -1,50 +1,55 @@
// 文字超出隐藏,默认为单行超出隐藏,可设置多行
@mixin text-overflow($line: 1, $fixed-width: true) {
@if $line == 1 and $fixed-width == true {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@else {
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
overflow: hidden;
}
@if ($line == 1 and $fixed-width == true) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@else {
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
overflow: hidden;
}
}
// 定位居中,默认水平居中,可选择垂直居中,或者水平垂直都居中
@mixin position-center($type: x) {
position: absolute;
@if $type == x {
left: 50%;
transform: translateX(-50%);
}
@if $type == y {
top: 50%;
transform: translateY(-50%);
}
@if $type == xy {
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
}
position: absolute;
@if ($type == x) {
left: 50%;
transform: translateX(-50%);
}
@if ($type == y) {
top: 50%;
transform: translateY(-50%);
}
@if ($type == xy) {
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
// 文字两端对齐
%justify-align {
text-align: justify;
text-align-last: justify;
text-align: justify;
text-align-last: justify;
}
// 清除浮动
%clearfix {
zoom: 1;
&::before,
&::after {
content: "";
display: block;
clear: both;
}
zoom: 1;
&::before,
&::after {
content: "";
display: block;
clear: both;
}
}
+78 -78
View File
@@ -1,5 +1,67 @@
:root {
--g-box-shadow-color: rgb(0 0 0 / 12%);
--g-box-shadow-color: rgb(0 0 0 / 12%);
/* ----- 应用 ----- */
// 应用背景色
--g-app-bg: var(--el-bg-color);
// 主区域背景色
--g-main-bg: var(--el-bg-color-page);
/* ----- 顶部 ----- */
// 背景色
--g-header-bg: #222b45;
// 文字颜色
--g-header-color: #fff;
// 导航文字颜色
--g-header-menu-color: #fff;
// 导航鼠标悬浮文字颜色
--g-header-menu-hover-color: #fff;
// 导航鼠标悬浮背景色
--g-header-menu-hover-bg: #334067;
// 导航选中文字颜色
--g-header-menu-active-color: #fff;
// 导航选中背景色
--g-header-menu-active-bg: #334067;
/* ----- 主导航 ----- */
// 背景色
--g-main-sidebar-bg: #222b45;
// 菜单文字颜色
--g-main-sidebar-menu-color: #eee;
// 菜单鼠标悬浮文字颜色
--g-main-sidebar-menu-hover-color: #fff;
// 菜单鼠标悬浮背景色
--g-main-sidebar-menu-hover-bg: #334067;
// 菜单选中文字颜色
--g-main-sidebar-menu-active-color: #fff;
// 菜单选中背景色
--g-main-sidebar-menu-active-bg: #334067;
/* ----- 次导航 ----- */
// 背景色
--g-sub-sidebar-bg: #fff;
// 菜单背景色
--g-sub-sidebar-menu-bg: #e8e8e8;
// 菜单文字颜色
--g-sub-sidebar-menu-color: var(--el-text-color-primary);
// 菜单鼠标悬浮文字颜色
--g-sub-sidebar-menu-hover-color: var(--el-text-color-primary);
// 菜单鼠标悬浮背景色
--g-sub-sidebar-menu-hover-bg: #e1e1e1;
// 菜单选中文字颜色
--g-sub-sidebar-menu-active-color: #fff;
// 菜单选中背景色
--g-sub-sidebar-menu-active-bg: var(--el-color-primary);
/* ----- 顶栏 ----- */
--g-toolbar-bg: var(--el-bg-color);
/* ----- 进度条 ----- */
--g-nprogress-color: var(--el-color-primary);
/* ----- 暗黑模式 ----- */
&.dark {
--g-box-shadow-color: rgb(0 0 0 / 72%);
/* ----- 应用 ----- */
// 应用背景色
@@ -9,116 +71,54 @@
/* ----- 顶部 ----- */
// 背景色
--g-header-bg: #222b45;
--g-header-bg: var(--el-bg-color);
// 文字颜色
--g-header-color: #fff;
// 导航文字颜色
--g-header-menu-color: #fff;
--g-header-menu-color: #ccc;
// 导航鼠标悬浮文字颜色
--g-header-menu-hover-color: #fff;
// 导航鼠标悬浮背景色
--g-header-menu-hover-bg: #334067;
--g-header-menu-hover-bg: var(--el-color-primary-light-5);
// 导航选中文字颜色
--g-header-menu-active-color: #fff;
// 导航选中背景色
--g-header-menu-active-bg: #334067;
--g-header-menu-active-bg: var(--el-color-primary-light-3);
/* ----- 主导航 ----- */
// 背景色
--g-main-sidebar-bg: #222b45;
--g-main-sidebar-bg: var(--el-bg-color);
// 菜单文字颜色
--g-main-sidebar-menu-color: #eee;
--g-main-sidebar-menu-color: #ccc;
// 菜单鼠标悬浮文字颜色
--g-main-sidebar-menu-hover-color: #fff;
// 菜单鼠标悬浮背景色
--g-main-sidebar-menu-hover-bg: #334067;
--g-main-sidebar-menu-hover-bg: var(--el-color-primary-light-5);
// 菜单选中文字颜色
--g-main-sidebar-menu-active-color: #fff;
// 菜单选中背景色
--g-main-sidebar-menu-active-bg: #334067;
--g-main-sidebar-menu-active-bg: var(--el-color-primary-light-3);
/* ----- 次导航 ----- */
// 背景色
--g-sub-sidebar-bg: #fff;
--g-sub-sidebar-bg: var(--el-bg-color-page);
// 菜单背景色
--g-sub-sidebar-menu-bg: #e8e8e8;
--g-sub-sidebar-menu-bg: var(--el-bg-color);
// 菜单文字颜色
--g-sub-sidebar-menu-color: var(--el-text-color-primary);
--g-sub-sidebar-menu-color: var(--el-text-color-secondary);
// 菜单鼠标悬浮文字颜色
--g-sub-sidebar-menu-hover-color: var(--el-text-color-primary);
// 菜单鼠标悬浮背景色
--g-sub-sidebar-menu-hover-bg: #e1e1e1;
--g-sub-sidebar-menu-hover-bg: var(--el-color-primary-light-5);
// 菜单选中文字颜色
--g-sub-sidebar-menu-active-color: #fff;
--g-sub-sidebar-menu-active-color: var(--el-text-color-primary);
// 菜单选中背景色
--g-sub-sidebar-menu-active-bg: var(--el-color-primary);
--g-sub-sidebar-menu-active-bg: var(--el-color-primary-light-3);
/* ----- 顶栏 ----- */
--g-toolbar-bg: var(--el-bg-color);
/* ----- 进度条 ----- */
--g-nprogress-color: var(--el-color-primary);
/* ----- 暗黑模式 ----- */
&.dark {
--g-box-shadow-color: rgb(0 0 0 / 72%);
/* ----- 应用 ----- */
// 应用背景色
--g-app-bg: var(--el-bg-color);
// 主区域背景色
--g-main-bg: var(--el-bg-color-page);
/* ----- 顶部 ----- */
// 背景色
--g-header-bg: var(--el-bg-color);
// 文字颜色
--g-header-color: #fff;
// 导航文字颜色
--g-header-menu-color: #ccc;
// 导航鼠标悬浮文字颜色
--g-header-menu-hover-color: #fff;
// 导航鼠标悬浮背景色
--g-header-menu-hover-bg: var(--el-color-primary-light-5);
// 导航选中文字颜色
--g-header-menu-active-color: #fff;
// 导航选中背景色
--g-header-menu-active-bg: var(--el-color-primary-light-3);
/* ----- 主导航 ----- */
// 背景色
--g-main-sidebar-bg: var(--el-bg-color);
// 菜单文字颜色
--g-main-sidebar-menu-color: #ccc;
// 菜单鼠标悬浮文字颜色
--g-main-sidebar-menu-hover-color: #fff;
// 菜单鼠标悬浮背景色
--g-main-sidebar-menu-hover-bg: var(--el-color-primary-light-5);
// 菜单选中文字颜色
--g-main-sidebar-menu-active-color: #fff;
// 菜单选中背景色
--g-main-sidebar-menu-active-bg: var(--el-color-primary-light-3);
/* ----- 次导航 ----- */
// 背景色
--g-sub-sidebar-bg: var(--el-bg-color-page);
// 菜单背景色
--g-sub-sidebar-menu-bg: var(--el-bg-color);
// 菜单文字颜色
--g-sub-sidebar-menu-color: var(--el-text-color-secondary);
// 菜单鼠标悬浮文字颜色
--g-sub-sidebar-menu-hover-color: var(--el-text-color-primary);
// 菜单鼠标悬浮背景色
--g-sub-sidebar-menu-hover-bg: var(--el-color-primary-light-5);
// 菜单选中文字颜色
--g-sub-sidebar-menu-active-color: var(--el-text-color-primary);
// 菜单选中背景色
--g-sub-sidebar-menu-active-bg: var(--el-color-primary-light-3);
/* ----- 顶栏 ----- */
--g-toolbar-bg: var(--el-bg-color);
/* ----- 进度条 ----- */
--g-nprogress-color: var(--el-color-primary);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// Generated by 'unplugin-auto-import'
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useAuth: typeof import('./utils/composables/useAuth')['default']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentInstance: typeof import('./utils/composables/useCurrentInstance')['default']
const useMainPage: typeof import('./utils/composables/useMainPage')['default']
const useMenu: typeof import('./utils/composables/useMenu')['default']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
+30
View File
@@ -0,0 +1,30 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
Auth: typeof import('./components/Auth/index.vue')['default']
AuthAll: typeof import('./components/AuthAll/index.vue')['default']
BatchActionBar: typeof import('./components/BatchActionBar/index.vue')['default']
Copyright: typeof import('./components/Copyright/index.vue')['default']
Editor: typeof import('./components/Editor/index.vue')['default']
FileUpload: typeof import('./components/FileUpload/index.vue')['default']
FixedActionBar: typeof import('./components/FixedActionBar/index.vue')['default']
ImagePreview: typeof import('./components/ImagePreview/index.vue')['default']
ImagesUpload: typeof import('./components/ImagesUpload/index.vue')['default']
ImageUpload: typeof import('./components/ImageUpload/index.vue')['default']
PageHeader: typeof import('./components/PageHeader/index.vue')['default']
PageMain: typeof import('./components/PageMain/index.vue')['default']
PcasCascader: typeof import('./components/PcasCascader/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./components/SearchBar/index.vue')['default']
SvgIcon: typeof import('./components/SvgIcon/index.vue')['default']
SystemInfo: typeof import('./components/SystemInfo/index.vue')['default']
Trend: typeof import('./components/Trend/index.vue')['default']
}
}
+9 -14
View File
@@ -1,21 +1,16 @@
<script setup name="Auth">
import { useAuth } from '@/utils/composables'
const props = defineProps({
value: {
type: [String, Array],
default: ''
}
})
<script lang="ts" setup name="Auth">
const props = defineProps<{
value: string | string[]
}>()
function check() {
return useAuth().auth(props.value)
return useAuth().auth(props.value)
}
</script>
<template>
<div>
<slot v-if="check()" />
<slot v-else name="no-auth" />
</div>
<div>
<slot v-if="check()" />
<slot v-else name="no-auth" />
</div>
</template>
+9 -14
View File
@@ -1,21 +1,16 @@
<script setup name="AuthAll">
import { useAuth } from '@/utils/composables'
const props = defineProps({
value: {
type: Array,
default: () => []
}
})
<script lang="ts" setup name="AuthAll">
const props = defineProps<{
value: string[]
}>()
function check() {
return useAuth().authAll(props.value)
return useAuth().authAll(props.value)
}
</script>
<template>
<div>
<slot v-if="check()" />
<slot v-else name="no-auth" />
</div>
<div>
<slot v-if="check()" />
<slot v-else name="no-auth" />
</div>
</template>
+61 -52
View File
@@ -1,73 +1,82 @@
<script setup name="BatchActionBar">
<script lang="ts" setup name="BatchActionBar">
const props = defineProps({
data: {
type: Array,
default: () => []
},
selectionData: {
type: Array,
default: () => []
}
data: {
type: Array,
default: () => [],
},
selectionData: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['check-all', 'check-null'])
const emit = defineEmits(['checkAll', 'checkNull'])
const checkAll = computed({
get: function() {
let flag = false
if (props.data.length != 0 && props.data.length == props.selectionData.length) {
flag = true
}
return flag
},
set: function(newVal) {
newVal ? emit('check-all') : emit('check-null')
}
get() {
let flag = false
if (props.data.length !== 0 && props.data.length === props.selectionData.length)
flag = true
return flag
},
set(newVal) {
newVal ? emit('checkAll') : emit('checkNull')
},
})
const isIndeterminate = computed(() => {
let flag = false
if (props.selectionData.length > 0 && props.selectionData.length < props.data.length) {
flag = true
}
return flag
let flag = false
if (props.selectionData.length > 0 && props.selectionData.length < props.data.length)
flag = true
return flag
})
</script>
<template>
<div class="batch-action-bar">
<el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" :disabled="!data.length">当页全选</el-checkbox>
<div v-if="selectionData.length" class="tips">已选 {{ selectionData.length }} 项</div>
<el-form :disabled="!selectionData.length">
<slot />
</el-form>
<div class="batch-action-bar">
<el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" :disabled="!data.length">
当页全选
</el-checkbox>
<div v-if="selectionData.length" class="tips">
已选 {{ selectionData.length }} 项
</div>
<el-form :disabled="!selectionData.length">
<slot />
</el-form>
</div>
</template>
<style lang="scss" scoped>
.batch-action-bar {
display: flex;
align-items: center;
margin: 20px 0;
padding: 0 10px;
&:first-child {
margin-top: 0;
display: flex;
align-items: center;
margin: 20px 0;
padding: 0 10px;
&:first-child {
margin-top: 0;
}
.el-checkbox {
margin-right: 20px;
}
.tips {
margin-right: 20px;
color: #909399;
font-size: 13px;
}
:deep(.el-form) {
> .el-button {
margin-right: 10px;
}
.el-checkbox {
margin-right: 20px;
}
.tips {
margin-right: 20px;
color: #909399;
font-size: 13px;
}
:deep(.el-form) {
> .el-button {
margin-right: 10px;
}
> .el-button-group {
margin-right: 10px;
}
> .el-button-group {
margin-right: 10px;
}
}
}
</style>
+37 -31
View File
@@ -1,44 +1,50 @@
<script setup name="Copyright">
<script lang="ts" setup name="Copyright">
import useSettingsStore from '@/store/modules/settings'
const route = useRoute()
const settingsStore = useSettingsStore()
</script>
<template>
<footer class="copyright">
<span>Copyright</span>
<span class="icon">©</span>
<span v-if="settingsStore.copyright.dates">{{ settingsStore.copyright.dates }}</span>
<template v-if="settingsStore.copyright.company">
<a v-if="settingsStore.copyright.website" :href="settingsStore.copyright.website" target="_blank" rel="noopener">{{ settingsStore.copyright.company }}</a>
<span v-else>{{ settingsStore.copyright.company }}</span>
</template>
<a v-if="settingsStore.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ settingsStore.copyright.beian }}</a>
</footer>
<footer v-if="route.meta.copyright ?? settingsStore.copyright.enable" class="copyright">
<span>Copyright</span>
<span class="icon">©</span>
<span v-if="settingsStore.copyright.dates">{{ settingsStore.copyright.dates }}</span>
<template v-if="settingsStore.copyright.company">
<a v-if="settingsStore.copyright.website" :href="settingsStore.copyright.website" target="_blank" rel="noopener">{{ settingsStore.copyright.company }}</a>
<span v-else>{{ settingsStore.copyright.company }}</span>
</template>
<a v-if="settingsStore.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ settingsStore.copyright.beian }}</a>
</footer>
</template>
<style lang="scss" scoped>
footer {
display: flex;
align-items: center;
justify-content: center;
margin: 40px 0 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 40px 0 20px;
color: var(--el-text-color-secondary);
font-size: 14px;
span,
a {
padding: 0 5px;
}
a {
text-decoration: none;
color: var(--el-text-color-secondary);
font-size: 14px;
span,
a {
padding: 0 5px;
}
a {
text-decoration: none;
color: var(--el-text-color-secondary);
transition: var(--el-transition-color);
&:hover {
color: var(--el-text-color-primary);
}
}
.icon {
padding: 0;
font-size: 18px;
transition: var(--el-transition-color);
&:hover {
color: var(--el-text-color-primary);
}
}
.icon {
padding: 0;
font-size: 18px;
}
}
</style>
+48 -48
View File
@@ -1,4 +1,4 @@
<script setup name="Editor">
<script lang="ts" setup name="Editor">
import tinymce from 'tinymce/tinymce'
import TinymceEditor from '@tinymce/tinymce-vue'
import 'tinymce/themes/silver/theme'
@@ -20,78 +20,78 @@ import 'tinymce/plugins/searchreplace'
import useSettingsStore from '@/store/modules/settings'
const settingsStore = useSettingsStore()
const props = defineProps({
modelValue: {
type: String,
default: ''
},
setting: {
type: Object,
default: () => {}
},
disabled: {
type: Boolean,
default: false
}
modelValue: {
type: String,
default: '',
},
setting: {
type: Object,
default: () => {},
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const settingsStore = useSettingsStore()
const defaultSetting = ref({
language_url: 'tinymce/langs/zh-Hans.js',
language: 'zh-Hans',
skin_url: settingsStore.app.colorScheme === 'light' ? 'tinymce/skins/ui/oxide' : 'tinymce/skins/ui/oxide-dark',
content_css: settingsStore.app.colorScheme === 'light' ? 'tinymce/skins/content/default/content.min.css' : 'tinymce/skins/content/dark/content.min.css',
min_height: 250,
max_height: 600,
selector: 'textarea',
plugins: 'autolink autoresize fullscreen image insertdatetime link lists media preview table wordcount code searchreplace',
toolbar: 'undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | forecolor backcolor removeformat | link image media table insertdatetime searchreplace | preview code fullscreen',
branding: false,
menubar: false,
toolbar_mode: 'sliding',
insertdatetime_formats: [
'%Y年%m月%d日',
'%H点%M分%S秒',
'%Y-%m-%d',
'%H:%M:%S'
],
// https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#images_upload_handler
images_upload_handler: blobInfo => new Promise(resolve => {
const img = `data:image/jpeg;base64,${blobInfo.base64()}`
resolve(img)
})
language_url: 'tinymce/langs/zh-Hans.js',
language: 'zh-Hans',
skin_url: settingsStore.app.colorScheme === 'light' ? 'tinymce/skins/ui/oxide' : 'tinymce/skins/ui/oxide-dark',
content_css: settingsStore.app.colorScheme === 'light' ? 'tinymce/skins/content/default/content.min.css' : 'tinymce/skins/content/dark/content.min.css',
min_height: 250,
max_height: 600,
selector: 'textarea',
plugins: 'autolink autoresize fullscreen image insertdatetime link lists media preview table wordcount code searchreplace',
toolbar: 'undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | forecolor backcolor removeformat | link image media table insertdatetime searchreplace | preview code fullscreen',
branding: false,
menubar: false,
toolbar_mode: 'sliding',
insertdatetime_formats: [
'%Y年%m月%d日',
'%H点%M分%S秒',
'%Y-%m-%d',
'%H:%M:%S',
],
// https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#images_upload_handler
images_upload_handler: (blobInfo: any, progress: any) => new Promise((resolve, reject) => {
const img = `data:image/jpeg;base64,${blobInfo.base64()}`
resolve(img)
}),
})
const myValue = ref(props.modelValue)
const completeSetting = computed(() => {
return Object.assign(defaultSetting.value, props.setting)
return Object.assign(defaultSetting.value, props.setting)
})
watch(() => myValue.value, newValue => {
emit('update:modelValue', newValue)
watch(() => myValue.value, (newValue) => {
emit('update:modelValue', newValue)
})
watch(() => props.modelValue, newValue => {
myValue.value = newValue
watch(() => props.modelValue, (newValue) => {
myValue.value = newValue
})
onMounted(() => {
tinymce.init({})
tinymce.init({})
})
</script>
<template>
<div class="editor">
<TinymceEditor v-model="myValue" :init="completeSetting" :disabled="disabled" />
</div>
<div class="editor">
<TinymceEditor v-model="myValue" :init="completeSetting" :disabled="disabled" />
</div>
</template>
<style lang="scss" scoped>
:deep(.tox-tinymce) {
border-radius: 4px;
border-radius: 4px;
}
</style>
+100 -90
View File
@@ -1,107 +1,117 @@
<script setup name="FileUpload">
<script lang="ts" setup name="FileUpload">
import { UploadUserFile } from 'element-plus'
import type { UploadProps } from 'element-plus'
const props = defineProps({
action: {
type: String,
required: true
},
headers: {
type: Object,
default: () => {}
},
data: {
type: Object,
default: () => {}
},
name: {
type: String,
default: 'file'
},
size: {
type: Number,
default: 2
},
max: {
type: Number,
default: 3
},
files: {
type: Array,
default: () => []
},
notip: {
type: Boolean,
default: false
},
ext: {
type: Array,
default: () => ['zip', 'rar']
}
action: {
type: String,
required: true,
},
headers: {
type: Object,
default: () => {},
},
data: {
type: Object,
default: () => {},
},
name: {
type: String,
default: 'file',
},
size: {
type: Number,
default: 2,
},
max: {
type: Number,
default: 3,
},
files: {
type: Array,
default: () => [],
},
notip: {
type: Boolean,
default: false,
},
ext: {
type: Array,
default: () => ['zip', 'rar'],
},
})
const emit = defineEmits(['on-success'])
const emit = defineEmits(['onSuccess'])
function beforeUpload(file) {
const fileName = file.name.split('.')
const fileExt = fileName[fileName.length - 1]
const isTypeOk = props.ext.indexOf(fileExt) >= 0
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
ElMessage.error(`上传文件只支持 ${ props.ext.join(' / ') } 格式!`)
}
if (!isSizeOk) {
ElMessage.error(`上传文件大小不能超过 ${props.size}MB!`)
}
return isTypeOk && isSizeOk
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1)
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk)
ElMessage.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`)
if (!isSizeOk)
ElMessage.error(`上传文件大小不能超过 ${props.size}MB!`)
return isTypeOk && isSizeOk
}
function onExceed() {
ElMessage.warning('文件上传超过限制')
const onExceed: UploadProps['onExceed'] = () => {
ElMessage.warning('文件上传超过限制')
}
function onSuccess(res, file, fileList) {
emit('on-success', res, file, fileList)
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
emit('onSuccess', res, file, fileList)
}
</script>
<template>
<el-upload
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-exceed="onExceed"
:on-success="onSuccess"
:file-list="files"
:limit="max"
drag
>
<div class="slot">
<el-icon class="el-icon--upload">
<svg-icon name="ep:upload-filled" />
</el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
<el-upload
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-exceed="onExceed"
:on-success="onSuccess"
:file-list="files as UploadUserFile[]"
:limit="max"
drag
>
<div class="slot">
<el-icon class="el-icon--upload">
<svg-icon name="ep:upload-filled" />
</el-icon>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
</div>
<template #tip>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<el-alert :title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`" type="info" show-icon :closable="false" />
</div>
<template #tip>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<el-alert :title="`上传文件支持 ${ ext.join(' / ') } 格式,单个文件大小不超过 ${ size }MB,且文件数量不超过 ${ max } 个`" type="info" show-icon :closable="false" />
</div>
</div>
</template>
</el-upload>
</div>
</template>
</el-upload>
</template>
<style lang="scss" scoped>
:deep(.el-upload.is-drag) {
display: inline-block;
.el-upload-dragger {
padding: 0;
}
&.is-dragover {
border-width: 1px;
}
.slot {
width: 300px;
padding: 40px 0;
}
display: inline-block;
.el-upload-dragger {
padding: 0;
}
&.is-dragover {
border-width: 1px;
}
.slot {
width: 300px;
padding: 40px 0;
}
}
</style>
+29 -33
View File
@@ -1,53 +1,49 @@
<script setup name="FixedActionBar">
const data = ref({
isBottom: true
})
<script lang="ts" setup name="FixedActionBar">
const isBottom = ref(false)
onMounted(() => {
onScroll()
window.addEventListener('scroll', onScroll)
onScroll()
window.addEventListener('scroll', onScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('scroll', onScroll)
})
function onScroll() {
// 变量scrollTop是滚动条滚动时,滚动条上端距离顶部的距离
var scrollTop = document.documentElement.scrollTop || document.body.scrollTop
// 变量windowHeight是可视区的高度
var windowHeight = document.documentElement.clientHeight || document.body.clientHeight
// 变量scrollHeight是滚动条的总高度(当前可滚动的页面的总高度)
var scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight
// 滚动条到底部
if (Math.ceil(scrollTop + windowHeight) >= scrollHeight) {
data.value.isBottom = true
} else {
data.value.isBottom = false
}
// 变量scrollTop是滚动条滚动时,滚动条上端距离顶部的距离
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
// 变量windowHeight是可视区的高度
const windowHeight = document.documentElement.clientHeight || document.body.clientHeight
// 变量scrollHeight是滚动条的总高度(当前可滚动的页面的总高度)
const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight
// 滚动条到底部
isBottom.value = Math.ceil(scrollTop + windowHeight) >= scrollHeight
}
</script>
<template>
<div class="actionbar" :class="{'shadow': !data.isBottom}" data-fixed-calc-width>
<slot />
</div>
<div class="actionbar" :class="{ shadow: !isBottom }" data-fixed-calc-width>
<slot />
</div>
</template>
<style lang="scss" scoped>
.actionbar {
z-index: 4;
bottom: 0;
padding: 20px;
text-align: center;
background-color: var(--g-app-bg);
box-shadow: 0 0 1px 0 var(--g-box-shadow-color);
transition: all 0.3s, var(--el-transition-box-shadow);
&.shadow {
box-shadow: 0 -10px 10px -10px var(--g-box-shadow-color);
}
z-index: 4;
bottom: 0;
padding: 20px;
text-align: center;
background-color: var(--g-app-bg);
box-shadow: 0 0 1px 0 var(--g-box-shadow-color);
transition: all 0.3s, var(--el-transition-box-shadow);
&.shadow {
box-shadow: 0 -10px 10px -10px var(--g-box-shadow-color);
}
}
:deep(.el-form-item) {
margin-bottom: 0;
margin-bottom: 0;
}
</style>
+46 -43
View File
@@ -1,61 +1,64 @@
<script setup name="ImagePreview">
<script lang="ts" setup name="ImagePreview">
const props = defineProps({
src: {
type: String,
required: true
},
width: {
type: [Number, String],
default: ''
},
height: {
type: [Number, String],
default: ''
}
src: {
type: String,
required: true,
},
width: {
type: [Number, String],
default: '',
},
height: {
type: [Number, String],
default: '',
},
})
const realWidth = computed(() => {
return typeof props.width == 'string' ? props.width : `${props.width}px`
return typeof props.width === 'string' ? props.width : `${props.width}px`
})
const realHeight = computed(() => {
return typeof props.height == 'string' ? props.height : `${props.height}px`
return typeof props.height === 'string' ? props.height : `${props.height}px`
})
</script>
<template>
<el-image :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]">
<template #error>
<div class="image-slot">
<el-icon>
<svg-icon name="image-load-fail" />
</el-icon>
</div>
</template>
</el-image>
<el-image :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]">
<template #error>
<div class="image-slot">
<el-icon>
<svg-icon name="image-load-fail" />
</el-icon>
</div>
</template>
</el-image>
</template>
<style lang="scss" scoped>
.el-image {
border-radius: 5px;
background-color: var(--el-fill-color);
box-shadow: var(--el-box-shadow-light);
transition: background-color 0.3s, var(--el-transition-box-shadow);
:deep(.el-image__inner) {
transition: all 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: #909399;
font-size: 30px;
border-radius: 5px;
background-color: var(--el-fill-color);
box-shadow: var(--el-box-shadow-light);
transition: background-color 0.3s, var(--el-transition-box-shadow);
:deep(.el-image__inner) {
transition: all 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: #909399;
font-size: 30px;
}
}
</style>
+223 -202
View File
@@ -1,242 +1,263 @@
<script setup name="ImageUpload">
<script lang="ts" setup name="ImageUpload">
import type { UploadProps } from 'element-plus'
const props = defineProps({
action: {
type: String,
required: true
},
headers: {
type: Object,
default: () => {}
},
data: {
type: Object,
default: () => {}
},
name: {
type: String,
default: 'file'
},
url: {
type: String,
default: ''
},
size: {
type: Number,
default: 2
},
width: {
type: Number,
default: 150
},
height: {
type: Number,
default: 150
},
placeholder: {
type: String,
default: ''
},
notip: {
type: Boolean,
default: false
},
ext: {
type: Array,
default: () => ['jpg', 'png', 'gif', 'bmp']
}
action: {
type: String,
required: true,
},
headers: {
type: Object,
default: () => {},
},
data: {
type: Object,
default: () => {},
},
name: {
type: String,
default: 'file',
},
url: {
type: String,
default: '',
},
size: {
type: Number,
default: 2,
},
width: {
type: Number,
default: 150,
},
height: {
type: Number,
default: 150,
},
placeholder: {
type: String,
default: '',
},
notip: {
type: Boolean,
default: false,
},
ext: {
type: Array,
default: () => ['jpg', 'png', 'gif', 'bmp'],
},
})
const emit = defineEmits(['update:url', 'on-success'])
const emit = defineEmits(['update:url', 'onSuccess'])
const uploadData = ref({
imageViewerVisible: false,
progress: {
preview: '',
percent: 0
}
imageViewerVisible: false,
progress: {
preview: '',
percent: 0,
},
})
// 预览
function preview() {
uploadData.value.imageViewerVisible = true
uploadData.value.imageViewerVisible = true
}
// 关闭预览
function previewClose() {
uploadData.value.imageViewerVisible = false
uploadData.value.imageViewerVisible = false
}
// 移除
function remove() {
emit('update:url', '')
emit('update:url', '')
}
function beforeUpload(file) {
const fileName = file.name.split('.')
const fileExt = fileName[fileName.length - 1]
const isTypeOk = props.ext.indexOf(fileExt) >= 0
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
ElMessage.error(`上传图片只支持 ${ props.ext.join(' / ') } 格式!`)
}
if (!isSizeOk) {
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
}
return isTypeOk && isSizeOk
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1)
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk)
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
if (!isSizeOk)
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
if (isTypeOk && isSizeOk)
uploadData.value.progress.preview = URL.createObjectURL(file)
return isTypeOk && isSizeOk
}
function onProgress(file) {
uploadData.value.progress.percent = ~~file.percent
const onProgress: UploadProps['onProgress'] = (file) => {
uploadData.value.progress.percent = ~~file.percent
}
function onSuccess(res) {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emit('on-success', res)
const onSuccess: UploadProps['onSuccess'] = (res) => {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emit('onSuccess', res)
}
</script>
<template>
<div class="upload-container">
<el-upload
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
drag
class="image-upload"
>
<el-image v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
<template #error>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<el-icon>
<svg-icon name="ep:plus" />
</el-icon>
</div>
</template>
</el-image>
<div v-else class="image">
<el-image :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<div class="mask">
<div class="actions">
<span title="预览" @click.stop="preview">
<el-icon>
<svg-icon name="ep:zoom-in" />
</el-icon>
</span>
<span title="移除" @click.stop="remove">
<el-icon>
<svg-icon name="ep:delete" />
</el-icon>
</span>
</div>
</div>
</div>
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</el-upload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<el-alert :title="`上传图片支持 ${ ext.join(' / ') } 格式,且图片大小不超过 ${ size }MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
</div>
<div class="upload-container">
<el-upload
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
drag
class="image-upload"
>
<el-image v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
<template #error>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<el-icon>
<svg-icon name="ep:plus" />
</el-icon>
</div>
</template>
</el-image>
<div v-else class="image">
<el-image :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<div class="mask">
<div class="actions">
<span title="预览" @click.stop="preview">
<el-icon>
<svg-icon name="ep:zoom-in" />
</el-icon>
</span>
<span title="移除" @click.stop="remove">
<el-icon>
<svg-icon name="ep:delete" />
</el-icon>
</span>
</div>
</div>
<el-image-viewer v-if="uploadData.imageViewerVisible" :url-list="[url]" @close="previewClose" />
</div>
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</el-upload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<el-alert :title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
</div>
</div>
<el-image-viewer v-if="uploadData.imageViewerVisible" :url-list="[url]" @close="previewClose" />
</div>
</template>
<style lang="scss" scoped>
.upload-container {
line-height: initial;
line-height: initial;
}
.el-image {
display: block;
display: block;
}
.image {
position: relative;
border-radius: 6px;
overflow: hidden;
.mask {
opacity: 0;
position: relative;
border-radius: 6px;
overflow: hidden;
.mask {
opacity: 0;
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
transition: opacity 0.3s;
.actions {
width: 100px;
height: 100px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
@include position-center(xy);
span {
width: 50%;
text-align: center;
cursor: pointer;
color: var(--el-color-white);
transition: color 0.1s, transform 0.1s;
&:hover {
transform: scale(1.5);
}
.el-icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.image-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
i {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
content: "";
position: absolute;
top: 0;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-color: var(--el-overlay-color-lighter);
transition: opacity 0.3s;
.actions {
width: 100px;
height: 100px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
@include position-center(xy);
span {
width: 50%;
text-align: center;
cursor: pointer;
color: var(--el-color-white);
transition: color 0.1s, transform 0.1s;
&:hover {
transform: scale(1.5);
}
.el-icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.image-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
i {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
content: "";
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-color: var(--el-overlay-color-lighter);
}
.el-progress {
z-index: 1;
@include position-center(xy);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
.el-progress {
z-index: 1;
@include position-center(xy);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
}
}
</style>
+257 -234
View File
@@ -1,275 +1,298 @@
<script setup name="ImagesUpload">
<script lang="ts" setup name="ImagesUpload">
import type { UploadProps } from 'element-plus'
const props = defineProps({
action: {
type: String,
required: true
},
headers: {
type: Object,
default: () => {}
},
data: {
type: Object,
default: () => {}
},
name: {
type: String,
default: 'file'
},
url: {
type: Array,
default: () => []
},
max: {
type: Number,
default: 3
},
size: {
type: Number,
default: 2
},
width: {
type: Number,
default: 150
},
height: {
type: Number,
default: 150
},
placeholder: {
type: String,
default: ''
},
notip: {
type: Boolean,
default: false
},
ext: {
type: Array,
default: () => ['jpg', 'png', 'gif', 'bmp']
}
action: {
type: String,
required: true,
},
headers: {
type: Object,
default: () => {},
},
data: {
type: Object,
default: () => {},
},
name: {
type: String,
default: 'file',
},
url: {
type: Array,
default: () => [],
},
max: {
type: Number,
default: 3,
},
size: {
type: Number,
default: 2,
},
width: {
type: Number,
default: 150,
},
height: {
type: Number,
default: 150,
},
placeholder: {
type: String,
default: '',
},
notip: {
type: Boolean,
default: false,
},
ext: {
type: Array,
default: () => ['jpg', 'png', 'gif', 'bmp'],
},
})
const emit = defineEmits(['update:url', 'on-success'])
const emit = defineEmits(['update:url', 'onSuccess'])
const uploadData = ref({
dialogImageIndex: 0,
imageViewerVisible: false,
progress: {
preview: '',
percent: 0
}
dialogImageIndex: 0,
imageViewerVisible: false,
progress: {
preview: '',
percent: 0,
},
})
// 预览
function preview(index) {
uploadData.value.dialogImageIndex = index
uploadData.value.imageViewerVisible = true
function preview(index: number) {
uploadData.value.dialogImageIndex = index
uploadData.value.imageViewerVisible = true
}
// 关闭预览
function previewClose() {
uploadData.value.imageViewerVisible = false
uploadData.value.imageViewerVisible = false
}
// 移除
function remove(index) {
let url = props.url
url.splice(index, 1)
emit('update:url', url)
function remove(index: number) {
const url = props.url
url.splice(index, 1)
emit('update:url', url)
}
// 移动
function move(index, type) {
let url = props.url
if (type == 'left' && index != 0) {
url[index] = url.splice(index - 1, 1, url[index])[0]
}
if (type == 'right' && index != url.length - 1) {
url[index] = url.splice(index + 1, 1, url[index])[0]
}
emit('update:url', url)
function move(index: number, type: 'left' | 'right') {
const url = props.url
if (type === 'left' && index !== 0)
url[index] = url.splice(index - 1, 1, url[index])[0]
if (type === 'right' && index !== url.length - 1)
url[index] = url.splice(index + 1, 1, url[index])[0]
emit('update:url', url)
}
function beforeUpload(file) {
const fileName = file.name.split('.')
const fileExt = fileName[fileName.length - 1]
const isTypeOk = props.ext.indexOf(fileExt) >= 0
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
ElMessage.error(`上传图片只支持 ${ props.ext.join(' / ') } 格式!`)
}
if (!isSizeOk) {
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
}
return isTypeOk && isSizeOk
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1)
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk)
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
if (!isSizeOk)
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
if (isTypeOk && isSizeOk)
uploadData.value.progress.preview = URL.createObjectURL(file)
return isTypeOk && isSizeOk
}
function onProgress(file) {
uploadData.value.progress.percent = ~~file.percent
const onProgress: UploadProps['onProgress'] = (file) => {
uploadData.value.progress.percent = ~~file.percent
}
function onSuccess(res) {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emit('on-success', res)
const onSuccess: UploadProps['onSuccess'] = (res) => {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emit('onSuccess', res)
}
</script>
<template>
<div class="upload-container">
<div v-for="(item, index) in url" :key="index" class="images">
<el-image v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
<div class="mask">
<div class="actions">
<span title="预览" @click="preview(index)">
<el-icon>
<svg-icon name="ep:zoom-in" />
</el-icon>
</span>
<span title="移除" @click="remove(index)">
<el-icon>
<svg-icon name="ep:delete" />
</el-icon>
</span>
<span v-show="url.length > 1" title="左移" :class="{'disabled': index == 0}" @click="move(index, 'left')">
<el-icon>
<svg-icon name="ep:back" />
</el-icon>
</span>
<span v-show="url.length > 1" title="右移" :class="{'disabled': index == url.length - 1}" @click="move(index, 'right')">
<el-icon>
<svg-icon name="ep:right" />
</el-icon>
</span>
</div>
</div>
<div class="upload-container">
<div v-for="(item, index) in (url as string[])" :key="index" class="images">
<el-image v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
<div class="mask">
<div class="actions">
<span title="预览" @click="preview(index)">
<el-icon>
<svg-icon name="ep:zoom-in" />
</el-icon>
</span>
<span title="移除" @click="remove(index)">
<el-icon>
<svg-icon name="ep:delete" />
</el-icon>
</span>
<span v-show="url.length > 1" title="左移" :class="{ disabled: index === 0 }" @click="move(index, 'left')">
<el-icon>
<svg-icon name="ep:back" />
</el-icon>
</span>
<span v-show="url.length > 1" title="右移" :class="{ disabled: index === url.length - 1 }" @click="move(index, 'right')">
<el-icon>
<svg-icon name="ep:right" />
</el-icon>
</span>
</div>
<el-upload
v-show="url.length < max"
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
drag
class="images-upload"
>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<el-icon>
<svg-icon name="ep:plus" />
</el-icon>
</div>
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</el-upload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<el-alert :title="`上传图片支持 ${ ext.join(' / ') } 格式,单张图片大小不超过 ${ size }MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${ max } 张`" type="info" show-icon :closable="false" />
</div>
</div>
<el-image-viewer v-if="uploadData.imageViewerVisible" :url-list="url" :initial-index="uploadData.dialogImageIndex" @close="previewClose" />
</div>
</div>
<el-upload
v-show="url.length < max"
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
drag
class="images-upload"
>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<el-icon>
<svg-icon name="ep:plus" />
</el-icon>
</div>
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<el-image :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<el-progress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</el-upload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<el-alert :title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`" type="info" show-icon :closable="false" />
</div>
</div>
<el-image-viewer v-if="uploadData.imageViewerVisible" :url-list="url as string[]" :initial-index="uploadData.dialogImageIndex" @close="previewClose" />
</div>
</template>
<style lang="scss" scoped>
.upload-container {
line-height: initial;
line-height: initial;
}
.el-image {
display: block;
display: block;
}
.images {
position: relative;
position: relative;
display: inline-block;
margin-right: 10px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
overflow: hidden;
.mask {
opacity: 0;
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
transition: opacity 0.3s;
.actions {
width: 100px;
height: 100px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
@include position-center(xy);
span {
width: 50%;
text-align: center;
cursor: pointer;
color: var(--el-color-white);
transition: color 0.1s, transform 0.1s;
&.disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
&:hover:not(.disabled) {
transform: scale(1.5);
}
.el-icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.images-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
margin-right: 10px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
overflow: hidden;
.mask {
opacity: 0;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
i {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
content: "";
position: absolute;
top: 0;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-color: var(--el-overlay-color-lighter);
transition: opacity 0.3s;
.actions {
width: 100px;
height: 100px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
@include position-center(xy);
span {
width: 50%;
text-align: center;
cursor: pointer;
color: var(--el-color-white);
transition: color 0.1s, transform 0.1s;
&.disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
&:hover:not(.disabled) {
transform: scale(1.5);
}
.el-icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.images-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
i {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
content: "";
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-color: var(--el-overlay-color-lighter);
}
.el-progress {
z-index: 1;
@include position-center(xy);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
.el-progress {
z-index: 1;
@include position-center(xy);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
}
}
</style>
+54 -45
View File
@@ -1,59 +1,68 @@
<script setup name="PageHeader">
<script lang="ts" setup name="PageHeader">
defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
default: ''
}
title: {
type: String,
required: true,
},
content: {
type: String,
default: '',
},
})
</script>
<template>
<div class="header">
<div class="main">
<div class="title">{{ title }}</div>
<div class="content">
<slot name="content">{{ content }}</slot>
</div>
</div>
<div class="sub">
<slot />
</div>
<div class="header">
<div class="main">
<div class="title">
{{ title }}
</div>
<div class="content">
<slot name="content">
{{ content }}
</slot>
</div>
</div>
<div class="sub">
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 16px 20px;
background-color: var(--g-app-bg);
transition: background-color 0.3s;
.main {
flex: 1;
margin-right: 20px;
.title {
font-size: 22px;
color: var(--el-text-color-primary);
transition: var(--el-transition-color);
}
.content {
margin-top: 10px;
font-size: 14px;
color: var(--el-text-color-secondary);
transition: var(--el-transition-color);
&:empty {
display: none;
}
}
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 16px 20px;
background-color: var(--g-app-bg);
transition: background-color 0.3s;
.main {
flex: 1;
margin-right: 20px;
.title {
font-size: 22px;
color: var(--el-text-color-primary);
transition: var(--el-transition-color);
}
.sub {
flex: none;
.content {
margin-top: 10px;
font-size: 14px;
color: var(--el-text-color-secondary);
transition: var(--el-transition-color);
&:empty {
display: none;
}
}
}
.sub {
flex: none;
}
}
</style>
+70 -65
View File
@@ -1,84 +1,89 @@
<script setup name="PageMain">
<script lang="ts" setup name="PageMain">
const props = defineProps({
title: {
type: String,
default: ''
},
collaspe: {
type: Boolean,
default: false
},
height: {
type: String,
default: ''
}
title: {
type: String,
default: '',
},
collaspe: {
type: Boolean,
default: false,
},
height: {
type: String,
default: '',
},
})
const titleSlot = !!useSlots().title
const collaspeData = ref(props.collaspe)
function unCollaspe() {
collaspeData.value = false
collaspeData.value = false
}
</script>
<template>
<div
:class="{
'page-main': true,
'is-collaspe': collaspeData
}" :style="{
'height': collaspeData ? height : ''
}"
>
<div v-if="titleSlot || title" class="title-container">
<slot v-if="titleSlot" name="title" />
<template v-else>{{ title }}</template>
</div>
<slot />
<div v-if="collaspeData" class="collaspe" title="展开" @click="unCollaspe">
<el-icon>
<svg-icon name="ep:arrow-down" />
</el-icon>
</div>
<div
class="page-main" :class="{
'is-collaspe': collaspeData,
}" :style="{
height: collaspeData ? height : '',
}"
>
<div v-if="titleSlot || title" class="title-container">
<slot v-if="titleSlot" name="title" />
<template v-else>
{{ title }}
</template>
</div>
<slot />
<div v-if="collaspeData" class="collaspe" title="展开" @click="unCollaspe">
<el-icon>
<svg-icon name="ep:arrow-down" />
</el-icon>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-main {
position: relative;
margin: 20px;
padding: 20px;
background-color: var(--g-app-bg);
transition: background-color 0.3s;
&.is-collaspe {
overflow: hidden;
.collaspe {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 40px 0 10px;
text-align: center;
font-size: 24px;
color: var(--el-text-color-primary);
text-shadow: 0 0 1px var(--el-text-color-primary);
background: linear-gradient(to bottom, transparent, var(--g-app-bg));
transition: background 0.3s, var(--el-transition-color);
cursor: pointer;
&:hover {
color: var(--el-text-color-secondary);
}
}
}
.title-container {
width: calc(100% + 40px);
padding: 14px 20px;
margin-left: -20px;
margin-top: -20px;
margin-bottom: 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: var(--el-transition-border);
position: relative;
margin: 20px;
padding: 20px;
background-color: var(--g-app-bg);
transition: background-color 0.3s;
&.is-collaspe {
overflow: hidden;
.collaspe {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 40px 0 10px;
text-align: center;
font-size: 24px;
color: var(--el-text-color-primary);
text-shadow: 0 0 1px var(--el-text-color-primary);
background: linear-gradient(to bottom, transparent, var(--g-app-bg));
transition: background 0.3s, var(--el-transition-color);
cursor: pointer;
&:hover {
color: var(--el-text-color-secondary);
}
}
}
.title-container {
width: calc(100% + 40px);
padding: 14px 20px;
margin-left: -20px;
margin-top: -20px;
margin-bottom: 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: var(--el-transition-border);
}
}
</style>
+114 -108
View File
@@ -1,131 +1,137 @@
<script setup name="PcasCascader">
<script lang="ts" setup name="PcasCascader">
// 行政区划数据来源于 https://github.com/modood/Administrative-divisions-of-China
import pcasRaw from './pcas-code.json'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'pca'
},
format: {
type: String,
default: 'code'
}
modelValue: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
type: {
type: String as () => 'pc' | 'pca' | 'pcas',
default: 'pca',
},
format: {
type: String as () => 'code' | 'name' | 'both',
default: 'code',
},
})
const emit = defineEmits(['update:modelValue'])
interface pcasItem {
code: string
name: string
children?: pcasItem[]
}
const pcasData = computed(() => {
const data = []
// 省份
pcasRaw.forEach(p => {
const tempP = {
code: p.code,
name: p.name
}
const tempChildrenC = []
// 城市
p.children.forEach(c => {
const tempC = {
code: c.code,
name: c.name
}
if (['pca', 'pcas'].includes(props.type)) {
const tempChildrenA = []
// 区县
c.children.forEach(a => {
const tempA = {
code: a.code,
name: a.name
}
if (props.type === 'pcas') {
const tempChildrenS = []
// 街道
a.children.forEach(s => {
const tempS = {
code: s.code,
name: s.name
}
tempChildrenS.push(tempS)
})
tempA.children = tempChildrenS
}
tempChildrenA.push(tempA)
})
tempC.children = tempChildrenA
}
tempChildrenC.push(tempC)
const data: pcasItem[] = []
// 省份
pcasRaw.forEach((p) => {
const tempP: pcasItem = {
code: p.code,
name: p.name,
}
const tempChildrenC: pcasItem[] = []
// 城市
p.children.forEach((c) => {
const tempC: pcasItem = {
code: c.code,
name: c.name,
}
if (['pca', 'pcas'].includes(props.type)) {
const tempChildrenA: pcasItem[] = []
// 区县
c.children.forEach((a) => {
const tempA: pcasItem = {
code: a.code,
name: a.name,
}
if (props.type === 'pcas') {
const tempChildrenS: pcasItem[] = []
// 街道
a.children.forEach((s) => {
const tempS: pcasItem = {
code: s.code,
name: s.name,
}
tempChildrenS.push(tempS)
})
tempA.children = tempChildrenS
}
tempChildrenA.push(tempA)
})
tempP.children = tempChildrenC
data.push(tempP)
tempC.children = tempChildrenA
}
tempChildrenC.push(tempC)
})
return data
tempP.children = tempChildrenC
data.push(tempP)
})
return data
})
const myValue = computed({
// 将入参数据转成 code 码
get: () => {
return anyToCode(props.modelValue)
},
// 将 code 码转成出参数据
set: value => {
emit('update:modelValue', value ? codeToAny(value) : [])
}
// 将入参数据转成 code 码
get: () => {
return anyToCode(props.modelValue)
},
// 将 code 码转成出参数据
set: (value) => {
emit('update:modelValue', value ? codeToAny(value) : [])
},
})
function anyToCode(value, dictionarie = pcasData.value) {
const input = []
if (value.length > 0) {
const findItem = dictionarie.find(item => {
if (props.format === 'code') {
return item.code === value[0]
} else if (props.format === 'name') {
return item.name === value[0]
} else {
return item.name === value[0].name && item.code === value[0].code
}
})
input.push(findItem.code)
if (findItem.children) {
input.push(...anyToCode(value.slice(1 - value.length), findItem.children))
}
}
return input
function anyToCode(value: any[], dictionarie: any[] = pcasData.value) {
const input: string[] = []
if (value.length > 0) {
const findItem = dictionarie.find((item) => {
if (props.format === 'code')
return item.code === value[0]
else if (props.format === 'name')
return item.name === value[0]
else
return item.name === value[0].name && item.code === value[0].code
})
input.push(findItem.code)
if (findItem.children)
input.push(...anyToCode(value.slice(1 - value.length), findItem.children))
}
return input
}
function codeToAny(codes, dictionarie = pcasData.value) {
const output = []
const findItem = dictionarie.find(item => item.code === codes[0])
if (findItem) {
switch (props.format) {
case 'code':
output.push(findItem.code)
break
case 'name':
output.push(findItem.name)
break
case 'both':
output.push({
code: findItem.code,
name: findItem.name
})
}
const newCodes = codes.slice(1 - codes.length)
if (newCodes.length > 0 && findItem.children) {
output.push(...codeToAny(newCodes, findItem.children))
}
function codeToAny(codes: string[], dictionarie: any[] = pcasData.value): any {
const output = []
const findItem = dictionarie.find(item => item.code === codes[0])
if (findItem) {
switch (props.format) {
case 'code':
output.push(findItem.code)
break
case 'name':
output.push(findItem.name)
break
case 'both':
output.push({
code: findItem.code,
name: findItem.name,
})
}
return output
const newCodes = codes.slice(1 - codes.length)
if (newCodes.length > 0 && findItem.children)
output.push(...codeToAny(newCodes, findItem.children))
}
return output
}
</script>
<template>
<el-cascader v-model="myValue" :options="pcasData" :props="{ value: 'code', label: 'name' }" :disabled="disabled" clearable filterable />
<el-cascader v-model="myValue" :options="pcasData as any[]" :props="{ value: 'code', label: 'name' }" :disabled="disabled" clearable filterable />
</template>
+54 -48
View File
@@ -1,13 +1,13 @@
<script setup name="SearchBar">
<script lang="ts" setup name="SearchBar">
const props = defineProps({
showMore: {
type: Boolean,
default: false
},
unfold: {
type: Boolean,
default: false
}
showMore: {
type: Boolean,
default: false,
},
unfold: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['toggle'])
@@ -15,57 +15,63 @@ const emit = defineEmits(['toggle'])
const isUnfold = ref(!props.unfold)
watch(() => props.unfold, () => toggle(), {
immediate: true
immediate: true,
})
function toggle() {
isUnfold.value = !isUnfold.value
emit('toggle', isUnfold.value)
isUnfold.value = !isUnfold.value
emit('toggle', isUnfold.value)
}
</script>
<template>
<div class="search-container">
<slot />
<div v-if="showMore" class="more">
<el-button text size="small" @click="toggle">
<template #icon>
<el-icon>
<svg-icon :name="isUnfold ? 'ep:caret-top' : 'ep:caret-bottom'" />
</el-icon>
</template>
{{ isUnfold ? '收起' : '展开' }}
</el-button>
</div>
<div class="search-container">
<slot />
<div v-if="showMore" class="more">
<el-button text size="small" @click="toggle">
<template #icon>
<el-icon>
<svg-icon :name="isUnfold ? 'ep:caret-top' : 'ep:caret-bottom'" />
</el-icon>
</template>
{{ isUnfold ? '收起' : '展开' }}
</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.search-container {
position: relative;
margin: 20px 0;
padding: 20px;
background-color: var(--el-fill-color-lighter);
transition: background-color 0.3s;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
:deep(.el-form) {
margin-bottom: -10px;
.el-select {
width: 100%;
}
.el-date-editor {
width: 100%;
}
}
.more {
position: relative;
margin: 20px 0;
padding: 20px;
background-color: var(--el-fill-color);
transition: background-color 0.3s;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
:deep(.el-form) {
margin-bottom: -10px;
.el-select {
width: 100%;
}
.el-date-editor {
width: 100%;
}
}
.more {
position: relative;
text-align: center;
margin-bottom: -10px;
}
text-align: center;
margin-bottom: -10px;
}
}
</style>
+35 -41
View File
@@ -1,53 +1,47 @@
<script setup name="SvgIcon">
<script lang="ts" setup name="SvgIcon">
import { Icon } from '@iconify/vue'
const props = defineProps({
name: {
type: String,
required: true
},
flip: {
type: String,
validator(value) {
return ['', 'horizontal', 'vertical', 'both'].includes(value)
},
default: ''
},
rotate: {
type: Number,
validator(value) {
return value >= 0 && value <= 360
},
default: 0
}
name: {
type: String,
required: true,
},
flip: {
type: String as () => 'horizontal' | 'vertical' | 'both' | '',
default: '',
},
rotate: {
type: Number,
default: 0,
},
})
const transformStyle = computed(() => {
let style = []
if (props.flip != '') {
switch (props.flip) {
case 'horizontal':
style.push('rotateY(180deg)')
break
case 'vertical':
style.push('rotateX(180deg)')
break
case 'both':
style.push('rotateX(180deg)')
style.push('rotateY(180deg)')
break
}
const style = []
if (props.flip !== '') {
switch (props.flip) {
case 'horizontal':
style.push('rotateY(180deg)')
break
case 'vertical':
style.push('rotateX(180deg)')
break
case 'both':
style.push('rotateX(180deg)')
style.push('rotateY(180deg)')
break
}
if (props.rotate != 0) {
style.push(`rotate(${props.rotate}deg)`)
}
return `transform: ${style.join(' ')};`
}
if (props.rotate !== 0)
style.push(`rotate(${props.rotate}deg)`)
return `transform: ${style.join(' ')};`
})
</script>
<template>
<Icon v-if="name.indexOf('ep:') === 0" :icon="name" :style="transformStyle" />
<svg v-else :style="transformStyle" aria-hidden="true">
<use :xlink:href="`#icon-${name}`" />
</svg>
<Icon v-if="name.indexOf('ep:') === 0" :icon="name" :style="transformStyle" />
<svg v-else :style="transformStyle" aria-hidden="true">
<use :xlink:href="`#icon-${name}`" />
</svg>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import eventBus from '@/utils/eventBus'
const isShow = ref(false)
const { pkg, lastBuildTime } = __SYSTEM_INFO__
onMounted(() => {
eventBus.on('global-system-info-toggle', () => {
isShow.value = !isShow.value
})
})
</script>
<template>
<div>
<el-drawer v-model="isShow" title="系统信息" direction="rtl" :size="360">
<el-descriptions direction="vertical" :column="1" border>
<el-descriptions-item label="版本号" align="center">
{{ pkg.version }}
</el-descriptions-item>
<el-descriptions-item label="最后编译时间" align="center">
{{ lastBuildTime }}
</el-descriptions-item>
</el-descriptions>
<el-descriptions title="生产环境依赖" :column="1" size="small" border>
<el-descriptions-item v-for="(val, key) in (pkg.dependencies as object)" :key="key" :label="key">
{{ val }}
</el-descriptions-item>
</el-descriptions>
<el-descriptions title="开发环境依赖" :column="1" size="small" border>
<el-descriptions-item v-for="(val, key) in (pkg.devDependencies as object)" :key="key" :label="key">
{{ val }}
</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: initial;
padding-bottom: 20px;
border-bottom: 1px solid var(--el-border-color);
transition: var(--el-transition-border);
}
:deep(.el-descriptions) {
margin-bottom: 20px;
}
</style>
+52 -49
View File
@@ -1,65 +1,68 @@
<script setup name="Trend">
<script lang="ts" setup name="Trend">
const props = defineProps({
value: {
type: String,
required: true
},
type: {
type: String,
validator: val => ['up', 'down'].includes(val),
default: 'up'
},
prefix: {
type: String,
default: ''
},
suffix: {
type: String,
default: ''
},
reverse: {
type: Boolean,
default: false
}
value: {
type: String,
required: true,
},
type: {
type: String as () => 'up' | 'down',
default: 'up',
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
reverse: {
type: Boolean,
default: false,
},
})
const isUp = computed(() => {
let isUp = props.type === 'up'
if (props.reverse) {
isUp = !isUp
}
return isUp
let isUp = props.type === 'up'
if (props.reverse)
isUp = !isUp
return isUp
})
</script>
<template>
<div :class="`trend ${isUp ? 'up' : 'down'}`">
<span v-if="prefix" class="prefix">{{ prefix }}</span>
<span class="text">{{ value }}</span>
<span v-if="suffix" class="suffix">{{ suffix }}</span>
<el-icon>
<svg-icon name="ep:caret-top" />
</el-icon>
</div>
<div :class="`trend ${isUp ? 'up' : 'down'}`">
<span v-if="prefix" class="prefix">{{ prefix }}</span>
<span class="text">{{ value }}</span>
<span v-if="suffix" class="suffix">{{ suffix }}</span>
<el-icon>
<svg-icon name="ep:caret-top" />
</el-icon>
</div>
</template>
<style lang="scss" scoped>
.trend {
display: flex;
align-items: center;
transition: var(--el-transition-color);
&.up {
color: var(--el-color-success);
}
&.down {
color: var(--el-color-danger);
.el-icon {
transform: rotate(180deg);
}
}
display: flex;
align-items: center;
transition: var(--el-transition-color);
&.up {
color: var(--el-color-success);
}
&.down {
color: var(--el-color-danger);
.el-icon {
margin-left: 5px;
transition: transform 0.3s;
transform: rotate(180deg);
}
}
.el-icon {
margin-left: 5px;
transition: transform 0.3s;
}
}
</style>
+266
View File
@@ -0,0 +1,266 @@
import type { StyleValue } from "vue"
import type { RouteRecordRaw, RouteMeta, NavigationGuard } from 'vue-router'
type RecursiveRequired<T> = {
[P in keyof T]-?: RecursiveRequired<T[P]>
}
declare namespace Settings {
interface app {
/**
* 颜色方案
* @默认值 `''` 跟随系统
* @可选值 `'light'` 明亮模式
* @可选值 `'dark'` 暗黑模式
*/
colorScheme?: '' | 'light' | 'dark'
/**
* Element Plus 组件默认尺寸
* @默认值 `'default'` 默认
* @可选值 `'large'` 大号
* @可选值 `'small'` 小号
*/
elementSize?: 'default' | 'large' | 'small'
/**
* 是否将 iconify 图标集中的 Element Plus 图标下载到本地,以便在离线环境下使用
* @默认值 `false`
*/
iconifyOfflineUse?: boolean
/**
* 是否开启权限功能
* @默认值 `false`
*/
enablePermission?: boolean
/**
* 是否开启载入进度条
* @默认值 `true`
*/
enableProgress?: boolean
/**
* 是否开启动态标题
* @默认值 `false`
*/
enableDynamicTitle?: boolean
/**
* 路由数据来源
* @默认值 `'frontend'` 前端
* @可选值 `'backend'` 后端
* @可选值 `'filesystem'` 文件系统
*/
routeBaseOn?: 'frontend' | 'backend' | 'filesystem'
}
interface dashboard {
/**
* 是否开启控制台页面
* @默认值 `true`
*/
enable?: boolean
/**
* 控制台名称
* @默认值 `'控制台'`
*/
title?: string
}
interface layout {
/**
* 是否开启移动端适配,开启后当页面宽度小于 992px 时自动切换为移动端展示
* @默认值 `false`
*/
enableMobileAdaptation?: boolean
}
interface menu {
/**
* 导航栏数据来源,当 `app.routeBaseOn: 'filesystem'` 时生效
* @默认值 `'frontend'` 前端
* @可选值 `'backend'` 后端
*/
baseOn?: 'frontend' | 'backend'
/**
* 导航栏模式
* @默认值 `'side'` 侧边栏模式(有主导航)
* @可选值 `'head'` 顶部模式
* @可选值 `'single'` 侧边栏模式(无主导航)
*/
menuMode?: 'side' | 'head' | 'single'
/**
* 切换主导航是否跳转页面
* @默认值 `false`
*/
switchMainMenuAndPageJump?: boolean
/**
* 次导航是否只保持一个子项的展开
* @默认值 `true`
*/
subMenuUniqueOpened?: boolean
/**
* 次导航是否收起
* @默认值 `false`
*/
subMenuCollapse?: boolean
/**
* 是否开启次导航的展开/收起按钮
* @默认值 `false`
*/
enableSubMenuCollapseButton?: boolean
/**
* 是否开启主导航切换快捷键
* @默认值 `false`
*/
enableHotkeys?: boolean
}
interface topbar {
/**
* 是否固定顶栏
* @默认值 `true`
*/
fixed?: boolean
}
interface toolbar {
/**
* 是否开启全屏
* @默认值 `false`
*/
enableFullscreen?: boolean
/**
* 是否开启页面刷新
* @默认值 `false`
*/
enablePageReload?: boolean
/**
* 是否开启颜色主题
* @默认值 `false`
*/
enableColorScheme?: boolean
/**
* 是否开启应用配置,强烈建议在生产环境中关闭
* @默认值 `false`
*/
enableAppSetting?: boolean
}
interface breadcrumb {
/**
* 是否开启面包屑导航
* @默认值 `true`
*/
enable?: boolean
}
interface navSearch {
/**
* 是否开启导航搜索
* @默认值 `true`
*/
enable?: boolean
/**
* 是否开启导航搜索快捷键
* @默认值 `true`
*/
enableHotkeys?: boolean
}
interface copyright {
/**
* 是否开启底部版权,同时在路由 meta 对象里可以单独设置某个路由是否显示底部版权信息
* @默认值 `true`
*/
enable?: boolean
/**
* 网站运行日期
* @默认值 `'2020-2022'`
*/
dates?: string
/**
* 公司名称
* @默认值 `'Fantastic-admin'`
*/
company?: string
/**
* 网站地址
* @默认值 `'https://hooray.gitee.io/fantastic-admin/'`
*/
website?: string
/**
* 网站备案号
* @默认值 `''`
*/
beian?: string
}
interface all {
/** 应用设置 */
app?: app
/** 控制台设置 */
dashboard?: dashboard
/** 布局设置 */
layout?: layout
/** 导航栏设置 */
menu?: menu
/** 顶栏设置 */
topbar?: topbar
/** 工具栏设置 */
toolbar?: toolbar
/** 面包屑导航设置 */
breadcrumb?: breadcrumb
/** 导航搜索设置 */
navSearch?: navSearch
/** 底部版权设置 */
copyright?: copyright
}
}
declare module 'vue-router' {
interface RouteMeta {
title: string | Function
i18n?: string
icon?: string
activeIcon?: string
defaultOpened?: boolean
auth?: string | string[]
sidebar?: boolean
breadcrumb?: boolean
activeMenu?: string
cache?: boolean | string | string[]
breadcrumbNeste?: Route.breadcrumb[]
}
}
declare namespace Route {
interface recordRaw extends Omit<RouteRecordRaw, 'meta' | 'children'> {
meta: RouteMeta
children?: recordRaw[]
}
interface recordMainRaw {
meta: {
title?: string | Function
icon?: string
auth?: string | string[]
}
children: recordRaw[]
}
interface breadcrumb {
path: string
title: string | Function
hide: boolean
}
}
declare namespace Menu {
/** 原始 */
interface recordRaw {
path?: string
meta: {
title: string
icon?: string
defaultOpened?: boolean
auth?: string | string[]
sidebar?: boolean
}
children?: recordRaw[]
}
/** 主导航 */
interface recordMainRaw {
meta: {
title?: string
icon?: string
auth?: string | string[]
}
children: recordRaw[]
}
}
-6
View File
@@ -1,6 +0,0 @@
import { addCollection } from '@iconify/vue'
export async function downloadAndInstall() {
const data = Object.freeze(await fetch('./icons/icons.json').then(r => r.json()))
addCollection(data)
}
+6
View File
@@ -0,0 +1,6 @@
import { addCollection } from '@iconify/vue'
export async function downloadAndInstall() {
const data = Object.freeze(await fetch('./icons/icons.json').then(r => r.json()))
addCollection(data)
}
-457
View File
@@ -1,457 +0,0 @@
<script setup name="AppSetting">
import { useClipboard } from '@vueuse/core'
import eventBus from '@/utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
import globalSettings from '@/settings'
const route = useRoute()
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const isShow = ref(false)
const settings = ref(globalSettings)
watch(() => settings, () => {
settingsStore.updateThemeSetting(settings.value)
menuStore.setActived(0)
settings.value.menu.menuMode !== 'single' && menuStore.setActived(route.fullPath)
}, {
deep: true
})
onMounted(() => {
eventBus.on('global-theme-toggle', () => {
isShow.value = !isShow.value
})
})
const { copy, copied, isSupported } = useClipboard()
watch(copied, val => {
if (val) {
ElMessage.success('复制成功,请粘贴到 src/settings.custom.json 文件中!')
}
})
function handleCopy() {
copy(JSON.stringify(settings.value, null, 4))
}
</script>
<template>
<div>
<el-drawer v-model="isShow" title="应用配置" direction="rtl" :size="360">
<el-alert title="应用配置可实时预览效果,但仅是临时生效,要想真正作用于项目,可以点击下方的“复制配置”按钮,将配置粘贴到 src/settings.custom.json 中即可,或者也可在 src/settings.js 中直接修改默认配置。同时建议在生产环境隐藏应用配置功能。" type="error" :closable="false" />
<el-divider>颜色主题</el-divider>
<div class="color-scheme">
<div class="switch" :class="settings.app.colorScheme" @click="settings.app.colorScheme = settings.app.colorScheme === 'dark' ? 'light' : 'dark'">
<el-icon class="icon">
<svg-icon v-show="settings.app.colorScheme === 'light'" name="ep:sunny" />
<svg-icon v-show="settings.app.colorScheme === 'dark'" name="ep:moon" />
</el-icon>
</div>
</div>
<el-divider v-if="settingsStore.mode === 'pc'">导航栏模式</el-divider>
<div v-if="settingsStore.mode === 'pc'" class="menu-mode">
<el-tooltip content="侧边栏模式(含主导航)" placement="top" :show-after="500">
<div class="mode mode-side" :class="{'active': settings.menu.menuMode === 'side'}" @click="settings.menu.menuMode = 'side'">
<div class="mode-container" />
<el-icon>
<svg-icon name="ep:check" />
</el-icon>
</div>
</el-tooltip>
<el-tooltip content="顶部模式" placement="top" :show-after="500">
<div class="mode mode-head" :class="{'active': settings.menu.menuMode === 'head'}" @click="settings.menu.menuMode = 'head'">
<div class="mode-container" />
<el-icon>
<svg-icon name="ep:check" />
</el-icon>
</div>
</el-tooltip>
<el-tooltip content="侧边栏模式(不含主导航)" placement="top" :show-after="500">
<div class="mode mode-single" :class="{'active': settings.menu.menuMode === 'single'}" @click="settings.menu.menuMode = 'single'">
<div class="mode-container" />
<el-icon>
<svg-icon name="ep:check" />
</el-icon>
</div>
</el-tooltip>
</div>
<el-divider>导航栏</el-divider>
<div class="setting-item">
<div class="label">是否折叠</div>
<el-switch v-model="settings.menu.subMenuCollapse" />
</div>
<div class="setting-item">
<div class="label">
切换跳转
<el-tooltip content="开启该功能后,切换侧边栏时,页面自动跳转至该侧边栏导航下第一个路由地址" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.menu.switchMainMenuAndPageJump" :disabled="['single'].includes(settings.menu.menuMode)" />
</div>
<div class="setting-item">
<div class="label">
保持展开一个
<el-tooltip content="开启该功能后,侧边栏只保持一个子菜单的展开" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.menu.subMenuUniqueOpened" />
</div>
<el-divider>顶栏</el-divider>
<div class="setting-item">
<div class="label">是否固定</div>
<el-switch v-model="settings.topbar.fixed" />
</div>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">折叠按钮</div>
<el-switch v-model="settings.topbar.enableSidebarCollapse" />
</div>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">面包屑导航</div>
<el-switch v-model="settings.topbar.enableBreadcrumb" />
</div>
<div class="setting-item">
<div class="label">
导航栏搜索
<el-tooltip content="对导航栏进行快捷搜索" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.topbar.enableNavSearch" />
</div>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">
全屏
<el-tooltip content="该功能使用场景极少,用户习惯于通过窗口“最大化”功能来扩大显示区域,以显示更多内容,并且使用 F11 键也可以进入全屏效果" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.topbar.enableFullscreen" />
</div>
<div class="setting-item">
<div class="label">
页面刷新
<el-tooltip content="开启时会阻止原生 F5 键刷新功能,并采用框架提供的刷新模式进行页面刷新" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.topbar.enablePageReload" />
</div>
<div class="setting-item">
<div class="label">
颜色主题
<el-tooltip content="开启后可在明亮/暗黑模式中切换" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.topbar.enableColorScheme" />
</div>
<el-divider>底部版权</el-divider>
<div class="setting-item">
<div class="label">是否启用</div>
<el-switch v-model="settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">日期</div>
<el-input v-model="settings.copyright.dates" size="small" :disabled="!settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">公司</div>
<el-input v-model="settings.copyright.company" size="small" :disabled="!settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">网址</div>
<el-input v-model="settings.copyright.website" size="small" :disabled="!settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">备案</div>
<el-input v-model="settings.copyright.beian" size="small" :disabled="!settings.copyright.enable" />
</div>
<el-divider>控制台</el-divider>
<div class="setting-item">
<div class="label">
是否开启
<el-tooltip content="该功能开启时,登录成功默认进入控制台页面,反之则默认进入导航栏里第一个导航页面" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.dashboard.enable" />
</div>
<div class="setting-item">
<div class="label">控制台名称</div>
<el-input v-model="settings.dashboard.title" size="small" />
</div>
<el-divider>其它</el-divider>
<div class="setting-item">
<div class="label">
组件尺寸
<el-tooltip content="全局设置 Element Plus 组件的默认尺寸大小" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-radio-group v-model="settings.app.elementSize" size="small">
<el-radio-button label="large">较大</el-radio-button>
<el-radio-button label="default">默认</el-radio-button>
<el-radio-button label="small">稍小</el-radio-button>
</el-radio-group>
</div>
<div class="setting-item">
<div class="label">是否启用权限</div>
<el-switch v-model="settings.app.enablePermission" />
</div>
<div class="setting-item">
<div class="label">
载入进度条
<el-tooltip content="该功能开启时,跳转路由会看到页面顶部有进度条" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.app.enableProgress" />
</div>
<div class="setting-item">
<div class="label">
动态标题
<el-tooltip content="该功能开启时,页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.app.enableDynamicTitle" />
</div>
<template v-if="isSupported" #footer>
<el-button type="primary" @click="handleCopy">
<template #icon>
<el-icon>
<svg-icon name="ep:document-copy" />
</el-icon>
</template>
复制配置
</el-button>
</template>
</el-drawer>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: initial;
padding-bottom: 20px;
border-bottom: 1px solid var(--el-border-color);
}
:deep(.el-drawer__footer) {
padding: 20px;
border-top: 1px solid var(--el-border-color);
transition: var(--el-transition-border);
.el-button {
width: 100%;
}
}
:deep(.el-divider) {
margin: 36px 0 24px;
}
.color-scheme {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 10px;
$width: 50px;
.switch {
width: $width;
height: 30px;
border-radius: 15px;
cursor: pointer;
background-color: var(--el-fill-color-darker);
transition: background-color 0.3s;
&.dark {
.icon {
margin-left: calc($width - 24px - 3px);
}
}
.icon {
margin: 3px;
padding: 5px;
font-size: 24px;
border-radius: 50%;
background-color: var(--el-fill-color-lighter);
transition: margin-left 0.3s, background-color 0.3s;
}
}
}
.menu-mode {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding-bottom: 10px;
.mode {
position: relative;
width: 80px;
height: 55px;
margin: 10px;
border-radius: 5px;
overflow: hidden;
cursor: pointer;
background-color: var(--g-app-bg);
box-shadow: 0 0 5px 1px var(--el-border-color-lighter);
transition: 0.2s;
&:hover {
box-shadow: 0 0 5px 1px var(--el-border-color-darker);
}
&.active {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
&::before,
&::after,
.mode-container {
pointer-events: none;
position: absolute;
border-radius: 3px;
}
.mode-container::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.2;
}
&-side {
&::before {
content: "";
top: 5px;
left: 5px;
bottom: 5px;
width: 10px;
background-color: var(--g-sub-sidebar-menu-active-bg);
}
&::after {
content: "";
top: 5px;
left: 20px;
bottom: 5px;
width: 15px;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.5;
}
.mode-container {
top: 5px;
left: 40px;
right: 5px;
bottom: 5px;
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
}
}
&-head {
&::before {
content: "";
top: 5px;
left: 5px;
right: 5px;
height: 10px;
background-color: var(--g-sub-sidebar-menu-active-bg);
}
&::after {
content: "";
top: 20px;
left: 5px;
bottom: 5px;
width: 15px;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.5;
}
.mode-container {
top: 20px;
left: 25px;
right: 5px;
bottom: 5px;
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
}
}
&-single {
&::before {
content: "";
position: absolute;
top: 5px;
left: 5px;
bottom: 5px;
width: 15px;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.5;
}
.mode-container {
top: 5px;
left: 25px;
right: 5px;
bottom: 5px;
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
}
}
i {
position: absolute;
right: 10px;
bottom: 10px;
display: none;
}
&.active i {
display: block;
color: var(--el-color-primary);
}
}
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 5px 0;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background: var(--el-fill-color);
}
.label {
font-size: 14px;
color: var(--el-text-color-regular);
display: flex;
align-items: center;
i {
margin-left: 4px;
font-size: 17px;
color: var(--el-color-warning);
cursor: help;
}
}
.el-switch {
height: auto;
}
.el-input {
width: 150px;
}
}
</style>
-170
View File
@@ -1,170 +0,0 @@
<script setup name="Header">
import Logo from '../Logo/index.vue'
import Tools from '../Tools/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
import { useMenu } from '@/utils/composables'
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const navRef = ref()
// 顶部模式鼠标滚动
function handlerMouserScroll(event) {
navRef.value.scrollBy({
left: (event.deltaY || event.detail) > 0 ? 50 : -50
})
}
</script>
<template>
<transition name="header">
<header v-if="settingsStore.mode === 'pc' && settingsStore.menu.menuMode === 'head'">
<div class="header-container">
<div class="main">
<Logo />
<!-- 顶部模式 -->
<div ref="navRef" class="nav" @wheel.prevent="handlerMouserScroll">
<template v-for="(item, index) in menuStore.allMenus" :key="index">
<div v-if="item.children && item.children.length !== 0" class="item-container" :class="{'active': index == menuStore.actived}">
<div class="item" @click="useMenu().switchTo(index)">
<el-icon>
<svg-icon v-if="item.meta.icon" :name="item.meta.icon" />
</el-icon>
<span v-if="item.meta.title">{{ item.meta.title }}</span>
</div>
</div>
</template>
</div>
</div>
<Tools />
</div>
</header>
</transition>
</template>
<style lang="scss" scoped>
header {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 0 20px;
height: var(--g-header-height);
color: var(--g-header-color);
background-color: var(--g-header-bg);
transition: background-color 0.3s, var(--el-transition-color);
.header-container {
width: var(--g-header-width);
height: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
.main {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
height: 100%;
}
}
@media screen and (max-width: var(--g-header-width)) {
.header-container {
width: 100%;
}
}
:deep(.title) {
position: relative;
width: inherit;
height: inherit;
padding: inherit;
background-color: inherit;
.logo {
width: 50px;
height: 50px;
}
span {
font-size: 24px;
letter-spacing: 1px;
color: var(--g-header-color);
}
}
.nav {
flex: 1;
display: flex;
width: 0;
height: 100%;
margin: 0 30px;
padding: 0 20px;
overflow-x: auto;
mask-image: linear-gradient(to right, transparent, #000 20px, #000 calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to right, transparent, #000 20px, #000 calc(100% - 20px), transparent);
// firefox隐藏滚动条
scrollbar-width: none;
// chrome隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
.item-container {
position: relative;
display: flex;
width: initial;
.item {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 5px;
width: 80px;
height: 100%;
cursor: pointer;
color: var(--g-header-menu-color);
background-color: var(--g-header-bg);
transition: background-color 0.3s, var(--el-transition-color);
&:hover {
color: var(--g-header-menu-hover-color);
background-color: var(--g-header-menu-hover-bg);
}
.el-icon {
font-size: 24px;
vertical-align: middle;
}
span {
text-align: center;
vertical-align: middle;
word-break: break-all;
@include text-overflow(1, false);
}
}
&.active .item {
color: var(--g-header-menu-active-color);
background-color: var(--g-header-menu-active-bg);
}
}
}
:deep(.tools) {
padding: 0;
.buttons .item .el-icon {
color: var(--g-header-color);
}
.user-container {
font-size: 16px;
color: var(--g-header-color);
}
}
}
// 头部动画
.header-enter-active,
.header-leave-active {
transition: transform 0.3s;
}
.header-enter-from,
.header-leave-to {
transform: translateY(calc(var(--g-header-height) * -1));
}
</style>
-68
View File
@@ -1,68 +0,0 @@
<script setup name="Logo">
import imgLogo from '@/assets/images/logo.png'
import useSettingsStore from '@/store/modules/settings'
const settingsStore = useSettingsStore()
defineProps({
showLogo: {
type: Boolean,
default: true
},
showTitle: {
type: Boolean,
default: true
}
})
const title = ref(import.meta.env.VITE_APP_TITLE)
const logo = ref(imgLogo)
const to = computed(() => {
let rtn = {}
if (settingsStore.dashboard.enable) {
rtn.name = 'dashboard'
}
return rtn
})
</script>
<template>
<router-link :to="to" class="title" :class="{'is-link': settingsStore.dashboard.enable}" :title="title">
<img v-if="showLogo" :src="logo" class="logo">
<span v-if="showTitle">{{ title }}</span>
</router-link>
</template>
<style lang="scss" scoped>
.title {
position: fixed;
z-index: 1000;
top: 0;
width: inherit;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
height: var(--g-sidebar-logo-height);
text-align: center;
overflow: hidden;
text-decoration: none;
&.is-link {
cursor: pointer;
}
.logo {
width: 30px;
height: 30px;
& + span {
margin-left: 10px;
}
}
span {
display: block;
font-weight: bold;
color: #fff;
@include text-overflow;
}
}
</style>
-101
View File
@@ -1,101 +0,0 @@
<script setup name="MainSidebar">
import Logo from '../Logo/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
import { useMenu } from '@/utils/composables'
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
</script>
<template>
<transition name="main-sidebar">
<div v-if="settingsStore.menu.menuMode === 'side' || (settingsStore.mode === 'mobile' && settingsStore.menu.menuMode !== 'single')" class="main-sidebar-container">
<Logo :show-title="false" class="sidebar-logo" />
<!-- 侧边栏模式(含主导航) -->
<div class="nav">
<template v-for="(item, index) in menuStore.allMenus">
<div
v-if="item.children && item.children.length !== 0" :key="index" :class="{
'item': true,
'active': index === menuStore.actived
}" :title="item.meta.title" @click="useMenu().switchTo(index)"
>
<el-icon>
<svg-icon v-if="item.meta.icon" :name="item.meta.icon" />
</el-icon>
<span>{{ item.meta.title }}</span>
</div>
</template>
</div>
</div>
</transition>
</template>
<style lang="scss" scoped>
.main-sidebar-container {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
// firefox隐藏滚动条
scrollbar-width: none;
// chrome隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
position: relative;
z-index: 1;
width: var(--g-main-sidebar-width);
color: var(--g-main-sidebar-menu-color);
background-color: var(--g-main-sidebar-bg);
transition: background-color 0.3s, var(--el-transition-color);
.sidebar-logo {
transition: 0.3s;
background-color: var(--g-main-sidebar-bg);
}
.nav {
width: inherit;
padding-top: var(--g-sidebar-logo-height);
.item {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
height: 60px;
padding: 0 5px;
cursor: pointer;
color: var(--g-main-sidebar-menu-color);
background-color: var(--g-main-sidebar-bg);
transition: background-color 0.3s, var(--el-transition-color);
&:hover {
color: var(--g-main-sidebar-menu-hover-color);
background-color: var(--g-main-sidebar-menu-hover-bg);
}
&.active {
color: var(--g-main-sidebar-menu-active-color);
background-color: var(--g-main-sidebar-menu-active-bg);
}
.el-icon {
margin: 0 auto;
font-size: 24px;
}
span {
text-align: center;
font-size: 14px;
@include text-overflow(1, false);
}
}
}
}
// 主侧边栏动画
.main-sidebar-enter-active,
.main-sidebar-leave-active {
transition: transform 0.3s;
}
.main-sidebar-enter-from,
.main-sidebar-leave-to {
transform: translateX(calc(var(--g-main-sidebar-width) * -1));
}
</style>
-398
View File
@@ -1,398 +0,0 @@
<script setup name="Search">
import hotkeys from 'hotkeys-js'
import { deepClone, isExternalLink } from '@/utils'
import eventBus from '@/utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
import useRouteStore from '@/store/modules/route'
import useMenuStore from '@/store/modules/menu'
const settingsStore = useSettingsStore()
const routeStore = useRouteStore()
const menuStore = useMenuStore()
const isShow = ref(false)
const searchInput = ref('')
const sourceList = ref([])
const actived = ref(-1)
const inputRef = ref()
const resultRef = ref()
const resultItemRef = ref([])
const setResultItemRef = el => resultItemRef.value.push(el)
onBeforeUpdate(() => {
resultItemRef.value = []
})
const resultList = computed(() => {
let result = []
result = sourceList.value.filter(item => {
let flag = false
if (item.title.indexOf(searchInput.value) >= 0) {
flag = true
}
if (item.path.indexOf(searchInput.value) >= 0) {
flag = true
}
if (item.breadcrumb.some(b => b.indexOf(searchInput.value) >= 0)) {
flag = true
}
return flag
})
return result
})
watch(() => isShow.value, val => {
if (val) {
document.querySelector('body').classList.add('hidden')
resultRef.value.scrollTop = 0
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
hotkeys('up', keyUp)
hotkeys('down', keyDown)
hotkeys('enter', keyEnter)
setTimeout(() => {
inputRef.value.focus()
}, 500)
} else {
document.querySelector('body').classList.remove('hidden')
hotkeys.unbind('up', keyUp)
hotkeys.unbind('down', keyDown)
hotkeys.unbind('enter', keyEnter)
setTimeout(() => {
searchInput.value = ''
actived.value = -1
}, 500)
}
})
watch(() => resultList.value, () => {
actived.value = -1
handleScroll()
})
onMounted(() => {
eventBus.on('global-search-toggle', () => {
isShow.value = !isShow.value
})
hotkeys('alt+s', e => {
if (settingsStore.topbar.enableNavSearch) {
e.preventDefault()
isShow.value = true
}
})
hotkeys('esc', e => {
if (settingsStore.topbar.enableNavSearch) {
e.preventDefault()
isShow.value = false
}
})
if (settingsStore.app.routeBaseOn !== 'filesystem') {
routeStore.routes.map(item => {
getSourceList(item.children)
})
} else {
menuStore.menus.map(item => {
getSourceList(item.children)
})
}
})
function hasChildren(item) {
let flag = true
if (item.children) {
if (item.children.every(i => i.meta.sidebar === false)) {
flag = false
}
} else {
flag = false
}
return flag
}
function getSourceList(arr) {
arr.map(item => {
if (item.meta.sidebar !== false) {
if (hasChildren(item)) {
let baseBreadcrumb = item.meta.baseBreadcrumb ? deepClone(item.meta.baseBreadcrumb) : []
baseBreadcrumb.push(item.meta.title)
let child = deepClone(item.children)
child.map(c => {
c.meta.baseIcon = item.meta.icon || item.meta.baseIcon
c.meta.baseBreadcrumb = baseBreadcrumb
c.meta.basePath = item.meta.basePath ? [item.meta.basePath, item.path].join('/') : item.path
})
getSourceList(child)
} else {
let breadcrumb = []
if (item.meta.baseBreadcrumb) {
breadcrumb = deepClone(item.meta.baseBreadcrumb)
}
breadcrumb.push(item.meta.title)
let path = ''
if (isExternalLink(item.path)) {
path = item.path
} else {
path = item.meta.basePath ? [item.meta.basePath, item.path].join('/') : item.path
}
sourceList.value.push({
icon: item.meta.icon || item.meta.baseIcon,
title: item.meta.title,
i18n: item.meta.i18n,
breadcrumb: breadcrumb,
path: path
})
}
}
})
}
function keyUp() {
if (resultList.value.length) {
actived.value -= 1
if (actived.value < 0) {
actived.value = resultList.value.length - 1
}
handleScroll()
}
}
function keyDown() {
if (resultList.value.length) {
actived.value += 1
if (actived.value > resultList.value.length - 1) {
actived.value = 0
}
handleScroll()
}
}
function keyEnter() {
if (actived.value !== -1) {
resultItemRef.value[actived.value].click()
}
}
function handleScroll() {
let scrollTo = 0
if (actived.value !== -1) {
scrollTo = resultRef.value.scrollTop
const activedOffsetTop = resultItemRef.value[actived.value].offsetTop
const activedClientHeight = resultItemRef.value[actived.value].clientHeight
const searchScrollTop = resultRef.value.scrollTop
const searchClientHeight = resultRef.value.clientHeight
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
} else if (activedOffsetTop <= searchScrollTop) {
scrollTo = activedOffsetTop
}
}
resultRef.value.scrollTo({
top: scrollTo
})
}
</script>
<template>
<div id="search" :class="{'searching': isShow}" @click="isShow && eventBus.emit('global-search-toggle')">
<div class="container">
<div class="search-box" @click.stop>
<el-input ref="inputRef" v-model="searchInput" placeholder="搜索页面,支持标题、URL模糊查询" clearable @keydown.esc="eventBus.emit('global-search-toggle')" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
<template #prefix>
<el-icon>
<svg-icon name="ep:search" />
</el-icon>
</template>
</el-input>
<div v-if="settingsStore.mode === 'pc'" class="tips">
<div class="tip">
<span>Alt</span>+<span>S</span>
唤醒搜索面板
</div>
<div class="tip">
<span>
<el-icon>
<svg-icon name="search-up" />
</el-icon>
</span>
<span>
<el-icon>
<svg-icon name="search-down" />
</el-icon>
</span>
切换搜索结果
</div>
<div class="tip">
<span>
<el-icon>
<svg-icon name="search-enter" />
</el-icon>
</span>
访问页面
</div>
<div class="tip">
<span>ESC</span>
退出
</div>
</div>
</div>
<div ref="resultRef" class="result" :class="{'mobile': settingsStore.mode === 'mobile'}">
<router-link v-for="(item, index) in resultList" :key="item.path" v-slot="{ href, navigate }" custom :to="isShow ? item.path : ''">
<a :ref="setResultItemRef" :href="isExternalLink(item.path) ? item.path : href" class="item" :class="{'actived': index === actived}" :target="isExternalLink(item.path) ? '_blank' : '_self'" @click="navigate" @mouseover="actived = index">
<el-icon class="icon">
<svg-icon v-if="item.icon" :name="item.icon" />
</el-icon>
<div class="info">
<div class="title">
{{ item.title }}
</div>
<div class="breadcrumb">
<span v-for="(bc, bcIndex) in item.breadcrumb" :key="bcIndex">
{{ bc }}
<el-icon>
<svg-icon name="ep:arrow-right" />
</el-icon>
</span>
</div>
<div class="path">{{ item.path }}</div>
</div>
</a>
</router-link>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
#search {
position: fixed;
z-index: 2000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
backdrop-filter: blur(10px);
transition: all 0.2s;
transform: translateZ(0);
opacity: 0;
visibility: hidden;
&.searching {
opacity: 1;
visibility: visible;
.container {
transform: initial;
filter: initial;
}
}
.container {
display: flex;
flex-direction: column;
max-width: 800px;
height: 100%;
margin: 0 auto;
transition: all 0.2s;
transform: scale(1.1);
filter: blur(10px);
.search-box {
margin: 50px 20px 40px;
:deep(.el-input__inner) {
height: 52px;
line-height: 52px;
}
:deep(.el-input__icon) {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.tips {
display: flex;
align-items: center;
justify-content: space-evenly;
margin-top: 20px;
line-height: 24px;
font-size: 14px;
color: #fff;
span {
margin: 0 5px;
padding: 3px 8px 5px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
color: var(--el-text-color-primary);
background-color: var(--el-fill-color);
box-shadow: inset 0 -2px var(--el-fill-color-lighter), inset 0 0 1px 1px var(--el-fill-color-darker);
}
}
}
.result {
position: relative;
margin: 0 20px;
max-height: calc(100% - 250px);
border-radius: 5px;
overflow: auto;
background-color: var(--el-bg-color);
box-shadow: 0 0 0 1px var(--el-border-color-darker);
&.mobile {
max-height: calc(100% - 200px);
}
.item {
display: flex;
align-items: center;
text-decoration: none;
cursor: pointer;
transition: all 0.3s;
&.actived {
background-color: var(--el-bg-color-page);
.icon {
color: var(--el-color-primary);
transform: scale(1.2);
}
.info {
border-left-color: var(--el-border-color);
.title {
color: var(--el-text-color-primary);
}
.breadcrumb,
.path {
color: var(--el-text-color-regular);
}
}
}
.icon {
flex: 0 0 66px;
text-align: center;
color: var(--el-color-info);
font-size: 20px;
transition: all 0.3s;
}
.info {
flex: 1;
height: 70px;
display: flex;
flex-direction: column;
justify-content: space-around;
border-left: 1px solid var(--el-border-color-lighter);
padding: 5px 10px 7px;
transition: all 0.3s;
@include text-overflow(1, true);
.title {
font-size: 18px;
font-weight: bold;
color: var(--el-text-color-regular);
@include text-overflow(1, true);
}
.breadcrumb,
.path {
font-size: 12px;
color: var(--el-text-color-secondary);
transition: all 0.3s;
@include text-overflow(1, true);
}
.breadcrumb {
span {
margin-right: 5px;
&:last-child i {
display: none;
}
}
}
}
}
}
}
}
</style>
-147
View File
@@ -1,147 +0,0 @@
<script setup name="SidebarItem">
import SidebarItem from './index.vue'
import { isExternalLink, resolveRoutePath } from '@/utils'
const props = defineProps({
item: {
type: Object,
required: true
},
basePath: {
type: String,
default: ''
}
})
const hasChildren = computed(() => {
let flag = true
if (props.item.children) {
if (props.item.children.every(item => item.meta.sidebar === false)) {
flag = false
}
} else {
flag = false
}
return flag
})
</script>
<template>
<div class="sidebar-item">
<el-sub-menu v-if="item.path == undefined" :title="item.meta.title" :index="JSON.stringify(item)">
<template #title>
<el-icon v-if="item.meta.icon" class="title-icon">
<svg-icon :name="item.meta.icon" />
</el-icon>
<span class="title">{{ item.meta.title }}</span>
</template>
<template v-for="route in item.children">
<SidebarItem v-if="route.meta.sidebar !== false" :key="route.path" :item="route" />
</template>
</el-sub-menu>
<router-link v-else-if="!hasChildren" v-slot="{ href, navigate, isActive, isExactActive }" custom :to="resolveRoutePath(basePath, item.path)">
<a :href="isExternalLink(resolveRoutePath(basePath, item.path)) ? resolveRoutePath(basePath, item.path) : href" :class="[isActive && 'router-link-active', isExactActive && 'router-link-exact-active']" :target="isExternalLink(resolveRoutePath(basePath, item.path)) ? '_blank' : '_self'" @click="navigate">
<el-menu-item :title="item.meta.title" :index="resolveRoutePath(basePath, item.path)">
<el-icon v-if="item.meta.icon" class="title-icon">
<svg-icon :name="item.meta.icon" />
</el-icon>
<span class="title">{{ item.meta.title }}</span>
</el-menu-item>
</a>
</router-link>
<el-sub-menu v-else :title="item.meta.title" :index="resolveRoutePath(basePath, item.path)">
<template #title>
<el-icon v-if="item.meta.icon" class="title-icon">
<svg-icon :name="item.meta.icon" />
</el-icon>
<span class="title">{{ item.meta.title }}</span>
</template>
<template v-for="route in item.children">
<SidebarItem v-if="route.meta.sidebar !== false" :key="route.path" :item="route" :base-path="resolveRoutePath(basePath, item.path)" />
</template>
</el-sub-menu>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-menu-item),
:deep(.el-menu-item span.title),
:deep(.el-sub-menu__title),
:deep(.el-sub-menu__title span.title) {
flex: 1;
vertical-align: inherit;
@include text-overflow;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.el-sub-menu),
:deep(.el-menu-item) {
.title-icon {
width: 20px;
font-size: 20px;
vertical-align: -0.25em;
transition: transform 0.3s;
color: unset;
}
.title-icon + .title {
margin-left: 10px;
}
&:hover > .title-icon,
.el-sub-menu__title:hover > .title-icon {
transform: scale(1.2);
}
}
a {
cursor: pointer;
color: inherit;
text-decoration: none;
}
.el-sub-menu__title {
> .badge {
&-dot {
right: 40px;
}
&-text {
right: 40px;
}
}
}
</style>
<style lang="scss">
.el-menu--inline {
background-color: var(--g-sub-sidebar-menu-bg) !important;
.el-menu-item,
.el-sub-menu > .el-sub-menu__title {
color: var(--g-sub-sidebar-menu-color);
background-color: var(--g-sub-sidebar-menu-bg) !important;
&:hover {
color: var(--g-sub-sidebar-menu-hover-color) !important;
background-color: var(--g-sub-sidebar-menu-hover-bg) !important;
}
}
}
.el-menu-item,
.el-sub-menu__title {
color: var(--g-sub-sidebar-menu-color) !important;
background: transparent !important;
&:hover {
color: var(--g-sub-sidebar-menu-hover-color) !important;
background-color: var(--g-sub-sidebar-menu-hover-bg) !important;
}
}
.el-menu-item.is-active,
.el-menu--collapse .el-sub-menu.is-active > .el-sub-menu__title,
.el-sub-menu .el-menu--inline .el-menu-item.is-active {
color: var(--g-sub-sidebar-menu-active-color) !important;
background-color: var(--g-sub-sidebar-menu-active-bg) !important;
.icon,
.el-sub-menu__icon-arrow {
color: unset;
}
}
</style>
-140
View File
@@ -1,140 +0,0 @@
<script setup name="SubSidebar">
import Logo from '../Logo/index.vue'
import SidebarItem from '../SidebarItem/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const sidebarScrollTop = ref(0)
function onSidebarScroll(e) {
sidebarScrollTop.value = e.target.scrollTop
}
</script>
<template>
<div v-if="['side', 'head', 'single'].includes(settingsStore.menu.menuMode) || settingsStore.mode === 'mobile'" class="sub-sidebar-container" :class="{'is-collapse': settingsStore.mode === 'pc' && settingsStore.menu.subMenuCollapse}" @scroll="onSidebarScroll">
<Logo
:show-logo="settingsStore.menu.menuMode === 'single'" :class="{
'sidebar-logo': true,
'sidebar-logo-bg': settingsStore.menu.menuMode === 'single',
'shadow': sidebarScrollTop
}"
/>
<!-- 侧边栏模式(无主导航) -->
<el-menu
:unique-opened="settingsStore.menu.subMenuUniqueOpened" :default-openeds="menuStore.defaultOpenedPaths" :default-active="$route.meta.activeMenu || $route.path" :collapse="settingsStore.mode === 'pc' && settingsStore.menu.subMenuCollapse" :collapse-transition="false" :class="{
'is-collapse-without-logo': settingsStore.menu.menuMode !== 'single' && settingsStore.menu.subMenuCollapse
}"
>
<transition-group name="sub-sidebar">
<template v-for="(route, index) in menuStore.sidebarMenus">
<SidebarItem v-if="route.meta.sidebar !== false" :key="route.path || index" :item="route" :base-path="route.path" />
</template>
</transition-group>
</el-menu>
</div>
</template>
<style lang="scss" scoped>
.sub-sidebar-container {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
// firefox隐藏滚动条
scrollbar-width: none;
// chrome隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
width: var(--g-sub-sidebar-width);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: var(--g-sub-sidebar-bg);
box-shadow: 10px 0 10px -10px var(--g-box-shadow-color);
transition: background-color 0.3s, var(--el-transition-box-shadow), left 0.3s, width 0.3s;
&.is-collapse {
width: 64px;
.sidebar-logo {
&:not(.sidebar-logo-bg) {
display: none;
}
:deep(span) {
display: none;
}
}
}
.sidebar-logo {
transition: box-shadow 0.2s, background-color 0.3s, color 0.3s;
background-color: var(--g-sub-sidebar-bg);
&:not(.sidebar-logo-bg) {
:deep(span) {
color: var(--g-sub-sidebar-menu-color);
}
}
&.sidebar-logo-bg {
background-color: var(--g-main-sidebar-bg);
}
&.shadow {
box-shadow: 0 10px 10px -10px var(--g-box-shadow-color);
}
}
.el-menu {
border-right: 0;
padding-top: var(--g-sidebar-logo-height);
transition: border-color 0.3s, background-color 0.3s, color 0.3s, padding-top 0.3s;
background-color: var(--g-sub-sidebar-bg);
&:not(.el-menu--collapse) {
width: inherit;
}
&.is-collapse-without-logo {
padding-top: 0;
}
&.el-menu--collapse {
:deep(.title-icon) {
margin-right: 0;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
span,
.el-sub-menu__icon-arrow {
display: none;
}
}
}
&.menu-radius:not(.el-menu--collapse) {
.sidebar-item {
padding: 0 10px;
&:first-child {
padding-top: 10px;
}
&:last-child {
padding-bottom: 10px;
}
}
:deep(.el-menu--inline),
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
border-radius: 10px;
}
}
}
}
// 次侧边栏动画
.sub-sidebar-enter-active {
transition: opacity 0.3s, transform 0.3s;
}
.sub-sidebar-enter-from,
.sub-sidebar-leave-active {
opacity: 0;
transform: translateY(30px) skewY(10deg);
}
.sub-sidebar-leave-active {
position: absolute;
}
</style>
-168
View File
@@ -1,168 +0,0 @@
<script setup name="Tools">
import { useFullscreen } from '@vueuse/core'
import eventBus from '@/utils/eventBus'
import { useMainPage } from '@/utils/composables'
import useSettingsStore from '@/store/modules/settings'
import useUserStore from '@/store/modules/user'
const router = useRouter()
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const { isFullscreen, toggle } = useFullscreen()
function userCommand(command) {
switch (command) {
case 'dashboard':
router.push({
name: 'dashboard'
})
break
case 'setting':
router.push({
name: 'personalSetting'
})
break
case 'logout':
userStore.logout().then(() => {
router.push({
name: 'login'
})
})
break
}
}
function pro() {
window.open('https://hooray.gitee.io/fantastic-admin-pro-example/', 'top')
}
</script>
<template>
<div class="tools">
<div class="buttons">
<span v-if="settingsStore.mode == 'pc'" class="item item-pro" @click="pro">
<el-icon>
<svg-icon name="pro" />
</el-icon>
<span class="title">查看专业版</span>
</span>
<span v-if="settingsStore.topbar.enableNavSearch" class="item" @click="eventBus.emit('global-search-toggle')">
<el-icon>
<svg-icon name="ep:search" />
</el-icon>
</span>
<span v-if="settingsStore.mode === 'pc' && settingsStore.topbar.enableFullscreen" class="item" @click="toggle">
<el-icon>
<svg-icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
</el-icon>
</span>
<span v-if="settingsStore.topbar.enablePageReload" class="item" @click="useMainPage().reload()">
<el-icon>
<svg-icon name="ep:refresh-right" />
</el-icon>
</span>
<span v-if="settingsStore.topbar.enableColorScheme" class="item" @click="settingsStore.setColorScheme(settingsStore.app.colorScheme === 'dark' ? 'light' : 'dark')">
<el-icon>
<svg-icon v-show="settingsStore.app.colorScheme === 'light'" name="ep:sunny" />
<svg-icon v-show="settingsStore.app.colorScheme === 'dark'" name="ep:moon" />
</el-icon>
</span>
<span v-if="settingsStore.topbar.enableAppSetting" class="item" @click="eventBus.emit('global-theme-toggle')">
<el-icon>
<svg-icon name="ep:setting" />
</el-icon>
</span>
</div>
<el-dropdown class="user-container" size="default" @command="userCommand">
<div class="user-wrapper">
<el-avatar size="small">
<el-icon>
<svg-icon name="ep:user-filled" />
</el-icon>
</el-avatar>
{{ userStore.account }}
<el-icon>
<svg-icon name="ep:caret-bottom" />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<el-dropdown-item v-if="settingsStore.dashboard.enable" command="dashboard">控制台</el-dropdown-item>
<el-dropdown-item command="setting">个人设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<style lang="scss" scoped>
.tools {
display: flex;
align-items: center;
padding: 0 20px;
white-space: nowrap;
.buttons {
margin-right: 20px;
.item {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
width: 34px;
cursor: pointer;
vertical-align: middle;
.el-icon {
color: var(--el-text-color-primary);
transition: var(--el-transition-color);
}
}
.item-pro {
display: inline-block;
width: auto;
padding: 0 10px;
transform-origin: right center;
animation: pro-text 3s ease-out infinite;
@keyframes pro-text {
0%,
20% {
transform: scale(1);
}
50%,
70% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
.el-icon {
vertical-align: middle;
}
.title {
padding-left: 5px;
font-weight: bold;
font-size: 14px;
background-image: linear-gradient(to right, #ffa237, #fc455d);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
}
:deep(.user-container) {
display: inline-block;
height: 24px;
line-height: 24px;
cursor: pointer;
.user-wrapper {
.el-avatar {
vertical-align: middle;
margin-top: -2px;
margin-right: 4px;
}
}
}
</style>
-142
View File
@@ -1,142 +0,0 @@
<script setup name="Topbar">
import { compile } from 'path-to-regexp'
import useSettingsStore from '@/store/modules/settings'
import Tools from '../Tools/index.vue'
const route = useRoute()
const settingsStore = useSettingsStore()
const enableSidebarCollapse = computed(() => {
return settingsStore.mode === 'mobile' || (
['side', 'head', 'single'].includes(settingsStore.menu.menuMode) &&
settingsStore.topbar.enableSidebarCollapse
)
})
const breadcrumbList = computed(() => {
let breadcrumbList = []
if (settingsStore.dashboard.enable) {
breadcrumbList.push({
path: '/dashboard',
title: settingsStore.dashboard.title
})
}
if (route.meta.breadcrumbNeste) {
breadcrumbList.push(...route.meta.breadcrumbNeste.filter(item => item.hide === false))
}
return breadcrumbList
})
const scrollTop = ref(0)
onMounted(() => {
window.addEventListener('scroll', onScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
function onScroll() {
scrollTop.value = document.documentElement.scrollTop || document.body.scrollTop
}
function pathCompile(path) {
let toPath = compile(path)
return toPath(route.params)
}
</script>
<template>
<div
class="topbar-container" :class="{
'fixed': settingsStore.topbar.fixed,
'shadow': scrollTop
}" data-fixed-calc-width
>
<div class="left-box">
<div v-if="enableSidebarCollapse" class="sidebar-collapse" :class="{'is-collapse': settingsStore.menu.subMenuCollapse}" @click="settingsStore.toggleSidebarCollapse()">
<el-icon>
<svg-icon name="toolbar-collapse" />
</el-icon>
</div>
<el-breadcrumb v-if="settingsStore.topbar.enableBreadcrumb && settingsStore.mode === 'pc' && settingsStore.app.routeBaseOn !== 'filesystem'">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path" :to="index < breadcrumbList.length - 1 ? pathCompile(item.path) : ''">
{{ item.title }}
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</div>
<Tools />
</div>
</template>
<style lang="scss" scoped>
.topbar-container {
position: absolute;
z-index: 999;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--g-topbar-height);
background-color: var(--g-toolbar-bg);
transition: width 0.3s, top 0.3s, transform 0.3s, background-color 0.3s, var(--el-transition-box-shadow);
&.fixed {
position: fixed;
&.shadow {
box-shadow: 0 10px 10px -10px var(--g-box-shadow-color);
}
}
.left-box {
display: flex;
align-items: center;
padding-right: 50px;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 50px), transparent);
mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 50px), transparent);
.sidebar-collapse {
display: flex;
align-items: center;
padding: 0 20px;
height: 50px;
cursor: pointer;
.el-icon {
color: var(--el-text-color-primary);
transition: var(--el-transition-color), var(--el-transition-md-fade);
}
&:hover .el-icon {
color: var(--el-color-primary);
}
&.is-collapse .el-icon {
transform: rotateZ(-180deg);
}
& + .el-breadcrumb {
margin-left: 0;
}
}
:deep(.el-breadcrumb) {
margin-left: 20px;
white-space: nowrap;
.el-breadcrumb__item {
display: inline-block;
float: none;
span {
font-weight: normal;
}
&:last-child span {
color: #97a8be;
}
}
}
}
}
// 面包屑动画
.breadcrumb-enter-active {
transition: all 0.25s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(30px) skewX(-50deg);
}
</style>
-208
View File
@@ -1,208 +0,0 @@
<script setup name="Layout">
import hotkeys from 'hotkeys-js'
import Header from './components/Header/index.vue'
import MainSidebar from './components/MainSidebar/index.vue'
import SubSidebar from './components/SubSidebar/index.vue'
import Topbar from './components/Topbar/index.vue'
import Search from './components/Search/index.vue'
import AppSetting from './components/AppSetting/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useKeepAliveStore from '@/store/modules/keepAlive'
import { useMainPage } from '@/utils/composables'
const routeInfo = useRoute()
const settingsStore = useSettingsStore()
const keepAliveStore = useKeepAliveStore()
const showCopyright = computed(() => {
return typeof routeInfo.meta.copyright !== 'undefined' ? routeInfo.meta.copyright : settingsStore.copyright.enable
})
watch(() => settingsStore.menu.subMenuCollapse, val => {
if (settingsStore.mode === 'mobile') {
if (!val) {
document.querySelector('body').classList.add('hidden')
} else {
document.querySelector('body').classList.remove('hidden')
}
}
})
watch(() => routeInfo.path, () => {
if (settingsStore.mode === 'mobile') {
settingsStore.$patch(state => {
state.menu.subMenuCollapse = true
})
}
})
onMounted(() => {
hotkeys('f5', e => {
if (settingsStore.topbar.enablePageReload) {
e.preventDefault()
useMainPage().reload()
}
})
})
onUnmounted(() => {
hotkeys.unbind('f5')
})
</script>
<template>
<div class="layout">
<div id="app-main">
<Header />
<div class="wrapper">
<div class="sidebar-container" :class="{'show': settingsStore.mode === 'mobile' && !settingsStore.menu.subMenuCollapse}">
<MainSidebar />
<SubSidebar />
</div>
<div class="sidebar-mask" :class="{'show': settingsStore.mode === 'mobile' && !settingsStore.menu.subMenuCollapse}" @click="settingsStore.toggleSidebarCollapse()" />
<div class="main-container" :style="{'padding-bottom': $route.meta.paddingBottom}">
<Topbar v-if="!(settingsStore.menu.menuMode === 'head' && !settingsStore.topbar.enableSidebarCollapse && !settingsStore.topbar.enableBreadcrumb)" />
<div class="main">
<router-view v-slot="{ Component, route }">
<transition name="main" mode="out-in" appear>
<keep-alive :include="keepAliveStore.list">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</div>
<Copyright v-if="showCopyright" />
</div>
</div>
<el-backtop :right="20" :bottom="20" title="回到顶部" />
</div>
<Search />
<AppSetting />
</div>
</template>
<style lang="scss" scoped>
[data-mode="mobile"] {
.sidebar-container {
transform: translateX(calc((var(--g-main-sidebar-width) + var(--g-sub-sidebar-width)) * -1));
&.show {
transform: translateX(0);
}
}
.main-container {
margin-left: 0 !important;
}
&[data-menu-mode="single"] {
.sidebar-container {
transform: translateX(calc(var(--g-sub-sidebar-width) * -1));
&.show {
transform: translateX(0);
}
}
}
}
.layout {
height: 100%;
}
#app-main {
width: 100%;
height: 100%;
margin: 0 auto;
transition: all 0.2s;
}
.wrapper {
position: relative;
width: 100%;
height: 100%;
box-shadow: -1px 0 0 0 var(--g-box-shadow-color);
transition: padding-top 0.3s;
.sidebar-container {
position: fixed;
z-index: 1010;
top: 0;
bottom: 0;
display: flex;
transition: transform 0.3s, top 0.3s;
width: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
}
.sidebar-mask {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(2px);
transition: all 0.2s;
transform: translateZ(0);
opacity: 0;
visibility: hidden;
&.show {
opacity: 1;
visibility: visible;
}
}
.main-sidebar-container:not(.main-sidebar-leave-active) + .sub-sidebar-container {
left: var(--g-main-sidebar-width);
}
.main-container {
display: flex;
flex-direction: column;
min-height: 100%;
margin-left: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
background-color: var(--g-main-bg);
transition: margin-left 0.3s, background-color 0.3s;
.topbar-container {
top: 0;
z-index: 998;
}
.main {
height: 100%;
flex: auto;
position: relative;
overflow: hidden;
transition: 0.3s;
}
.topbar-container + .main {
margin: var(--g-topbar-height) 0 0;
}
}
}
header:not(.header-leave-active) + .wrapper {
padding-top: var(--g-header-height);
.sidebar-container {
top: var(--g-header-height);
:deep(.sidebar-logo) {
display: none;
}
:deep(.el-menu) {
padding-top: 0;
}
}
.main-container {
.topbar-container {
top: var(--g-header-height);
:deep(.tools) {
display: none;
}
}
}
}
// 主内容区动画
.main-enter-active {
transition: 0.2s;
}
.main-leave-active {
transition: 0.15s;
}
.main-enter-from {
opacity: 0;
margin-left: -20px;
}
.main-leave-to {
opacity: 0;
margin-left: 20px;
}
</style>
+532
View File
@@ -0,0 +1,532 @@
<script lang="ts" setup name="AppSetting">
import { useClipboard } from '@vueuse/core'
import eventBus from '@/utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
import globalSettingsDefault from '@/settings.default'
const route = useRoute()
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const isShow = ref(false)
const settings = ref(globalSettingsDefault)
watch(() => settings, () => {
settingsStore.updateSettings(settings.value)
menuStore.setActived(0)
settings.value.menu.menuMode !== 'single' && menuStore.setActived(route.fullPath)
}, {
deep: true,
})
onMounted(() => {
eventBus.on('global-theme-toggle', () => {
isShow.value = !isShow.value
})
})
const { copy, copied, isSupported } = useClipboard()
watch(copied, (val) => {
if (val)
ElMessage.success('复制成功,请粘贴到 src/settings.ts 文件中!')
})
function handleCopy() {
copy(JSON.stringify(settings.value, null, 4))
}
</script>
<template>
<div>
<el-drawer v-model="isShow" title="应用配置" direction="rtl" :size="360">
<el-alert title="应用配置可实时预览效果,但只是临时生效,要想真正作用于项目,可以点击下方的“复制配置”按钮,并将配置粘贴到 src/settings.ts 文件中。同时建议在生产环境隐藏应用配置功能。" type="error" :closable="false" />
<el-divider>颜色主题</el-divider>
<div class="color-scheme">
<div class="switch" :class="settings.app.colorScheme" @click="settings.app.colorScheme = settings.app.colorScheme === 'dark' ? 'light' : 'dark'">
<el-icon class="icon">
<svg-icon :name="settings.app.colorScheme === 'light' ? 'ep:sunny' : 'ep:moon'" />
</el-icon>
</div>
</div>
<el-divider v-if="settingsStore.mode === 'pc'">
导航栏模式
</el-divider>
<div v-if="settingsStore.mode === 'pc'" class="menu-mode">
<el-tooltip content="侧边栏模式(含主导航)" placement="top" :show-after="500">
<div class="mode mode-side" :class="{ active: settings.menu.menuMode === 'side' }" @click="settings.menu.menuMode = 'side'">
<div class="mode-container" />
<el-icon>
<svg-icon name="ep:check" />
</el-icon>
</div>
</el-tooltip>
<el-tooltip content="顶部模式" placement="top" :show-after="500">
<div class="mode mode-head" :class="{ active: settings.menu.menuMode === 'head' }" @click="settings.menu.menuMode = 'head'">
<div class="mode-container" />
<el-icon>
<svg-icon name="ep:check" />
</el-icon>
</div>
</el-tooltip>
<el-tooltip content="侧边栏模式(不含主导航)" placement="top" :show-after="500">
<div class="mode mode-single" :class="{ active: settings.menu.menuMode === 'single' }" @click="settings.menu.menuMode = 'single'">
<div class="mode-container" />
<el-icon>
<svg-icon name="ep:check" />
</el-icon>
</div>
</el-tooltip>
</div>
<el-divider>导航栏</el-divider>
<div class="setting-item">
<div class="label">
主导航切换跳转
<el-tooltip content="开启该功能后,切换主导航时,页面自动跳转至该主导航下,次导航里第一个导航" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.menu.switchMainMenuAndPageJump" :disabled="['single'].includes(settings.menu.menuMode)" />
</div>
<div class="setting-item">
<div class="label">
次导航保持展开一个
<el-tooltip content="开启该功能后,次导航只保持单个菜单的展开" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.menu.subMenuUniqueOpened" />
</div>
<div class="setting-item">
<div class="label">
次导航是否折叠
</div>
<el-switch v-model="settings.menu.subMenuCollapse" />
</div>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">
显示次导航折叠按钮
</div>
<el-switch v-model="settings.menu.enableSubMenuCollapseButton" />
</div>
<div class="setting-item">
<div class="label">
是否启用快捷键
</div>
<el-switch v-model="settings.menu.enableHotkeys" :disabled="['single'].includes(settingsStore.menu.menuMode)" />
</div>
<el-divider>顶栏</el-divider>
<div class="setting-item">
<div class="label">
是否固定
</div>
<el-switch v-model="settings.topbar.fixed" />
</div>
<el-divider>工具栏</el-divider>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">
全屏
<el-tooltip content="该功能使用场景极少,用户习惯于通过窗口“最大化”功能来扩大显示区域,以显示更多内容,并且使用 F11 键也可以进入全屏效果" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.toolbar.enableFullscreen" />
</div>
<div class="setting-item">
<div class="label">
页面刷新
<el-tooltip content="开启时会阻止原生 F5 键刷新功能,并采用框架提供的刷新模式进行页面刷新" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.toolbar.enablePageReload" />
</div>
<div class="setting-item">
<div class="label">
颜色主题
<el-tooltip content="开启后可在明亮/暗黑模式中切换" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.toolbar.enableColorScheme" />
</div>
<el-divider v-if="settingsStore.mode === 'pc'">
面包屑导航
</el-divider>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">
是否启用
</div>
<el-switch v-model="settings.breadcrumb.enable" />
</div>
<el-divider>导航搜索</el-divider>
<div class="setting-item">
<div class="label">
是否启用
<el-tooltip content="对导航进行快捷搜索" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.navSearch.enable" />
</div>
<div class="setting-item">
<div class="label">
是否启用快捷键
</div>
<el-switch v-model="settings.navSearch.enableHotkeys" :disabled="!settings.navSearch.enable" />
</div>
<el-divider>底部版权</el-divider>
<div class="setting-item">
<div class="label">
是否启用
</div>
<el-switch v-model="settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
日期
</div>
<el-input v-model="settings.copyright.dates" size="small" :disabled="!settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
公司
</div>
<el-input v-model="settings.copyright.company" size="small" :disabled="!settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
网址
</div>
<el-input v-model="settings.copyright.website" size="small" :disabled="!settings.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
备案
</div>
<el-input v-model="settings.copyright.beian" size="small" :disabled="!settings.copyright.enable" />
</div>
<el-divider>控制台</el-divider>
<div class="setting-item">
<div class="label">
是否开启
<el-tooltip content="该功能开启时,登录成功默认进入控制台页面,反之则默认进入导航栏里第一个导航页面" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.dashboard.enable" />
</div>
<div class="setting-item">
<div class="label">
控制台名称
</div>
<el-input v-model="settings.dashboard.title" size="small" />
</div>
<el-divider>其它</el-divider>
<div class="setting-item">
<div class="label">
组件尺寸
<el-tooltip content="全局设置 Element Plus 组件的默认尺寸大小" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-radio-group v-model="settings.app.elementSize" size="small">
<el-radio-button label="large">
较大
</el-radio-button>
<el-radio-button label="default">
默认
</el-radio-button>
<el-radio-button label="small">
稍小
</el-radio-button>
</el-radio-group>
</div>
<div class="setting-item">
<div class="label">
是否启用权限
</div>
<el-switch v-model="settings.app.enablePermission" />
</div>
<div class="setting-item">
<div class="label">
载入进度条
<el-tooltip content="该功能开启时,跳转路由会看到页面顶部有进度条" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.app.enableProgress" />
</div>
<div class="setting-item">
<div class="label">
动态标题
<el-tooltip content="该功能开启时,页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置" placement="top">
<el-icon>
<svg-icon name="ep:question-filled" />
</el-icon>
</el-tooltip>
</div>
<el-switch v-model="settings.app.enableDynamicTitle" />
</div>
<template v-if="isSupported" #footer>
<el-button type="primary" @click="handleCopy">
<template #icon>
<el-icon>
<svg-icon name="ep:document-copy" />
</el-icon>
</template>
复制配置
</el-button>
</template>
</el-drawer>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: initial;
padding-bottom: 20px;
border-bottom: 1px solid var(--el-border-color);
}
:deep(.el-drawer__footer) {
padding: 20px;
border-top: 1px solid var(--el-border-color);
transition: var(--el-transition-border);
.el-button {
width: 100%;
}
}
:deep(.el-divider) {
margin: 36px 0 24px;
}
.color-scheme {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 10px;
$width: 50px;
.switch {
width: $width;
height: 30px;
border-radius: 15px;
cursor: pointer;
background-color: var(--el-fill-color-darker);
transition: background-color 0.3s;
&.dark {
.icon {
margin-left: calc($width - 24px - 3px);
}
}
.icon {
margin: 3px;
padding: 5px;
font-size: 24px;
border-radius: 50%;
background-color: var(--el-fill-color-lighter);
transition: margin-left 0.3s, background-color 0.3s;
}
}
}
.menu-mode {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding-bottom: 10px;
.mode {
position: relative;
width: 80px;
height: 55px;
margin: 10px;
border-radius: 5px;
overflow: hidden;
cursor: pointer;
background-color: var(--g-app-bg);
box-shadow: 0 0 5px 1px var(--el-border-color-lighter);
transition: 0.2s;
&:hover {
box-shadow: 0 0 5px 1px var(--el-border-color-darker);
}
&.active {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
&::before,
&::after,
.mode-container {
pointer-events: none;
position: absolute;
border-radius: 3px;
}
.mode-container::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.2;
}
&-side {
&::before {
content: "";
top: 5px;
left: 5px;
bottom: 5px;
width: 10px;
background-color: var(--g-sub-sidebar-menu-active-bg);
}
&::after {
content: "";
top: 5px;
left: 20px;
bottom: 5px;
width: 15px;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.5;
}
.mode-container {
top: 5px;
left: 40px;
right: 5px;
bottom: 5px;
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
}
}
&-head {
&::before {
content: "";
top: 5px;
left: 5px;
right: 5px;
height: 10px;
background-color: var(--g-sub-sidebar-menu-active-bg);
}
&::after {
content: "";
top: 20px;
left: 5px;
bottom: 5px;
width: 15px;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.5;
}
.mode-container {
top: 20px;
left: 25px;
right: 5px;
bottom: 5px;
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
}
}
&-single {
&::before {
content: "";
position: absolute;
top: 5px;
left: 5px;
bottom: 5px;
width: 15px;
background-color: var(--g-sub-sidebar-menu-active-bg);
opacity: 0.5;
}
.mode-container {
top: 5px;
left: 25px;
right: 5px;
bottom: 5px;
border: 1px dashed var(--g-sub-sidebar-menu-active-bg);
}
}
i {
position: absolute;
right: 10px;
bottom: 10px;
display: none;
}
&.active i {
display: block;
color: var(--el-color-primary);
}
}
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 5px 0;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background: var(--el-fill-color);
}
.label {
font-size: 14px;
color: var(--el-text-color-regular);
display: flex;
align-items: center;
i {
margin-left: 4px;
font-size: 17px;
color: var(--el-color-warning);
cursor: help;
}
}
.el-switch {
height: auto;
}
.el-input {
width: 150px;
}
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts" setup name="Header">
import Logo from '../Logo/index.vue'
import Tools from '../Tools/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const { switchTo } = useMenu()
const navRef = ref()
// 顶部模式鼠标滚动
function handlerMouserScroll(event: WheelEvent) {
navRef.value.scrollBy({
left: (event.deltaY || event.detail) > 0 ? 50 : -50,
})
}
</script>
<template>
<transition name="header">
<header v-if="settingsStore.mode === 'pc' && settingsStore.menu.menuMode === 'head'">
<div class="header-container">
<div class="main">
<Logo />
<!-- 顶部模式 -->
<div ref="navRef" class="nav" @wheel.prevent="handlerMouserScroll">
<template v-for="(item, index) in menuStore.allMenus" :key="index">
<div v-if="item.children && item.children.length !== 0" class="item-container" :class="{ active: index === menuStore.actived }">
<div class="item" @click="switchTo(index)">
<el-icon>
<svg-icon v-if="item.meta.icon" :name="item.meta.icon" />
</el-icon>
<span v-if="item.meta.title">{{ item.meta.title }}</span>
</div>
</div>
</template>
</div>
</div>
<Tools />
</div>
</header>
</transition>
</template>
<style lang="scss" scoped>
header {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 0 20px;
height: var(--g-header-height);
color: var(--g-header-color);
background-color: var(--g-header-bg);
transition: background-color 0.3s, var(--el-transition-color);
.header-container {
width: var(--g-header-width);
height: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
.main {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
height: 100%;
}
}
@media screen and (max-width: var(--g-header-width)) {
.header-container {
width: 100%;
}
}
:deep(.title) {
position: relative;
width: inherit;
height: inherit;
padding: inherit;
background-color: inherit;
.logo {
width: 50px;
height: 50px;
}
span {
font-size: 24px;
letter-spacing: 1px;
color: var(--g-header-color);
}
}
.nav {
flex: 1;
display: flex;
width: 0;
height: 100%;
margin: 0 30px;
padding: 0 20px;
overflow-x: auto;
mask-image: linear-gradient(to right, transparent, #000 20px, #000 calc(100% - 20px), transparent);
// firefox隐藏滚动条
scrollbar-width: none;
// chrome隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
.item-container {
position: relative;
display: flex;
width: initial;
.item {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 5px;
width: 80px;
height: 100%;
cursor: pointer;
color: var(--g-header-menu-color);
background-color: var(--g-header-bg);
transition: background-color 0.3s, var(--el-transition-color);
&:hover {
color: var(--g-header-menu-hover-color);
background-color: var(--g-header-menu-hover-bg);
}
.el-icon {
font-size: 24px;
vertical-align: middle;
}
span {
text-align: center;
vertical-align: middle;
word-break: break-all;
@include text-overflow(1, false);
}
}
&.active .item {
color: var(--g-header-menu-active-color);
background-color: var(--g-header-menu-active-bg);
}
}
}
:deep(.tools) {
padding: 0;
.buttons .item .el-icon {
color: var(--g-header-color);
}
.user-container {
font-size: 16px;
color: var(--g-header-color);
}
}
}
// 头部动画
.header-enter-active,
.header-leave-active {
transition: transform 0.3s;
}
.header-enter-from,
.header-leave-to {
transform: translateY(calc(var(--g-header-height) * -1));
}
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import eventBus from '@/utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
const isShow = ref(false)
const settingsStore = useSettingsStore()
onMounted(() => {
eventBus.on('global-hotkeys-intro-toggle', () => {
isShow.value = !isShow.value
})
})
</script>
<template>
<div>
<el-drawer v-model="isShow" title="快捷键介绍" direction="rtl" :size="360">
<el-descriptions title="全局" :column="1" border>
<el-descriptions-item label="查看系统信息">
Alt + I
</el-descriptions-item>
<el-descriptions-item v-if="settingsStore.navSearch.enable && settingsStore.navSearch.enableHotkeys" label="唤起导航搜索">
Alt + S
</el-descriptions-item>
</el-descriptions>
<el-descriptions v-if="settingsStore.menu.enableHotkeys && ['side', 'head'].includes(settingsStore.menu.menuMode)" title="主导航" :column="1" border>
<el-descriptions-item label="激活下一个主导航">
Alt + `
</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: initial;
padding-bottom: 20px;
border-bottom: 1px solid var(--el-border-color);
transition: var(--el-transition-border);
}
:deep(.el-descriptions) {
margin-bottom: 20px;
.el-descriptions__label {
width: 200px;
}
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts" setup name="Logo">
import imgLogo from '@/assets/images/logo.png'
import useSettingsStore from '@/store/modules/settings'
defineProps({
showLogo: {
type: Boolean,
default: true,
},
showTitle: {
type: Boolean,
default: true,
},
})
const settingsStore = useSettingsStore()
const title = ref(import.meta.env.VITE_APP_TITLE)
const logo = ref(imgLogo)
const to = computed(() => {
const rtn: {
name?: string
} = {}
if (settingsStore.dashboard.enable)
rtn.name = 'dashboard'
return rtn
})
</script>
<template>
<router-link :to="to" class="title" :class="{ 'is-link': settingsStore.dashboard.enable }" :title="title">
<img v-if="showLogo" :src="logo" class="logo">
<span v-if="showTitle">{{ title }}</span>
</router-link>
</template>
<style lang="scss" scoped>
.title {
position: fixed;
z-index: 1000;
top: 0;
width: inherit;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
height: var(--g-sidebar-logo-height);
text-align: center;
overflow: hidden;
text-decoration: none;
&.is-link {
cursor: pointer;
}
.logo {
width: 30px;
height: 30px;
& + span {
margin-left: 10px;
}
}
span {
display: block;
font-weight: bold;
color: #fff;
@include text-overflow;
}
}
</style>
@@ -0,0 +1,113 @@
<script lang="ts" setup name="MainSidebar">
import Logo from '../Logo/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const { switchTo } = useMenu()
</script>
<template>
<transition name="main-sidebar">
<div v-if="settingsStore.menu.menuMode === 'side' || (settingsStore.mode === 'mobile' && settingsStore.menu.menuMode !== 'single')" class="main-sidebar-container">
<Logo :show-title="false" class="sidebar-logo" />
<!-- 侧边栏模式(含主导航) -->
<div class="nav">
<template v-for="(item, index) in menuStore.allMenus">
<div
v-if="item.children && item.children.length !== 0" :key="index" class="item" :class="{
active: index === menuStore.actived,
}" :title="item.meta.title" @click="switchTo(index)"
>
<el-icon>
<svg-icon v-if="item.meta.icon" :name="item.meta.icon" />
</el-icon>
<span>{{ item.meta.title }}</span>
</div>
</template>
</div>
</div>
</transition>
</template>
<style lang="scss" scoped>
.main-sidebar-container {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
// firefox隐藏滚动条
scrollbar-width: none;
// chrome隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
position: relative;
z-index: 1;
width: var(--g-main-sidebar-width);
color: var(--g-main-sidebar-menu-color);
background-color: var(--g-main-sidebar-bg);
transition: background-color 0.3s, var(--el-transition-color);
.sidebar-logo {
transition: 0.3s;
background-color: var(--g-main-sidebar-bg);
}
.nav {
width: inherit;
padding-top: var(--g-sidebar-logo-height);
.item {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
height: 60px;
padding: 0 5px;
cursor: pointer;
color: var(--g-main-sidebar-menu-color);
background-color: var(--g-main-sidebar-bg);
transition: background-color 0.3s, var(--el-transition-color);
&:hover {
color: var(--g-main-sidebar-menu-hover-color);
background-color: var(--g-main-sidebar-menu-hover-bg);
}
&.active {
color: var(--g-main-sidebar-menu-active-color);
background-color: var(--g-main-sidebar-menu-active-bg);
}
.el-icon {
margin: 0 auto;
font-size: 24px;
}
span {
text-align: center;
font-size: 14px;
@include text-overflow(1, false);
}
}
}
}
// 主侧边栏动画
.main-sidebar-enter-active,
.main-sidebar-leave-active {
transition: transform 0.3s;
}
.main-sidebar-enter-from,
.main-sidebar-leave-to {
transform: translateX(calc(var(--g-main-sidebar-width) * -1));
}
</style>
+448
View File
@@ -0,0 +1,448 @@
<script lang="ts" setup name="Search">
import { cloneDeep } from 'lodash-es'
import hotkeys from 'hotkeys-js'
import { isExternalLink } from '@/utils'
import eventBus from '@/utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
import useRouteStore from '@/store/modules/route'
import useMenuStore from '@/store/modules/menu'
import type { Menu, Route } from '@/global'
const router = useRouter()
const settingsStore = useSettingsStore()
const routeStore = useRouteStore()
const menuStore = useMenuStore()
interface listTypes {
icon?: string
title: string
breadcrumb: string[]
path: string
}
const isShow = ref(false)
const searchInput = ref('')
const sourceList = ref<listTypes[]>([])
const actived = ref(-1)
const searchInputRef = ref()
const searchResultRef = ref()
const searchResultItemRef = ref<any>([])
const setSearchResultItemRef = (el: any) => searchResultItemRef.value.push(el)
onBeforeUpdate(() => {
searchResultItemRef.value = []
})
const resultList = computed(() => {
let result = []
result = sourceList.value.filter((item) => {
let flag = false
if (item.title.includes(searchInput.value))
flag = true
if (item.path.includes(searchInput.value))
flag = true
if (item.breadcrumb.some(b => b.includes(searchInput.value)))
flag = true
return flag
})
return result
})
watch(() => isShow.value, (val) => {
if (val) {
document.body.classList.add('hidden')
searchResultRef.value.scrollTop = 0
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
hotkeys('up', keyUp)
hotkeys('down', keyDown)
hotkeys('enter', keyEnter)
setTimeout(() => {
searchInputRef.value.focus()
}, 500)
}
else {
document.body.classList.remove('hidden')
hotkeys.unbind('up', keyUp)
hotkeys.unbind('down', keyDown)
hotkeys.unbind('enter', keyEnter)
setTimeout(() => {
searchInput.value = ''
actived.value = -1
}, 500)
}
})
watch(() => resultList.value, () => {
actived.value = -1
searchResultItemRef.value = []
handleScroll()
})
onMounted(() => {
eventBus.on('global-search-toggle', () => {
isShow.value = !isShow.value
})
hotkeys('alt+s', (e) => {
if (settingsStore.navSearch.enable && settingsStore.navSearch.enableHotkeys) {
e.preventDefault()
isShow.value = true
}
})
hotkeys('esc', (e) => {
if (settingsStore.navSearch.enable && settingsStore.navSearch.enableHotkeys) {
e.preventDefault()
isShow.value = false
}
})
if (settingsStore.app.routeBaseOn !== 'filesystem') {
routeStore.routes.forEach((item) => {
item.children && getSourceList(item.children)
})
}
else {
menuStore.menus.forEach((item) => {
getSourceListByMenus(item.children)
})
}
})
function hasChildren(item: Route.recordRaw) {
let flag = true
if (item.children && item.children.every(i => i.meta.sidebar === false))
flag = false
return flag
}
function getSourceList(arr: Route.recordRaw[], basePath?: string, icon?: string, breadcrumb?: string[]) {
arr.forEach((item) => {
if (item.meta.sidebar !== false) {
const breadcrumbTemp = cloneDeep(breadcrumb) || []
if (item.children && hasChildren(item)) {
breadcrumbTemp.push(typeof item.meta.title === 'function' ? item.meta.title() : item.meta.title)
getSourceList(item.children, basePath ? [basePath, item.path].join('/') : item.path, item.meta.icon || icon, breadcrumbTemp)
}
else {
breadcrumbTemp.push(typeof item.meta.title === 'function' ? item.meta.title() : item.meta.title)
let path = ''
if (isExternalLink(item.path))
path = item.path
else
path = basePath ? [basePath, item.path].join('/') : item.path
sourceList.value.push({
icon: item.meta.icon || icon,
title: typeof item.meta.title === 'function' ? item.meta.title() : item.meta.title,
breadcrumb: breadcrumbTemp,
path,
})
}
}
})
}
function getSourceListByMenus(arr: Menu.recordRaw[], icon?: string, breadcrumb?: string[]) {
arr.forEach((item) => {
const breadcrumbTemp = cloneDeep(breadcrumb) || []
if (item.children && item.children.length > 0) {
breadcrumbTemp.push(item.meta.title)
getSourceListByMenus(item.children, item.meta.icon, breadcrumbTemp)
}
else {
breadcrumbTemp.push(item.meta.title)
sourceList.value.push({
icon: item.meta.icon || icon,
title: item.meta.title,
path: item.path as string,
breadcrumb: breadcrumbTemp,
})
}
})
}
function keyUp() {
if (resultList.value.length) {
actived.value -= 1
if (actived.value < 0)
actived.value = resultList.value.length - 1
handleScroll()
}
}
function keyDown() {
if (resultList.value.length) {
actived.value += 1
if (actived.value > resultList.value.length - 1)
actived.value = 0
handleScroll()
}
}
function keyEnter() {
if (actived.value !== -1)
searchResultItemRef.value[actived.value].click()
}
function handleScroll() {
let scrollTo = 0
if (actived.value !== -1) {
scrollTo = searchResultRef.value.scrollTop
const activedOffsetTop = searchResultItemRef.value[actived.value].offsetTop
const activedClientHeight = searchResultItemRef.value[actived.value].clientHeight
const searchScrollTop = searchResultRef.value.scrollTop
const searchClientHeight = searchResultRef.value.clientHeight
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight)
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
else if (activedOffsetTop <= searchScrollTop)
scrollTo = activedOffsetTop
}
searchResultRef.value.scrollTo({
top: scrollTo,
})
}
function pageJump(url: string) {
if (isExternalLink(url))
window.open(url, 'top')
else
router.push(url)
}
</script>
<template>
<div id="search" :class="{ searching: isShow }" @click="isShow && eventBus.emit('global-search-toggle')">
<div class="container">
<div class="search-box" @click.stop>
<el-input ref="searchInputRef" v-model="searchInput" placeholder="搜索页面,支持标题、URL模糊查询" clearable @keydown.esc="eventBus.emit('global-search-toggle')" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
<template #prefix>
<el-icon>
<svg-icon name="ep:search" />
</el-icon>
</template>
</el-input>
<div v-if="settingsStore.mode === 'pc'" class="tips">
<div class="tip">
<span>Alt</span>+<span>S</span>
唤醒搜索面板
</div>
<div class="tip">
<span>
<el-icon>
<svg-icon name="search-up" />
</el-icon>
</span>
<span>
<el-icon>
<svg-icon name="search-down" />
</el-icon>
</span>
切换搜索结果
</div>
<div class="tip">
<span>
<el-icon>
<svg-icon name="search-enter" />
</el-icon>
</span>
访问页面
</div>
<div class="tip">
<span>ESC</span>
退出
</div>
</div>
</div>
<div ref="searchResultRef" class="result" :class="{ mobile: settingsStore.mode === 'mobile' }">
<a v-for="(item, index) in resultList" :key="item.path" :ref="setSearchResultItemRef" class="item" :class="{ actived: index === actived }" @click="pageJump(item.path)" @mouseover="actived = index">
<el-icon class="icon">
<svg-icon v-if="item.icon" :name="item.icon" />
</el-icon>
<div class="info">
<div class="title">
{{ item.title }}
</div>
<div class="breadcrumb">
<span v-for="(bc, bcIndex) in item.breadcrumb" :key="bcIndex">
{{ bc }}
<el-icon>
<svg-icon name="ep:arrow-right" />
</el-icon>
</span>
</div>
<div class="path">{{ item.path }}</div>
</div>
</a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
#search {
position: fixed;
z-index: 2000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
backdrop-filter: blur(10px);
transition: all 0.2s;
transform: translateZ(0);
opacity: 0;
visibility: hidden;
&.searching {
opacity: 1;
visibility: visible;
.container {
transform: initial;
filter: initial;
}
}
.container {
display: flex;
flex-direction: column;
max-width: 800px;
height: 100%;
margin: 0 auto;
transition: all 0.2s;
transform: scale(1.1);
filter: blur(10px);
.search-box {
margin: 50px 20px 40px;
:deep(.el-input__inner) {
height: 52px;
line-height: 52px;
}
:deep(.el-input__icon) {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.tips {
display: flex;
align-items: center;
justify-content: space-evenly;
margin-top: 20px;
line-height: 24px;
font-size: 14px;
color: #fff;
span {
margin: 0 5px;
padding: 3px 8px 5px;
border-radius: 5px;
font-size: 12px;
font-weight: bold;
color: var(--el-text-color-primary);
background-color: var(--el-fill-color);
box-shadow: inset 0 -2px var(--el-fill-color-lighter), inset 0 0 1px 1px var(--el-fill-color-darker);
}
}
}
.result {
position: relative;
margin: 0 20px;
max-height: calc(100% - 250px);
border-radius: 5px;
overflow: auto;
background-color: var(--el-bg-color);
box-shadow: 0 0 0 1px var(--el-border-color-darker);
&.mobile {
max-height: calc(100% - 200px);
}
.item {
display: flex;
align-items: center;
text-decoration: none;
cursor: pointer;
transition: all 0.3s;
&.actived {
background-color: var(--el-bg-color-page);
.icon {
color: var(--el-color-primary);
transform: scale(1.2);
}
.info {
border-left-color: var(--el-border-color);
.title {
color: var(--el-text-color-primary);
}
.breadcrumb,
.path {
color: var(--el-text-color-regular);
}
}
}
.icon {
flex: 0 0 66px;
text-align: center;
color: var(--el-color-info);
font-size: 20px;
transition: all 0.3s;
}
.info {
flex: 1;
height: 70px;
display: flex;
flex-direction: column;
justify-content: space-around;
border-left: 1px solid var(--el-border-color-lighter);
padding: 5px 10px 7px;
transition: all 0.3s;
@include text-overflow(1, true);
.title {
font-size: 18px;
font-weight: bold;
color: var(--el-text-color-regular);
@include text-overflow(1, true);
}
.breadcrumb,
.path {
font-size: 12px;
color: var(--el-text-color-secondary);
transition: all 0.3s;
@include text-overflow(1, true);
}
.breadcrumb {
span {
margin-right: 5px;
&:last-child i {
display: none;
}
}
}
}
}
}
}
}
</style>
@@ -0,0 +1,151 @@
<script lang="ts" setup name="SidebarItem">
// import SidebarItem from './index.vue'
import { isExternalLink, resolveRoutePath } from '@/utils'
import type { Menu } from '@/global'
const props = defineProps({
item: {
type: Object as () => Menu.recordRaw,
required: true,
},
basePath: {
type: String,
default: '',
},
})
const hasChildren = computed(() => {
let flag = true
if (props.item.children) {
if (props.item.children.every(item => item.meta.sidebar === false))
flag = false
}
else {
flag = false
}
return flag
})
</script>
<template>
<div class="sidebar-item">
<router-link v-if="!hasChildren" v-slot="{ href, navigate, isActive, isExactActive }" custom :to="resolveRoutePath(basePath, item.path || '')">
<a :href="isExternalLink(resolveRoutePath(basePath, item.path || '')) ? resolveRoutePath(basePath, item.path || '') : href" :class="[isActive && 'router-link-active', isExactActive && 'router-link-exact-active']" :target="isExternalLink(resolveRoutePath(basePath, item.path || '')) ? '_blank' : '_self'" @click="navigate">
<el-menu-item :title="item.meta.title" :index="resolveRoutePath(basePath, item.path || '')">
<el-icon v-if="item.meta.icon" class="title-icon">
<svg-icon :name="item.meta.icon" />
</el-icon>
<span class="title">{{ item.meta.title }}</span>
</el-menu-item>
</a>
</router-link>
<el-sub-menu v-else :title="item.meta.title" :index="resolveRoutePath(basePath, item.path || '')">
<template #title>
<el-icon v-if="item.meta.icon" class="title-icon">
<svg-icon :name="item.meta.icon" />
</el-icon>
<span class="title">{{ item.meta.title }}</span>
</template>
<template v-for="route in item.children">
<SidebarItem v-if="route.meta.sidebar !== false" :key="route.path" :item="route" :base-path="resolveRoutePath(basePath, item.path || '')" />
</template>
</el-sub-menu>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-menu-item),
:deep(.el-menu-item span.title),
:deep(.el-sub-menu__title),
:deep(.el-sub-menu__title span.title) {
flex: 1;
vertical-align: inherit;
@include text-overflow;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.el-sub-menu),
:deep(.el-menu-item) {
.title-icon {
width: 20px;
font-size: 20px;
vertical-align: -0.25em;
transition: transform 0.3s;
color: unset;
}
.title-icon + .title {
margin-left: 10px;
}
&:hover > .title-icon,
.el-sub-menu__title:hover > .title-icon {
transform: scale(1.2);
}
}
a {
cursor: pointer;
color: inherit;
text-decoration: none;
}
.el-sub-menu__title {
> .badge {
&-dot {
right: 40px;
}
&-text {
right: 40px;
}
}
}
</style>
<style lang="scss">
.el-menu--inline {
background-color: var(--g-sub-sidebar-menu-bg) !important;
.el-menu-item,
.el-sub-menu > .el-sub-menu__title {
color: var(--g-sub-sidebar-menu-color);
background-color: var(--g-sub-sidebar-menu-bg) !important;
&:hover {
color: var(--g-sub-sidebar-menu-hover-color) !important;
background-color: var(--g-sub-sidebar-menu-hover-bg) !important;
}
}
}
.el-menu-item,
.el-sub-menu__title {
color: var(--g-sub-sidebar-menu-color) !important;
background: transparent !important;
&:hover {
color: var(--g-sub-sidebar-menu-hover-color) !important;
background-color: var(--g-sub-sidebar-menu-hover-bg) !important;
}
}
.el-menu-item.is-active,
.el-menu--collapse .el-sub-menu.is-active > .el-sub-menu__title,
.el-sub-menu .el-menu--inline .el-menu-item.is-active {
color: var(--g-sub-sidebar-menu-active-color) !important;
background-color: var(--g-sub-sidebar-menu-active-bg) !important;
.icon,
.el-sub-menu__icon-arrow {
color: unset;
}
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts" setup name="SubSidebar">
import Logo from '../Logo/index.vue'
import SidebarItem from '../SidebarItem/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useMenuStore from '@/store/modules/menu'
const settingsStore = useSettingsStore()
const menuStore = useMenuStore()
const sidebarScrollTop = ref(0)
function onSidebarScroll(e: Event) {
sidebarScrollTop.value = (e.target as HTMLElement).scrollTop
}
</script>
<template>
<div v-if="['side', 'head', 'single'].includes(settingsStore.menu.menuMode) || settingsStore.mode === 'mobile'" class="sub-sidebar-container" :class="{ 'is-collapse': settingsStore.mode === 'pc' && settingsStore.menu.subMenuCollapse }" @scroll="onSidebarScroll">
<Logo
:show-logo="settingsStore.menu.menuMode === 'single'" class="sidebar-logo" :class="{
'sidebar-logo-bg': settingsStore.menu.menuMode === 'single',
'shadow': sidebarScrollTop,
}"
/>
<!-- 侧边栏模式(无主导航) -->
<el-menu
:unique-opened="settingsStore.menu.subMenuUniqueOpened" :default-openeds="menuStore.defaultOpenedPaths" :default-active="$route.meta.activeMenu || $route.path" :collapse="settingsStore.mode === 'pc' && settingsStore.menu.subMenuCollapse" :collapse-transition="false" :class="{
'is-collapse-without-logo': settingsStore.menu.menuMode !== 'single' && settingsStore.menu.subMenuCollapse,
}"
>
<transition-group name="sub-sidebar">
<template v-for="(route, index) in menuStore.sidebarMenus">
<SidebarItem v-if="route.meta.sidebar !== false" :key="route.path || index" :item="route" :base-path="route.path" />
</template>
</transition-group>
</el-menu>
</div>
</template>
<style lang="scss" scoped>
.sub-sidebar-container {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
// firefox隐藏滚动条
scrollbar-width: none;
// chrome隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
width: var(--g-sub-sidebar-width);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: var(--g-sub-sidebar-bg);
box-shadow: 10px 0 10px -10px var(--g-box-shadow-color);
transition: background-color 0.3s, var(--el-transition-box-shadow), left 0.3s, width 0.3s;
&.is-collapse {
width: 64px;
.sidebar-logo {
&:not(.sidebar-logo-bg) {
display: none;
}
:deep(span) {
display: none;
}
}
}
.sidebar-logo {
transition: box-shadow 0.2s, background-color 0.3s, color 0.3s;
background-color: var(--g-sub-sidebar-bg);
&:not(.sidebar-logo-bg) {
:deep(span) {
color: var(--g-sub-sidebar-menu-color);
}
}
&.sidebar-logo-bg {
background-color: var(--g-main-sidebar-bg);
}
&.shadow {
box-shadow: 0 10px 10px -10px var(--g-box-shadow-color);
}
}
.el-menu {
border-right: 0;
padding-top: var(--g-sidebar-logo-height);
transition: border-color 0.3s, background-color 0.3s, color 0.3s, padding-top 0.3s;
background-color: var(--g-sub-sidebar-bg);
&:not(.el-menu--collapse) {
width: inherit;
}
&.is-collapse-without-logo {
padding-top: 0;
}
&.el-menu--collapse {
:deep(.title-icon) {
margin-right: 0;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
span,
.el-sub-menu__icon-arrow {
display: none;
}
}
}
&.menu-radius:not(.el-menu--collapse) {
.sidebar-item {
padding: 0 10px;
&:first-child {
padding-top: 10px;
}
&:last-child {
padding-bottom: 10px;
}
}
:deep(.el-menu--inline),
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
border-radius: 10px;
}
}
}
}
// 次侧边栏动画
.sub-sidebar-enter-active {
transition: opacity 0.3s, transform 0.3s;
}
.sub-sidebar-enter-from,
.sub-sidebar-leave-active {
opacity: 0;
transform: translateY(30px) skewY(10deg);
}
.sub-sidebar-leave-active {
position: absolute;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts" setup name="Tools">
import { useFullscreen } from '@vueuse/core'
import eventBus from '@/utils/eventBus'
import useSettingsStore from '@/store/modules/settings'
import useUserStore from '@/store/modules/user'
const router = useRouter()
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const mainPage = useMainPage()
const { isFullscreen, toggle } = useFullscreen()
function userCommand(command: 'dashboard' | 'setting' | 'hotkeys' | 'logout') {
switch (command) {
case 'dashboard':
router.push({
name: 'dashboard',
})
break
case 'setting':
router.push({
name: 'personalSetting',
})
break
case 'hotkeys':
eventBus.emit('global-hotkeys-intro-toggle')
break
case 'logout':
userStore.logout().then(() => {
router.push({
name: 'login',
})
})
break
}
}
function pro() {
window.open('https://hooray.gitee.io/fantastic-admin-pro-example/', 'top')
}
</script>
<template>
<div class="tools">
<div class="buttons">
<span v-if="settingsStore.navSearch.enable" class="item" @click="eventBus.emit('global-search-toggle')">
<el-icon>
<svg-icon name="ep:search" />
</el-icon>
</span>
<span v-if="settingsStore.mode === 'pc' && settingsStore.toolbar.enableFullscreen" class="item" @click="toggle">
<el-icon>
<svg-icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
</el-icon>
</span>
<span v-if="settingsStore.toolbar.enablePageReload" class="item" @click="mainPage.reload()">
<el-icon>
<svg-icon name="ep:refresh-right" />
</el-icon>
</span>
<span v-if="settingsStore.toolbar.enableColorScheme" class="item" @click="settingsStore.setColorScheme(settingsStore.app.colorScheme === 'dark' ? 'light' : 'dark')">
<el-icon>
<svg-icon v-show="settingsStore.app.colorScheme === 'light'" name="ep:sunny" />
<svg-icon v-show="settingsStore.app.colorScheme === 'dark'" name="ep:moon" />
</el-icon>
</span>
<span v-if="settingsStore.toolbar.enableAppSetting" class="item" @click="eventBus.emit('global-theme-toggle')">
<el-icon>
<svg-icon name="ep:setting" />
</el-icon>
</span>
</div>
<el-dropdown class="user-container" size="default" @command="userCommand">
<div class="user-wrapper">
<el-avatar size="small">
<el-icon>
<svg-icon name="ep:user-filled" />
</el-icon>
</el-avatar>
{{ userStore.account }}
<el-icon>
<svg-icon name="ep:caret-bottom" />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<el-dropdown-item v-if="settingsStore.dashboard.enable" command="dashboard">
控制台
</el-dropdown-item>
<el-dropdown-item command="setting">
个人设置
</el-dropdown-item>
<el-dropdown-item v-if="settingsStore.mode === 'pc'" divided command="hotkeys">
快捷键介绍
</el-dropdown-item>
<el-dropdown-item divided command="logout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<style lang="scss" scoped>
.tools {
display: flex;
align-items: center;
padding: 0 20px;
white-space: nowrap;
.buttons {
margin-right: 20px;
.item {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
width: 34px;
cursor: pointer;
vertical-align: middle;
.el-icon {
color: var(--el-text-color-primary);
transition: var(--el-transition-color);
}
}
}
}
:deep(.user-container) {
display: inline-block;
height: 24px;
line-height: 24px;
cursor: pointer;
.user-wrapper {
.el-avatar {
vertical-align: middle;
margin-top: -2px;
margin-right: 4px;
}
}
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts" setup name="Topbar">
import { compile } from 'path-to-regexp'
import Tools from '../Tools/index.vue'
import useSettingsStore from '@/store/modules/settings'
const route = useRoute()
const settingsStore = useSettingsStore()
const enableSubMenuCollapseButton = computed(() => {
return settingsStore.mode === 'mobile' || (
['side', 'head', 'single'].includes(settingsStore.menu.menuMode)
&& settingsStore.menu.enableSubMenuCollapseButton
)
})
const breadcrumbList = computed(() => {
const breadcrumbList = []
if (settingsStore.dashboard.enable) {
breadcrumbList.push({
path: '/dashboard',
title: settingsStore.dashboard.title,
})
}
if (route.meta.breadcrumbNeste)
breadcrumbList.push(...route.meta.breadcrumbNeste.filter(item => item.hide === false))
return breadcrumbList
})
const scrollTop = ref(0)
onMounted(() => {
window.addEventListener('scroll', onScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
function onScroll() {
scrollTop.value = document.documentElement.scrollTop || document.body.scrollTop
}
function pathCompile(path: string) {
const toPath = compile(path)
return toPath(route.params)
}
</script>
<template>
<div
class="topbar-container" :class="{
fixed: settingsStore.topbar.fixed,
shadow: scrollTop,
}" data-fixed-calc-width
>
<div class="left-box">
<div v-if="enableSubMenuCollapseButton" class="sidebar-collapse" :class="{ 'is-collapse': settingsStore.menu.subMenuCollapse }" @click="settingsStore.toggleSidebarCollapse()">
<el-icon>
<svg-icon name="toolbar-collapse" />
</el-icon>
</div>
<el-breadcrumb v-if="settingsStore.breadcrumb.enable && settingsStore.mode === 'pc' && settingsStore.app.routeBaseOn !== 'filesystem'">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path" :to="index < breadcrumbList.length - 1 ? pathCompile(item.path) : ''">
{{ item.title }}
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</div>
<Tools />
</div>
</template>
<style lang="scss" scoped>
.topbar-container {
position: absolute;
z-index: 999;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--g-topbar-height);
background-color: var(--g-toolbar-bg);
transition: width 0.3s, top 0.3s, transform 0.3s, background-color 0.3s, var(--el-transition-box-shadow);
&.fixed {
position: fixed;
&.shadow {
box-shadow: 0 10px 10px -10px var(--g-box-shadow-color);
}
}
.left-box {
display: flex;
align-items: center;
padding-right: 50px;
overflow: hidden;
mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 50px), transparent);
.sidebar-collapse {
display: flex;
align-items: center;
padding: 0 20px;
height: 50px;
cursor: pointer;
.el-icon {
color: var(--el-text-color-primary);
transition: var(--el-transition-color), var(--el-transition-md-fade);
}
&:hover .el-icon {
color: var(--el-color-primary);
}
&.is-collapse .el-icon {
transform: rotateZ(-180deg);
}
& + .el-breadcrumb {
margin-left: 0;
}
}
:deep(.el-breadcrumb) {
margin-left: 20px;
white-space: nowrap;
.el-breadcrumb__item {
display: inline-block;
float: none;
span {
font-weight: normal;
}
&:last-child span {
color: #97a8be;
}
}
}
}
}
// 面包屑动画
.breadcrumb-enter-active {
transition: all 0.25s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(30px) skewX(-50deg);
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<script lang="ts" setup name="Layout">
import hotkeys from 'hotkeys-js'
import Header from './components/Header/index.vue'
import MainSidebar from './components/MainSidebar/index.vue'
import SubSidebar from './components/SubSidebar/index.vue'
import Topbar from './components/Topbar/index.vue'
import Search from './components/Search/index.vue'
import AppSetting from './components/AppSetting/index.vue'
import HotkeysIntro from './components/HotkeysIntro/index.vue'
import useSettingsStore from '@/store/modules/settings'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useMenuStore from '@/store/modules/menu'
const routeInfo = useRoute()
const settingsStore = useSettingsStore()
const keepAliveStore = useKeepAliveStore()
const menuStore = useMenuStore()
watch(() => settingsStore.menu.subMenuCollapse, (val) => {
if (settingsStore.mode === 'mobile') {
if (!val)
document.body.classList.add('hidden')
else
document.body.classList.remove('hidden')
}
})
watch(() => routeInfo.path, () => {
if (settingsStore.mode === 'mobile') {
settingsStore.$patch((state) => {
state.menu.subMenuCollapse = true
})
}
})
onMounted(() => {
hotkeys('f5', (e) => {
if (settingsStore.toolbar.enablePageReload) {
e.preventDefault()
useMainPage().reload()
}
})
hotkeys('alt+`', (e) => {
if (settingsStore.menu.enableHotkeys) {
e.preventDefault()
useMenu().switchTo(menuStore.actived + 1 < menuStore.allMenus.length ? menuStore.actived + 1 : 0)
}
})
})
onUnmounted(() => {
hotkeys.unbind('f5')
hotkeys.unbind('alt+`')
})
</script>
<template>
<div class="layout">
<div id="app-main">
<Header />
<div class="wrapper">
<div class="sidebar-container" :class="{ show: settingsStore.mode === 'mobile' && !settingsStore.menu.subMenuCollapse }">
<MainSidebar />
<SubSidebar />
</div>
<div class="sidebar-mask" :class="{ show: settingsStore.mode === 'mobile' && !settingsStore.menu.subMenuCollapse }" @click="settingsStore.toggleSidebarCollapse()" />
<div class="main-container" :style="{ 'padding-bottom': $route.meta.paddingBottom } as any">
<Topbar v-if="!(settingsStore.menu.menuMode === 'head' && !settingsStore.menu.enableSubMenuCollapseButton && !settingsStore.breadcrumb.enable)" />
<div class="main">
<router-view v-slot="{ Component, route }">
<transition name="main" mode="out-in" appear>
<keep-alive :include="keepAliveStore.list">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</div>
<Copyright />
</div>
</div>
<el-backtop :right="20" :bottom="20" title="回到顶部" />
</div>
<Search />
<AppSetting />
<HotkeysIntro />
</div>
</template>
<style lang="scss" scoped>
[data-mode="mobile"] {
.sidebar-container {
transform: translateX(calc((var(--g-main-sidebar-width) + var(--g-sub-sidebar-width)) * -1));
&.show {
transform: translateX(0);
}
}
.main-container {
margin-left: 0 !important;
}
&[data-menu-mode="single"] {
.sidebar-container {
transform: translateX(calc(var(--g-sub-sidebar-width) * -1));
&.show {
transform: translateX(0);
}
}
}
}
.layout {
height: 100%;
}
#app-main {
width: 100%;
height: 100%;
margin: 0 auto;
transition: all 0.2s;
}
.wrapper {
position: relative;
width: 100%;
height: 100%;
box-shadow: -1px 0 0 0 var(--g-box-shadow-color);
transition: padding-top 0.3s;
.sidebar-container {
position: fixed;
z-index: 1010;
top: 0;
bottom: 0;
display: flex;
transition: transform 0.3s, top 0.3s;
width: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
}
.sidebar-mask {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(2px);
transition: all 0.2s;
transform: translateZ(0);
opacity: 0;
visibility: hidden;
&.show {
opacity: 1;
visibility: visible;
}
}
.main-sidebar-container:not(.main-sidebar-leave-active) + .sub-sidebar-container {
left: var(--g-main-sidebar-width);
}
.main-container {
display: flex;
flex-direction: column;
min-height: 100%;
margin-left: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
background-color: var(--g-main-bg);
transition: margin-left 0.3s, background-color 0.3s;
.topbar-container {
top: 0;
z-index: 998;
}
.main {
height: 100%;
flex: auto;
position: relative;
overflow: hidden;
transition: 0.3s;
}
.topbar-container + .main {
margin: var(--g-topbar-height) 0 0;
}
}
}
header:not(.header-leave-active) + .wrapper {
padding-top: var(--g-header-height);
.sidebar-container {
top: var(--g-header-height);
:deep(.sidebar-logo) {
display: none;
}
:deep(.el-menu) {
padding-top: 0;
}
}
.main-container {
.topbar-container {
top: var(--g-header-height);
:deep(.tools) {
display: none;
}
}
}
}
// 主内容区动画
.main-enter-active {
transition: 0.2s;
}
.main-leave-active {
transition: 0.15s;
}
.main-enter-from {
opacity: 0;
margin-left: -20px;
}
.main-leave-to {
opacity: 0;
margin-left: 20px;
}
</style>
+12 -17
View File
@@ -1,30 +1,25 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import App from './App.vue'
const app = createApp(App)
import pinia from './store'
app.use(pinia)
import router from './router'
app.use(router)
/* importElementPlusPlaceholder */
import useSettingsStore from './store/modules/settings'
// 自定义指令
import directive from '@/utils/directive'
directive(app)
// 加载 svg 图标
import 'virtual:svg-icons-register'
// 加载 iconify 图标(element plus)
import { downloadAndInstall } from '@/iconify-ep'
import useSettingsStore from './store/modules/settings'
if (useSettingsStore().app.iconifyOfflineUse) {
downloadAndInstall()
}
// 全局样式
import '@/assets/styles/globals.scss'
// 加载 iconify 图标(element plus)
import { downloadAndInstall } from '@/iconify-ep'
if (useSettingsStore().app.iconifyOfflineUse)
downloadAndInstall()
const app = createApp(App)
app.use(ElementPlus)
app.use(pinia)
app.use(router)
directive(app)
app.mount('#app')
-15
View File
@@ -1,15 +0,0 @@
import MultilevelMenuExample from './modules/multilevel.menu.example'
const menu = [
{
meta: {
title: '演示',
icon: 'sidebar-default'
},
children: [
MultilevelMenuExample
]
}
]
export default menu
+17
View File
@@ -0,0 +1,17 @@
import MultilevelMenuExample from './modules/multilevel.menu.example'
import type { Menu } from '@/global'
const menu: Menu.recordMainRaw[] = [
{
meta: {
title: '演示',
icon: 'sidebar-default',
},
children: [
MultilevelMenuExample,
],
},
]
export default menu
@@ -1,46 +0,0 @@
export default {
meta: {
title: '多级导航',
icon: 'sidebar-menu'
},
children: [
{
path: '/multilevel_menu_example/page',
meta: {
title: '导航1'
}
},
{
meta: {
title: '导航2'
},
children: [
{
path: '/multilevel_menu_example/level2/page',
meta: {
title: '导航2-1'
}
},
{
meta: {
title: '导航2-2'
},
children: [
{
path: '/multilevel_menu_example/level2/level3/page1',
meta: {
title: '导航2-2-1'
}
},
{
path: '/multilevel_menu_example/level2/level3/page2',
meta: {
title: '导航2-2-2'
}
}
]
}
]
}
]
}
@@ -0,0 +1,50 @@
import type { Menu } from '@/global'
const menus: Menu.recordRaw = {
meta: {
title: '多级导航',
icon: 'heroicons-solid:menu-alt-3',
},
children: [
{
path: '/multilevel_menu_example/page',
meta: {
title: '导航1',
},
},
{
meta: {
title: '导航2',
},
children: [
{
path: '/multilevel_menu_example/level2/page',
meta: {
title: '导航2-1',
},
},
{
meta: {
title: '导航2-2',
},
children: [
{
path: '/multilevel_menu_example/level2/level3/page1',
meta: {
title: '导航2-2-1',
},
},
{
path: '/multilevel_menu_example/level2/level3/page2',
meta: {
title: '导航2-2-2',
},
},
],
},
],
},
],
}
export default menus
-54
View File
@@ -1,54 +0,0 @@
export default [
{
url: '/mock/member/login',
method: 'post',
response: option => {
return {
error: '',
status: 1,
data: {
account: option.body.account,
token: '@string',
failure_time: Math.ceil(new Date().getTime() / 1000) + 24 * 60 * 60
}
}
}
},
{
url: '/mock/member/permission',
method: 'get',
response: option => {
let permissions = []
if (option.query.account == 'admin') {
permissions = [
'permission.browse',
'permission.create',
'permission.edit',
'permission.remove'
]
} else if (option.query.account == 'test') {
permissions = [
'permission.browse'
]
}
return {
error: '',
status: 1,
data: {
permissions
}
}
}
},
{
url: '/mock/member/edit/password',
method: 'post',
response: {
error: '',
status: 1,
data: {
isSuccess: true
}
}
}
]
+55
View File
@@ -0,0 +1,55 @@
export default [
{
url: '/mock/member/login',
method: 'post',
response: (option: any) => {
return {
error: '',
status: 1,
data: {
account: option.body.account,
token: '@string',
failure_time: Math.ceil(new Date().getTime() / 1000) + 24 * 60 * 60,
},
}
},
},
{
url: '/mock/member/permission',
method: 'get',
response: (option: any) => {
let permissions: string[] = []
if (option.query.account === 'admin') {
permissions = [
'permission.browse',
'permission.create',
'permission.edit',
'permission.remove',
]
}
else if (option.query.account === 'test') {
permissions = [
'permission.browse',
]
}
return {
error: '',
status: 1,
data: {
permissions,
},
}
},
},
{
url: '/mock/member/edit/password',
method: 'post',
response: {
error: '',
status: 1,
data: {
isSuccess: true,
},
},
},
]
-11
View File
@@ -1,11 +0,0 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const mocks = []
const mockContext = import.meta.globEager('./mock/*.js')
Object.keys(mockContext).forEach(v => {
mocks.push(...mockContext[v].default)
})
export function setupProdMockServer() {
createProdMockServer(mocks)
}
+11
View File
@@ -0,0 +1,11 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const mocks: any[] = []
const mockContext = import.meta.glob('./mock/*.ts', { eager: true })
Object.keys(mockContext).forEach((v) => {
mocks.push(...(mockContext[v] as any).default)
})
export function setupProdMockServer() {
createProdMockServer(mocks)
}
-153
View File
@@ -1,153 +0,0 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import useSettingsStore from '@/store/modules/settings'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useUserStore from '@/store/modules/user'
import useMenuStore from '@/store/modules/menu'
import useRouteStore from '@/store/modules/route'
import '@/assets/styles/nprogress.scss'
import { useNProgress } from '@vueuse/integrations/useNProgress'
const { isLoading } = useNProgress()
// 路由相关数据
import { constantRoutes, asyncRoutes } from './routes'
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes
})
router.beforeEach(async(to, from, next) => {
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const menuStore = useMenuStore()
const routeStore = useRouteStore()
settingsStore.app.enableProgress && (isLoading.value = true)
// 是否已登录
if (userStore.isLogin) {
// 是否已根据权限动态生成并挂载路由
if (routeStore.isGenerate) {
// 导航栏如果不是 single 模式,则需要根据 path 定位主导航的选中状态
settingsStore.menu.menuMode !== 'single' && menuStore.setActived(to.path)
if (to.name) {
if (to.matched.length !== 0) {
// 如果已登录状态下,进入登录页会强制跳转到控制台页面
if (to.name == 'login') {
next({
name: 'dashboard',
replace: true
})
} else if (!settingsStore.dashboard.enable && to.name == 'dashboard') {
// 如果未开启控制台页面,则默认进入侧边栏导航第一个模块
if (menuStore.sidebarMenus.length > 0) {
next({
path: menuStore.sidebarMenusFirstDeepestPath,
replace: true
})
} else {
next()
}
} else {
next()
}
} else {
// 如果是通过 name 跳转,并且 name 对应的路由没有权限时,需要做这步处理,手动指向到 404 页面
next({
path: '/404'
})
}
} else {
next()
}
} else {
switch (settingsStore.app.routeBaseOn) {
case 'frontend':
await routeStore.generateRoutesAtFront(asyncRoutes)
break
case 'backend':
await routeStore.generateRoutesAtBack()
break
case 'filesystem':
await routeStore.generateRoutesAtFilesystem(asyncRoutes)
switch (settingsStore.menu.baseOn) {
case 'frontend':
await menuStore.generateMenusAtFront()
break
case 'backend':
await menuStore.generateMenusAtBack()
break
}
break
}
let removeRoutes = []
routeStore.flatRoutes.forEach(route => {
if (!/^(https?:|mailto:|tel:)/.test(route.path)) {
removeRoutes.push(router.addRoute(route))
}
})
if (settingsStore.app.routeBaseOn !== 'filesystem') {
routeStore.flatSystemRoutes.forEach(route => {
removeRoutes.push(router.addRoute(route))
})
}
// 记录的 accessRoutes 路由数据,在登出时会使用到,不使用 router.removeRoute 是考虑配置的路由可能不一定有设置 name ,则通过调用 router.addRoute() 返回的回调进行删除
routeStore.setCurrentRemoveRoutes(removeRoutes)
next({
path: to.fullPath,
query: to.query,
replace: true
})
}
} else {
if (to.name != 'login') {
next({
name: 'login',
query: {
redirect: to.fullPath
}
})
} else {
next()
}
}
})
router.afterEach((to, from) => {
const settingsStore = useSettingsStore()
const keepAliveStore = useKeepAliveStore()
settingsStore.app.enableProgress && (isLoading.value = false)
// 设置页面 title
to.meta.title && settingsStore.setTitle(typeof to.meta.title === 'function' ? to.meta.title() : to.meta.title)
// 判断当前页面是否开启缓存,如果开启,则将当前页面的 name 信息存入 keep-alive 全局状态
if (to.meta.cache) {
let componentName = to.matched[to.matched.length - 1].components.default.name
if (componentName) {
keepAliveStore.add(componentName)
} else {
console.warn('该页面组件未设置组件名,会导致缓存失效,请检查')
}
}
// 判断离开页面是否开启缓存,如果开启,则根据缓存规则判断是否需要清空 keep-alive 全局状态里离开页面的 name 信息
if (from.meta.cache) {
let componentName = from.matched[from.matched.length - 1].components.default.name
// 通过 meta.cache 判断针对哪些页面进行缓存
switch (typeof from.meta.cache) {
case 'string':
if (from.meta.cache != to.name) {
keepAliveStore.remove(componentName)
}
break
case 'object':
if (!from.meta.cache.includes(to.name)) {
keepAliveStore.remove(componentName)
}
break
}
// 如果进入的是 reload 页面,则也将离开页面的缓存清空
if (to.name == 'reload') {
keepAliveStore.remove(componentName)
}
}
})
export default router
+164
View File
@@ -0,0 +1,164 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useNProgress } from '@vueuse/integrations/useNProgress'
import '@/assets/styles/nprogress.scss'
// 路由相关数据
import { asyncRoutes, asyncRoutesByFilesystem, constantRoutes, constantRoutesByFilesystem } from './routes'
import pinia from '@/store'
import useSettingsStore from '@/store/modules/settings'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useUserStore from '@/store/modules/user'
import useMenuStore from '@/store/modules/menu'
import useRouteStore from '@/store/modules/route'
const { isLoading } = useNProgress()
const router = createRouter({
history: createWebHashHistory(),
routes: useSettingsStore(pinia).app.routeBaseOn === 'filesystem' ? constantRoutesByFilesystem : constantRoutes as RouteRecordRaw[],
})
router.beforeEach(async (to, from, next) => {
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const menuStore = useMenuStore()
const routeStore = useRouteStore()
settingsStore.app.enableProgress && (isLoading.value = true)
// 是否已登录
if (userStore.isLogin) {
// 是否已根据权限动态生成并挂载路由
if (routeStore.isGenerate) {
// 导航栏如果不是 single 模式,则需要根据 path 定位主导航的选中状态
settingsStore.menu.menuMode !== 'single' && menuStore.setActived(to.path)
if (to.name) {
if (to.matched.length !== 0) {
// 如果已登录状态下,进入登录页会强制跳转到控制台页面
if (to.name === 'login') {
next({
name: 'dashboard',
replace: true,
})
}
else if (!settingsStore.dashboard.enable && to.name === 'dashboard') {
// 如果未开启控制台页面,则默认进入侧边栏导航第一个模块
if (menuStore.sidebarMenus.length > 0) {
next({
path: menuStore.sidebarMenusFirstDeepestPath,
replace: true,
})
}
else {
next()
}
}
else {
next()
}
}
else {
// 如果是通过 name 跳转,并且 name 对应的路由没有权限时,需要做这步处理,手动指向到 404 页面
next({
path: '/404',
})
}
}
else {
next()
}
}
else {
switch (settingsStore.app.routeBaseOn) {
case 'frontend':
await routeStore.generateRoutesAtFront(asyncRoutes)
break
case 'backend':
await routeStore.generateRoutesAtBack()
break
case 'filesystem':
await routeStore.generateRoutesAtFilesystem(asyncRoutesByFilesystem)
switch (settingsStore.menu.baseOn) {
case 'frontend':
await menuStore.generateMenusAtFront()
break
case 'backend':
await menuStore.generateMenusAtBack()
break
}
break
}
const removeRoutes: Function[] = []
routeStore.flatRoutes.forEach((route) => {
if (!/^(https?:|mailto:|tel:)/.test(route.path))
removeRoutes.push(router.addRoute(route as RouteRecordRaw))
})
if (settingsStore.app.routeBaseOn !== 'filesystem') {
routeStore.flatSystemRoutes.forEach((route) => {
removeRoutes.push(router.addRoute(route as RouteRecordRaw))
})
}
// 记录路由数据,在登出时会使用到,不使用 router.removeRoute 是考虑配置的路由可能不一定有设置 name ,则通过调用 router.addRoute() 返回的回调进行删除
routeStore.setCurrentRemoveRoutes(removeRoutes)
next({
path: to.path,
query: to.query,
replace: true,
})
}
}
else {
if (to.name !== 'login') {
next({
name: 'login',
query: {
redirect: to.fullPath,
},
})
}
else {
next()
}
}
})
router.afterEach((to, from) => {
const settingsStore = useSettingsStore()
const keepAliveStore = useKeepAliveStore()
settingsStore.app.enableProgress && (isLoading.value = false)
// 设置页面 title
to.meta.title && settingsStore.setTitle(typeof to.meta.title === 'function' ? to.meta.title() : to.meta.title)
// 判断当前页面是否开启缓存,如果开启,则将当前页面的 name 信息存入 keep-alive 全局状态
if (to.meta.cache) {
const componentName = to.matched.at(-1)?.components?.default.name
if (componentName)
keepAliveStore.add(componentName)
else
console.warn('该页面组件未设置组件名,会导致缓存失效,请检查')
}
// 判断离开页面是否开启缓存,如果开启,则根据缓存规则判断是否需要清空 keep-alive 全局状态里离开页面的 name 信息
if (from.meta.cache) {
const componentName = from.matched.at(-1)?.components?.default.name
if (componentName) {
// 通过 meta.cache 判断针对哪些页面进行缓存
switch (typeof from.meta.cache) {
case 'string':
if (from.meta.cache !== to.name)
keepAliveStore.remove(componentName)
break
case 'object':
if (!from.meta.cache.includes(to.name as string))
keepAliveStore.remove(componentName)
break
}
// 如果进入的是 reload 页面,则也将离开页面的缓存清空
if (to.name === 'reload')
keepAliveStore.remove(componentName)
}
}
document.body.scrollTop = 0
document.documentElement.scrollTop = 0
})
export default router

Some files were not shown because too many files have changed in this diff Show More