commit
fbc1b7a9a8
@ -0,0 +1 @@ |
||||
VITE_API_BASE_URL= 'http://localhost:8080' |
@ -0,0 +1,3 @@ |
||||
/*.json |
||||
/*.js |
||||
dist |
@ -0,0 +1,70 @@ |
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path'); |
||||
|
||||
module.exports = { |
||||
root: true, |
||||
parser: 'vue-eslint-parser', |
||||
parserOptions: { |
||||
// Parser that checks the content of the <script> tag
|
||||
parser: '@typescript-eslint/parser', |
||||
sourceType: 'module', |
||||
ecmaVersion: 2020, |
||||
ecmaFeatures: { |
||||
jsx: true, |
||||
}, |
||||
}, |
||||
env: { |
||||
'browser': true, |
||||
'node': true, |
||||
'vue/setup-compiler-macros': true, |
||||
}, |
||||
plugins: ['@typescript-eslint'], |
||||
extends: [ |
||||
// Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
|
||||
'airbnb-base', |
||||
'plugin:@typescript-eslint/recommended', |
||||
'plugin:import/recommended', |
||||
'plugin:import/typescript', |
||||
'plugin:vue/vue3-recommended', |
||||
'plugin:prettier/recommended', |
||||
], |
||||
settings: { |
||||
'import/resolver': { |
||||
typescript: { |
||||
project: path.resolve(__dirname, './tsconfig.json'), |
||||
}, |
||||
}, |
||||
}, |
||||
rules: { |
||||
'prettier/prettier': 1, |
||||
// Vue: Recommended rules to be closed or modify
|
||||
'vue/require-default-prop': 0, |
||||
'vue/singleline-html-element-content-newline': 0, |
||||
'vue/max-attributes-per-line': 0, |
||||
// Vue: Add extra rules
|
||||
'vue/custom-event-name-casing': [2, 'camelCase'], |
||||
'vue/no-v-text': 1, |
||||
'vue/padding-line-between-blocks': 1, |
||||
'vue/require-direct-export': 1, |
||||
'vue/multi-word-component-names': 0, |
||||
// Allow @ts-ignore comment
|
||||
'@typescript-eslint/ban-ts-comment': 0, |
||||
'@typescript-eslint/no-unused-vars': 1, |
||||
'@typescript-eslint/no-empty-function': 1, |
||||
'@typescript-eslint/no-explicit-any': 0, |
||||
'import/extensions': [ |
||||
2, |
||||
'ignorePackages', |
||||
{ |
||||
js: 'never', |
||||
jsx: 'never', |
||||
ts: 'never', |
||||
tsx: 'never', |
||||
}, |
||||
], |
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, |
||||
'no-param-reassign': 0, |
||||
'prefer-regex-literals': 0, |
||||
'import/no-extraneous-dependencies': 0, |
||||
}, |
||||
}; |
@ -0,0 +1,10 @@ |
||||
node_modules |
||||
.DS_Store |
||||
dist |
||||
dist-ssr |
||||
*.local |
||||
node_modules |
||||
.DS_Store |
||||
dist |
||||
dist-ssr |
||||
*.local |
@ -0,0 +1,4 @@ |
||||
#!/bin/sh |
||||
. "$(dirname "$0")/_/husky.sh" |
||||
|
||||
yarn commitlint --edit $1 |
@ -0,0 +1,4 @@ |
||||
#!/bin/sh |
||||
. "$(dirname "$0")/_/husky.sh" |
||||
|
||||
npm run lint-staged |
@ -0,0 +1,7 @@ |
||||
/dist/* |
||||
.local |
||||
.output.js |
||||
/node_modules/** |
||||
|
||||
**/*.svg |
||||
**/*.sh |
@ -0,0 +1,9 @@ |
||||
module.exports = { |
||||
tabWidth: 2, |
||||
semi: true, |
||||
printWidth: 80, |
||||
singleQuote: true, |
||||
quoteProps: 'consistent', |
||||
htmlWhitespaceSensitivity: 'strict', |
||||
vueIndentScriptAndStyle: true, |
||||
}; |
@ -0,0 +1,30 @@ |
||||
module.exports = { |
||||
extends: [ |
||||
'stylelint-config-standard', |
||||
'stylelint-config-rational-order', |
||||
'stylelint-config-prettier', |
||||
'stylelint-config-recommended-vue', |
||||
], |
||||
defaultSeverity: 'warning', |
||||
plugins: ['stylelint-order'], |
||||
rules: { |
||||
'at-rule-no-unknown': [ |
||||
true, |
||||
{ |
||||
ignoreAtRules: ['plugin'], |
||||
}, |
||||
], |
||||
'rule-empty-line-before': [ |
||||
'always', |
||||
{ |
||||
except: ['after-single-line-comment', 'first-nested'], |
||||
}, |
||||
], |
||||
'selector-pseudo-class-no-unknown': [ |
||||
true, |
||||
{ |
||||
ignorePseudoClasses: ['deep'], |
||||
}, |
||||
], |
||||
}, |
||||
}; |
@ -0,0 +1,3 @@ |
||||
module.exports = { |
||||
plugins: ['@vue/babel-plugin-jsx'], |
||||
}; |
@ -0,0 +1,3 @@ |
||||
module.exports = { |
||||
extends: ['@commitlint/config-conventional'], |
||||
}; |
@ -0,0 +1,81 @@ |
||||
// 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 { |
||||
AAffix: typeof import('@arco-design/web-vue')['Affix'] |
||||
AAlert: typeof import('@arco-design/web-vue')['Alert'] |
||||
AAvatar: typeof import('@arco-design/web-vue')['Avatar'] |
||||
AAvatarGroup: typeof import('@arco-design/web-vue')['AvatarGroup'] |
||||
ABadge: typeof import('@arco-design/web-vue')['Badge'] |
||||
ABreadcrumb: typeof import('@arco-design/web-vue')['Breadcrumb'] |
||||
ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem'] |
||||
AButton: typeof import('@arco-design/web-vue')['Button'] |
||||
ACard: typeof import('@arco-design/web-vue')['Card'] |
||||
ACardMeta: typeof import('@arco-design/web-vue')['CardMeta'] |
||||
ACarousel: typeof import('@arco-design/web-vue')['Carousel'] |
||||
ACarouselItem: typeof import('@arco-design/web-vue')['CarouselItem'] |
||||
ACascader: typeof import('@arco-design/web-vue')['Cascader'] |
||||
ACheckbox: typeof import('@arco-design/web-vue')['Checkbox'] |
||||
ACol: typeof import('@arco-design/web-vue')['Col'] |
||||
AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider'] |
||||
ADescriptions: typeof import('@arco-design/web-vue')['Descriptions'] |
||||
ADivider: typeof import('@arco-design/web-vue')['Divider'] |
||||
ADoption: typeof import('@arco-design/web-vue')['Doption'] |
||||
ADrawer: typeof import('@arco-design/web-vue')['Drawer'] |
||||
ADropdown: typeof import('@arco-design/web-vue')['Dropdown'] |
||||
AForm: typeof import('@arco-design/web-vue')['Form'] |
||||
AFormItem: typeof import('@arco-design/web-vue')['FormItem'] |
||||
AGrid: typeof import('@arco-design/web-vue')['Grid'] |
||||
AGridItem: typeof import('@arco-design/web-vue')['GridItem'] |
||||
AInput: typeof import('@arco-design/web-vue')['Input'] |
||||
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber'] |
||||
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] |
||||
AInputSearch: typeof import('@arco-design/web-vue')['InputSearch'] |
||||
ALayout: typeof import('@arco-design/web-vue')['Layout'] |
||||
ALayoutContent: typeof import('@arco-design/web-vue')['LayoutContent'] |
||||
ALayoutFooter: typeof import('@arco-design/web-vue')['LayoutFooter'] |
||||
ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider'] |
||||
ALink: typeof import('@arco-design/web-vue')['Link'] |
||||
AList: typeof import('@arco-design/web-vue')['List'] |
||||
AListItem: typeof import('@arco-design/web-vue')['ListItem'] |
||||
AListItemMeta: typeof import('@arco-design/web-vue')['ListItemMeta'] |
||||
AMenu: typeof import('@arco-design/web-vue')['Menu'] |
||||
AMenuItem: typeof import('@arco-design/web-vue')['MenuItem'] |
||||
AOption: typeof import('@arco-design/web-vue')['Option'] |
||||
APopover: typeof import('@arco-design/web-vue')['Popover'] |
||||
ARadio: typeof import('@arco-design/web-vue')['Radio'] |
||||
ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup'] |
||||
ARangePicker: typeof import('@arco-design/web-vue')['RangePicker'] |
||||
AResult: typeof import('@arco-design/web-vue')['Result'] |
||||
ARow: typeof import('@arco-design/web-vue')['Row'] |
||||
ASelect: typeof import('@arco-design/web-vue')['Select'] |
||||
ASkeleton: typeof import('@arco-design/web-vue')['Skeleton'] |
||||
ASkeletonLine: typeof import('@arco-design/web-vue')['SkeletonLine'] |
||||
ASkeletonShape: typeof import('@arco-design/web-vue')['SkeletonShape'] |
||||
ASpace: typeof import('@arco-design/web-vue')['Space'] |
||||
ASpin: typeof import('@arco-design/web-vue')['Spin'] |
||||
AStatistic: typeof import('@arco-design/web-vue')['Statistic'] |
||||
AStep: typeof import('@arco-design/web-vue')['Step'] |
||||
ASteps: typeof import('@arco-design/web-vue')['Steps'] |
||||
ASubMenu: typeof import('@arco-design/web-vue')['SubMenu'] |
||||
ASwitch: typeof import('@arco-design/web-vue')['Switch'] |
||||
ATable: typeof import('@arco-design/web-vue')['Table'] |
||||
ATableColumn: typeof import('@arco-design/web-vue')['TableColumn'] |
||||
ATabPane: typeof import('@arco-design/web-vue')['TabPane'] |
||||
ATabs: typeof import('@arco-design/web-vue')['Tabs'] |
||||
ATag: typeof import('@arco-design/web-vue')['Tag'] |
||||
ATextarea: typeof import('@arco-design/web-vue')['Textarea'] |
||||
ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] |
||||
ATypographyParagraph: typeof import('@arco-design/web-vue')['TypographyParagraph'] |
||||
ATypographyText: typeof import('@arco-design/web-vue')['TypographyText'] |
||||
ATypographyTitle: typeof import('@arco-design/web-vue')['TypographyTitle'] |
||||
AUpload: typeof import('@arco-design/web-vue')['Upload'] |
||||
RouterLink: typeof import('vue-router')['RouterLink'] |
||||
RouterView: typeof import('vue-router')['RouterView'] |
||||
} |
||||
} |
@ -0,0 +1,19 @@ |
||||
/** |
||||
* If you use the template method for development, you can use the unplugin-vue-components plugin to enable on-demand loading support. |
||||
* 按需引入 |
||||
* https://github.com/antfu/unplugin-vue-components
|
||||
* https://arco.design/vue/docs/start
|
||||
* Although the Pro project is full of imported components, this plugin will be used by default. |
||||
* 虽然Pro项目中是全量引入组件,但此插件会默认使用。 |
||||
*/ |
||||
import Components from 'unplugin-vue-components/vite'; |
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers'; |
||||
|
||||
export default function configArcoResolverPlugin() { |
||||
const arcoResolverPlugin = Components({ |
||||
dirs: [], // Avoid parsing src/components. 避免解析到src/components
|
||||
deep: false, |
||||
resolvers: [ArcoResolver()], |
||||
}); |
||||
return arcoResolverPlugin; |
||||
} |
@ -0,0 +1,34 @@ |
||||
/** |
||||
* Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated |
||||
* gzip压缩 |
||||
* https://github.com/anncwb/vite-plugin-compression
|
||||
*/ |
||||
import type { Plugin } from 'vite'; |
||||
import compressPlugin from 'vite-plugin-compression'; |
||||
|
||||
export default function configCompressPlugin( |
||||
compress: 'gzip' | 'brotli', |
||||
deleteOriginFile = false |
||||
): Plugin | Plugin[] { |
||||
const plugins: Plugin[] = []; |
||||
|
||||
if (compress === 'gzip') { |
||||
plugins.push( |
||||
compressPlugin({ |
||||
ext: '.gz', |
||||
deleteOriginFile, |
||||
}) |
||||
); |
||||
} |
||||
|
||||
if (compress === 'brotli') { |
||||
plugins.push( |
||||
compressPlugin({ |
||||
ext: '.br', |
||||
algorithm: 'brotliCompress', |
||||
deleteOriginFile, |
||||
}) |
||||
); |
||||
} |
||||
return plugins; |
||||
} |
@ -0,0 +1,37 @@ |
||||
/** |
||||
* Image resource files used to compress the output of the production environment |
||||
* 图片压缩 |
||||
* https://github.com/anncwb/vite-plugin-imagemin
|
||||
*/ |
||||
import viteImagemin from 'vite-plugin-imagemin'; |
||||
|
||||
export default function configImageminPlugin() { |
||||
const imageminPlugin = viteImagemin({ |
||||
gifsicle: { |
||||
optimizationLevel: 7, |
||||
interlaced: false, |
||||
}, |
||||
optipng: { |
||||
optimizationLevel: 7, |
||||
}, |
||||
mozjpeg: { |
||||
quality: 20, |
||||
}, |
||||
pngquant: { |
||||
quality: [0.8, 0.9], |
||||
speed: 4, |
||||
}, |
||||
svgo: { |
||||
plugins: [ |
||||
{ |
||||
name: 'removeViewBox', |
||||
}, |
||||
{ |
||||
name: 'removeEmptyAttrs', |
||||
active: false, |
||||
}, |
||||
], |
||||
}, |
||||
}); |
||||
return imageminPlugin; |
||||
} |
@ -0,0 +1,87 @@ |
||||
/** |
||||
* Introduces component library styles on demand. |
||||
* 按需引入组件库样式 |
||||
* https://github.com/anncwb/vite-plugin-style-import
|
||||
*/ |
||||
|
||||
import styleImport from 'vite-plugin-style-import'; |
||||
|
||||
export default function configStyleImportPlugin() { |
||||
const styleImportPlugin = styleImport({ |
||||
libs: [ |
||||
{ |
||||
libraryName: '@arco-design/web-vue', |
||||
esModule: true, |
||||
resolveStyle: (name) => { |
||||
// The use of this part of the component must depend on the parent, so it can be ignored directly.
|
||||
// 这部分组件的使用必须依赖父级,所以直接忽略即可。
|
||||
const ignoreList = [ |
||||
'config-provider', |
||||
'anchor-link', |
||||
'sub-menu', |
||||
'menu-item', |
||||
'menu-item-group', |
||||
'breadcrumb-item', |
||||
'form-item', |
||||
'step', |
||||
'card-grid', |
||||
'card-meta', |
||||
'collapse-panel', |
||||
'collapse-item', |
||||
'descriptions-item', |
||||
'list-item', |
||||
'list-item-meta', |
||||
'table-column', |
||||
'table-column-group', |
||||
'tab-pane', |
||||
'tab-content', |
||||
'timeline-item', |
||||
'tree-node', |
||||
'skeleton-line', |
||||
'skeleton-shape', |
||||
'grid-item', |
||||
'carousel-item', |
||||
'doption', |
||||
'option', |
||||
'optgroup', |
||||
'icon', |
||||
]; |
||||
// List of components that need to map imported styles
|
||||
// 需要映射引入样式的组件列表
|
||||
const replaceList = { |
||||
'typography-text': 'typography', |
||||
'typography-title': 'typography', |
||||
'typography-paragraph': 'typography', |
||||
'typography-link': 'typography', |
||||
'dropdown-button': 'dropdown', |
||||
'input-password': 'input', |
||||
'input-search': 'input', |
||||
'input-group': 'input', |
||||
'radio-group': 'radio', |
||||
'checkbox-group': 'checkbox', |
||||
'layout-sider': 'layout', |
||||
'layout-content': 'layout', |
||||
'layout-footer': 'layout', |
||||
'layout-header': 'layout', |
||||
'month-picker': 'date-picker', |
||||
'range-picker': 'date-picker', |
||||
'row': 'grid', // 'grid/row.less'
|
||||
'col': 'grid', // 'grid/col.less'
|
||||
'avatar-group': 'avatar', |
||||
'image-preview': 'image', |
||||
'image-preview-group': 'image', |
||||
'cascader-panel': 'cascader', |
||||
}; |
||||
if (ignoreList.includes(name)) return ''; |
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
return replaceList.hasOwnProperty(name) |
||||
? `@arco-design/web-vue/es/${replaceList[name]}/style/css.js` |
||||
: `@arco-design/web-vue/es/${name}/style/css.js`; |
||||
// less
|
||||
// return `@arco-design/web-vue/es/${name}/style/index.js`;
|
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
return styleImportPlugin; |
||||
} |
@ -0,0 +1,18 @@ |
||||
/** |
||||
* Generation packaging analysis |
||||
* 生成打包分析 |
||||
*/ |
||||
import visualizer from 'rollup-plugin-visualizer'; |
||||
import { isReportMode } from '../utils'; |
||||
|
||||
export default function configVisualizerPlugin() { |
||||
if (isReportMode()) { |
||||
return visualizer({ |
||||
filename: './node_modules/.cache/visualizer/stats.html', |
||||
open: true, |
||||
gzipSize: true, |
||||
brotliSize: true, |
||||
}); |
||||
} |
||||
return []; |
||||
} |
@ -0,0 +1,9 @@ |
||||
/** |
||||
* Whether to generate package preview |
||||
* 是否生成打包报告 |
||||
*/ |
||||
export default {}; |
||||
|
||||
export function isReportMode(): boolean { |
||||
return process.env.REPORT === 'true'; |
||||
} |
@ -0,0 +1,45 @@ |
||||
import { resolve } from 'path'; |
||||
import { defineConfig } from 'vite'; |
||||
import vue from '@vitejs/plugin-vue'; |
||||
import vueJsx from '@vitejs/plugin-vue-jsx'; |
||||
import svgLoader from 'vite-svg-loader'; |
||||
|
||||
export default defineConfig({ |
||||
plugins: [vue(), vueJsx(), svgLoader({ svgoConfig: {} })], |
||||
resolve: { |
||||
alias: [ |
||||
{ |
||||
find: '@', |
||||
replacement: resolve(__dirname, '../src'), |
||||
}, |
||||
{ |
||||
find: 'assets', |
||||
replacement: resolve(__dirname, '../src/assets'), |
||||
}, |
||||
{ |
||||
find: 'vue-i18n', |
||||
replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
|
||||
}, |
||||
{ |
||||
find: 'vue', |
||||
replacement: 'vue/dist/vue.esm-bundler.js', // compile template
|
||||
}, |
||||
], |
||||
extensions: ['.ts', '.js'], |
||||
}, |
||||
define: { |
||||
'process.env': {}, |
||||
}, |
||||
css: { |
||||
preprocessorOptions: { |
||||
less: { |
||||
modifyVars: { |
||||
hack: `true; @import (reference) "${resolve( |
||||
'src/assets/style/breakpoint.less' |
||||
)}";`,
|
||||
}, |
||||
javascriptEnabled: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
@ -0,0 +1,23 @@ |
||||
import { mergeConfig } from 'vite'; |
||||
import eslint from 'vite-plugin-eslint'; |
||||
import baseConfig from './vite.config.base'; |
||||
|
||||
export default mergeConfig( |
||||
{ |
||||
mode: 'development', |
||||
server: { |
||||
open: true, |
||||
fs: { |
||||
strict: true, |
||||
}, |
||||
}, |
||||
plugins: [ |
||||
eslint({ |
||||
cache: false, |
||||
include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'], |
||||
exclude: ['node_modules'], |
||||
}), |
||||
], |
||||
}, |
||||
baseConfig |
||||
); |
@ -0,0 +1,33 @@ |
||||
import { mergeConfig } from 'vite'; |
||||
import baseConfig from './vite.config.base'; |
||||
import configCompressPlugin from './plugin/compress'; |
||||
import configVisualizerPlugin from './plugin/visualizer'; |
||||
import configArcoResolverPlugin from './plugin/arcoResolver'; |
||||
import configStyleImportPlugin from './plugin/styleImport'; |
||||
import configImageminPlugin from './plugin/imagemin'; |
||||
|
||||
export default mergeConfig( |
||||
{ |
||||
mode: 'production', |
||||
plugins: [ |
||||
configCompressPlugin('gzip'), |
||||
configVisualizerPlugin(), |
||||
configArcoResolverPlugin(), |
||||
configStyleImportPlugin(), |
||||
configImageminPlugin(), |
||||
], |
||||
build: { |
||||
rollupOptions: { |
||||
output: { |
||||
manualChunks: { |
||||
arco: ['@arco-design/web-vue'], |
||||
chart: ['echarts', 'vue-echarts'], |
||||
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'], |
||||
}, |
||||
}, |
||||
}, |
||||
chunkSizeWarningLimit: 2000, |
||||
}, |
||||
}, |
||||
baseConfig |
||||
); |
@ -0,0 +1,13 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>Arco Design Pro - 开箱即用的中台前端/设计解决方案</title> |
||||
</head> |
||||
<body> |
||||
<div id="app"></div> |
||||
<script type="module" src="/src/main.ts"></script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,103 @@ |
||||
{ |
||||
"name": "arco-design-pro-vue", |
||||
"description": "Arco Design Pro for Vue", |
||||
"version": "1.0.0", |
||||
"private": true, |
||||
"author": "ArcoDesign Team", |
||||
"license": "MIT", |
||||
"scripts": { |
||||
"dev": "vite --config ./config/vite.config.dev.ts", |
||||
"build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts", |
||||
"report": "cross-env REPORT=true npm run build", |
||||
"preview": "npm run build && vite preview --host", |
||||
"type:check": "vue-tsc --noEmit --skipLibCheck", |
||||
"lint-staged": "npx lint-staged", |
||||
"prepare": "husky install" |
||||
}, |
||||
"lint-staged": { |
||||
"*.{js,ts,jsx,tsx}": [ |
||||
"prettier --write", |
||||
"eslint --fix" |
||||
], |
||||
"*.vue": [ |
||||
"stylelint --fix", |
||||
"prettier --write", |
||||
"eslint --fix" |
||||
], |
||||
"*.{less,css}": [ |
||||
"stylelint --fix", |
||||
"prettier --write" |
||||
] |
||||
}, |
||||
"dependencies": { |
||||
"@arco-design/web-vue": "^2.40.0", |
||||
"@vueuse/core": "^9.3.0", |
||||
"arco-design-pro-vue": "^2.6.0", |
||||
"axios": "^0.24.0", |
||||
"dayjs": "^1.11.5", |
||||
"echarts": "^5.4.0", |
||||
"lodash": "^4.17.21", |
||||
"mitt": "^3.0.0", |
||||
"nprogress": "^0.2.0", |
||||
"pinia": "^2.0.23", |
||||
"query-string": "^8.0.3", |
||||
"sortablejs": "^1.15.0", |
||||
"vue": "^3.2.40", |
||||
"vue-echarts": "^6.2.3", |
||||
"vue-i18n": "^9.2.2", |
||||
"vue-router": "^4.0.14" |
||||
}, |
||||
"devDependencies": { |
||||
"@commitlint/cli": "^17.1.2", |
||||
"@commitlint/config-conventional": "^17.1.0", |
||||
"@types/lodash": "^4.14.186", |
||||
"@types/mockjs": "^1.0.7", |
||||
"@types/nprogress": "^0.2.0", |
||||
"@types/sortablejs": "^1.15.0", |
||||
"@typescript-eslint/eslint-plugin": "^5.40.0", |
||||
"@typescript-eslint/parser": "^5.40.0", |
||||
"@vitejs/plugin-vue": "^4.0.0", |
||||
"@vitejs/plugin-vue-jsx": "^3.0.0", |
||||
"@vue/babel-plugin-jsx": "^1.1.1", |
||||
"consola": "^2.15.3", |
||||
"cross-env": "^7.0.3", |
||||
"eslint": "^8.25.0", |
||||
"eslint-config-airbnb-base": "^15.0.0", |
||||
"eslint-config-prettier": "^8.5.0", |
||||
"eslint-import-resolver-typescript": "^3.5.1", |
||||
"eslint-plugin-import": "^2.26.0", |
||||
"eslint-plugin-prettier": "^4.2.1", |
||||
"eslint-plugin-vue": "^9.6.0", |
||||
"husky": "^8.0.1", |
||||
"less": "^4.1.3", |
||||
"lint-staged": "^13.0.3", |
||||
"mockjs": "^1.1.0", |
||||
"postcss-html": "^1.5.0", |
||||
"prettier": "^2.7.1", |
||||
"rollup": "^3.9.1", |
||||
"rollup-plugin-visualizer": "^5.8.2", |
||||
"stylelint": "^14.13.0", |
||||
"stylelint-config-prettier": "^9.0.3", |
||||
"stylelint-config-rational-order": "^0.1.2", |
||||
"stylelint-config-recommended-vue": "^1.4.0", |
||||
"stylelint-config-standard": "^29.0.0", |
||||
"stylelint-order": "^5.0.0", |
||||
"typescript": "^4.8.4", |
||||
"unplugin-vue-components": "^0.22.8", |
||||
"vite": "^3.2.5", |
||||
"vite-plugin-compression": "^0.5.1", |
||||
"vite-plugin-eslint": "^1.8.1", |
||||
"vite-plugin-imagemin": "^0.6.1", |
||||
"vite-plugin-style-import": "1.4.1", |
||||
"vite-svg-loader": "^3.6.0", |
||||
"vue-tsc": "^1.0.14" |
||||
}, |
||||
"engines": { |
||||
"node": ">=14.0.0" |
||||
}, |
||||
"resolutions": { |
||||
"bin-wrapper": "npm:bin-wrapper-china", |
||||
"rollup": "^2.56.3", |
||||
"gifsicle": "5.2.0" |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
<template> |
||||
<a-config-provider :locale="locale"> |
||||
<router-view /> |
||||
<global-setting /> |
||||
</a-config-provider> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { computed } from 'vue'; |
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us'; |
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn'; |
||||
import GlobalSetting from '@/components/global-setting/index.vue'; |
||||
import useLocale from '@/hooks/locale'; |
||||
|
||||
const { currentLocale } = useLocale(); |
||||
const locale = computed(() => { |
||||
switch (currentLocale.value) { |
||||
case 'zh-CN': |
||||
return zhCN; |
||||
case 'en-US': |
||||
return enUS; |
||||
default: |
||||
return enUS; |
||||
} |
||||
}); |
||||
</script> |
@ -0,0 +1,22 @@ |
||||
import axios from 'axios'; |
||||
import type { TableData } from '@arco-design/web-vue/es/table/interface'; |
||||
|
||||
export interface ContentDataRecord { |
||||
x: string; |
||||
y: number; |
||||
} |
||||
|
||||
export function queryContentData() { |
||||
return axios.get<ContentDataRecord[]>('/api/content-data'); |
||||
} |
||||
|
||||
export interface PopularRecord { |
||||
key: number; |
||||
clickNumber: string; |
||||
title: string; |
||||
increases: number; |
||||
} |
||||
|
||||
export function queryPopularList(params: { type: string }) { |
||||
return axios.get<TableData[]>('/api/popular/list', { params }); |
||||
} |
@ -0,0 +1,77 @@ |
||||
import axios from 'axios'; |
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios'; |
||||
import { Message, Modal } from '@arco-design/web-vue'; |
||||
import { useUserStore } from '@/store'; |
||||
import { getToken } from '@/utils/auth'; |
||||
|
||||
export interface HttpResponse<T = unknown> { |
||||
status: number; |
||||
msg: string; |
||||
code: number; |
||||
data: T; |
||||
} |
||||
|
||||
if (import.meta.env.VITE_API_BASE_URL) { |
||||
axios.defaults.baseURL = import.meta.env.VITE_API_BASE_URL; |
||||
} |
||||
|
||||
axios.interceptors.request.use( |
||||
(config: AxiosRequestConfig) => { |
||||
// let each request carry token
|
||||
// this example using the JWT token
|
||||
// Authorization is a custom headers key
|
||||
// please modify it according to the actual situation
|
||||
const token = getToken(); |
||||
if (token) { |
||||
if (!config.headers) { |
||||
config.headers = {}; |
||||
} |
||||
config.headers.Authorization = `Bearer ${token}`; |
||||
} |
||||
return config; |
||||
}, |
||||
(error) => { |
||||
// do something
|
||||
return Promise.reject(error); |
||||
} |
||||
); |
||||
// add response interceptors
|
||||
axios.interceptors.response.use( |
||||
(response: AxiosResponse<HttpResponse>) => { |
||||
const res = response.data; |
||||
// if the custom code is not 20000, it is judged as an error.
|
||||
if (res.code !== 20000) { |
||||
Message.error({ |
||||
content: res.msg || 'Error', |
||||
duration: 5 * 1000, |
||||
}); |
||||
// 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
|
||||
if ( |
||||
[50008, 50012, 50014].includes(res.code) && |
||||
response.config.url !== '/api/user/info' |
||||
) { |
||||
Modal.error({ |
||||
title: 'Confirm logout', |
||||
content: |
||||
'You have been logged out, you can cancel to stay on this page, or log in again', |
||||
okText: 'Re-Login', |
||||
async onOk() { |
||||
const userStore = useUserStore(); |
||||
|
||||
await userStore.logout(); |
||||
window.location.reload(); |
||||
}, |
||||
}); |
||||
} |
||||
return Promise.reject(new Error(res.msg || 'Error')); |
||||
} |
||||
return res; |
||||
}, |
||||
(error) => { |
||||
Message.error({ |
||||
content: error.msg || 'Request Error', |
||||
duration: 5 * 1000, |
||||
}); |
||||
return Promise.reject(error); |
||||
} |
||||
); |
@ -0,0 +1,38 @@ |
||||
import axios from 'axios'; |
||||
|
||||
export interface MessageRecord { |
||||
id: number; |
||||
type: string; |
||||
title: string; |
||||
subTitle: string; |
||||
avatar?: string; |
||||
content: string; |
||||
time: string; |
||||
status: 0 | 1; |
||||
messageType?: number; |
||||
} |
||||
export type MessageListType = MessageRecord[]; |
||||
|
||||
export function queryMessageList() { |
||||
return axios.post<MessageListType>('/api/message/list'); |
||||
} |
||||
|
||||
interface MessageStatus { |
||||
ids: number[]; |
||||
} |
||||
|
||||
export function setMessageStatus(data: MessageStatus) { |
||||
return axios.post<MessageListType>('/api/message/read', data); |
||||
} |
||||
|
||||
export interface ChatRecord { |
||||
id: number; |
||||
username: string; |
||||
content: string; |
||||
time: string; |
||||
isCollect: boolean; |
||||
} |
||||
|
||||
export function queryChatList() { |
||||
return axios.post<ChatRecord[]>('/api/chat/list'); |
||||
} |
@ -0,0 +1,27 @@ |
||||
import axios from 'axios'; |
||||
import type { RouteRecordNormalized } from 'vue-router'; |
||||
import { UserState } from '@/store/modules/user/types'; |
||||
|
||||
export interface LoginData { |
||||
username: string; |
||||
password: string; |
||||
} |
||||
|
||||
export interface LoginRes { |
||||
token: string; |
||||
} |
||||
export function login(data: LoginData) { |
||||
return axios.post<LoginRes>('/api/user/login', data); |
||||
} |
||||
|
||||
export function logout() { |
||||
return axios.post<LoginRes>('/api/user/logout'); |
||||
} |
||||
|
||||
export function getUserInfo() { |
||||
return axios.post<UserState>('/api/user/info'); |
||||
} |
||||
|
||||
export function getMenuList() { |
||||
return axios.post<RouteRecordNormalized[]>('/api/user/menu'); |
||||
} |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1,19 @@ |
||||
// ==============breakpoint============ |
||||
|
||||
// Extra small screen / phone |
||||
@screen-xs: 480px; |
||||
|
||||
// Small screen / tablet |
||||
@screen-sm: 576px; |
||||
|
||||
// Medium screen / desktop |
||||
@screen-md: 768px; |
||||
|
||||
// Large screen / wide desktop |
||||
@screen-lg: 992px; |
||||
|
||||
// Extra large screen / full hd |
||||
@screen-xl: 1200px; |
||||
|
||||
// Extra extra large screen / large desktop |
||||
@screen-xxl: 1600px; |
@ -0,0 +1,94 @@ |
||||
* { |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
html, |
||||
body { |
||||
width: 100%; |
||||
height: 100%; |
||||
margin: 0; |
||||
padding: 0; |
||||
font-size: 14px; |
||||
background-color: var(--color-bg-1); |
||||
-moz-osx-font-smoothing: grayscale; |
||||
-webkit-font-smoothing: antialiased; |
||||
} |
||||
|
||||
.echarts-tooltip-diy { |
||||
background: linear-gradient( |
||||
304.17deg, |
||||
rgba(253, 254, 255, 0.6) -6.04%, |
||||
rgba(244, 247, 252, 0.6) 85.2% |
||||
) !important; |
||||
border: none !important; |
||||
backdrop-filter: blur(10px) !important; |
||||
/* Note: backdrop-filter has minimal browser support */ |
||||
|
||||
border-radius: 6px !important; |
||||
.content-panel { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
padding: 0 9px; |
||||
background: rgba(255, 255, 255, 0.8); |
||||
width: 164px; |
||||
height: 32px; |
||||
line-height: 32px; |
||||
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1); |
||||
border-radius: 4px; |
||||
margin-bottom: 4px; |
||||
} |
||||
.tooltip-title { |
||||
margin: 0 0 10px 0; |
||||
} |
||||
p { |
||||
margin: 0; |
||||
} |
||||
.tooltip-title, |
||||
.tooltip-value { |
||||
font-size: 13px; |
||||
line-height: 15px; |
||||
display: flex; |
||||
align-items: center; |
||||
text-align: right; |
||||
color: #1d2129; |
||||
font-weight: bold; |
||||
} |
||||
.tooltip-item-icon { |
||||
display: inline-block; |
||||
margin-right: 8px; |
||||
width: 10px; |
||||
height: 10px; |
||||
border-radius: 50%; |
||||
} |
||||
} |
||||
|
||||
.general-card { |
||||
border-radius: 4px; |
||||
border: none; |
||||
& > .arco-card-header { |
||||
height: auto; |
||||
padding: 20px; |
||||
border: none; |
||||
} |
||||
& > .arco-card-body { |
||||
padding: 0 20px 20px 20px; |
||||
} |
||||
} |
||||
|
||||
.split-line { |
||||
border-color: rgb(var(--gray-2)); |
||||
} |
||||
|
||||
.arco-table-cell { |
||||
.circle { |
||||
display: inline-block; |
||||
margin-right: 4px; |
||||
width: 6px; |
||||
height: 6px; |
||||
border-radius: 50%; |
||||
background-color: rgb(var(--blue-6)); |
||||
&.pass { |
||||
background-color: rgb(var(--green-6)); |
||||
} |
||||
} |
||||
} |
File diff suppressed because one or more lines are too long
@ -0,0 +1,35 @@ |
||||
<template> |
||||
<a-breadcrumb class="container-breadcrumb"> |
||||
<a-breadcrumb-item> |
||||
<icon-apps /> |
||||
</a-breadcrumb-item> |
||||
<a-breadcrumb-item v-for="item in items" :key="item"> |
||||
{{ $t(item) }} |
||||
</a-breadcrumb-item> |
||||
</a-breadcrumb> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { PropType } from 'vue'; |
||||
|
||||
defineProps({ |
||||
items: { |
||||
type: Array as PropType<string[]>, |
||||
default() { |
||||
return []; |
||||
}, |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
.container-breadcrumb { |
||||
margin: 16px 0; |
||||
:deep(.arco-breadcrumb-item) { |
||||
color: rgb(var(--gray-6)); |
||||
&:last-child { |
||||
color: rgb(var(--gray-8)); |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,47 @@ |
||||
<template> |
||||
<VCharts |
||||
v-if="renderChart" |
||||
:option="options" |
||||
:autoresize="autoResize" |
||||
:style="{ width, height }" |
||||
/> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { ref, nextTick } from 'vue'; |
||||
import VCharts from 'vue-echarts'; |
||||
// import { useAppStore } from '@/store'; |
||||
|
||||
defineProps({ |
||||
options: { |
||||
type: Object, |
||||
default() { |
||||
return {}; |
||||
}, |
||||
}, |
||||
autoResize: { |
||||
type: Boolean, |
||||
default: true, |
||||
}, |
||||
width: { |
||||
type: String, |
||||
default: '100%', |
||||
}, |
||||
height: { |
||||
type: String, |
||||
default: '100%', |
||||
}, |
||||
}); |
||||
// const appStore = useAppStore(); |
||||
// const theme = computed(() => { |
||||
// if (appStore.theme === 'dark') return 'dark'; |
||||
// return ''; |
||||
// }); |
||||
const renderChart = ref(false); |
||||
// wait container expand |
||||
nextTick(() => { |
||||
renderChart.value = true; |
||||
}); |
||||
</script> |
||||
|
||||
<style scoped lang="less"></style> |
@ -0,0 +1,16 @@ |
||||
<template> |
||||
<a-layout-footer class="footer">Arco Pro</a-layout-footer> |
||||
</template> |
||||
|
||||
<script lang="ts" setup></script> |
||||
|
||||
<style lang="less" scoped> |
||||
.footer { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
height: 40px; |
||||
color: var(--color-text-2); |
||||
text-align: center; |
||||
} |
||||
</style> |
@ -0,0 +1,79 @@ |
||||
<template> |
||||
<div class="block"> |
||||
<h5 class="title">{{ title }}</h5> |
||||
<div v-for="option in options" :key="option.name" class="switch-wrapper"> |
||||
<span>{{ $t(option.name) }}</span> |
||||
<form-wrapper |
||||
:type="option.type || 'switch'" |
||||
:name="option.key" |
||||
:default-value="option.defaultVal" |
||||
@input-change="handleChange" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { PropType } from 'vue'; |
||||
import { useAppStore } from '@/store'; |
||||
import FormWrapper from './form-wrapper.vue'; |
||||
|
||||
interface OptionsProps { |
||||
name: string; |
||||
key: string; |
||||
type?: string; |
||||
defaultVal?: boolean | string | number; |
||||
} |
||||
defineProps({ |
||||
title: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
options: { |
||||
type: Array as PropType<OptionsProps[]>, |
||||
default() { |
||||
return []; |
||||
}, |
||||
}, |
||||
}); |
||||
const appStore = useAppStore(); |
||||
const handleChange = async ({ |
||||
key, |
||||
value, |
||||
}: { |
||||
key: string; |
||||
value: unknown; |
||||
}) => { |
||||
if (key === 'colorWeak') { |
||||
document.body.style.filter = value ? 'invert(80%)' : 'none'; |
||||
} |
||||
if (key === 'menuFromServer' && value) { |
||||
await appStore.fetchServerMenuConfig(); |
||||
} |
||||
if (key === 'topMenu') { |
||||
appStore.updateSettings({ |
||||
menuCollapse: false, |
||||
}); |
||||
} |
||||
appStore.updateSettings({ [key]: value }); |
||||
}; |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
.block { |
||||
margin-bottom: 24px; |
||||
} |
||||
|
||||
.title { |
||||
margin: 10px 0; |
||||
padding: 0; |
||||
font-size: 14px; |
||||
} |
||||
|
||||
.switch-wrapper { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
height: 32px; |
||||
} |
||||
</style> |
@ -0,0 +1,39 @@ |
||||
<template> |
||||
<a-input-number |
||||
v-if="type === 'number'" |
||||
:style="{ width: '80px' }" |
||||
size="small" |
||||
:default-value="(defaultValue as number)" |
||||
@change="handleChange" |
||||
/> |
||||
<a-switch |
||||
v-else |
||||
:default-checked="(defaultValue as boolean)" |
||||
size="small" |
||||
@change="handleChange" |
||||
/> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
const props = defineProps({ |
||||
type: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
name: { |
||||
type: String, |
||||
default: '', |
||||
}, |
||||
defaultValue: { |
||||
type: [String, Boolean, Number], |
||||
default: '', |
||||
}, |
||||
}); |
||||
const emit = defineEmits(['inputChange']); |
||||
const handleChange = (value: unknown) => { |
||||
emit('inputChange', { |
||||
value, |
||||
key: props.name, |
||||
}); |
||||
}; |
||||
</script> |
@ -0,0 +1,98 @@ |
||||
<template> |
||||
<div v-if="!appStore.navbar" class="fixed-settings" @click="setVisible"> |
||||
<a-button type="primary"> |
||||
<template #icon> |
||||
<icon-settings /> |
||||
</template> |
||||
</a-button> |
||||
</div> |
||||
<a-drawer |
||||
:width="300" |
||||
unmount-on-close |
||||
:visible="visible" |
||||
:cancel-text="$t('settings.close')" |
||||
:ok-text="$t('settings.copySettings')" |
||||
@ok="copySettings" |
||||
@cancel="cancel" |
||||
> |
||||
<template #title> {{ $t('settings.title') }} </template> |
||||
<Block :options="contentOpts" :title="$t('settings.content')" /> |
||||
<Block :options="othersOpts" :title="$t('settings.otherSettings')" /> |
||||
<a-alert>{{ $t('settings.alertContent') }}</a-alert> |
||||
</a-drawer> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { computed } from 'vue'; |
||||
import { Message } from '@arco-design/web-vue'; |
||||
import { useI18n } from 'vue-i18n'; |
||||
import { useClipboard } from '@vueuse/core'; |
||||
import { useAppStore } from '@/store'; |
||||
import Block from './block.vue'; |
||||
|
||||
const emit = defineEmits(['cancel']); |
||||
|
||||
const appStore = useAppStore(); |
||||
const { t } = useI18n(); |
||||
const { copy } = useClipboard(); |
||||
const visible = computed(() => appStore.globalSettings); |
||||
const contentOpts = computed(() => [ |
||||
{ name: 'settings.navbar', key: 'navbar', defaultVal: appStore.navbar }, |
||||
{ |
||||
name: 'settings.menu', |
||||
key: 'menu', |
||||
defaultVal: appStore.menu, |
||||
}, |
||||
{ |
||||
name: 'settings.topMenu', |
||||
key: 'topMenu', |
||||
defaultVal: appStore.topMenu, |
||||
}, |
||||
{ name: 'settings.footer', key: 'footer', defaultVal: appStore.footer }, |
||||
{ name: 'settings.tabBar', key: 'tabBar', defaultVal: appStore.tabBar }, |
||||
{ |
||||
name: 'settings.menuFromServer', |
||||
key: 'menuFromServer', |
||||
defaultVal: appStore.menuFromServer, |
||||
}, |
||||
{ |
||||
name: 'settings.menuWidth', |
||||
key: 'menuWidth', |
||||
defaultVal: appStore.menuWidth, |
||||
type: 'number', |
||||
}, |
||||
]); |
||||
const othersOpts = computed(() => [ |
||||
{ |
||||
name: 'settings.colorWeak', |
||||
key: 'colorWeak', |
||||
defaultVal: appStore.colorWeak, |
||||
}, |
||||
]); |
||||
|
||||
const cancel = () => { |
||||
appStore.updateSettings({ globalSettings: false }); |
||||
emit('cancel'); |
||||
}; |
||||
const copySettings = async () => { |
||||
const text = JSON.stringify(appStore.$state, null, 2); |
||||
await copy(text); |
||||
Message.success(t('settings.copySettings.message')); |
||||
}; |
||||
const setVisible = () => { |
||||
appStore.updateSettings({ globalSettings: true }); |
||||
}; |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
.fixed-settings { |
||||
position: fixed; |
||||
top: 280px; |
||||
right: 0; |
||||
|
||||
svg { |
||||
font-size: 18px; |
||||
vertical-align: -4px; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,35 @@ |
||||
import { App } from 'vue'; |
||||
import { use } from 'echarts/core'; |
||||
import { CanvasRenderer } from 'echarts/renderers'; |
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'; |
||||
import { |
||||
GridComponent, |
||||
TooltipComponent, |
||||
LegendComponent, |
||||
DataZoomComponent, |
||||
GraphicComponent, |
||||
} from 'echarts/components'; |
||||
import Chart from './chart/index.vue'; |
||||
import Breadcrumb from './breadcrumb/index.vue'; |
||||
|
||||
// Manually introduce ECharts modules to reduce packing size
|
||||
|
||||
use([ |
||||
CanvasRenderer, |
||||
BarChart, |
||||
LineChart, |
||||
PieChart, |
||||
RadarChart, |
||||
GridComponent, |
||||
TooltipComponent, |
||||
LegendComponent, |
||||
DataZoomComponent, |
||||
GraphicComponent, |
||||
]); |
||||
|
||||
export default { |
||||
install(Vue: App) { |
||||
Vue.component('Chart', Chart); |
||||
Vue.component('Breadcrumb', Breadcrumb); |
||||
}, |
||||
}; |
@ -0,0 +1,160 @@ |
||||
<script lang="tsx"> |
||||
import { defineComponent, ref, h, compile, computed } from 'vue'; |
||||
import { useI18n } from 'vue-i18n'; |
||||
import { useRoute, useRouter, RouteRecordRaw } from 'vue-router'; |
||||
import type { RouteMeta } from 'vue-router'; |
||||
import { useAppStore } from '@/store'; |
||||
import { listenerRouteChange } from '@/utils/route-listener'; |
||||
import { openWindow, regexUrl } from '@/utils'; |
||||
import useMenuTree from './use-menu-tree'; |
||||
|
||||
export default defineComponent({ |
||||
emit: ['collapse'], |
||||
setup() { |
||||
const { t } = useI18n(); |
||||
const appStore = useAppStore(); |
||||
const router = useRouter(); |
||||
const route = useRoute(); |
||||
const { menuTree } = useMenuTree(); |
||||
const collapsed = computed({ |
||||
get() { |
||||
if (appStore.device === 'desktop') return appStore.menuCollapse; |
||||
return false; |
||||
}, |
||||
set(value: boolean) { |
||||
appStore.updateSettings({ menuCollapse: value }); |
||||
}, |
||||
}); |
||||
|
||||
const topMenu = computed(() => appStore.topMenu); |
||||
const openKeys = ref<string[]>([]); |
||||
const selectedKey = ref<string[]>([]); |
||||
|
||||
const goto = (item: RouteRecordRaw) => { |
||||
// Open external link |
||||
if (regexUrl.test(item.path)) { |
||||
openWindow(item.path); |
||||
selectedKey.value = [item.name as string]; |
||||
return; |
||||
} |
||||
// Eliminate external link side effects |
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta; |
||||
if (route.name === item.name && !hideInMenu && !activeMenu) { |
||||
selectedKey.value = [item.name as string]; |
||||
return; |
||||
} |
||||
// Trigger router change |
||||
router.push({ |
||||
name: item.name, |
||||
}); |
||||
}; |
||||
const findMenuOpenKeys = (target: string) => { |
||||
const result: string[] = []; |
||||
let isFind = false; |
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => { |
||||
if (item.name === target) { |
||||
isFind = true; |
||||
result.push(...keys); |
||||
return; |
||||
} |
||||
if (item.children?.length) { |
||||
item.children.forEach((el) => { |
||||
backtrack(el, [...keys, el.name as string]); |
||||
}); |
||||
} |
||||
}; |
||||
menuTree.value.forEach((el: RouteRecordRaw) => { |
||||
if (isFind) return; // Performance optimization |
||||
backtrack(el, [el.name as string]); |
||||
}); |
||||
return result; |
||||
}; |
||||
listenerRouteChange((newRoute) => { |
||||
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta; |
||||
if (requiresAuth && (!hideInMenu || activeMenu)) { |
||||
const menuOpenKeys = findMenuOpenKeys( |
||||
(activeMenu || newRoute.name) as string |
||||
); |
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]); |
||||
openKeys.value = [...keySet]; |
||||
|
||||
selectedKey.value = [ |
||||
activeMenu || menuOpenKeys[menuOpenKeys.length - 1], |
||||
]; |
||||
} |
||||
}, true); |
||||
const setCollapse = (val: boolean) => { |
||||
if (appStore.device === 'desktop') |
||||
appStore.updateSettings({ menuCollapse: val }); |
||||
}; |
||||
|
||||
const renderSubMenu = () => { |
||||
function travel(_route: RouteRecordRaw[], nodes = []) { |
||||
if (_route) { |
||||
_route.forEach((element) => { |
||||
// This is demo, modify nodes as needed |
||||
const icon = element?.meta?.icon |
||||
? () => h(compile(`<${element?.meta?.icon}/>`)) |
||||
: null; |
||||
const node = |
||||
element?.children && element?.children.length !== 0 ? ( |
||||
<a-sub-menu |
||||
key={element?.name} |
||||
v-slots={{ |
||||
icon, |
||||
title: () => h(compile(t(element?.meta?.locale || ''))), |
||||
}} |
||||
> |
||||
{travel(element?.children)} |
||||
</a-sub-menu> |
||||
) : ( |
||||
<a-menu-item |
||||
key={element?.name} |
||||
v-slots={{ icon }} |
||||
onClick={() => goto(element)} |
||||
> |
||||
{t(element?.meta?.locale || '')} |
||||
</a-menu-item> |
||||
); |
||||
nodes.push(node as never); |
||||
}); |
||||
} |
||||
return nodes; |
||||
} |
||||
return travel(menuTree.value); |
||||
}; |
||||
|
||||
return () => ( |
||||
<a-menu |
||||
mode={topMenu.value ? 'horizontal' : 'vertical'} |
||||
v-model:collapsed={collapsed.value} |
||||
v-model:open-keys={openKeys.value} |
||||
show-collapse-button={appStore.device !== 'mobile'} |
||||
auto-open={false} |
||||
selected-keys={selectedKey.value} |
||||
auto-open-selected={true} |
||||
level-indent={34} |
||||
style="height: 100%;width:100%;" |
||||
onCollapse={setCollapse} |
||||
> |
||||
{renderSubMenu()} |
||||
</a-menu> |
||||
); |
||||
}, |
||||
}); |
||||
</script> |
||||
|
||||
<style lang="less" scoped> |
||||
:deep(.arco-menu-inner) { |
||||
.arco-menu-inline-header { |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
.arco-icon { |
||||
&:not(.arco-icon-down) { |
||||
font-size: 18px; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,69 @@ |
||||
import { computed } from 'vue'; |
||||
import { RouteRecordRaw, RouteRecordNormalized } from 'vue-router'; |
||||
import usePermission from '@/hooks/permission'; |
||||
import { useAppStore } from '@/store'; |
||||
import appClientMenus from '@/router/app-menus'; |
||||
import { cloneDeep } from 'lodash'; |
||||
|
||||
export default function useMenuTree() { |
||||
const permission = usePermission(); |
||||
const appStore = useAppStore(); |
||||
const appRoute = computed(() => { |
||||
if (appStore.menuFromServer) { |
||||
return appStore.appAsyncMenus; |
||||
} |
||||
return appClientMenus; |
||||
}); |
||||
const menuTree = computed(() => { |
||||
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[]; |
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => { |
||||
return (a.meta.order || 0) - (b.meta.order || 0); |
||||
}); |
||||
function travel(_routes: RouteRecordRaw[], layer: number) { |
||||
if (!_routes) return null; |
||||
|
||||
const collector: any = _routes.map((element) => { |
||||
// no access
|
||||
if (!permission.accessRouter(element)) { |
||||
return null; |
||||
} |
||||
|
||||
// leaf node
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) { |
||||
element.children = []; |
||||
return element; |
||||
} |
||||
|
||||
// route filter hideInMenu true
|
||||
element.children = element.children.filter( |
||||
(x) => x.meta?.hideInMenu !== true |
||||
); |
||||
|
||||
// Associated child node
|
||||
const subItem = travel(element.children, layer + 1); |
||||
|
||||
if (subItem.length) { |
||||
element.children = subItem; |
||||
return element; |
||||
} |
||||
// the else logic
|
||||
if (layer > 1) { |
||||
element.children = subItem; |
||||
return element; |
||||
} |
||||
|
||||
if (element.meta?.hideInMenu === false) { |
||||
return element; |
||||
} |
||||
|
||||
return null; |
||||
}); |
||||
return collector.filter(Boolean); |
||||
} |
||||
return travel(copyRouter, 0); |
||||
}); |
||||
|
||||
return { |
||||
menuTree, |
||||
}; |
||||
} |
@ -0,0 +1,129 @@ |
||||
<template> |
||||
<a-spin style="display: block" :loading="loading"> |
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide> |
||||
<a-tab-pane v-for="item in tabList" :key="item.key"> |
||||
<template #title> |
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span> |
||||
</template> |
||||
<a-result v-if="!renderList.length" status="404"> |
||||
<template #subtitle> {{ $t('messageBox.noContent') }} </template> |
||||
</a-result> |
||||
<List |
||||
:render-list="renderList" |
||||
:unread-count="unreadCount" |
||||
@item-click="handleItemClick" |
||||
/> |
||||
</a-tab-pane> |
||||
<template #extra> |
||||
<a-button type="text" @click="emptyList"> |
||||
{{ $t('messageBox.tab.button') }} |
||||
</a-button> |
||||
</template> |
||||
</a-tabs> |
||||
</a-spin> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { ref, reactive, toRefs, computed } from 'vue'; |
||||
import { useI18n } from 'vue-i18n'; |
||||
import { |
||||
queryMessageList, |
||||
setMessageStatus, |
||||
MessageRecord, |
||||
MessageListType, |
||||
} from '@/api/message'; |
||||
import useLoading from '@/hooks/loading'; |
||||
import List from './list.vue'; |
||||
|
||||
interface TabItem { |
||||
key: string; |
||||
title: string; |
||||
avatar?: string; |
||||
} |
||||
const { loading, setLoading } = useLoading(true); |
||||
const messageType = ref('message'); |
||||
const { t } = useI18n(); |
||||
const messageData = reactive<{ |
||||
renderList: MessageRecord[]; |
||||
messageList: MessageRecord[]; |
||||
}>({ |
||||
renderList: [], |
||||
messageList: [], |
||||
}); |
||||
toRefs(messageData); |
||||
const tabList: TabItem[] = [ |
||||
{ |
||||
key: 'message', |
||||
title: t('messageBox.tab.title.message'), |
||||
}, |
||||
{ |
||||
key: 'notice', |
||||
title: t('messageBox.tab.title.notice'), |
||||
}, |
||||
{ |
||||
key: 'todo', |
||||
title: t('messageBox.tab.title.todo'), |
||||
}, |
||||
]; |
||||
async function fetchSourceData() { |
||||
setLoading(true); |
||||
try { |
||||
const { data } = await queryMessageList(); |
||||
messageData.messageList = data; |
||||
} catch (err) { |
||||
// you can report use errorHandler or other |
||||
} finally { |
||||
setLoading(false); |
||||
} |
||||
} |
||||
async function readMessage(data: MessageListType) { |
||||
const ids = data.map((item) => item.id); |
||||
await setMessageStatus({ ids }); |
||||
fetchSourceData(); |
||||
} |
||||
const renderList = computed(() => { |
||||
return messageData.messageList.filter( |
||||
(item) => messageType.value === item.type |
||||
); |
||||
}); |
||||
const unreadCount = computed(() => { |
||||
return renderList.value.filter((item) => !item.status).length; |
||||
}); |
||||
const getUnreadList = (type: string) => { |
||||
const list = messageData.messageList.filter( |
||||
(item) => item.type === type && !item.status |
||||
); |
||||
return list; |
||||
}; |
||||
const formatUnreadLength = (type: string) => { |
||||
const list = getUnreadList(type); |
||||
return list.length ? `(${list.length})` : ``; |
||||
}; |
||||
const handleItemClick = (items: MessageListType) => { |
||||
if (renderList.value.length) readMessage([...items]); |
||||
}; |
||||
const emptyList = () => { |
||||
messageData.messageList = []; |
||||
}; |
||||
fetchSourceData(); |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
:deep(.arco-popover-popup-content) { |
||||
padding: 0; |
||||
} |
||||
|
||||
:deep(.arco-list-item-meta) { |
||||
align-items: flex-start; |
||||
} |
||||
:deep(.arco-tabs-nav) { |
||||
padding: 14px 0 12px 16px; |
||||
border-bottom: 1px solid var(--color-neutral-3); |
||||
} |
||||
:deep(.arco-tabs-content) { |
||||
padding-top: 0; |
||||
.arco-result-subtitle { |
||||
color: rgb(var(--gray-6)); |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,149 @@ |
||||
<template> |
||||
<a-list :bordered="false"> |
||||
<a-list-item |
||||
v-for="item in renderList" |
||||
:key="item.id" |
||||
action-layout="vertical" |
||||
:style="{ |
||||
opacity: item.status ? 0.5 : 1, |
||||
}" |
||||
> |
||||
<template #extra> |
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag> |
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag> |
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag> |
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag> |
||||
</template> |
||||
<div class="item-wrap" @click="onItemClick(item)"> |
||||
<a-list-item-meta> |
||||
<template v-if="item.avatar" #avatar> |
||||
<a-avatar shape="circle"> |
||||
<img v-if="item.avatar" :src="item.avatar" /> |
||||
<icon-desktop v-else /> |
||||
</a-avatar> |
||||
</template> |
||||
<template #title> |
||||
<a-space :size="4"> |
||||
<span>{{ item.title }}</span> |
||||
<a-typography-text type="secondary"> |
||||
{{ item.subTitle }} |
||||
</a-typography-text> |
||||
</a-space> |
||||
</template> |
||||
<template #description> |
||||
<div> |
||||
<a-typography-paragraph |
||||
:ellipsis="{ |
||||
rows: 1, |
||||
}" |
||||
>{{ item.content }}</a-typography-paragraph |
||||
> |
||||
<a-typography-text |
||||
v-if="item.type === 'message'" |
||||
class="time-text" |
||||
> |
||||
{{ item.time }} |
||||
</a-typography-text> |
||||
</div> |
||||
</template> |
||||
</a-list-item-meta> |
||||
</div> |
||||
</a-list-item> |
||||
<template #footer> |
||||
<a-space |
||||
fill |
||||
:size="0" |
||||
:class="{ 'add-border-top': renderList.length < showMax }" |
||||
> |
||||
<div class="footer-wrap"> |
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link> |
||||
</div> |
||||
<div class="footer-wrap"> |
||||
<a-link>{{ $t('messageBox.viewMore') }}</a-link> |
||||
</div> |
||||
</a-space> |
||||
</template> |
||||
<div |
||||
v-if="renderList.length && renderList.length < 3" |
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }" |
||||
></div> |
||||
</a-list> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { PropType } from 'vue'; |
||||
import { MessageRecord, MessageListType } from '@/api/message'; |
||||
|
||||
const props = defineProps({ |
||||
renderList: { |
||||
type: Array as PropType<MessageListType>, |
||||
required: true, |
||||
}, |
||||
unreadCount: { |
||||
type: Number, |
||||
default: 0, |
||||
}, |
||||
}); |
||||
const emit = defineEmits(['itemClick']); |
||||
const allRead = () => { |
||||
emit('itemClick', [...props.renderList]); |
||||
}; |
||||
|
||||
const onItemClick = (item: MessageRecord) => { |
||||
if (!item.status) { |
||||
emit('itemClick', [item]); |
||||
} |
||||
}; |
||||
const showMax = 3; |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
:deep(.arco-list) { |
||||
.arco-list-item { |
||||
min-height: 86px; |
||||
border-bottom: 1px solid rgb(var(--gray-3)); |
||||
} |
||||
.arco-list-item-extra { |
||||
position: absolute; |
||||
right: 20px; |
||||
} |
||||
.arco-list-item-meta-content { |
||||
flex: 1; |
||||
} |
||||
.item-wrap { |
||||
cursor: pointer; |
||||
} |
||||
.time-text { |
||||
font-size: 12px; |
||||
color: rgb(var(--gray-6)); |
||||
} |
||||
.arco-empty { |
||||
display: none; |
||||
} |
||||
.arco-list-footer { |
||||
padding: 0; |
||||
height: 50px; |
||||
line-height: 50px; |
||||
border-top: none; |
||||
.arco-space-item { |
||||
width: 100%; |
||||
border-right: 1px solid rgb(var(--gray-3)); |
||||
&:last-child { |
||||
border-right: none; |
||||
} |
||||
} |
||||
.add-border-top { |
||||
border-top: 1px solid rgb(var(--gray-3)); |
||||
} |
||||
} |
||||
.footer-wrap { |
||||
text-align: center; |
||||
} |
||||
.arco-typography { |
||||
margin-bottom: 0; |
||||
} |
||||
.add-border { |
||||
border-top: 1px solid rgb(var(--gray-3)); |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,13 @@ |
||||
export default { |
||||
'messageBox.tab.title.message': 'Message', |
||||
'messageBox.tab.title.notice': 'Notice', |
||||
'messageBox.tab.title.todo': 'Todo', |
||||
'messageBox.tab.button': 'empty', |
||||
'messageBox.allRead': 'All Read', |
||||
'messageBox.viewMore': 'View More', |
||||
'messageBox.noContent': 'No Content', |
||||
'messageBox.switchRoles': 'Switch Roles', |
||||
'messageBox.userCenter': 'User Center', |
||||
'messageBox.userSettings': 'User Settings', |
||||
'messageBox.logout': 'Logout', |
||||
}; |
@ -0,0 +1,13 @@ |
||||
export default { |
||||
'messageBox.tab.title.message': '消息', |
||||
'messageBox.tab.title.notice': '通知', |
||||
'messageBox.tab.title.todo': '待办', |
||||
'messageBox.tab.button': '清空', |
||||
'messageBox.allRead': '全部已读', |
||||
'messageBox.viewMore': '查看更多', |
||||
'messageBox.noContent': '暂无内容', |
||||
'messageBox.switchRoles': '切换角色', |
||||
'messageBox.userCenter': '用户中心', |
||||
'messageBox.userSettings': '用户设置', |
||||
'messageBox.logout': '登出登录', |
||||
}; |
@ -0,0 +1,320 @@ |
||||
<template> |
||||
<div class="navbar"> |
||||
<div class="left-side"> |
||||
<a-space> |
||||
<img |
||||
alt="logo" |
||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image" |
||||
/> |
||||
<a-typography-title |
||||
:style="{ margin: 0, fontSize: '18px' }" |
||||
:heading="5" |
||||
> |
||||
Arco Pro |
||||
</a-typography-title> |
||||
<icon-menu-fold |
||||
v-if="!topMenu && appStore.device === 'mobile'" |
||||
style="font-size: 22px; cursor: pointer" |
||||
@click="toggleDrawerMenu" |
||||
/> |
||||
</a-space> |
||||
</div> |
||||
<div class="center-side"> |
||||
<Menu v-if="topMenu" /> |
||||
</div> |
||||
<ul class="right-side"> |
||||
<li> |
||||
<a-tooltip :content="$t('settings.search')"> |
||||
<a-button class="nav-btn" type="outline" :shape="'circle'"> |
||||
<template #icon> |
||||
<icon-search /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
</li> |
||||
<li> |
||||
<a-tooltip :content="$t('settings.language')"> |
||||
<a-button |
||||
class="nav-btn" |
||||
type="outline" |
||||
:shape="'circle'" |
||||
@click="setDropDownVisible" |
||||
> |
||||
<template #icon> |
||||
<icon-language /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
<a-dropdown trigger="click" @select="changeLocale as any"> |
||||
<div ref="triggerBtn" class="trigger-btn"></div> |
||||
<template #content> |
||||
<a-doption |
||||
v-for="item in locales" |
||||
:key="item.value" |
||||
:value="item.value" |
||||
> |
||||
{{ item.label }} |
||||
</a-doption> |
||||
</template> |
||||
</a-dropdown> |
||||
</li> |
||||
<li> |
||||
<a-tooltip |
||||
:content=" |
||||
theme === 'light' |
||||
? $t('settings.navbar.theme.toDark') |
||||
: $t('settings.navbar.theme.toLight') |
||||
" |
||||
> |
||||
<a-button |
||||
class="nav-btn" |
||||
type="outline" |
||||
:shape="'circle'" |
||||
@click="handleToggleTheme" |
||||
> |
||||
<template #icon> |
||||
<icon-moon-fill v-if="theme === 'dark'" /> |
||||
<icon-sun-fill v-else /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
</li> |
||||
<li> |
||||
<a-tooltip :content="$t('settings.navbar.alerts')"> |
||||
<div class="message-box-trigger"> |
||||
<a-badge :count="9" dot> |
||||
<a-button |
||||
class="nav-btn" |
||||
type="outline" |
||||
:shape="'circle'" |
||||
@click="setPopoverVisible" |
||||
> |
||||
<icon-notification /> |
||||
</a-button> |
||||
</a-badge> |
||||
</div> |
||||
</a-tooltip> |
||||
<a-popover |
||||
trigger="click" |
||||
:arrow-style="{ display: 'none' }" |
||||
:content-style="{ padding: 0, minWidth: '400px' }" |
||||
content-class="message-popover" |
||||
> |
||||
<div ref="refBtn" class="ref-btn"></div> |
||||
<template #content> |
||||
<message-box /> |
||||
</template> |
||||
</a-popover> |
||||
</li> |
||||
<li> |
||||
<a-tooltip |
||||
:content=" |
||||
isFullscreen |
||||
? $t('settings.navbar.screen.toExit') |
||||
: $t('settings.navbar.screen.toFull') |
||||
" |
||||
> |
||||
<a-button |
||||
class="nav-btn" |
||||
type="outline" |
||||
:shape="'circle'" |
||||
@click="toggleFullScreen" |
||||
> |
||||
<template #icon> |
||||
<icon-fullscreen-exit v-if="isFullscreen" /> |
||||
<icon-fullscreen v-else /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
</li> |
||||
<li> |
||||
<a-tooltip :content="$t('settings.title')"> |
||||
<a-button |
||||
class="nav-btn" |
||||
type="outline" |
||||
:shape="'circle'" |
||||
@click="setVisible" |
||||
> |
||||
<template #icon> |
||||
<icon-settings /> |
||||
</template> |
||||
</a-button> |
||||
</a-tooltip> |
||||
</li> |
||||
<li> |
||||
<a-dropdown trigger="click"> |
||||
<a-avatar |
||||
:size="32" |
||||
:style="{ marginRight: '8px', cursor: 'pointer' }" |
||||
> |
||||
<img alt="avatar" :src="avatar" /> |
||||
</a-avatar> |
||||
<template #content> |
||||
<a-doption> |
||||
<a-space @click="switchRoles"> |
||||
<icon-tag /> |
||||
<span> |
||||
{{ $t('messageBox.switchRoles') }} |
||||
</span> |
||||
</a-space> |
||||
</a-doption> |
||||
<a-doption> |
||||
<a-space @click="$router.push({ name: 'Info' })"> |
||||
<icon-user /> |
||||
<span> |
||||
{{ $t('messageBox.userCenter') }} |
||||
</span> |
||||
</a-space> |
||||
</a-doption> |
||||
<a-doption> |
||||
<a-space @click="$router.push({ name: 'Setting' })"> |
||||
<icon-settings /> |
||||
<span> |
||||
{{ $t('messageBox.userSettings') }} |
||||
</span> |
||||
</a-space> |
||||
</a-doption> |
||||
<a-doption> |
||||
<a-space @click="handleLogout"> |
||||
<icon-export /> |
||||
<span> |
||||
{{ $t('messageBox.logout') }} |
||||
</span> |
||||
</a-space> |
||||
</a-doption> |
||||
</template> |
||||
</a-dropdown> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { computed, ref, inject } from 'vue'; |
||||
import { Message } from '@arco-design/web-vue'; |
||||
import { useDark, useToggle, useFullscreen } from '@vueuse/core'; |
||||
import { useAppStore, useUserStore } from '@/store'; |
||||
import { LOCALE_OPTIONS } from '@/locale'; |
||||
import useLocale from '@/hooks/locale'; |
||||
import useUser from '@/hooks/user'; |
||||
import Menu from '@/components/menu/index.vue'; |
||||
import MessageBox from '../message-box/index.vue'; |
||||
|
||||
const appStore = useAppStore(); |
||||
const userStore = useUserStore(); |
||||
const { logout } = useUser(); |
||||
const { changeLocale } = useLocale(); |
||||
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen(); |
||||
const locales = [...LOCALE_OPTIONS]; |
||||
const avatar = computed(() => { |
||||
return userStore.avatar; |
||||
}); |
||||
const theme = computed(() => { |
||||
return appStore.theme; |
||||
}); |
||||
const topMenu = computed(() => appStore.topMenu && appStore.menu); |
||||
const isDark = useDark({ |
||||
selector: 'body', |
||||
attribute: 'arco-theme', |
||||
valueDark: 'dark', |
||||
valueLight: 'light', |
||||
storageKey: 'arco-theme', |
||||
onChanged(dark: boolean) { |
||||
// overridden default behavior |
||||
appStore.toggleTheme(dark); |
||||
}, |
||||
}); |
||||
const toggleTheme = useToggle(isDark); |
||||
const handleToggleTheme = () => { |
||||
toggleTheme(); |
||||
}; |
||||
const setVisible = () => { |
||||
appStore.updateSettings({ globalSettings: true }); |
||||
}; |
||||
const refBtn = ref(); |
||||
const triggerBtn = ref(); |
||||
const setPopoverVisible = () => { |
||||
const event = new MouseEvent('click', { |
||||
view: window, |
||||
bubbles: true, |
||||
cancelable: true, |
||||
}); |
||||
refBtn.value.dispatchEvent(event); |
||||
}; |
||||
const handleLogout = () => { |
||||
logout(); |
||||
}; |
||||
const setDropDownVisible = () => { |
||||
const event = new MouseEvent('click', { |
||||
view: window, |
||||
bubbles: true, |
||||
cancelable: true, |
||||
}); |
||||
triggerBtn.value.dispatchEvent(event); |
||||
}; |
||||
const switchRoles = async () => { |
||||
const res = await userStore.switchRoles(); |
||||
Message.success(res as string); |
||||
}; |
||||
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void; |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
.navbar { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
height: 100%; |
||||
background-color: var(--color-bg-2); |
||||
border-bottom: 1px solid var(--color-border); |
||||
} |
||||
|
||||
.left-side { |
||||
display: flex; |
||||
align-items: center; |
||||
padding-left: 20px; |
||||
} |
||||
|
||||
.center-side { |
||||
flex: 1; |
||||
} |
||||
|
||||
.right-side { |
||||
display: flex; |
||||
padding-right: 20px; |
||||
list-style: none; |
||||
:deep(.locale-select) { |
||||
border-radius: 20px; |
||||
} |
||||
li { |
||||
display: flex; |
||||
align-items: center; |
||||
padding: 0 10px; |
||||
} |
||||
|
||||
a { |
||||
color: var(--color-text-1); |
||||
text-decoration: none; |
||||
} |
||||
.nav-btn { |
||||
border-color: rgb(var(--gray-2)); |
||||
color: rgb(var(--gray-8)); |
||||
font-size: 16px; |
||||
} |
||||
.trigger-btn, |
||||
.ref-btn { |
||||
position: absolute; |
||||
bottom: 14px; |
||||
} |
||||
.trigger-btn { |
||||
margin-left: 14px; |
||||
} |
||||
} |
||||
</style> |
||||
|
||||
<style lang="less"> |
||||
.message-popover { |
||||
.arco-popover-content { |
||||
margin-top: 0; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,101 @@ |
||||
<template> |
||||
<div class="tab-bar-container"> |
||||
<a-affix ref="affixRef" :offset-top="offsetTop"> |
||||
<div class="tab-bar-box"> |
||||
<div class="tab-bar-scroll"> |
||||
<div class="tags-wrap"> |
||||
<tab-item |
||||
v-for="(tag, index) in tagList" |
||||
:key="tag.fullPath" |
||||
:index="index" |
||||
:item-data="tag" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div class="tag-bar-operation"></div> |
||||
</div> |
||||
</a-affix> |
||||
</div> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { ref, computed, watch, onUnmounted } from 'vue'; |
||||
import type { RouteLocationNormalized } from 'vue-router'; |
||||
import { |
||||
listenerRouteChange, |
||||
removeRouteListener, |
||||
} from '@/utils/route-listener'; |
||||
import { useAppStore, useTabBarStore } from '@/store'; |
||||
import tabItem from './tab-item.vue'; |
||||
|
||||
const appStore = useAppStore(); |
||||
const tabBarStore = useTabBarStore(); |
||||
|
||||
const affixRef = ref(); |
||||
const tagList = computed(() => { |
||||
return tabBarStore.getTabList; |
||||
}); |
||||
const offsetTop = computed(() => { |
||||
return appStore.navbar ? 60 : 0; |
||||
}); |
||||
|
||||
watch( |
||||
() => appStore.navbar, |
||||
() => { |
||||
affixRef.value.updatePosition(); |
||||
} |
||||
); |
||||
listenerRouteChange((route: RouteLocationNormalized) => { |
||||
if ( |
||||
!route.meta.noAffix && |
||||
!tagList.value.some((tag) => tag.fullPath === route.fullPath) |
||||
) { |
||||
tabBarStore.updateTabList(route); |
||||
} |
||||
}, true); |
||||
|
||||
onUnmounted(() => { |
||||
removeRouteListener(); |
||||
}); |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
.tab-bar-container { |
||||
position: relative; |
||||
background-color: var(--color-bg-2); |
||||
.tab-bar-box { |
||||
display: flex; |
||||
padding: 0 0 0 20px; |
||||
background-color: var(--color-bg-2); |
||||
border-bottom: 1px solid var(--color-border); |
||||
.tab-bar-scroll { |
||||
height: 32px; |
||||
flex: 1; |
||||
overflow: hidden; |
||||
.tags-wrap { |
||||
padding: 4px 0; |
||||
height: 48px; |
||||
white-space: nowrap; |
||||
overflow-x: auto; |
||||
|
||||
:deep(.arco-tag) { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
margin-right: 6px; |
||||
cursor: pointer; |
||||
&:first-child { |
||||
.arco-tag-close-btn { |
||||
display: none; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
.tag-bar-operation { |
||||
width: 100px; |
||||
height: 32px; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,12 @@ |
||||
## 组件说明 |
||||
|
||||
该组件非官方最终设计规范,以单独组件存在。 |
||||
|
||||
同时仅仅提供最基本的功能,后续进行优化及更改。 |
||||
|
||||
|
||||
## Component description |
||||
|
||||
The component unofficial final design specification exists as a separate component. |
||||
|
||||
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made. |
@ -0,0 +1,200 @@ |
||||
<template> |
||||
<a-dropdown |
||||
trigger="contextMenu" |
||||
:popup-max-height="false" |
||||
@select="actionSelect" |
||||
> |
||||
<span |
||||
class="arco-tag arco-tag-size-medium arco-tag-checked" |
||||
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }" |
||||
@click="goto(itemData)" |
||||
> |
||||
<span class="tag-link"> |
||||
{{ $t(itemData.title) }} |
||||
</span> |
||||
<span |
||||
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn" |
||||
@click.stop="tagClose(itemData, index)" |
||||
> |
||||
<icon-close /> |
||||
</span> |
||||
</span> |
||||
<template #content> |
||||
<a-doption :disabled="disabledReload" :value="Eaction.reload"> |
||||
<icon-refresh /> |
||||
<span>重新加载</span> |
||||
</a-doption> |
||||
<a-doption |
||||
class="sperate-line" |
||||
:disabled="disabledCurrent" |
||||
:value="Eaction.current" |
||||
> |
||||
<icon-close /> |
||||
<span>关闭当前标签页</span> |
||||
</a-doption> |
||||
<a-doption :disabled="disabledLeft" :value="Eaction.left"> |
||||
<icon-to-left /> |
||||
<span>关闭左侧标签页</span> |
||||
</a-doption> |
||||
<a-doption |
||||
class="sperate-line" |
||||
:disabled="disabledRight" |
||||
:value="Eaction.right" |
||||
> |
||||
<icon-to-right /> |
||||
<span>关闭右侧标签页</span> |
||||
</a-doption> |
||||
<a-doption :value="Eaction.others"> |
||||
<icon-swap /> |
||||
<span>关闭其它标签页</span> |
||||
</a-doption> |
||||
<a-doption :value="Eaction.all"> |
||||
<icon-folder-delete /> |
||||
<span>关闭全部标签页</span> |
||||
</a-doption> |
||||
</template> |
||||
</a-dropdown> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { PropType, computed } from 'vue'; |
||||
import { useRouter, useRoute } from 'vue-router'; |
||||
import { useTabBarStore } from '@/store'; |
||||
import type { TagProps } from '@/store/modules/tab-bar/types'; |
||||
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants'; |
||||
|
||||
// eslint-disable-next-line no-shadow |
||||
enum Eaction { |
||||
reload = 'reload', |
||||
current = 'current', |
||||
left = 'left', |
||||
right = 'right', |
||||
others = 'others', |
||||
all = 'all', |
||||
} |
||||
|
||||
const props = defineProps({ |
||||
itemData: { |
||||
type: Object as PropType<TagProps>, |
||||
default() { |
||||
return []; |
||||
}, |
||||
}, |
||||
index: { |
||||
type: Number, |
||||
default: 0, |
||||
}, |
||||
}); |
||||
|
||||
const router = useRouter(); |
||||
const route = useRoute(); |
||||
const tabBarStore = useTabBarStore(); |
||||
|
||||
const goto = (tag: TagProps) => { |
||||
router.push({ ...tag }); |
||||
}; |
||||
const tagList = computed(() => { |
||||
return tabBarStore.getTabList; |
||||
}); |
||||
|
||||
const disabledReload = computed(() => { |
||||
return props.itemData.fullPath !== route.fullPath; |
||||
}); |
||||
|
||||
const disabledCurrent = computed(() => { |
||||
return props.index === 0; |
||||
}); |
||||
|
||||
const disabledLeft = computed(() => { |
||||
return [0, 1].includes(props.index); |
||||
}); |
||||
|
||||
const disabledRight = computed(() => { |
||||
return props.index === tagList.value.length - 1; |
||||
}); |
||||
|
||||
const tagClose = (tag: TagProps, idx: number) => { |
||||
tabBarStore.deleteTag(idx, tag); |
||||
if (props.itemData.fullPath === route.fullPath) { |
||||
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab |
||||
router.push({ name: latest.name }); |
||||
} |
||||
}; |
||||
|
||||
const findCurrentRouteIndex = () => { |
||||
return tagList.value.findIndex((el) => el.fullPath === route.fullPath); |
||||
}; |
||||
const actionSelect = async (value: any) => { |
||||
const { itemData, index } = props; |
||||
const copyTagList = [...tagList.value]; |
||||
if (value === Eaction.current) { |
||||
tagClose(itemData, index); |
||||
} else if (value === Eaction.left) { |
||||
const currentRouteIdx = findCurrentRouteIndex(); |
||||
copyTagList.splice(1, props.index - 1); |
||||
|
||||
tabBarStore.freshTabList(copyTagList); |
||||
if (currentRouteIdx < index) { |
||||
router.push({ name: itemData.name }); |
||||
} |
||||
} else if (value === Eaction.right) { |
||||
const currentRouteIdx = findCurrentRouteIndex(); |
||||
copyTagList.splice(props.index + 1); |
||||
|
||||
tabBarStore.freshTabList(copyTagList); |
||||
if (currentRouteIdx > index) { |
||||
router.push({ name: itemData.name }); |
||||
} |
||||
} else if (value === Eaction.others) { |
||||
const filterList = tagList.value.filter((el, idx) => { |
||||
return idx === 0 || idx === props.index; |
||||
}); |
||||
tabBarStore.freshTabList(filterList); |
||||
router.push({ name: itemData.name }); |
||||
} else if (value === Eaction.reload) { |
||||
tabBarStore.deleteCache(itemData); |
||||
await router.push({ |
||||
name: REDIRECT_ROUTE_NAME, |
||||
params: { |
||||
path: route.fullPath, |
||||
}, |
||||
}); |
||||
tabBarStore.addCache(itemData.name); |
||||
} else { |
||||
tabBarStore.resetTabList(); |
||||
router.push({ name: DEFAULT_ROUTE_NAME }); |
||||
} |
||||
}; |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
.tag-link { |
||||
color: var(--color-text-2); |
||||
text-decoration: none; |
||||
} |
||||
.link-activated { |
||||
color: rgb(var(--link-6)); |
||||
.tag-link { |
||||
color: rgb(var(--link-6)); |
||||
} |
||||
& + .arco-tag-close-btn { |
||||
color: rgb(var(--link-6)); |
||||
} |
||||
} |
||||
:deep(.arco-dropdown-option-content) { |
||||
span { |
||||
margin-left: 10px; |
||||
} |
||||
} |
||||
.arco-dropdown-open { |
||||
.tag-link { |
||||
color: rgb(var(--danger-6)); |
||||
} |
||||
.arco-tag-close-btn { |
||||
color: rgb(var(--danger-6)); |
||||
} |
||||
} |
||||
.sperate-line { |
||||
border-bottom: 1px solid var(--color-neutral-3); |
||||
} |
||||
</style> |
@ -0,0 +1,17 @@ |
||||
{ |
||||
"theme": "light", |
||||
"colorWeak": false, |
||||
"navbar": true, |
||||
"menu": true, |
||||
"topMenu": false, |
||||
"hideMenu": false, |
||||
"menuCollapse": false, |
||||
"footer": true, |
||||
"themeColor": "#165DFF", |
||||
"menuWidth": 220, |
||||
"globalSettings": false, |
||||
"device": "desktop", |
||||
"tabBar": false, |
||||
"menuFromServer": false, |
||||
"serverMenu": [] |
||||
} |
@ -0,0 +1,8 @@ |
||||
import { App } from 'vue'; |
||||
import permission from './permission'; |
||||
|
||||
export default { |
||||
install(Vue: App) { |
||||
Vue.directive('permission', permission); |
||||
}, |
||||
}; |
@ -0,0 +1,30 @@ |
||||
import { DirectiveBinding } from 'vue'; |
||||
import { useUserStore } from '@/store'; |
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding) { |
||||
const { value } = binding; |
||||
const userStore = useUserStore(); |
||||
const { role } = userStore; |
||||
|
||||
if (Array.isArray(value)) { |
||||
if (value.length > 0) { |
||||
const permissionValues = value; |
||||
|
||||
const hasPermission = permissionValues.includes(role); |
||||
if (!hasPermission && el.parentNode) { |
||||
el.parentNode.removeChild(el); |
||||
} |
||||
} |
||||
} else { |
||||
throw new Error(`need roles! Like v-permission="['admin','user']"`); |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
mounted(el: HTMLElement, binding: DirectiveBinding) { |
||||
checkPermission(el, binding); |
||||
}, |
||||
updated(el: HTMLElement, binding: DirectiveBinding) { |
||||
checkPermission(el, binding); |
||||
}, |
||||
}; |
@ -0,0 +1,11 @@ |
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' { |
||||
import { DefineComponent } from 'vue'; |
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>; |
||||
export default component; |
||||
} |
||||
interface ImportMetaEnv { |
||||
readonly VITE_API_BASE_URL: string; |
||||
} |
@ -0,0 +1,27 @@ |
||||
import { computed } from 'vue'; |
||||
import { EChartsOption } from 'echarts'; |
||||
import { useAppStore } from '@/store'; |
||||
|
||||
// for code hints
|
||||
// import { SeriesOption } from 'echarts';
|
||||
// Because there are so many configuration items, this provides a relatively convenient code hint.
|
||||
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
|
||||
interface optionsFn { |
||||
(isDark: boolean): EChartsOption; |
||||
} |
||||
|
||||
export default function useChartOption(sourceOption: optionsFn) { |
||||
const appStore = useAppStore(); |
||||
const isDark = computed(() => { |
||||
return appStore.theme === 'dark'; |
||||
}); |
||||
// echarts support https://echarts.apache.org/zh/theme-builder.html
|
||||
// It's not used here
|
||||
// TODO echarts themes
|
||||
const chartOption = computed<EChartsOption>(() => { |
||||
return sourceOption(isDark.value); |
||||
}); |
||||
return { |
||||
chartOption, |
||||
}; |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { ref } from 'vue'; |
||||
|
||||
export default function useLoading(initValue = false) { |
||||
const loading = ref(initValue); |
||||
const setLoading = (value: boolean) => { |
||||
loading.value = value; |
||||
}; |
||||
const toggle = () => { |
||||
loading.value = !loading.value; |
||||
}; |
||||
return { |
||||
loading, |
||||
setLoading, |
||||
toggle, |
||||
}; |
||||
} |
@ -0,0 +1,19 @@ |
||||
import { computed } from 'vue'; |
||||
import { useI18n } from 'vue-i18n'; |
||||
import { Message } from '@arco-design/web-vue'; |
||||
|
||||
export default function useLocale() { |
||||
const i18 = useI18n(); |
||||
const currentLocale = computed(() => { |
||||
return i18.locale.value; |
||||
}); |
||||
const changeLocale = (value: string) => { |
||||
i18.locale.value = value; |
||||
localStorage.setItem('arco-locale', value); |
||||
Message.success(i18.t('navbar.action.locale')); |
||||
}; |
||||
return { |
||||
currentLocale, |
||||
changeLocale, |
||||
}; |
||||
} |
@ -0,0 +1,33 @@ |
||||
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'; |
||||
import { useUserStore } from '@/store'; |
||||
|
||||
export default function usePermission() { |
||||
const userStore = useUserStore(); |
||||
return { |
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) { |
||||
return ( |
||||
!route.meta?.requiresAuth || |
||||
!route.meta?.roles || |
||||
route.meta?.roles?.includes('*') || |
||||
route.meta?.roles?.includes(userStore.role) |
||||
); |
||||
}, |
||||
findFirstPermissionRoute(_routers: any, role = 'admin') { |
||||
const cloneRouters = [..._routers]; |
||||
while (cloneRouters.length) { |
||||
const firstElement = cloneRouters.shift(); |
||||
if ( |
||||
firstElement?.meta?.roles?.find((el: string[]) => { |
||||
return el.includes('*') || el.includes(role); |
||||
}) |
||||
) |
||||
return { name: firstElement.name }; |
||||
if (firstElement?.children) { |
||||
cloneRouters.push(...firstElement.children); |
||||
} |
||||
} |
||||
return null; |
||||
}, |
||||
// You can add any rules you want
|
||||
}; |
||||
} |
@ -0,0 +1,26 @@ |
||||
import { ref, UnwrapRef } from 'vue'; |
||||
import { AxiosResponse } from 'axios'; |
||||
import { HttpResponse } from '@/api/interceptor'; |
||||
import useLoading from './loading'; |
||||
|
||||
// use to fetch list
|
||||
// Don't use async function. It doesn't work in async function.
|
||||
// Use the bind function to add parameters
|
||||
// example: useRequest(api.bind(null, {}))
|
||||
|
||||
export default function useRequest<T>( |
||||
api: () => Promise<AxiosResponse<HttpResponse>>, |
||||
defaultValue = [] as unknown as T, |
||||
isLoading = true |
||||
) { |
||||
const { loading, setLoading } = useLoading(isLoading); |
||||
const response = ref<T>(defaultValue); |
||||
api() |
||||
.then((res) => { |
||||
response.value = res.data as unknown as UnwrapRef<T>; |
||||
}) |
||||
.finally(() => { |
||||
setLoading(false); |
||||
}); |
||||
return { loading, response }; |
||||
} |
@ -0,0 +1,32 @@ |
||||
import { onMounted, onBeforeMount, onBeforeUnmount } from 'vue'; |
||||
import { useDebounceFn } from '@vueuse/core'; |
||||
import { useAppStore } from '@/store'; |
||||
import { addEventListen, removeEventListen } from '@/utils/event'; |
||||
|
||||
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
|
||||
|
||||
function queryDevice() { |
||||
const rect = document.body.getBoundingClientRect(); |
||||
return rect.width - 1 < WIDTH; |
||||
} |
||||
|
||||
export default function useResponsive(immediate?: boolean) { |
||||
const appStore = useAppStore(); |
||||
function resizeHandler() { |
||||
if (!document.hidden) { |
||||
const isMobile = queryDevice(); |
||||
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop'); |
||||
appStore.toggleMenu(isMobile); |
||||
} |
||||
} |
||||
const debounceFn = useDebounceFn(resizeHandler, 100); |
||||
onMounted(() => { |
||||
if (immediate) debounceFn(); |
||||
}); |
||||
onBeforeMount(() => { |
||||
addEventListen(window, 'resize', debounceFn); |
||||
}); |
||||
onBeforeUnmount(() => { |
||||
removeEventListen(window, 'resize', debounceFn); |
||||
}); |
||||
} |
@ -0,0 +1,12 @@ |
||||
import { computed } from 'vue'; |
||||
import { useAppStore } from '@/store'; |
||||
|
||||
export default function useThemes() { |
||||
const appStore = useAppStore(); |
||||
const isDark = computed(() => { |
||||
return appStore.theme === 'dark'; |
||||
}); |
||||
return { |
||||
isDark, |
||||
}; |
||||
} |
@ -0,0 +1,24 @@ |
||||
import { useRouter } from 'vue-router'; |
||||
import { Message } from '@arco-design/web-vue'; |
||||
|
||||
import { useUserStore } from '@/store'; |
||||
|
||||
export default function useUser() { |
||||
const router = useRouter(); |
||||
const userStore = useUserStore(); |
||||
const logout = async (logoutTo?: string) => { |
||||
await userStore.logout(); |
||||
const currentRoute = router.currentRoute.value; |
||||
Message.success('登出成功'); |
||||
router.push({ |
||||
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login', |
||||
query: { |
||||
...router.currentRoute.value.query, |
||||
redirect: currentRoute.name as string, |
||||
}, |
||||
}); |
||||
}; |
||||
return { |
||||
logout, |
||||
}; |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { ref } from 'vue'; |
||||
|
||||
export default function useVisible(initValue = false) { |
||||
const visible = ref(initValue); |
||||
const setVisible = (value: boolean) => { |
||||
visible.value = value; |
||||
}; |
||||
const toggle = () => { |
||||
visible.value = !visible.value; |
||||
}; |
||||
return { |
||||
visible, |
||||
setVisible, |
||||
toggle, |
||||
}; |
||||
} |
@ -0,0 +1,173 @@ |
||||
<template> |
||||
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }"> |
||||
<div v-if="navbar" class="layout-navbar"> |
||||
<NavBar /> |
||||
</div> |
||||
<a-layout> |
||||
<a-layout> |
||||
<a-layout-sider |
||||
v-if="renderMenu" |
||||
v-show="!hideMenu" |
||||
class="layout-sider" |
||||
breakpoint="xl" |
||||
:collapsed="collapsed" |
||||
:collapsible="true" |
||||
:width="menuWidth" |
||||
:style="{ paddingTop: navbar ? '60px' : '' }" |
||||
:hide-trigger="true" |
||||
@collapse="setCollapsed" |
||||
> |
||||
<div class="menu-wrapper"> |
||||
<Menu /> |
||||
</div> |
||||
</a-layout-sider> |
||||
<a-drawer |
||||
v-if="hideMenu" |
||||
:visible="drawerVisible" |
||||
placement="left" |
||||
:footer="false" |
||||
mask-closable |
||||
:closable="false" |
||||
@cancel="drawerCancel" |
||||
> |
||||
<Menu /> |
||||
</a-drawer> |
||||
<a-layout class="layout-content" :style="paddingStyle"> |
||||
<TabBar v-if="appStore.tabBar" /> |
||||
<a-layout-content> |
||||
<PageLayout /> |
||||
</a-layout-content> |
||||
<Footer v-if="footer" /> |
||||
</a-layout> |
||||
</a-layout> |
||||
</a-layout> |
||||
</a-layout> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { ref, computed, watch, provide } from 'vue'; |
||||
import { useRouter, useRoute } from 'vue-router'; |
||||
import { useAppStore, useUserStore } from '@/store'; |
||||
import NavBar from '@/components/navbar/index.vue'; |
||||
import Menu from '@/components/menu/index.vue'; |
||||
import Footer from '@/components/footer/index.vue'; |
||||
import TabBar from '@/components/tab-bar/index.vue'; |
||||
import usePermission from '@/hooks/permission'; |
||||
import useResponsive from '@/hooks/responsive'; |
||||
import PageLayout from './page-layout.vue'; |
||||
|
||||
const appStore = useAppStore(); |
||||
const userStore = useUserStore(); |
||||
const router = useRouter(); |
||||
const route = useRoute(); |
||||
const permission = usePermission(); |
||||
useResponsive(true); |
||||
const navbarHeight = `60px`; |
||||
const navbar = computed(() => appStore.navbar); |
||||
const renderMenu = computed(() => appStore.menu && !appStore.topMenu); |
||||
const hideMenu = computed(() => appStore.hideMenu); |
||||
const footer = computed(() => appStore.footer); |
||||
const menuWidth = computed(() => { |
||||
return appStore.menuCollapse ? 48 : appStore.menuWidth; |
||||
}); |
||||
const collapsed = computed(() => { |
||||
return appStore.menuCollapse; |
||||
}); |
||||
const paddingStyle = computed(() => { |
||||
const paddingLeft = |
||||
renderMenu.value && !hideMenu.value |
||||
? { paddingLeft: `${menuWidth.value}px` } |
||||
: {}; |
||||
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {}; |
||||
return { ...paddingLeft, ...paddingTop }; |
||||
}); |
||||
const setCollapsed = (val: boolean) => { |
||||
appStore.updateSettings({ menuCollapse: val }); |
||||
}; |
||||
watch( |
||||
() => userStore.role, |
||||
(roleValue) => { |
||||
if (roleValue && !permission.accessRouter(route)) |
||||
router.push({ name: 'notFound' }); |
||||
} |
||||
); |
||||
const drawerVisible = ref(false); |
||||
const drawerCancel = () => { |
||||
drawerVisible.value = false; |
||||
}; |
||||
provide('toggleDrawerMenu', () => { |
||||
drawerVisible.value = !drawerVisible.value; |
||||
}); |
||||
</script> |
||||
|
||||
<style scoped lang="less"> |
||||
@nav-size-height: 60px; |
||||
@layout-max-width: 1100px; |
||||
|
||||
.layout { |
||||
width: 100%; |
||||
height: 100%; |
||||
} |
||||
|
||||
.layout-navbar { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
z-index: 100; |
||||
width: 100%; |
||||
height: @nav-size-height; |
||||
} |
||||
|
||||
.layout-sider { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
z-index: 99; |
||||
height: 100%; |
||||
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1); |
||||
&::after { |
||||
position: absolute; |
||||
top: 0; |
||||
right: -1px; |
||||
display: block; |
||||
width: 1px; |
||||
height: 100%; |
||||
background-color: var(--color-border); |
||||
content: ''; |
||||
} |
||||
|
||||
> :deep(.arco-layout-sider-children) { |
||||
overflow-y: hidden; |
||||
} |
||||
} |
||||
|
||||
.menu-wrapper { |
||||
height: 100%; |
||||
overflow: auto; |
||||
overflow-x: hidden; |
||||
:deep(.arco-menu) { |
||||
::-webkit-scrollbar { |
||||
width: 12px; |
||||
height: 4px; |
||||
} |
||||
|
||||
::-webkit-scrollbar-thumb { |
||||
border: 4px solid transparent; |
||||
background-clip: padding-box; |
||||
border-radius: 7px; |
||||
background-color: var(--color-text-4); |
||||
} |
||||
|
||||
::-webkit-scrollbar-thumb:hover { |
||||
background-color: var(--color-text-3); |
||||
} |
||||
} |
||||
} |
||||
|
||||
.layout-content { |
||||
min-height: 100vh; |
||||
overflow-y: hidden; |
||||
background-color: var(--color-fill-2); |
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1); |
||||
} |
||||
</style> |
@ -0,0 +1,25 @@ |
||||
<template> |
||||
<router-view v-slot="{ Component, route }"> |
||||
<transition name="fade" mode="out-in" appear> |
||||
<component |
||||
:is="Component" |
||||
v-if="route.meta.ignoreCache" |
||||
:key="route.fullPath" |
||||
/> |
||||
<keep-alive v-else :include="cacheList"> |
||||
<component :is="Component" :key="route.fullPath" /> |
||||
</keep-alive> |
||||
</transition> |
||||
</router-view> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { computed } from 'vue'; |
||||
import { useTabBarStore } from '@/store'; |
||||
|
||||
const tabBarStore = useTabBarStore(); |
||||
|
||||
const cacheList = computed(() => tabBarStore.getCacheList); |
||||
</script> |
||||
|
||||
<style scoped lang="less"></style> |
@ -0,0 +1,28 @@ |
||||
import localeMessageBox from '@/components/message-box/locale/en-US'; |
||||
import localeLogin from '@/views/login/locale/en-US'; |
||||
|
||||
import localeWorkplace from '@/views/dashboard/workplace/locale/en-US'; |
||||
|
||||
import localeSettings from './en-US/settings'; |
||||
|
||||
export default { |
||||
'menu.dashboard': 'Dashboard', |
||||
'menu.server.dashboard': 'Dashboard-Server', |
||||
'menu.server.workplace': 'Workplace-Server', |
||||
'menu.server.monitor': 'Monitor-Server', |
||||
'menu.list': 'List', |
||||
'menu.result': 'Result', |
||||
'menu.exception': 'Exception', |
||||
'menu.form': 'Form', |
||||
'menu.profile': 'Profile', |
||||
'menu.visualization': 'Data Visualization', |
||||
'menu.user': 'User Center', |
||||
'menu.arcoWebsite': 'Arco Design', |
||||
'menu.faq': 'FAQ', |
||||
'navbar.docs': 'Docs', |
||||
'navbar.action.locale': 'Switch to English', |
||||
...localeSettings, |
||||
...localeMessageBox, |
||||
...localeLogin, |
||||
...localeWorkplace, |
||||
}; |
@ -0,0 +1,29 @@ |
||||
export default { |
||||
'settings.title': 'Settings', |
||||
'settings.themeColor': 'Theme Color', |
||||
'settings.content': 'Content Setting', |
||||
'settings.search': 'Search', |
||||
'settings.language': 'Language', |
||||
'settings.navbar': 'Navbar', |
||||
'settings.menuWidth': 'Menu Width (px)', |
||||
'settings.navbar.theme.toLight': 'Click to use light mode', |
||||
'settings.navbar.theme.toDark': 'Click to use dark mode', |
||||
'settings.navbar.screen.toFull': 'Click to switch to full screen mode', |
||||
'settings.navbar.screen.toExit': 'Click to exit the full screen mode', |
||||
'settings.navbar.alerts': 'alerts', |
||||
'settings.menu': 'Menu', |
||||
'settings.topMenu': 'Top Menu', |
||||
'settings.tabBar': 'Tab Bar', |
||||
'settings.footer': 'Footer', |
||||
'settings.otherSettings': 'Other Settings', |
||||
'settings.colorWeak': 'Color Weak', |
||||
'settings.alertContent': |
||||
'After the configuration is only temporarily effective, if you want to really affect the project, click the "Copy Settings" button below and replace the configuration in settings.json.', |
||||
'settings.copySettings': 'Copy Settings', |
||||
'settings.copySettings.message': |
||||
'Copy succeeded, please paste to file src/settings.json.', |
||||
'settings.close': 'Close', |
||||
'settings.color.tooltip': |
||||
'10 gradient colors generated according to the theme color', |
||||
'settings.menuFromServer': 'Menu From Server', |
||||
}; |
@ -0,0 +1,21 @@ |
||||
import { createI18n } from 'vue-i18n'; |
||||
import en from './en-US'; |
||||
import cn from './zh-CN'; |
||||
|
||||
export const LOCALE_OPTIONS = [ |
||||
{ label: '中文', value: 'zh-CN' }, |
||||
{ label: 'English', value: 'en-US' }, |
||||
]; |
||||
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN'; |
||||
|
||||
const i18n = createI18n({ |
||||
locale: defaultLocale, |
||||
fallbackLocale: 'en-US', |
||||
allowComposition: true, |
||||
messages: { |
||||
'en-US': en, |
||||
'zh-CN': cn, |
||||
}, |
||||
}); |
||||
|
||||
export default i18n; |
@ -0,0 +1,28 @@ |
||||
import localeMessageBox from '@/components/message-box/locale/zh-CN'; |
||||
import localeLogin from '@/views/login/locale/zh-CN'; |
||||
|
||||
import localeWorkplace from '@/views/dashboard/workplace/locale/zh-CN'; |
||||
|
||||
import localeSettings from './zh-CN/settings'; |
||||
|
||||
export default { |
||||
'menu.dashboard': '仪表盘', |
||||
'menu.server.dashboard': '仪表盘-服务端', |
||||
'menu.server.workplace': '工作台-服务端', |
||||
'menu.server.monitor': '实时监控-服务端', |
||||
'menu.list': '列表页', |
||||
'menu.result': '结果页', |
||||
'menu.exception': '异常页', |
||||
'menu.form': '表单页', |
||||
'menu.profile': '详情页', |
||||
'menu.visualization': '数据可视化', |
||||
'menu.user': '个人中心', |
||||
'menu.arcoWebsite': 'Arco Design', |
||||
'menu.faq': '常见问题', |
||||
'navbar.docs': '文档中心', |
||||
'navbar.action.locale': '切换为中文', |
||||
...localeSettings, |
||||
...localeMessageBox, |
||||
...localeLogin, |
||||
...localeWorkplace, |
||||
}; |
@ -0,0 +1,29 @@ |
||||
export default { |
||||
'settings.title': '页面配置', |
||||
'settings.themeColor': '主题色', |
||||
'settings.content': '内容区域', |
||||
'settings.search': '搜索', |
||||
'settings.language': '语言', |
||||
'settings.navbar': '导航栏', |
||||
'settings.menuWidth': '菜单宽度 (px)', |
||||
'settings.navbar.theme.toLight': '点击切换为亮色模式', |
||||
'settings.navbar.theme.toDark': '点击切换为暗黑模式', |
||||
'settings.navbar.screen.toFull': '点击切换全屏模式', |
||||
'settings.navbar.screen.toExit': '点击退出全屏模式', |
||||
'settings.navbar.alerts': '消息通知', |
||||
'settings.menu': '菜单栏', |
||||
'settings.topMenu': '顶部菜单栏', |
||||
'settings.tabBar': '多页签', |
||||
'settings.footer': '底部', |
||||
'settings.otherSettings': '其他设置', |
||||
'settings.colorWeak': '色弱模式', |
||||
'settings.alertContent': |
||||
'配置之后仅是临时生效,要想真正作用于项目,点击下方的 "复制配置" 按钮,将配置替换到 settings.json 中即可。', |
||||
'settings.copySettings': '复制配置', |
||||
'settings.copySettings.message': |
||||
'复制成功,请粘贴到 src/settings.json 文件中', |
||||
'settings.close': '关闭', |
||||
'settings.color.tooltip': |
||||
'根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)', |
||||
'settings.menuFromServer': '菜单来源于后台', |
||||
}; |
@ -0,0 +1,26 @@ |
||||
import { createApp } from 'vue'; |
||||
import ArcoVue from '@arco-design/web-vue'; |
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon'; |
||||
import globalComponents from '@/components'; |
||||
import router from './router'; |
||||
import store from './store'; |
||||
import i18n from './locale'; |
||||
import directive from './directive'; |
||||
import './mock'; |
||||
import App from './App.vue'; |
||||
import '@arco-design/web-vue/dist/arco.css'; |
||||
import '@/assets/style/global.less'; |
||||
import '@/api/interceptor'; |
||||
|
||||
const app = createApp(App); |
||||
|
||||
app.use(ArcoVue, {}); |
||||
app.use(ArcoVueIcon); |
||||
|
||||
app.use(router); |
||||
app.use(store); |
||||
app.use(i18n); |
||||
app.use(globalComponents); |
||||
app.use(directive); |
||||
|
||||
app.mount('#app'); |
@ -0,0 +1,10 @@ |
||||
import Mock from 'mockjs'; |
||||
|
||||
import './user'; |
||||
import './message-box'; |
||||
|
||||
import '@/views/dashboard/workplace/mock'; |
||||
|
||||
Mock.setup({ |
||||
timeout: '600-1000', |
||||
}); |
@ -0,0 +1,85 @@ |
||||
import Mock from 'mockjs'; |
||||
import setupMock, { successResponseWrap } from '@/utils/setup-mock'; |
||||
|
||||
const haveReadIds: number[] = []; |
||||
const getMessageList = () => { |
||||
return [ |
||||
{ |
||||
id: 1, |
||||
type: 'message', |
||||
title: '郑曦月', |
||||
subTitle: '的私信', |
||||
avatar: |
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp', |
||||
content: '审批请求已发送,请查收', |
||||
time: '今天 12:30:01', |
||||
}, |
||||
{ |
||||
id: 2, |
||||
type: 'message', |
||||
title: '宁波', |
||||
subTitle: '的回复', |
||||
avatar: |
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp', |
||||
content: '此处 bug 已经修复', |
||||
time: '今天 12:30:01', |
||||
}, |
||||
{ |
||||
id: 3, |
||||
type: 'message', |
||||
title: '宁波', |
||||
subTitle: '的回复', |
||||
avatar: |
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp', |
||||
content: '此处 bug 已经修复', |
||||
time: '今天 12:20:01', |
||||
}, |
||||
{ |
||||
id: 4, |
||||
type: 'notice', |
||||
title: '续费通知', |
||||
subTitle: '', |
||||
avatar: '', |
||||
content: '您的产品使用期限即将截止,如需继续使用产品请前往购…', |
||||
time: '今天 12:20:01', |
||||
messageType: 3, |
||||
}, |
||||
{ |
||||
id: 5, |
||||
type: 'notice', |
||||
title: '规则开通成功', |
||||
subTitle: '', |
||||
avatar: '', |
||||
content: '内容屏蔽规则于 2021-12-01 开通成功并生效', |
||||
time: '今天 12:20:01', |
||||
messageType: 1, |
||||
}, |
||||
{ |
||||
id: 6, |
||||
type: 'todo', |
||||
title: '质检队列变更', |
||||
subTitle: '', |
||||
avatar: '', |
||||
content: '内容质检队列于 2021-12-01 19:50:23 进行变更,请重新…', |
||||
time: '今天 12:20:01', |
||||
messageType: 0, |
||||
}, |
||||
].map((item) => ({ |
||||
...item, |
||||
status: haveReadIds.indexOf(item.id) === -1 ? 0 : 1, |
||||
})); |
||||
}; |
||||
|
||||
setupMock({ |
||||
setup: () => { |
||||
Mock.mock(new RegExp('/api/message/list'), () => { |
||||
return successResponseWrap(getMessageList()); |
||||
}); |
||||
|
||||
Mock.mock(new RegExp('/api/message/read'), (params: { body: string }) => { |
||||
const { ids } = JSON.parse(params.body); |
||||
haveReadIds.push(...(ids || [])); |
||||
return successResponseWrap(true); |
||||
}); |
||||
}, |
||||
}); |
@ -0,0 +1,105 @@ |
||||
import Mock from 'mockjs'; |
||||
import setupMock, { |
||||
successResponseWrap, |
||||
failResponseWrap, |
||||
} from '@/utils/setup-mock'; |
||||
|
||||
import { MockParams } from '@/types/mock'; |
||||
import { isLogin } from '@/utils/auth'; |
||||
|
||||
setupMock({ |
||||
setup() { |
||||
// Mock.XHR.prototype.withCredentials = true;
|
||||
|
||||
// 用户信息
|
||||
Mock.mock(new RegExp('/api/user/info'), () => { |
||||
if (isLogin()) { |
||||
const role = window.localStorage.getItem('userRole') || 'admin'; |
||||
return successResponseWrap({ |
||||
name: '王立群', |
||||
avatar: |
||||
'//lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png', |
||||
email: 'wangliqun@email.com', |
||||
job: 'frontend', |
||||
jobName: '前端艺术家', |
||||
organization: 'Frontend', |
||||
organizationName: '前端', |
||||
location: 'beijing', |
||||
locationName: '北京', |
||||
introduction: '人潇洒,性温存', |
||||
personalWebsite: 'https://www.arco.design', |
||||
phone: '150****0000', |
||||
registrationDate: '2013-05-10 12:10:00', |
||||
accountId: '15012312300', |
||||
certification: 1, |
||||
role, |
||||
}); |
||||
} |
||||
return failResponseWrap(null, '未登录', 50008); |
||||
}); |
||||
|
||||
// 登录
|
||||
Mock.mock(new RegExp('/api/user/login'), (params: MockParams) => { |
||||
const { username, password } = JSON.parse(params.body); |
||||
if (!username) { |
||||
return failResponseWrap(null, '用户名不能为空', 50000); |
||||
} |
||||
if (!password) { |
||||
return failResponseWrap(null, '密码不能为空', 50000); |
||||
} |
||||
if (username === 'admin' && password === 'admin') { |
||||
window.localStorage.setItem('userRole', 'admin'); |
||||
return successResponseWrap({ |
||||
token: '12345', |
||||
}); |
||||
} |
||||
if (username === 'user' && password === 'user') { |
||||
window.localStorage.setItem('userRole', 'user'); |
||||
return successResponseWrap({ |
||||
token: '54321', |
||||
}); |
||||
} |
||||
return failResponseWrap(null, '账号或者密码错误', 50000); |
||||
}); |
||||
|
||||
// 登出
|
||||
Mock.mock(new RegExp('/api/user/logout'), () => { |
||||
return successResponseWrap(null); |
||||
}); |
||||
|
||||
// 用户的服务端菜单
|
||||
Mock.mock(new RegExp('/api/user/menu'), () => { |
||||
const menuList = [ |
||||
{ |
||||
path: '/dashboard', |
||||
name: 'dashboard', |
||||
meta: { |
||||
locale: 'menu.server.dashboard', |
||||
requiresAuth: true, |
||||
icon: 'icon-dashboard', |
||||
order: 1, |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: 'workplace', |
||||
name: 'Workplace', |
||||
meta: { |
||||
locale: 'menu.server.workplace', |
||||
requiresAuth: true, |
||||
}, |
||||
}, |
||||
{ |
||||
path: 'https://arco.design', |
||||
name: 'arcoWebsite', |
||||
meta: { |
||||
locale: 'menu.arcoWebsite', |
||||
requiresAuth: true, |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
]; |
||||
return successResponseWrap(menuList); |
||||
}); |
||||
}, |
||||
}); |
@ -0,0 +1,16 @@ |
||||
import { appRoutes, appExternalRoutes } from '../routes'; |
||||
|
||||
const mixinRoutes = [...appRoutes, ...appExternalRoutes]; |
||||
|
||||
const appClientMenus = mixinRoutes.map((el) => { |
||||
const { name, path, meta, redirect, children } = el; |
||||
return { |
||||
name, |
||||
path, |
||||
meta, |
||||
redirect, |
||||
children, |
||||
}; |
||||
}); |
||||
|
||||
export default appClientMenus; |
@ -0,0 +1,18 @@ |
||||
export const WHITE_LIST = [ |
||||
{ name: 'notFound', children: [] }, |
||||
{ name: 'login', children: [] }, |
||||
]; |
||||
|
||||
export const NOT_FOUND = { |
||||
name: 'notFound', |
||||
}; |
||||
|
||||
export const REDIRECT_ROUTE_NAME = 'Redirect'; |
||||
|
||||
export const DEFAULT_ROUTE_NAME = 'Workplace'; |
||||
|
||||
export const DEFAULT_ROUTE = { |
||||
title: 'menu.dashboard.workplace', |
||||
name: DEFAULT_ROUTE_NAME, |
||||
fullPath: '/dashboard/workplace', |
||||
}; |
@ -0,0 +1,17 @@ |
||||
import type { Router } from 'vue-router'; |
||||
import { setRouteEmitter } from '@/utils/route-listener'; |
||||
import setupUserLoginInfoGuard from './userLoginInfo'; |
||||
import setupPermissionGuard from './permission'; |
||||
|
||||
function setupPageGuard(router: Router) { |
||||
router.beforeEach(async (to) => { |
||||
// emit route change
|
||||
setRouteEmitter(to); |
||||
}); |
||||
} |
||||
|
||||
export default function createRouteGuard(router: Router) { |
||||
setupPageGuard(router); |
||||
setupUserLoginInfoGuard(router); |
||||
setupPermissionGuard(router); |
||||
} |
@ -0,0 +1,55 @@ |
||||
import type { Router, RouteRecordNormalized } from 'vue-router'; |
||||
import NProgress from 'nprogress'; // progress bar
|
||||
|
||||
import usePermission from '@/hooks/permission'; |
||||
import { useUserStore, useAppStore } from '@/store'; |
||||
import { appRoutes } from '../routes'; |
||||
import { WHITE_LIST, NOT_FOUND } from '../constants'; |
||||
|
||||
export default function setupPermissionGuard(router: Router) { |
||||
router.beforeEach(async (to, from, next) => { |
||||
const appStore = useAppStore(); |
||||
const userStore = useUserStore(); |
||||
const Permission = usePermission(); |
||||
const permissionsAllow = Permission.accessRouter(to); |
||||
if (appStore.menuFromServer) { |
||||
// 针对来自服务端的菜单配置进行处理
|
||||
// Handle routing configuration from the server
|
||||
|
||||
// 根据需要自行完善来源于服务端的菜单配置的permission逻辑
|
||||
// Refine the permission logic from the server's menu configuration as needed
|
||||
if ( |
||||
!appStore.appAsyncMenus.length && |
||||
!WHITE_LIST.find((el) => el.name === to.name) |
||||
) { |
||||
await appStore.fetchServerMenuConfig(); |
||||
} |
||||
const serverMenuConfig = [...appStore.appAsyncMenus, ...WHITE_LIST]; |
||||
|
||||
let exist = false; |
||||
while (serverMenuConfig.length && !exist) { |
||||
const element = serverMenuConfig.shift(); |
||||
if (element?.name === to.name) exist = true; |
||||
|
||||
if (element?.children) { |
||||
serverMenuConfig.push( |
||||
...(element.children as unknown as RouteRecordNormalized[]) |
||||
); |
||||
} |
||||
} |
||||
if (exist && permissionsAllow) { |
||||
next(); |
||||
} else next(NOT_FOUND); |
||||
} else { |
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (permissionsAllow) next(); |
||||
else { |
||||
const destination = |
||||
Permission.findFirstPermissionRoute(appRoutes, userStore.role) || |
||||
NOT_FOUND; |
||||
next(destination); |
||||
} |
||||
} |
||||
NProgress.done(); |
||||
}); |
||||
} |
@ -0,0 +1,43 @@ |
||||
import type { Router, LocationQueryRaw } from 'vue-router'; |
||||
import NProgress from 'nprogress'; // progress bar
|
||||
|
||||
import { useUserStore } from '@/store'; |
||||
import { isLogin } from '@/utils/auth'; |
||||
|
||||
export default function setupUserLoginInfoGuard(router: Router) { |
||||
router.beforeEach(async (to, from, next) => { |
||||
NProgress.start(); |
||||
const userStore = useUserStore(); |
||||
if (isLogin()) { |
||||
if (userStore.role) { |
||||
next(); |
||||
} else { |
||||
try { |
||||
await userStore.info(); |
||||
next(); |
||||
} catch (error) { |
||||
await userStore.logout(); |
||||
next({ |
||||
name: 'login', |
||||
query: { |
||||
redirect: to.name, |
||||
...to.query, |
||||
} as LocationQueryRaw, |
||||
}); |
||||
} |
||||
} |
||||
} else { |
||||
if (to.name === 'login') { |
||||
next(); |
||||
return; |
||||
} |
||||
next({ |
||||
name: 'login', |
||||
query: { |
||||
redirect: to.name, |
||||
...to.query, |
||||
} as LocationQueryRaw, |
||||
}); |
||||
} |
||||
}); |
||||
} |
@ -0,0 +1,37 @@ |
||||
import { createRouter, createWebHistory } from 'vue-router'; |
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import 'nprogress/nprogress.css'; |
||||
|
||||
import { appRoutes } from './routes'; |
||||
import { REDIRECT_MAIN, NOT_FOUND_ROUTE } from './routes/base'; |
||||
import createRouteGuard from './guard'; |
||||
|
||||
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||
|
||||
const router = createRouter({ |
||||
history: createWebHistory(), |
||||
routes: [ |
||||
{ |
||||
path: '/', |
||||
redirect: 'login', |
||||
}, |
||||
{ |
||||
path: '/login', |
||||
name: 'login', |
||||
component: () => import('@/views/login/index.vue'), |
||||
meta: { |
||||
requiresAuth: false, |
||||
}, |
||||
}, |
||||
...appRoutes, |
||||
REDIRECT_MAIN, |
||||
NOT_FOUND_ROUTE, |
||||
], |
||||
scrollBehavior() { |
||||
return { top: 0 }; |
||||
}, |
||||
}); |
||||
|
||||
createRouteGuard(router); |
||||
|
||||
export default router; |
@ -0,0 +1,31 @@ |
||||
import type { RouteRecordRaw } from 'vue-router'; |
||||
import { REDIRECT_ROUTE_NAME } from '@/router/constants'; |
||||
|
||||
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue'); |
||||
|
||||
export const REDIRECT_MAIN: RouteRecordRaw = { |
||||
path: '/redirect', |
||||
name: 'redirectWrapper', |
||||
component: DEFAULT_LAYOUT, |
||||
meta: { |
||||
requiresAuth: true, |
||||
hideInMenu: true, |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: '/redirect/:path', |
||||
name: REDIRECT_ROUTE_NAME, |
||||
component: () => import('@/views/redirect/index.vue'), |
||||
meta: { |
||||
requiresAuth: true, |
||||
hideInMenu: true, |
||||
}, |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const NOT_FOUND_ROUTE: RouteRecordRaw = { |
||||
path: '/:pathMatch(.*)*', |
||||
name: 'notFound', |
||||
component: () => import('@/views/not-found/index.vue'), |
||||
}; |
@ -0,0 +1,10 @@ |
||||
export default { |
||||
path: 'https://arco.design', |
||||
name: 'arcoWebsite', |
||||
meta: { |
||||
locale: 'menu.arcoWebsite', |
||||
icon: 'icon-link', |
||||
requiresAuth: true, |
||||
order: 8, |
||||
}, |
||||
}; |
@ -0,0 +1,10 @@ |
||||
export default { |
||||
path: 'https://arco.design/vue/docs/pro/faq', |
||||
name: 'faq', |
||||
meta: { |
||||
locale: 'menu.faq', |
||||
icon: 'icon-question-circle', |
||||
requiresAuth: true, |
||||
order: 9, |
||||
}, |
||||
}; |
@ -0,0 +1,25 @@ |
||||
import type { RouteRecordNormalized } from 'vue-router'; |
||||
|
||||
const modules = import.meta.glob('./modules/*.ts', { eager: true }); |
||||
const externalModules = import.meta.glob('./externalModules/*.ts', { |
||||
eager: true, |
||||
}); |
||||
|
||||
function formatModules(_modules: any, result: RouteRecordNormalized[]) { |
||||
Object.keys(_modules).forEach((key) => { |
||||
const defaultModule = _modules[key].default; |
||||
if (!defaultModule) return; |
||||
const moduleList = Array.isArray(defaultModule) |
||||
? [...defaultModule] |
||||
: [defaultModule]; |
||||
result.push(...moduleList); |
||||
}); |
||||
return result; |
||||
} |
||||
|
||||
export const appRoutes: RouteRecordNormalized[] = formatModules(modules, []); |
||||
|
||||
export const appExternalRoutes: RouteRecordNormalized[] = formatModules( |
||||
externalModules, |
||||
[] |
||||
); |
@ -0,0 +1,28 @@ |
||||
import { DEFAULT_LAYOUT } from '../base'; |
||||
import { AppRouteRecordRaw } from '../types'; |
||||
|
||||
const DASHBOARD: AppRouteRecordRaw = { |
||||
path: '/dashboard', |
||||
name: 'dashboard', |
||||
component: DEFAULT_LAYOUT, |
||||
meta: { |
||||
locale: 'menu.dashboard', |
||||
requiresAuth: true, |
||||
icon: 'icon-dashboard', |
||||
order: 0, |
||||
}, |
||||
children: [ |
||||
{ |
||||
path: 'workplace', |
||||
name: 'Workplace', |
||||
component: () => import('@/views/dashboard/workplace/index.vue'), |
||||
meta: { |
||||
locale: 'menu.dashboard.workplace', |
||||
requiresAuth: true, |
||||
roles: ['*'], |
||||
}, |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export default DASHBOARD; |
@ -0,0 +1,20 @@ |
||||
import { defineComponent } from 'vue'; |
||||
import type { RouteMeta, NavigationGuard } from 'vue-router'; |
||||
|
||||
export type Component<T = any> = |
||||
| ReturnType<typeof defineComponent> |
||||
| (() => Promise<typeof import('*.vue')>) |
||||
| (() => Promise<T>); |
||||
|
||||
export interface AppRouteRecordRaw { |
||||
path: string; |
||||
name?: string | symbol; |
||||
meta?: RouteMeta; |
||||
redirect?: string; |
||||
component: Component | string; |
||||
children?: AppRouteRecordRaw[]; |
||||
alias?: string | string[]; |
||||
props?: Record<string, any>; |
||||
beforeEnter?: NavigationGuard | NavigationGuard[]; |
||||
fullPath?: string; |
||||
} |
@ -0,0 +1,16 @@ |
||||
import 'vue-router'; |
||||
|
||||
declare module 'vue-router' { |
||||
interface RouteMeta { |
||||
roles?: string[]; // Controls roles that have access to the page
|
||||
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
|
||||
icon?: string; // The icon show in the side menu
|
||||
locale?: string; // The locale name show in side menu and breadcrumb
|
||||
hideInMenu?: boolean; // If true, it is not displayed in the side menu
|
||||
hideChildrenInMenu?: boolean; // if set true, the children are not displayed in the side menu
|
||||
activeMenu?: string; // if set name, the menu will be highlighted according to the name you set
|
||||
order?: number; // Sort routing menu items. If set key, the higher the value, the more forward it is
|
||||
noAffix?: boolean; // if set true, the tag will not affix in the tab-bar
|
||||
ignoreCache?: boolean; // if set true, the page will not be cached
|
||||
} |
||||
} |
@ -0,0 +1,9 @@ |
||||
import { createPinia } from 'pinia'; |
||||
import useAppStore from './modules/app'; |
||||
import useUserStore from './modules/user'; |
||||
import useTabBarStore from './modules/tab-bar'; |
||||
|
||||
const pinia = createPinia(); |
||||
|
||||
export { useAppStore, useUserStore, useTabBarStore }; |
||||
export default pinia; |
@ -0,0 +1,77 @@ |
||||
import { defineStore } from 'pinia'; |
||||
import { Notification } from '@arco-design/web-vue'; |
||||
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface'; |
||||
import type { RouteRecordNormalized } from 'vue-router'; |
||||
import defaultSettings from '@/config/settings.json'; |
||||
import { getMenuList } from '@/api/user'; |
||||
import { AppState } from './types'; |
||||
|
||||
const useAppStore = defineStore('app', { |
||||
state: (): AppState => ({ ...defaultSettings }), |
||||
|
||||
getters: { |
||||
appCurrentSetting(state: AppState): AppState { |
||||
return { ...state }; |
||||
}, |
||||
appDevice(state: AppState) { |
||||
return state.device; |
||||
}, |
||||
appAsyncMenus(state: AppState): RouteRecordNormalized[] { |
||||
return state.serverMenu as unknown as RouteRecordNormalized[]; |
||||
}, |
||||
}, |
||||
|
||||
actions: { |
||||
// Update app settings
|
||||
updateSettings(partial: Partial<AppState>) { |
||||
// @ts-ignore-next-line
|
||||
this.$patch(partial); |
||||
}, |
||||
|
||||
// Change theme color
|
||||
toggleTheme(dark: boolean) { |
||||
if (dark) { |
||||
this.theme = 'dark'; |
||||
document.body.setAttribute('arco-theme', 'dark'); |
||||
} else { |
||||
this.theme = 'light'; |
||||
document.body.removeAttribute('arco-theme'); |
||||
} |
||||
}, |
||||
toggleDevice(device: string) { |
||||
this.device = device; |
||||
}, |
||||
toggleMenu(value: boolean) { |
||||
this.hideMenu = value; |
||||
}, |
||||
async fetchServerMenuConfig() { |
||||
let notifyInstance: NotificationReturn | null = null; |
||||
try { |
||||
notifyInstance = Notification.info({ |
||||
id: 'menuNotice', // Keep the instance id the same
|
||||
content: 'loading', |
||||
closable: true, |
||||
}); |
||||
const { data } = await getMenuList(); |
||||
this.serverMenu = data; |
||||
notifyInstance = Notification.success({ |
||||
id: 'menuNotice', |
||||
content: 'success', |
||||
closable: true, |
||||
}); |
||||
} catch (error) { |
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
notifyInstance = Notification.error({ |
||||
id: 'menuNotice', |
||||
content: 'error', |
||||
closable: true, |
||||
}); |
||||
} |
||||
}, |
||||
clearServerMenu() { |
||||
this.serverMenu = []; |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
export default useAppStore; |
@ -0,0 +1,20 @@ |
||||
import type { RouteRecordNormalized } from 'vue-router'; |
||||
|
||||
export interface AppState { |
||||
theme: string; |
||||
colorWeak: boolean; |
||||
navbar: boolean; |
||||
menu: boolean; |
||||
topMenu: boolean; |
||||
hideMenu: boolean; |
||||
menuCollapse: boolean; |
||||
footer: boolean; |
||||
themeColor: string; |
||||
menuWidth: number; |
||||
globalSettings: boolean; |
||||
device: string; |
||||
tabBar: boolean; |
||||
menuFromServer: boolean; |
||||
serverMenu: RouteRecordNormalized[]; |
||||
[key: string]: unknown; |
||||
} |
@ -0,0 +1,74 @@ |
||||
import type { RouteLocationNormalized } from 'vue-router'; |
||||
import { defineStore } from 'pinia'; |
||||
import { |
||||
DEFAULT_ROUTE, |
||||
DEFAULT_ROUTE_NAME, |
||||
REDIRECT_ROUTE_NAME, |
||||
} from '@/router/constants'; |
||||
import { isString } from '@/utils/is'; |
||||
import { TabBarState, TagProps } from './types'; |
||||
|
||||
const formatTag = (route: RouteLocationNormalized): TagProps => { |
||||
const { name, meta, fullPath, query } = route; |
||||
return { |
||||
title: meta.locale || '', |
||||
name: String(name), |
||||
fullPath, |
||||
query, |
||||
ignoreCache: meta.ignoreCache, |
||||
}; |
||||
}; |
||||
|
||||
const BAN_LIST = [REDIRECT_ROUTE_NAME]; |
||||
|
||||
const useAppStore = defineStore('tabBar', { |
||||
state: (): TabBarState => ({ |
||||
cacheTabList: new Set([DEFAULT_ROUTE_NAME]), |
||||
tagList: [DEFAULT_ROUTE], |
||||
}), |
||||
|
||||
getters: { |
||||
getTabList(): TagProps[] { |
||||
return this.tagList; |
||||
}, |
||||
getCacheList(): string[] { |
||||
return Array.from(this.cacheTabList); |
||||
}, |
||||
}, |
||||
|
||||
actions: { |
||||
updateTabList(route: RouteLocationNormalized) { |
||||
if (BAN_LIST.includes(route.name as string)) return; |
||||
this.tagList.push(formatTag(route)); |
||||
if (!route.meta.ignoreCache) { |
||||
this.cacheTabList.add(route.name as string); |
||||
} |
||||
}, |
||||
deleteTag(idx: number, tag: TagProps) { |
||||
this.tagList.splice(idx, 1); |
||||
this.cacheTabList.delete(tag.name); |
||||
}, |
||||
addCache(name: string) { |
||||
if (isString(name) && name !== '') this.cacheTabList.add(name); |
||||
}, |
||||
deleteCache(tag: TagProps) { |
||||
this.cacheTabList.delete(tag.name); |
||||
}, |
||||
freshTabList(tags: TagProps[]) { |
||||
this.tagList = tags; |
||||
this.cacheTabList.clear(); |
||||
// 要先判断ignoreCache
|
||||
this.tagList |
||||
.filter((el) => !el.ignoreCache) |
||||
.map((el) => el.name) |
||||
.forEach((x) => this.cacheTabList.add(x)); |
||||
}, |
||||
resetTabList() { |
||||
this.tagList = [DEFAULT_ROUTE]; |
||||
this.cacheTabList.clear(); |
||||
this.cacheTabList.add(DEFAULT_ROUTE_NAME); |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
export default useAppStore; |
@ -0,0 +1,12 @@ |
||||
export interface TagProps { |
||||
title: string; |
||||
name: string; |
||||
fullPath: string; |
||||
query?: any; |
||||
ignoreCache?: boolean; |
||||
} |
||||
|
||||
export interface TabBarState { |
||||
tagList: TagProps[]; |
||||
cacheTabList: Set<string>; |
||||
} |
@ -0,0 +1,91 @@ |
||||
import { defineStore } from 'pinia'; |
||||
import { |
||||
login as userLogin, |
||||
logout as userLogout, |
||||
getUserInfo, |
||||
LoginData, |
||||
} from '@/api/user'; |
||||
import { setToken, clearToken } from '@/utils/auth'; |
||||
import { removeRouteListener } from '@/utils/route-listener'; |
||||
import { UserState } from './types'; |
||||
import useAppStore from '../app'; |
||||
|
||||
const useUserStore = defineStore('user', { |
||||
state: (): UserState => ({ |
||||
name: undefined, |
||||
avatar: undefined, |
||||
job: undefined, |
||||
organization: undefined, |
||||
location: undefined, |
||||
email: undefined, |
||||
introduction: undefined, |
||||
personalWebsite: undefined, |
||||
jobName: undefined, |
||||
organizationName: undefined, |
||||
locationName: undefined, |
||||
phone: undefined, |
||||
registrationDate: undefined, |
||||
accountId: undefined, |
||||
certification: undefined, |
||||
role: '', |
||||
}), |
||||
|
||||
getters: { |
||||
userInfo(state: UserState): UserState { |
||||
return { ...state }; |
||||
}, |
||||
}, |
||||
|
||||
actions: { |
||||
switchRoles() { |
||||
return new Promise((resolve) => { |
||||
this.role = this.role === 'user' ? 'admin' : 'user'; |
||||
resolve(this.role); |
||||
}); |
||||
}, |
||||
// Set user's information
|
||||
setInfo(partial: Partial<UserState>) { |
||||
this.$patch(partial); |
||||
}, |
||||
|
||||
// Reset user's information
|
||||
resetInfo() { |
||||
this.$reset(); |
||||
}, |
||||
|
||||
// Get user's information
|
||||
async info() { |
||||
const res = await getUserInfo(); |
||||
|
||||
this.setInfo(res.data); |
||||
}, |
||||
|
||||
// Login
|
||||
async login(loginForm: LoginData) { |
||||
try { |
||||
const res = await userLogin(loginForm); |
||||
setToken(res.data.token); |
||||
} catch (err) { |
||||
clearToken(); |
||||
throw err; |
||||
} |
||||
}, |
||||
logoutCallBack() { |
||||
const appStore = useAppStore(); |
||||
this.resetInfo(); |
||||
clearToken(); |
||||
removeRouteListener(); |
||||
appStore.clearServerMenu(); |
||||
}, |
||||
// Logout
|
||||
async logout() { |
||||
try { |
||||
await userLogout(); |
||||
} finally { |
||||
this.logoutCallBack(); |
||||
} |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
export default useUserStore; |
@ -0,0 +1,19 @@ |
||||
export type RoleType = '' | '*' | 'admin' | 'user'; |
||||
export interface UserState { |
||||
name?: string; |
||||
avatar?: string; |
||||
job?: string; |
||||
organization?: string; |
||||
location?: string; |
||||
email?: string; |
||||
introduction?: string; |
||||
personalWebsite?: string; |
||||
jobName?: string; |
||||
organizationName?: string; |
||||
locationName?: string; |
||||
phone?: string; |
||||
registrationDate?: string; |
||||
accountId?: string; |
||||
certification?: number; |
||||
role: RoleType; |
||||
} |
@ -0,0 +1,10 @@ |
||||
import { CallbackDataParams } from 'echarts/types/dist/shared'; |
||||
|
||||
export interface ToolTipFormatterParams extends CallbackDataParams { |
||||
axisDim: string; |
||||
axisIndex: number; |
||||
axisType: string; |
||||
axisId: string; |
||||
axisValue: string; |
||||
axisValueLabel: string; |
||||
} |
@ -0,0 +1,37 @@ |
||||
export interface AnyObject { |
||||
[key: string]: unknown; |
||||
} |
||||
|
||||
export interface Options { |
||||
value: unknown; |
||||
label: string; |
||||
} |
||||
|
||||
export interface NodeOptions extends Options { |
||||
children?: NodeOptions[]; |
||||
} |
||||
|
||||
export interface GetParams { |
||||
body: null; |
||||
type: string; |
||||
url: string; |
||||
} |
||||
|
||||
export interface PostData { |
||||
body: string; |
||||
type: string; |
||||
url: string; |
||||
} |
||||
|
||||
export interface Pagination { |
||||
current: number; |
||||
pageSize: number; |
||||
total?: number; |
||||
} |
||||
|
||||
export type TimeRanger = [string, string]; |
||||
|
||||
export interface GeneralChart { |
||||
xAxis: string[]; |
||||
data: Array<{ name: string; value: number[] }>; |
||||
} |
@ -0,0 +1,5 @@ |
||||
export interface MockParams { |
||||
url: string; |
||||
type: string; |
||||
body: string; |
||||
} |
@ -0,0 +1,19 @@ |
||||
const TOKEN_KEY = 'token'; |
||||
|
||||
const isLogin = () => { |
||||
return !!localStorage.getItem(TOKEN_KEY); |
||||
}; |
||||
|
||||
const getToken = () => { |
||||
return localStorage.getItem(TOKEN_KEY); |
||||
}; |
||||
|
||||
const setToken = (token: string) => { |
||||
localStorage.setItem(TOKEN_KEY, token); |
||||
}; |
||||
|
||||
const clearToken = () => { |
||||
localStorage.removeItem(TOKEN_KEY); |
||||
}; |
||||
|
||||
export { isLogin, getToken, setToken, clearToken }; |
@ -0,0 +1,3 @@ |
||||
const debug = process.env.NODE_ENV !== 'production'; |
||||
|
||||
export default debug; |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue