├── src
├── lang
│ ├── es.js
│ ├── ja.js
│ └── index.js
├── assets
│ ├── 401_images
│ │ └── 401.gif
│ ├── 404_images
│ │ ├── 404.png
│ │ └── 404_cloud.png
│ ├── GithubImages
│ │ ├── api.PNG
│ │ ├── menu.PNG
│ │ ├── role.PNG
│ │ ├── user.PNG
│ │ ├── login.PNG
│ │ └── rolePermission.PNG
│ ├── backgd-image
│ │ └── backimg.jpg
│ ├── sidebar-logo
│ │ └── webmini.png
│ └── custom-theme
│ │ └── fonts
│ │ ├── element-icons.ttf
│ │ └── element-icons.woff
├── App.vue
├── icons
│ ├── svg
│ │ ├── chart.svg
│ │ ├── size.svg
│ │ ├── component.svg
│ │ ├── guide.svg
│ │ ├── link.svg
│ │ ├── drag.svg
│ │ ├── money.svg
│ │ ├── guide-2.svg
│ │ ├── search.svg
│ │ ├── email.svg
│ │ ├── documentation.svg
│ │ ├── lock.svg
│ │ ├── fullscreen.svg
│ │ ├── user.svg
│ │ ├── example.svg
│ │ ├── excel.svg
│ │ ├── like.svg
│ │ ├── star.svg
│ │ ├── education.svg
│ │ ├── message.svg
│ │ ├── table.svg
│ │ ├── back-top.svg
│ │ ├── theme.svg
│ │ ├── close-circle-svgrepo-com.svg
│ │ ├── tab.svg
│ │ ├── arrow-circle-up-svgrepo-com.svg
│ │ ├── peoples.svg
│ │ ├── nested.svg
│ │ ├── password.svg
│ │ ├── data-transfer-both-svgrepo-com.svg
│ │ ├── check-circle-svgrepo-com.svg
│ │ ├── hamburger.svg
│ │ ├── edit.svg
│ │ ├── list.svg
│ │ ├── eye-off.svg
│ │ ├── clipboard.svg
│ │ ├── check-mark-button-svgrepo-com.svg
│ │ ├── icon.svg
│ │ ├── tree-table.svg
│ │ ├── international.svg
│ │ ├── people.svg
│ │ ├── wechat.svg
│ │ ├── skill.svg
│ │ ├── 404.svg
│ │ ├── zip.svg
│ │ ├── bug.svg
│ │ ├── eye.svg
│ │ ├── language.svg
│ │ ├── eye-on.svg
│ │ ├── exit-fullscreen.svg
│ │ ├── pdf.svg
│ │ ├── tree.svg
│ │ ├── upload-svgrepo-com.svg
│ │ ├── dashboard.svg
│ │ ├── download-svgrepo-com.svg
│ │ ├── headscale3-dots.svg
│ │ ├── power-up-svgrepo-com.svg
│ │ ├── power-down-svgrepo-com.svg
│ │ ├── eye-open.svg
│ │ ├── shopping.svg
│ │ ├── form.svg
│ │ ├── qq.svg
│ │ ├── network-backup-svgrepo-com.svg
│ │ ├── arrow-circle-down-svgrepo-com.svg
│ │ └── menu1.svg
│ ├── index.js
│ └── svgo.yml
├── components
│ ├── ImageCropper
│ │ └── utils
│ │ │ ├── mimes.js
│ │ │ ├── data2blob.js
│ │ │ └── effectRipple.js
│ ├── IconSelect
│ │ ├── requireIcons.js
│ │ └── index.vue
│ ├── Tinymce
│ │ ├── toolbar.js
│ │ ├── plugins.js
│ │ └── dynamicLoadScript.js
│ ├── MarkdownEditor
│ │ └── default-options.js
│ ├── LangSelect
│ │ └── index.vue
│ ├── Screenfull
│ │ └── index.vue
│ ├── Hamburger
│ │ └── index.vue
│ ├── SvgIcon
│ │ └── index.vue
│ ├── SizeSelect
│ │ └── index.vue
│ ├── GithubCorner
│ │ └── index.vue
│ ├── YamlEditor
│ │ └── index.vue
│ ├── Echarts
│ │ ├── PieChart.vue
│ │ └── BarChart.vue
│ ├── JsonEditor
│ │ └── index.vue
│ ├── Sticky
│ │ └── index.vue
│ ├── Pagination
│ │ └── index.vue
│ ├── ErrorLog
│ │ └── index.vue
│ ├── Breadcrumb
│ │ └── index.vue
│ └── Share
│ │ └── DropdownMenu.vue
├── api
│ ├── connect
│ │ └── connect.js
│ ├── console
│ │ ├── acl.js
│ │ ├── routes.js
│ │ ├── preauthkey.js
│ │ └── machines.js
│ ├── system
│ │ ├── headscale.js
│ │ ├── base.js
│ │ ├── api.js
│ │ ├── user.js
│ │ ├── menu.js
│ │ └── role.js
│ ├── log
│ │ └── operationLog.js
│ └── dashboard
│ │ └── status.js
├── layout
│ ├── components
│ │ ├── index.js
│ │ ├── Sidebar
│ │ │ ├── FixiOSBug.js
│ │ │ ├── Link.vue
│ │ │ ├── Item.vue
│ │ │ ├── index.vue
│ │ │ └── Logo.vue
│ │ ├── AppMain.vue
│ │ └── Settings
│ │ │ └── index.vue
│ └── mixin
│ │ └── ResizeHandler.js
├── utils
│ ├── get-page-title.js
│ ├── auth.js
│ ├── event-source.js
│ ├── permission.js
│ ├── clipboard.js
│ ├── error-log.js
│ ├── open-window.js
│ ├── dataSize.js
│ ├── scroll-to.js
│ └── validate.js
├── directive
│ ├── waves
│ │ ├── index.js
│ │ ├── waves.css
│ │ └── waves.js
│ ├── el-drag-dialog
│ │ ├── index.js
│ │ └── drag.js
│ ├── clipboard
│ │ ├── index.js
│ │ └── clipboard.js
│ ├── permission
│ │ ├── index.js
│ │ └── permission.js
│ ├── el-table
│ │ ├── index.js
│ │ └── adaptive.js
│ └── sticky.js
├── views
│ ├── redirect
│ │ └── index.vue
│ ├── profile
│ │ └── index.vue
│ ├── dashboard
│ │ └── components
│ │ │ ├── SystemInfoGroup.vue
│ │ │ └── mixins
│ │ │ └── resize.js
│ ├── console
│ │ ├── machines
│ │ │ └── mixins
│ │ │ │ └── data.js
│ │ ├── setting
│ │ │ └── components
│ │ │ │ ├── Card.vue
│ │ │ │ ├── mixins
│ │ │ │ └── dataFormat.js
│ │ │ │ └── CardTab.vue
│ │ └── acl
│ │ │ └── index.vue
│ ├── message
│ │ └── system
│ │ │ └── index.vue
│ └── error-page
│ │ └── 401.vue
├── vendor
│ ├── publickey.js
│ └── Export2Zip.js
├── store
│ ├── modules
│ │ ├── errorLog.js
│ │ ├── settings.js
│ │ └── app.js
│ ├── getters.js
│ └── index.js
├── styles
│ ├── variables.scss
│ ├── element-variables.scss
│ ├── transition.scss
│ ├── element-ui.scss
│ ├── mixin.scss
│ └── btn.scss
├── settings.js
├── main.js
└── filters
│ └── index.js
├── .eslintignore
├── tests
└── unit
│ ├── .eslintrc.js
│ ├── utils
│ ├── param2Obj.spec.js
│ ├── formatTime.spec.js
│ ├── validate.spec.js
│ └── parseTime.spec.js
│ └── components
│ ├── Hamburger.spec.js
│ └── SvgIcon.spec.js
├── postcss.config.js
├── docs
├── images
│ ├── role.png
│ ├── user.png
│ ├── login.png
│ ├── dashboard.png
│ ├── machine.png
│ ├── rolePermission.png
│ ├── headscaleconfig.png
│ └── ERR_OSSL_EVP_UNSUPPORTED.png
└── nginx.md
├── public
├── favicon.ico
├── index.html
└── webmini.svg
├── .travis.yml
├── plop-templates
├── utils.js
├── store
│ ├── index.hbs
│ └── prompt.js
├── view
│ ├── index.hbs
│ └── prompt.js
└── component
│ ├── index.hbs
│ └── prompt.js
├── .env.staging
├── .env.development
├── .env.production
├── jsconfig.json
├── .editorconfig
├── .gitignore
├── plopfile.js
├── babel.config.js
├── jest.config.js
├── LICENSE
└── README.md
/src/lang/es.js:
--------------------------------------------------------------------------------
1 | export default {}
2 |
--------------------------------------------------------------------------------
/src/lang/ja.js:
--------------------------------------------------------------------------------
1 | export default {}
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | build/*.js
2 | src/assets
3 | public
4 | dist
5 |
--------------------------------------------------------------------------------
/tests/unit/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | jest: true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {}
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/docs/images/role.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/role.png
--------------------------------------------------------------------------------
/docs/images/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/user.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/docs/images/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/login.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js: 10
3 | script: npm run test
4 | notifications:
5 | email: false
6 |
--------------------------------------------------------------------------------
/docs/images/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/dashboard.png
--------------------------------------------------------------------------------
/docs/images/machine.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/machine.png
--------------------------------------------------------------------------------
/plop-templates/utils.js:
--------------------------------------------------------------------------------
1 | exports.notEmpty = name => v =>
2 | !v || v.trim() === '' ? `${name} is required` : true
3 |
--------------------------------------------------------------------------------
/docs/images/rolePermission.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/rolePermission.png
--------------------------------------------------------------------------------
/src/assets/401_images/401.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/401_images/401.gif
--------------------------------------------------------------------------------
/src/assets/404_images/404.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/404_images/404.png
--------------------------------------------------------------------------------
/docs/images/headscaleconfig.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/headscaleconfig.png
--------------------------------------------------------------------------------
/src/assets/GithubImages/api.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/GithubImages/api.PNG
--------------------------------------------------------------------------------
/src/assets/GithubImages/menu.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/GithubImages/menu.PNG
--------------------------------------------------------------------------------
/src/assets/GithubImages/role.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/GithubImages/role.PNG
--------------------------------------------------------------------------------
/src/assets/GithubImages/user.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/GithubImages/user.PNG
--------------------------------------------------------------------------------
/src/assets/404_images/404_cloud.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/404_images/404_cloud.png
--------------------------------------------------------------------------------
/src/assets/GithubImages/login.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/GithubImages/login.PNG
--------------------------------------------------------------------------------
/src/assets/backgd-image/backimg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/backgd-image/backimg.jpg
--------------------------------------------------------------------------------
/src/assets/sidebar-logo/webmini.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/sidebar-logo/webmini.png
--------------------------------------------------------------------------------
/.env.staging:
--------------------------------------------------------------------------------
1 | NODE_ENV = production
2 |
3 | # just a flag
4 | ENV = 'staging'
5 |
6 | # base api
7 | VUE_APP_BASE_API = '/stage-api'
8 |
9 |
--------------------------------------------------------------------------------
/docs/images/ERR_OSSL_EVP_UNSUPPORTED.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/docs/images/ERR_OSSL_EVP_UNSUPPORTED.png
--------------------------------------------------------------------------------
/src/assets/GithubImages/rolePermission.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/GithubImages/rolePermission.PNG
--------------------------------------------------------------------------------
/src/assets/custom-theme/fonts/element-icons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/custom-theme/fonts/element-icons.ttf
--------------------------------------------------------------------------------
/src/assets/custom-theme/fonts/element-icons.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QianheYu/headscale-panel-ui/HEAD/src/assets/custom-theme/fonts/element-icons.woff
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | # just a flag
2 | ENV = 'development'
3 |
4 | # base api
5 | VUE_APP_BASE_API = 'http://localhost:8088'
6 | VUE_APP_WS_API = 'ws://localhost:8088'
7 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | # just a flag
2 | ENV = 'production'
3 |
4 | # base api
5 | VUE_APP_BASE_API = 'http://localhost:8088'
6 | VUE_APP_WS_API = 'ws://localhost:8088'
7 |
8 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | }
7 | },
8 | "exclude": ["node_modules", "dist"]
9 | }
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/src/icons/svg/chart.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/size.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/components/ImageCropper/utils/mimes.js:
--------------------------------------------------------------------------------
1 | export default {
2 | 'jpg': 'image/jpeg',
3 | 'png': 'image/png',
4 | 'gif': 'image/gif',
5 | 'svg': 'image/svg+xml',
6 | 'psd': 'image/photoshop'
7 | }
8 |
--------------------------------------------------------------------------------
/src/api/connect/connect.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function connect(data) {
4 | return request({
5 | url: '/api/oidc/authorize',
6 | method: 'post',
7 | data
8 | })
9 | }
10 |
--------------------------------------------------------------------------------
/src/icons/svg/component.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/guide.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/link.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/icons/svg/drag.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/money.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/plop-templates/store/index.hbs:
--------------------------------------------------------------------------------
1 | {{#if state}}
2 | const state = {}
3 | {{/if}}
4 |
5 | {{#if mutations}}
6 | const mutations = {}
7 | {{/if}}
8 |
9 | {{#if actions}}
10 | const actions = {}
11 | {{/if}}
12 |
13 | export default {
14 | namespaced: true,
15 | {{options}}
16 | }
17 |
--------------------------------------------------------------------------------
/src/layout/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as AppMain } from './AppMain'
2 | export { default as Navbar } from './Navbar'
3 | export { default as Settings } from './Settings'
4 | export { default as Sidebar } from './Sidebar/index.vue'
5 | export { default as TagsView } from './TagsView/index.vue'
6 |
--------------------------------------------------------------------------------
/src/utils/get-page-title.js:
--------------------------------------------------------------------------------
1 | import defaultSettings from '@/settings'
2 |
3 | const title = defaultSettings.title || 'Vue Element Admin'
4 |
5 | export default function getPageTitle(pageTitle) {
6 | if (pageTitle) {
7 | return `${pageTitle} - ${title}`
8 | }
9 | return `${title}`
10 | }
11 |
--------------------------------------------------------------------------------
/src/directive/waves/index.js:
--------------------------------------------------------------------------------
1 | import waves from './waves'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('waves', waves)
5 | }
6 |
7 | if (window.Vue) {
8 | window.waves = waves
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | waves.install = install
13 | export default waves
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | end_of_line = lf
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
12 | [*.md]
13 | insert_final_newline = false
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/src/icons/svg/guide-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/search.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/components/IconSelect/requireIcons.js:
--------------------------------------------------------------------------------
1 |
2 | const req = require.context('@/icons/svg', false, /\.svg$/)
3 | const requireAll = requireContext => requireContext.keys()
4 |
5 | const re = /\.\/(.*)\.svg/
6 |
7 | const icons = requireAll(req).map(i => {
8 | return i.match(re)[1]
9 | })
10 |
11 | export default icons
12 |
--------------------------------------------------------------------------------
/src/icons/svg/email.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/views/redirect/index.vue:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/src/directive/el-drag-dialog/index.js:
--------------------------------------------------------------------------------
1 | import drag from './drag'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('el-drag-dialog', drag)
5 | }
6 |
7 | if (window.Vue) {
8 | window['el-drag-dialog'] = drag
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | drag.install = install
13 | export default drag
14 |
--------------------------------------------------------------------------------
/src/icons/svg/documentation.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/api/console/acl.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function getACL() {
4 | return request({
5 | url: '/api/console/acl',
6 | method: 'get'
7 | })
8 | }
9 | export function postACL(data) {
10 | return request({
11 | url: '/api/console/acl',
12 | method: 'post',
13 | data
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/src/icons/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import SvgIcon from '@/components/SvgIcon'// svg component
3 |
4 | // register globally
5 | Vue.component('svg-icon', SvgIcon)
6 |
7 | const req = require.context('./svg', false, /\.svg$/)
8 | const requireAll = requireContext => requireContext.keys().map(requireContext)
9 | requireAll(req)
10 |
--------------------------------------------------------------------------------
/src/directive/clipboard/index.js:
--------------------------------------------------------------------------------
1 | import Clipboard from './clipboard'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('Clipboard', Clipboard)
5 | }
6 |
7 | if (window.Vue) {
8 | window.clipboard = Clipboard
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | Clipboard.install = install
13 | export default Clipboard
14 |
--------------------------------------------------------------------------------
/src/icons/svg/lock.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/directive/permission/index.js:
--------------------------------------------------------------------------------
1 | import permission from './permission'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('permission', permission)
5 | }
6 |
7 | if (window.Vue) {
8 | window['permission'] = permission
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | permission.install = install
13 | export default permission
14 |
--------------------------------------------------------------------------------
/src/icons/svg/fullscreen.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/user.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svgo.yml:
--------------------------------------------------------------------------------
1 | # replace default config
2 |
3 | # multipass: true
4 | # full: true
5 |
6 | plugins:
7 |
8 | # - name
9 | #
10 | # or:
11 | # - name: false
12 | # - name: true
13 | #
14 | # or:
15 | # - name:
16 | # param1: 1
17 | # param2: 2
18 |
19 | - removeAttrs:
20 | attrs:
21 | - 'fill'
22 | - 'fill-rule'
23 |
--------------------------------------------------------------------------------
/src/utils/auth.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie'
2 |
3 | const TokenKey = 'headscale-panel-Token'
4 |
5 | export function getToken() {
6 | return Cookies.get(TokenKey)
7 | }
8 |
9 | export function setToken(token) {
10 | return Cookies.set(TokenKey, token)
11 | }
12 |
13 | export function removeToken() {
14 | return Cookies.remove(TokenKey)
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | dist/
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | **/*.log
8 |
9 | tests/**/coverage/
10 | tests/e2e/reports
11 | selenium-debug.log
12 |
13 | # Editor directories and files
14 | .idea
15 | .vscode
16 | *.suo
17 | *.ntvs*
18 | *.njsproj
19 | *.sln
20 | *.local
21 |
22 | package-lock.json
23 | yarn.lock
24 |
--------------------------------------------------------------------------------
/src/directive/el-table/index.js:
--------------------------------------------------------------------------------
1 | import adaptive from './adaptive'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('el-height-adaptive-table', adaptive)
5 | }
6 |
7 | if (window.Vue) {
8 | window['el-height-adaptive-table'] = adaptive
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | adaptive.install = install
13 | export default adaptive
14 |
--------------------------------------------------------------------------------
/src/utils/event-source.js:
--------------------------------------------------------------------------------
1 | import { getToken } from '@/utils/auth'
2 | import { EventSourcePolyfill } from 'event-source-polyfill'
3 |
4 | export function UtilEventSource(resoureUrl, options = {}) {
5 | return new EventSourcePolyfill(resoureUrl, {
6 | headers: {
7 | 'Authorization': 'Bearer ' + getToken()
8 | },
9 | ...options
10 | })
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/src/icons/svg/example.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/excel.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/api/system/headscale.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function getHeadscaleConfig() {
4 | return request({
5 | url: '/api/system/headscale',
6 | method: 'get'
7 | })
8 | }
9 |
10 | export function postHeadscaleConfig(data) {
11 | return request({
12 | url: '/api/system/headscale',
13 | method: 'post',
14 | data
15 | })
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/src/icons/svg/like.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/star.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/education.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/plopfile.js:
--------------------------------------------------------------------------------
1 | const viewGenerator = require('./plop-templates/view/prompt')
2 | const componentGenerator = require('./plop-templates/component/prompt')
3 | const storeGenerator = require('./plop-templates/store/prompt.js')
4 |
5 | module.exports = function(plop) {
6 | plop.setGenerator('view', viewGenerator)
7 | plop.setGenerator('component', componentGenerator)
8 | plop.setGenerator('store', storeGenerator)
9 | }
10 |
--------------------------------------------------------------------------------
/src/icons/svg/message.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/plop-templates/view/index.hbs:
--------------------------------------------------------------------------------
1 | {{#if template}}
2 |
3 |
4 |
5 | {{/if}}
6 |
7 | {{#if script}}
8 |
20 | {{/if}}
21 |
22 | {{#if style}}
23 |
26 | {{/if}}
27 |
--------------------------------------------------------------------------------
/plop-templates/component/index.hbs:
--------------------------------------------------------------------------------
1 | {{#if template}}
2 |
3 |
4 |
5 | {{/if}}
6 |
7 | {{#if script}}
8 |
20 | {{/if}}
21 |
22 | {{#if style}}
23 |
26 | {{/if}}
27 |
--------------------------------------------------------------------------------
/src/icons/svg/table.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/api/log/operationLog.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | // 获取操作日志列表
4 | export function getOperationLogs(params) {
5 | return request({
6 | url: '/api/log/operation/list',
7 | method: 'get',
8 | params
9 | })
10 | }
11 |
12 | // 批量删除操作日志
13 | export function batchDeleteOperationLogByIds(data) {
14 | return request({
15 | url: '/api/log/operation/delete/batch',
16 | method: 'delete',
17 | data
18 | })
19 | }
20 |
21 |
--------------------------------------------------------------------------------
/src/icons/svg/back-top.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/tests/unit/utils/param2Obj.spec.js:
--------------------------------------------------------------------------------
1 | import { param2Obj } from '@/utils/index.js'
2 | describe('Utils:param2Obj', () => {
3 | const url = 'https://github.com/PanJiaChen/vue-element-admin?name=bill&age=29&sex=1&field=dGVzdA==&key=%E6%B5%8B%E8%AF%95'
4 |
5 | it('param2Obj test', () => {
6 | expect(param2Obj(url)).toEqual({
7 | name: 'bill',
8 | age: '29',
9 | sex: '1',
10 | field: window.btoa('test'),
11 | key: '测试'
12 | })
13 | })
14 | })
15 |
--------------------------------------------------------------------------------
/src/api/system/base.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function login(data) {
4 | return request({
5 | url: '/api/base/login',
6 | method: 'post',
7 | data
8 | })
9 | }
10 |
11 | export function refreshToken() {
12 | return request({
13 | url: '/api/base/refreshToken',
14 | method: 'post'
15 | })
16 | }
17 |
18 | export function logout() {
19 | return request({
20 | url: '/api/base/logout',
21 | method: 'post'
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/src/icons/svg/theme.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/close-circle-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/icons/svg/tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/arrow-circle-up-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/peoples.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/icons/svg/nested.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/password.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/components/Tinymce/toolbar.js:
--------------------------------------------------------------------------------
1 | // Here is a list of the toolbar
2 | // Detail list see https://www.tinymce.com/docs/advanced/editor-control-identifiers/#toolbarcontrols
3 |
4 | const toolbar = ['searchreplace bold italic underline strikethrough alignleft aligncenter alignright outdent indent blockquote undo redo removeformat subscript superscript code codesample', 'hr bullist numlist link image charmap preview anchor pagebreak insertdatetime media table emoticons forecolor backcolor fullscreen']
5 |
6 | export default toolbar
7 |
--------------------------------------------------------------------------------
/src/icons/svg/data-transfer-both-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/vendor/publickey.js:
--------------------------------------------------------------------------------
1 | export const publicKey = `-----BEGIN PUBLIC KEY-----
2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsfk9FP4ZeVLYSzRMJpv4
3 | MSzm1WzM+3c9zfi98ldcLa7aQADzDi7vnk0TqXNMPY2r5Dmykrvf6HtC8DCEg01b
4 | oBapvkfJy0KdQ1XbGihOGrZKMqCvS6DUx8Gt1osc7ag13GcQC/pI+VNMrNO1wuZX
5 | veGEKsd6K8sw8Xc8DtyJMr2m/GVZftWSzzDu6/dHWFglFQ5nbLogiA7PDJc9m3Pu
6 | cMOWfHEHkeymvRTnTD0FlqjpWL7Dr42CdqtUqL4abQW6xJcbde5ow1SXzznO240m
7 | 5xDTnBxS5gh+uvz6NzENc2JUX1fP7W9zYwvV7/PMNoi47gQ61O5sp/H+vpmyXyV+
8 | mwIDAQAB
9 | -----END PUBLIC KEY-----`
10 |
--------------------------------------------------------------------------------
/src/api/dashboard/status.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function getSystemInfo(params) {
4 | return request({
5 | url: '/api/system/info',
6 | method: 'get',
7 | params
8 | })
9 | }
10 | export function getStatus(params) {
11 | return request({
12 | url: '/api/system/status',
13 | method: 'get',
14 | params
15 | })
16 | }
17 |
18 | export function install(data) {
19 | return request({
20 | url: '/api/system/install',
21 | method: 'post',
22 | data
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/api/console/routes.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function getRoute(params) {
4 | return request({
5 | url: '/api/console/route',
6 | method: 'get',
7 | params
8 | })
9 | }
10 |
11 | export function switchRoute(data) {
12 | return request({
13 | url: '/api/console/route',
14 | method: 'patch',
15 | data
16 | })
17 | }
18 |
19 | export function deleteRoute(data) {
20 | return request({
21 | url: '/api/console/route',
22 | method: 'delete',
23 | data
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/icons/svg/check-circle-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/api/console/preauthkey.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function getPreAuthKeys() {
4 | return request({
5 | url: '/api/console/preauthkey',
6 | method: 'get'
7 | })
8 | }
9 |
10 | export function createPreAuthKey(data) {
11 | return request({
12 | url: '/api/console/preauthkey',
13 | method: 'post',
14 | data
15 | })
16 | }
17 |
18 | export function expirePreAuthKey(data) {
19 | return request({
20 | url: '/api/console/preauthkey',
21 | method: 'delete',
22 | data
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/icons/svg/hamburger.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/store/modules/errorLog.js:
--------------------------------------------------------------------------------
1 | const state = {
2 | logs: []
3 | }
4 |
5 | const mutations = {
6 | ADD_ERROR_LOG: (state, log) => {
7 | state.logs.push(log)
8 | },
9 | CLEAR_ERROR_LOG: (state) => {
10 | state.logs.splice(0)
11 | }
12 | }
13 |
14 | const actions = {
15 | addErrorLog({ commit }, log) {
16 | commit('ADD_ERROR_LOG', log)
17 | },
18 | clearErrorLog({ commit }) {
19 | commit('CLEAR_ERROR_LOG')
20 | }
21 | }
22 |
23 | export default {
24 | namespaced: true,
25 | state,
26 | mutations,
27 | actions
28 | }
29 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | <%= webpackConfig.name %>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/icons/svg/edit.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/components/Tinymce/plugins.js:
--------------------------------------------------------------------------------
1 | // Any plugins you want to use has to be imported
2 | // Detail plugins list see https://www.tinymce.com/docs/plugins/
3 | // Custom builds see https://www.tinymce.com/download/custom-builds/
4 |
5 | const plugins = ['advlist anchor autolink autosave code codesample colorpicker colorpicker contextmenu directionality emoticons fullscreen hr image imagetools insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textcolor textpattern visualblocks visualchars wordcount']
6 |
7 | export default plugins
8 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
4 | '@vue/cli-plugin-babel/preset'
5 | ],
6 | 'env': {
7 | 'development': {
8 | // babel-plugin-dynamic-import-node plugin only does one thing by converting all import() to require().
9 | // This plugin can significantly increase the speed of hot updates, when you have a large number of pages.
10 | // https://panjiachen.github.io/vue-element-admin-site/guide/advanced/lazy-loading.html
11 | 'plugins': ['dynamic-import-node']
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/icons/svg/list.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/components/ImageCropper/utils/data2blob.js:
--------------------------------------------------------------------------------
1 | /**
2 | * database64文件格式转换为2进制
3 | *
4 | * @param {[String]} data dataURL 的格式为 “data:image/png;base64,****”,逗号之前都是一些说明性的文字,我们只需要逗号之后的就行了
5 | * @param {[String]} mime [description]
6 | * @return {[blob]} [description]
7 | */
8 | export default function(data, mime) {
9 | data = data.split(',')[1]
10 | data = window.atob(data)
11 | var ia = new Uint8Array(data.length)
12 | for (var i = 0; i < data.length; i++) {
13 | ia[i] = data.charCodeAt(i)
14 | }
15 | // canvas.toDataURL 返回的默认格式就是 image/png
16 | return new Blob([ia], {
17 | type: mime
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/src/icons/svg/eye-off.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/clipboard.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/utils/permission.js:
--------------------------------------------------------------------------------
1 | import store from '@/store'
2 |
3 | /**
4 | * @param {Array} value
5 | * @returns {Boolean}
6 | * @example see @/views/permission/directive.vue
7 | */
8 | export default function checkPermission(value) {
9 | if (value && value instanceof Array && value.length > 0) {
10 | const roles = store.getters && store.getters.roles
11 | const permissionRoles = value
12 |
13 | const hasPermission = roles.some(role => {
14 | return permissionRoles.includes(role)
15 | })
16 | return hasPermission
17 | } else {
18 | console.error(`need roles! Like v-permission="['admin','editor']"`)
19 | return false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/store/getters.js:
--------------------------------------------------------------------------------
1 | const getters = {
2 | sidebar: state => state.app.sidebar,
3 | size: state => state.app.size,
4 | device: state => state.app.device,
5 | visitedViews: state => state.tagsView.visitedViews,
6 | cachedViews: state => state.tagsView.cachedViews,
7 | token: state => state.user.token,
8 | avatar: state => state.user.avatar,
9 | name: state => state.user.name,
10 | introduction: state => state.user.introduction,
11 | roles: state => state.user.roles,
12 | permission_routes: state => state.permission.routes,
13 | errorLogs: state => state.errorLog.logs,
14 | routes: state => state.permission.routes
15 | }
16 | export default getters
17 |
--------------------------------------------------------------------------------
/src/icons/svg/check-mark-button-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/src/icons/svg/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/tree-table.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/vendor/Export2Zip.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { saveAs } from 'file-saver'
3 | import JSZip from 'jszip'
4 |
5 | export function export_txt_to_zip(th, jsonData, txtName, zipName) {
6 | const zip = new JSZip()
7 | const txt_name = txtName || 'file'
8 | const zip_name = zipName || 'file'
9 | const data = jsonData
10 | let txtData = `${th}\r\n`
11 | data.forEach((row) => {
12 | let tempStr = ''
13 | tempStr = row.toString()
14 | txtData += `${tempStr}\r\n`
15 | })
16 | zip.file(`${txt_name}.txt`, txtData)
17 | zip.generateAsync({
18 | type: "blob"
19 | }).then((blob) => {
20 | saveAs(blob, `${zip_name}.zip`)
21 | }, (err) => {
22 | alert('导出失败')
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/MarkdownEditor/default-options.js:
--------------------------------------------------------------------------------
1 | // doc: https://nhnent.github.io/tui.editor/api/latest/ToastUIEditor.html#ToastUIEditor
2 | export default {
3 | minHeight: '200px',
4 | previewStyle: 'vertical',
5 | useCommandShortcut: true,
6 | useDefaultHTMLSanitizer: true,
7 | usageStatistics: false,
8 | hideModeSwitch: false,
9 | toolbarItems: [
10 | 'heading',
11 | 'bold',
12 | 'italic',
13 | 'strike',
14 | 'divider',
15 | 'hr',
16 | 'quote',
17 | 'divider',
18 | 'ul',
19 | 'ol',
20 | 'task',
21 | 'indent',
22 | 'outdent',
23 | 'divider',
24 | 'table',
25 | 'image',
26 | 'link',
27 | 'divider',
28 | 'code',
29 | 'codeblock'
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/src/icons/svg/international.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/tests/unit/components/Hamburger.spec.js:
--------------------------------------------------------------------------------
1 | import { shallowMount } from '@vue/test-utils'
2 | import Hamburger from '@/components/Hamburger/index.vue'
3 | describe('Hamburger.vue', () => {
4 | it('toggle click', () => {
5 | const wrapper = shallowMount(Hamburger)
6 | const mockFn = jest.fn()
7 | wrapper.vm.$on('toggleClick', mockFn)
8 | wrapper.find('.hamburger').trigger('click')
9 | expect(mockFn).toBeCalled()
10 | })
11 | it('prop isActive', () => {
12 | const wrapper = shallowMount(Hamburger)
13 | wrapper.setProps({ isActive: true })
14 | expect(wrapper.contains('.is-active')).toBe(true)
15 | wrapper.setProps({ isActive: false })
16 | expect(wrapper.contains('.is-active')).toBe(false)
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/tests/unit/components/SvgIcon.spec.js:
--------------------------------------------------------------------------------
1 | import { shallowMount } from '@vue/test-utils'
2 | import SvgIcon from '@/components/SvgIcon/index.vue'
3 | describe('SvgIcon.vue', () => {
4 | it('iconClass', () => {
5 | const wrapper = shallowMount(SvgIcon, {
6 | propsData: {
7 | iconClass: 'test'
8 | }
9 | })
10 | expect(wrapper.find('use').attributes().href).toBe('#icon-test')
11 | })
12 | it('className', () => {
13 | const wrapper = shallowMount(SvgIcon, {
14 | propsData: {
15 | iconClass: 'test'
16 | }
17 | })
18 | expect(wrapper.classes().length).toBe(1)
19 | wrapper.setProps({ className: 'test' })
20 | expect(wrapper.classes().includes('test')).toBe(true)
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/src/icons/svg/people.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/wechat.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/layout/components/Sidebar/FixiOSBug.js:
--------------------------------------------------------------------------------
1 | export default {
2 | computed: {
3 | device() {
4 | return this.$store.state.app.device
5 | }
6 | },
7 | mounted() {
8 | // In order to fix the click on menu on the ios device will trigger the mouseleave bug
9 | // https://github.com/PanJiaChen/vue-element-admin/issues/1135
10 | this.fixBugIniOS()
11 | },
12 | methods: {
13 | fixBugIniOS() {
14 | const $subMenu = this.$refs.subMenu
15 | if ($subMenu) {
16 | const handleMouseleave = $subMenu.handleMouseleave
17 | $subMenu.handleMouseleave = (e) => {
18 | if (this.device === 'mobile') {
19 | return
20 | }
21 | handleMouseleave(e)
22 | }
23 | }
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/clipboard.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Clipboard from 'clipboard'
3 |
4 | function clipboardSuccess() {
5 | Vue.prototype.$message({
6 | message: 'Copy successfully',
7 | type: 'success',
8 | duration: 1500
9 | })
10 | }
11 |
12 | function clipboardError() {
13 | Vue.prototype.$message({
14 | message: 'Copy failed',
15 | type: 'error'
16 | })
17 | }
18 |
19 | export default function handleClipboard(text, event) {
20 | const clipboard = new Clipboard(event.target, {
21 | text: () => text
22 | })
23 | clipboard.on('success', () => {
24 | clipboardSuccess()
25 | clipboard.destroy()
26 | })
27 | clipboard.on('error', () => {
28 | clipboardError()
29 | clipboard.destroy()
30 | })
31 | clipboard.onClick(event)
32 | }
33 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 | import getters from './getters'
4 |
5 | Vue.use(Vuex)
6 |
7 | // https://webpack.js.org/guides/dependency-management/#requirecontext
8 | const modulesFiles = require.context('./modules', true, /\.js$/)
9 |
10 | // you do not need `import app from './modules/app'`
11 | // it will auto require all vuex module from modules file
12 | const modules = modulesFiles.keys().reduce((modules, modulePath) => {
13 | // set './app.js' => 'app'
14 | const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
15 | const value = modulesFiles(modulePath)
16 | modules[moduleName] = value.default
17 | return modules
18 | }, {})
19 |
20 | const store = new Vuex.Store({
21 | modules,
22 | getters
23 | })
24 |
25 | export default store
26 |
--------------------------------------------------------------------------------
/src/icons/svg/skill.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/directive/permission/permission.js:
--------------------------------------------------------------------------------
1 | import store from '@/store'
2 |
3 | function checkPermission(el, binding) {
4 | const { value } = binding
5 | const roles = store.getters && store.getters.roles
6 |
7 | if (value && value instanceof Array) {
8 | if (value.length > 0) {
9 | const permissionRoles = value
10 |
11 | const hasPermission = roles.some(role => {
12 | return permissionRoles.includes(role)
13 | })
14 |
15 | if (!hasPermission) {
16 | el.parentNode && el.parentNode.removeChild(el)
17 | }
18 | }
19 | } else {
20 | throw new Error(`need roles! Like v-permission="['admin','editor']"`)
21 | }
22 | }
23 |
24 | export default {
25 | inserted(el, binding) {
26 | checkPermission(el, binding)
27 | },
28 | update(el, binding) {
29 | checkPermission(el, binding)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/icons/svg/404.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/store/modules/settings.js:
--------------------------------------------------------------------------------
1 | import variables from '@/styles/element-variables.scss'
2 | import defaultSettings from '@/settings'
3 |
4 | const { showSettings, tagsView, fixedHeader, sidebarLogo } = defaultSettings
5 |
6 | const state = {
7 | theme: variables.theme,
8 | showSettings: showSettings,
9 | tagsView: tagsView,
10 | fixedHeader: fixedHeader,
11 | sidebarLogo: sidebarLogo
12 | }
13 |
14 | const mutations = {
15 | CHANGE_SETTING: (state, { key, value }) => {
16 | // eslint-disable-next-line no-prototype-builtins
17 | if (state.hasOwnProperty(key)) {
18 | state[key] = value
19 | }
20 | }
21 | }
22 |
23 | const actions = {
24 | changeSetting({ commit }, data) {
25 | commit('CHANGE_SETTING', data)
26 | }
27 | }
28 |
29 | export default {
30 | namespaced: true,
31 | state,
32 | mutations,
33 | actions
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
3 | transform: {
4 | '^.+\\.vue$': 'vue-jest',
5 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$':
6 | 'jest-transform-stub',
7 | '^.+\\.jsx?$': 'babel-jest'
8 | },
9 | moduleNameMapper: {
10 | '^@/(.*)$': '/src/$1'
11 | },
12 | snapshotSerializers: ['jest-serializer-vue'],
13 | testMatch: [
14 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
15 | ],
16 | collectCoverageFrom: ['src/utils/**/*.{js,vue}', '!src/utils/auth.js', '!src/utils/request.js', 'src/components/**/*.{js,vue}'],
17 | coverageDirectory: '/tests/unit/coverage',
18 | // 'collectCoverage': true,
19 | 'coverageReporters': [
20 | 'lcov',
21 | 'text-summary'
22 | ],
23 | testURL: 'http://localhost/'
24 | }
25 |
--------------------------------------------------------------------------------
/src/api/console/machines.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function getMachines(params) {
4 | return request({
5 | url: '/api/console/machine',
6 | method: 'get',
7 | params
8 | })
9 | }
10 |
11 | export function postMachine(data) {
12 | return request({
13 | url: '/api/console/machine',
14 | method: 'post',
15 | data
16 | })
17 | }
18 |
19 | export function deleteMachine(data) {
20 | return request({
21 | url: '/api/console/machine',
22 | method: 'delete',
23 | data
24 | })
25 | }
26 |
27 | export function updateTags(data) {
28 | return request({
29 | url: '/api/console/machine',
30 | method: 'patch',
31 | data
32 | })
33 | }
34 |
35 | export function moveMachine(data) {
36 | return request({
37 | url: '/api/console/machine',
38 | method: 'put',
39 | data
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/src/icons/svg/zip.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/layout/components/Sidebar/Link.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
44 |
--------------------------------------------------------------------------------
/src/layout/components/Sidebar/Item.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
42 |
--------------------------------------------------------------------------------
/src/icons/svg/bug.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/eye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/styles/variables.scss:
--------------------------------------------------------------------------------
1 | // base color
2 | $blue:#324157;
3 | $light-blue:#3A71A8;
4 | $red:#C03639;
5 | $pink: #E65D6E;
6 | $green: #30B08F;
7 | $tiffany: #4AB7BD;
8 | $yellow:#FEC171;
9 | $panGreen: #30B08F;
10 |
11 | // sidebar
12 | $menuText:#bfcbd9;
13 | $menuActiveText:#409EFF;
14 | $subMenuActiveText:#f4f4f5; // https://github.com/ElemeFE/element/issues/12951
15 |
16 | $menuBg:#304156;
17 | $menuHover:#263445;
18 |
19 | $subMenuBg:#1f2d3d;
20 | $subMenuHover:#001528;
21 |
22 | $sideBarWidth: 210px;
23 |
24 | // the :export directive is the magic sauce for webpack
25 | // https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
26 | :export {
27 | menuText: $menuText;
28 | menuActiveText: $menuActiveText;
29 | subMenuActiveText: $subMenuActiveText;
30 | menuBg: $menuBg;
31 | menuHover: $menuHover;
32 | subMenuBg: $subMenuBg;
33 | subMenuHover: $subMenuHover;
34 | sideBarWidth: $sideBarWidth;
35 | }
36 |
--------------------------------------------------------------------------------
/src/api/system/api.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | // 获取接口列表
4 | export function getApis(params) {
5 | return request({
6 | url: '/api/api/list',
7 | method: 'get',
8 | params
9 | })
10 | }
11 |
12 | // 获取接口树(按接口Category字段分类)
13 | export function getApiTree(params) {
14 | return request({
15 | url: '/api/api/tree',
16 | method: 'get',
17 | params
18 | })
19 | }
20 |
21 | // 创建接口
22 | export function createApi(data) {
23 | return request({
24 | url: '/api/api/create',
25 | method: 'post',
26 | data
27 | })
28 | }
29 |
30 | // 更新接口
31 | export function updateApiById(Id, data) {
32 | return request({
33 | url: '/api/api/update/' + Id,
34 | method: 'patch',
35 | data
36 | })
37 | }
38 |
39 | // 批量删除接口
40 | export function batchDeleteApiByIds(data) {
41 | return request({
42 | url: '/api/api/delete/batch',
43 | method: 'delete',
44 | data
45 | })
46 | }
47 |
--------------------------------------------------------------------------------
/src/directive/waves/waves.css:
--------------------------------------------------------------------------------
1 | .waves-ripple {
2 | position: absolute;
3 | border-radius: 100%;
4 | background-color: rgba(0, 0, 0, 0.15);
5 | background-clip: padding-box;
6 | pointer-events: none;
7 | -webkit-user-select: none;
8 | -moz-user-select: none;
9 | -ms-user-select: none;
10 | user-select: none;
11 | -webkit-transform: scale(0);
12 | -ms-transform: scale(0);
13 | transform: scale(0);
14 | opacity: 1;
15 | }
16 |
17 | .waves-ripple.z-active {
18 | opacity: 0;
19 | -webkit-transform: scale(2);
20 | -ms-transform: scale(2);
21 | transform: scale(2);
22 | -webkit-transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
23 | transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
24 | transition: opacity 1.2s ease-out, transform 0.6s ease-out;
25 | transition: opacity 1.2s ease-out, transform 0.6s ease-out, -webkit-transform 0.6s ease-out;
26 | }
--------------------------------------------------------------------------------
/src/styles/element-variables.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * I think element-ui's default theme color is too light for long-term use.
3 | * So I modified the default color and you can modify it to your liking.
4 | **/
5 |
6 | /* theme color */
7 | $--color-primary: #1890ff;
8 | $--color-success: #13ce66;
9 | $--color-warning: #ffba00;
10 | $--color-danger: #ff4949;
11 | // $--color-info: #1E1E1E;
12 |
13 | $--button-font-weight: 400;
14 |
15 | // $--color-text-regular: #1f2d3d;
16 |
17 | $--border-color-light: #dfe4ed;
18 | $--border-color-lighter: #e6ebf5;
19 |
20 | $--table-border: 1px solid #dfe6ec;
21 |
22 | /* icon font path, required */
23 | $--font-path: "~element-ui/lib/theme-chalk/fonts";
24 |
25 | @import "~element-ui/packages/theme-chalk/src/index";
26 |
27 | // the :export directive is the magic sauce for webpack
28 | // https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
29 | :export {
30 | theme: $--color-primary;
31 | }
32 |
--------------------------------------------------------------------------------
/src/styles/transition.scss:
--------------------------------------------------------------------------------
1 | // global transition css
2 |
3 | /* fade */
4 | .fade-enter-active,
5 | .fade-leave-active {
6 | transition: opacity 0.28s;
7 | }
8 |
9 | .fade-enter,
10 | .fade-leave-active {
11 | opacity: 0;
12 | }
13 |
14 | /* fade-transform */
15 | .fade-transform-leave-active,
16 | .fade-transform-enter-active {
17 | transition: all .5s;
18 | }
19 |
20 | .fade-transform-enter {
21 | opacity: 0;
22 | transform: translateX(-30px);
23 | }
24 |
25 | .fade-transform-leave-to {
26 | opacity: 0;
27 | transform: translateX(30px);
28 | }
29 |
30 | /* breadcrumb transition */
31 | .breadcrumb-enter-active,
32 | .breadcrumb-leave-active {
33 | transition: all .5s;
34 | }
35 |
36 | .breadcrumb-enter,
37 | .breadcrumb-leave-active {
38 | opacity: 0;
39 | transform: translateX(20px);
40 | }
41 |
42 | .breadcrumb-move {
43 | transition: all .5s;
44 | }
45 |
46 | .breadcrumb-leave-active {
47 | position: absolute;
48 | }
49 |
--------------------------------------------------------------------------------
/docs/nginx.md:
--------------------------------------------------------------------------------
1 | 如果你想要将前端与后端部署到同一个服务器上,建议你使用nginx或其他能够同时提供
2 |
3 | ```
4 | server
5 | {
6 | listen 80;
7 | listen [::]:80;
8 | server_name example.com;
9 |
10 | # headscale-panel
11 | location ~ ^/(api|\.well-known/openid-configuration) {
12 | proxy_pass http://localhost:8088;
13 | proxy_set_header Host $host;
14 | proxy_set_header X-Real-IP $remote_addr;
15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
16 | }
17 |
18 | # headscale
19 | location ~ ^/(health|oidc|windows|apple|key|register|drep|bootstrap-dns|swagger|ts2021) {
20 | proxy_pass http://localhost:8080;
21 | proxy_set_header Host $host;
22 | proxy_set_header X-Real-IP $remote_addr;
23 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
24 | }
25 |
26 | location / {
27 | root /path/to/your/static/files;
28 | index index.html;
29 | }
30 | }
31 | ```
--------------------------------------------------------------------------------
/src/settings.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | title: 'Headscale Panel',
3 |
4 | /**
5 | * @type {boolean} true | false
6 | * @description Whether show the settings right-panel
7 | */
8 | showSettings: false,
9 |
10 | /**
11 | * @type {boolean} true | false
12 | * @description Whether need tagsView
13 | */
14 | tagsView: false,
15 |
16 | /**
17 | * @type {boolean} true | false
18 | * @description Whether fix the header
19 | */
20 | fixedHeader: false,
21 |
22 | /**
23 | * @type {boolean} true | false
24 | * @description Whether show the logo in sidebar
25 | */
26 | sidebarLogo: false,
27 |
28 | /**
29 | * @type {string | array} 'production' | ['production', 'development']
30 | * @description Need show err logs component.
31 | * The default is only used in the production env
32 | * If you want to also use it in dev, you can pass ['production', 'development']
33 | */
34 | errorLog: 'production'
35 | }
36 |
--------------------------------------------------------------------------------
/src/icons/svg/language.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/icons/svg/eye-on.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/icons/svg/exit-fullscreen.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/pdf.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/utils/error-log.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import store from '@/store'
3 | import { isString, isArray } from '@/utils/validate'
4 | import settings from '@/settings'
5 |
6 | // you can set in settings.js
7 | // errorLog:'production' | ['production', 'development']
8 | const { errorLog: needErrorLog } = settings
9 |
10 | function checkNeed() {
11 | const env = process.env.NODE_ENV
12 | if (isString(needErrorLog)) {
13 | return env === needErrorLog
14 | }
15 | if (isArray(needErrorLog)) {
16 | return needErrorLog.includes(env)
17 | }
18 | return false
19 | }
20 |
21 | if (checkNeed()) {
22 | Vue.config.errorHandler = function(err, vm, info, a) {
23 | // Don't ask me why I use Vue.nextTick, it just a hack.
24 | // detail see https://forum.vuejs.org/t/dispatch-in-vue-config-errorhandler-has-some-problem/23500
25 | Vue.nextTick(() => {
26 | store.dispatch('errorLog/addErrorLog', {
27 | err,
28 | vm,
29 | info,
30 | url: window.location.href
31 | })
32 | console.error(err, info)
33 | })
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/api/system/user.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | // 获取当前登录用户信息
4 | export function getInfo() {
5 | return request({
6 | url: '/api/user/info',
7 | method: 'post'
8 | })
9 | }
10 |
11 | // 获取用户列表
12 | export function getUsers(params) {
13 | return request({
14 | url: '/api/user/list',
15 | method: 'get',
16 | params
17 | })
18 | }
19 |
20 | // 更新用户登录密码
21 | export function changePwd(data) {
22 | return request({
23 | url: '/api/user/changePwd',
24 | method: 'put',
25 | data
26 | })
27 | }
28 |
29 | // 创建用户
30 | export function createUser(data) {
31 | return request({
32 | url: '/api/user/create',
33 | method: 'post',
34 | data
35 | })
36 | }
37 |
38 | // 更新用户
39 | export function updateUserById(id, data) {
40 | return request({
41 | url: '/api/user/update/' + id,
42 | method: 'patch',
43 | data
44 | })
45 | }
46 |
47 | // 批量删除用户
48 | export function batchDeleteUserByIds(data) {
49 | return request({
50 | url: '/api/user/delete/batch',
51 | method: 'delete',
52 | data
53 | })
54 | }
55 |
56 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 QianheYu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/icons/svg/tree.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 |
3 | import Cookies from 'js-cookie'
4 |
5 | import 'normalize.css/normalize.css' // a modern alternative to CSS resets
6 |
7 | import Element from 'element-ui'
8 | import './styles/element-variables.scss'
9 |
10 | import '@/styles/index.scss' // global css
11 |
12 | import App from './App'
13 | import store from './store'
14 | import router from './router'
15 |
16 | import './icons' // icon
17 | import './permission' // permission control
18 | // import './utils/error-log' // error log
19 | import i18n from './lang'
20 |
21 | import * as filters from './filters' // global filters
22 |
23 | import VueDeviceDetector from 'vue-device-detector'
24 | Vue.use(VueDeviceDetector)
25 |
26 | Vue.use(Element, {
27 | size: Cookies.get('size') || 'medium', // set element-ui default size
28 | i18n: (key, value) => i18n.t(key, value)
29 | })
30 |
31 | // register global utility filters
32 | Object.keys(filters).forEach(key => {
33 | Vue.filter(key, filters[key])
34 | })
35 |
36 | Vue.config.productionTip = false
37 |
38 | new Vue({
39 | el: '#app',
40 | router,
41 | i18n,
42 | store,
43 | render: h => h(App)
44 | })
45 |
--------------------------------------------------------------------------------
/tests/unit/utils/formatTime.spec.js:
--------------------------------------------------------------------------------
1 | import { formatTime } from '@/utils/index.js'
2 | describe('Utils:formatTime', () => {
3 | const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
4 | const retrofit = 5 * 1000
5 |
6 | it('ten digits timestamp', () => {
7 | expect(formatTime((d / 1000).toFixed(0))).toBe('7月13日17时54分')
8 | })
9 | it('test now', () => {
10 | expect(formatTime(+new Date() - 1)).toBe('刚刚')
11 | })
12 | it('less two minute', () => {
13 | expect(formatTime(+new Date() - 60 * 2 * 1000 + retrofit)).toBe('2分钟前')
14 | })
15 | it('less two hour', () => {
16 | expect(formatTime(+new Date() - 60 * 60 * 2 * 1000 + retrofit)).toBe('2小时前')
17 | })
18 | it('less one day', () => {
19 | expect(formatTime(+new Date() - 60 * 60 * 24 * 1 * 1000)).toBe('1天前')
20 | })
21 | it('more than one day', () => {
22 | expect(formatTime(d)).toBe('7月13日17时54分')
23 | })
24 | it('format', () => {
25 | expect(formatTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
26 | expect(formatTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
27 | expect(formatTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
28 | })
29 | })
30 |
--------------------------------------------------------------------------------
/src/views/profile/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
55 |
--------------------------------------------------------------------------------
/src/icons/svg/upload-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/dashboard.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/download-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/headscale3-dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/views/dashboard/components/SystemInfoGroup.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ info.version }}
5 |
6 | {{ info.branch }}
7 |
8 | {{ info.build_time }}
9 | {{ info.go_version }}
10 | {{ info.os }}
11 | {{ info.arch }}
12 |
13 |
14 |
15 |
16 |
32 |
33 |
36 |
--------------------------------------------------------------------------------
/src/icons/svg/power-up-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
12 |
13 |
14 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/layout/components/AppMain.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
24 |
25 |
49 |
50 |
58 |
--------------------------------------------------------------------------------
/src/icons/svg/power-down-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
12 |
13 |
14 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/api/system/menu.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | // 获取菜单树
4 | export function getMenuTree() {
5 | return request({
6 | url: '/api/menu/tree',
7 | method: 'get'
8 | })
9 | }
10 |
11 | // 获取菜单列表
12 | export function getMenus() {
13 | return request({
14 | url: '/api/menu/list',
15 | method: 'get'
16 | })
17 | }
18 |
19 | // 创建菜单
20 | export function createMenu(data) {
21 | return request({
22 | url: '/api/menu/create',
23 | method: 'post',
24 | data
25 | })
26 | }
27 |
28 | // 更新菜单
29 | export function updateMenuById(Id, data) {
30 | return request({
31 | url: '/api/menu/update/' + Id,
32 | method: 'patch',
33 | data
34 | })
35 | }
36 |
37 | // 批量删除菜单
38 | export function batchDeleteMenuByIds(data) {
39 | return request({
40 | url: '/api/menu/delete/batch',
41 | method: 'delete',
42 | data
43 | })
44 | }
45 |
46 | // 获取用户的可访问菜单列表
47 | export function getUserMenusByUserId(Id) {
48 | return request({
49 | url: '/api/menu/access/list/' + Id,
50 | method: 'get'
51 | })
52 | }
53 |
54 | // 获取用户的可访问菜单树
55 | export function getUserMenuTreeByUserId(Id) {
56 | return request({
57 | url: '/api/menu/access/tree/' + Id,
58 | method: 'get'
59 | })
60 | }
61 |
--------------------------------------------------------------------------------
/tests/unit/utils/validate.spec.js:
--------------------------------------------------------------------------------
1 | import { validUsername, validURL, validLowerCase, validUpperCase, validAlphabets } from '@/utils/validate.js'
2 | describe('Utils:validate', () => {
3 | it('validUsername', () => {
4 | expect(validUsername('admin')).toBe(true)
5 | expect(validUsername('editor')).toBe(true)
6 | expect(validUsername('xxxx')).toBe(false)
7 | })
8 | it('validURL', () => {
9 | expect(validURL('https://github.com/PanJiaChen/vue-element-admin')).toBe(true)
10 | expect(validURL('http://github.com/PanJiaChen/vue-element-admin')).toBe(true)
11 | expect(validURL('github.com/PanJiaChen/vue-element-admin')).toBe(false)
12 | })
13 | it('validLowerCase', () => {
14 | expect(validLowerCase('abc')).toBe(true)
15 | expect(validLowerCase('Abc')).toBe(false)
16 | expect(validLowerCase('123abc')).toBe(false)
17 | })
18 | it('validUpperCase', () => {
19 | expect(validUpperCase('ABC')).toBe(true)
20 | expect(validUpperCase('Abc')).toBe(false)
21 | expect(validUpperCase('123ABC')).toBe(false)
22 | })
23 | it('validAlphabets', () => {
24 | expect(validAlphabets('ABC')).toBe(true)
25 | expect(validAlphabets('Abc')).toBe(true)
26 | expect(validAlphabets('123aBC')).toBe(false)
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/src/icons/svg/eye-open.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/utils/open-window.js:
--------------------------------------------------------------------------------
1 | /**
2 | *Created by PanJiaChen on 16/11/29.
3 | * @param {Sting} url
4 | * @param {Sting} title
5 | * @param {Number} w
6 | * @param {Number} h
7 | */
8 | export default function openWindow(url, title, w, h) {
9 | // Fixes dual-screen position Most browsers Firefox
10 | const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
11 | const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
12 |
13 | const width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width
14 | const height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height
15 |
16 | const left = ((width / 2) - (w / 2)) + dualScreenLeft
17 | const top = ((height / 2) - (h / 2)) + dualScreenTop
18 | const newWindow = window.open(url, title, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=yes, copyhistory=no, width=' + w + ', height=' + h + ', top=' + top + ', left=' + left)
19 |
20 | // Puts focus on the newWindow
21 | if (window.focus) {
22 | newWindow.focus()
23 | }
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/src/components/LangSelect/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 中文
9 |
10 |
11 | English
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
42 |
--------------------------------------------------------------------------------
/tests/unit/utils/parseTime.spec.js:
--------------------------------------------------------------------------------
1 | import { parseTime } from '@/utils/index.js'
2 |
3 | describe('Utils:parseTime', () => {
4 | const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
5 | it('timestamp', () => {
6 | expect(parseTime(d)).toBe('2018-07-13 17:54:01')
7 | })
8 |
9 | it('timestamp string', () => {
10 | expect(parseTime((d + ''))).toBe('2018-07-13 17:54:01')
11 | })
12 |
13 | it('ten digits timestamp', () => {
14 | expect(parseTime((d / 1000).toFixed(0))).toBe('2018-07-13 17:54:01')
15 | })
16 | it('new Date', () => {
17 | expect(parseTime(new Date(d))).toBe('2018-07-13 17:54:01')
18 | })
19 | it('format', () => {
20 | expect(parseTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
21 | expect(parseTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
22 | expect(parseTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
23 | })
24 | it('get the day of the week', () => {
25 | expect(parseTime(d, '{a}')).toBe('五') // 星期五
26 | })
27 | it('get the day of the week', () => {
28 | expect(parseTime(+d + 1000 * 60 * 60 * 24 * 2, '{a}')).toBe('日') // 星期日
29 | })
30 | it('empty argument', () => {
31 | expect(parseTime()).toBeNull()
32 | })
33 |
34 | it('null', () => {
35 | expect(parseTime(null)).toBeNull()
36 | })
37 | })
38 |
--------------------------------------------------------------------------------
/src/components/Screenfull/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
50 |
51 |
61 |
--------------------------------------------------------------------------------
/src/icons/svg/shopping.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/directive/el-table/adaptive.js:
--------------------------------------------------------------------------------
1 | import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event'
2 |
3 | /**
4 | * How to use
5 | * ...
6 | * el-table height is must be set
7 | * bottomOffset: 30(default) // The height of the table from the bottom of the page.
8 | */
9 |
10 | const doResize = (el, binding, vnode) => {
11 | const { componentInstance: $table } = vnode
12 |
13 | const { value } = binding
14 |
15 | if (!$table.height) {
16 | throw new Error(`el-$table must set the height. Such as height='100px'`)
17 | }
18 | const bottomOffset = (value && value.bottomOffset) || 30
19 |
20 | if (!$table) return
21 |
22 | const height = window.innerHeight - el.getBoundingClientRect().top - bottomOffset
23 | $table.layout.setHeight(height)
24 | $table.doLayout()
25 | }
26 |
27 | export default {
28 | bind(el, binding, vnode) {
29 | el.resizeListener = () => {
30 | doResize(el, binding, vnode)
31 | }
32 | // parameter 1 is must be "Element" type
33 | addResizeListener(window.document.body, el.resizeListener)
34 | },
35 | inserted(el, binding, vnode) {
36 | doResize(el, binding, vnode)
37 | },
38 | unbind(el) {
39 | removeResizeListener(window.document.body, el.resizeListener)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Hamburger/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
32 |
33 |
45 |
--------------------------------------------------------------------------------
/plop-templates/view/prompt.js:
--------------------------------------------------------------------------------
1 | const { notEmpty } = require('../utils.js')
2 |
3 | module.exports = {
4 | description: 'generate a view',
5 | prompts: [{
6 | type: 'input',
7 | name: 'name',
8 | message: 'view name please',
9 | validate: notEmpty('name')
10 | },
11 | {
12 | type: 'checkbox',
13 | name: 'blocks',
14 | message: 'Blocks:',
15 | choices: [{
16 | name: '',
17 | value: 'template',
18 | checked: true
19 | },
20 | {
21 | name: '
47 |
48 |
63 |
--------------------------------------------------------------------------------
/plop-templates/store/prompt.js:
--------------------------------------------------------------------------------
1 | const { notEmpty } = require('../utils.js')
2 |
3 | module.exports = {
4 | description: 'generate store',
5 | prompts: [{
6 | type: 'input',
7 | name: 'name',
8 | message: 'store name please',
9 | validate: notEmpty('name')
10 | },
11 | {
12 | type: 'checkbox',
13 | name: 'blocks',
14 | message: 'Blocks:',
15 | choices: [{
16 | name: 'state',
17 | value: 'state',
18 | checked: true
19 | },
20 | {
21 | name: 'mutations',
22 | value: 'mutations',
23 | checked: true
24 | },
25 | {
26 | name: 'actions',
27 | value: 'actions',
28 | checked: true
29 | }
30 | ],
31 | validate(value) {
32 | if (!value.includes('state') || !value.includes('mutations')) {
33 | return 'store require at least state and mutations'
34 | }
35 | return true
36 | }
37 | }
38 | ],
39 | actions(data) {
40 | const name = '{{name}}'
41 | const { blocks } = data
42 | const options = ['state', 'mutations']
43 | const joinFlag = `,
44 | `
45 | if (blocks.length === 3) {
46 | options.push('actions')
47 | }
48 |
49 | const actions = [{
50 | type: 'add',
51 | path: `src/store/modules/${name}.js`,
52 | templateFile: 'plop-templates/store/index.hbs',
53 | data: {
54 | options: options.join(joinFlag),
55 | state: blocks.includes('state'),
56 | mutations: blocks.includes('mutations'),
57 | actions: blocks.includes('actions')
58 | }
59 | }]
60 | return actions
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/styles/mixin.scss:
--------------------------------------------------------------------------------
1 | @mixin clearfix {
2 | &:after {
3 | content: "";
4 | display: table;
5 | clear: both;
6 | }
7 | }
8 |
9 | @mixin scrollBar {
10 | &::-webkit-scrollbar-track-piece {
11 | background: #d3dce6;
12 | }
13 |
14 | &::-webkit-scrollbar {
15 | width: 6px;
16 | }
17 |
18 | &::-webkit-scrollbar-thumb {
19 | background: #99a9bf;
20 | border-radius: 20px;
21 | }
22 | }
23 |
24 | @mixin relative {
25 | position: relative;
26 | width: 100%;
27 | height: 100%;
28 | }
29 |
30 | @mixin pct($pct) {
31 | width: #{$pct};
32 | position: relative;
33 | margin: 0 auto;
34 | }
35 |
36 | @mixin triangle($width, $height, $color, $direction) {
37 | $width: $width/2;
38 | $color-border-style: $height solid $color;
39 | $transparent-border-style: $width solid transparent;
40 | height: 0;
41 | width: 0;
42 |
43 | @if $direction==up {
44 | border-bottom: $color-border-style;
45 | border-left: $transparent-border-style;
46 | border-right: $transparent-border-style;
47 | }
48 |
49 | @else if $direction==right {
50 | border-left: $color-border-style;
51 | border-top: $transparent-border-style;
52 | border-bottom: $transparent-border-style;
53 | }
54 |
55 | @else if $direction==down {
56 | border-top: $color-border-style;
57 | border-left: $transparent-border-style;
58 | border-right: $transparent-border-style;
59 | }
60 |
61 | @else if $direction==left {
62 | border-right: $color-border-style;
63 | border-top: $transparent-border-style;
64 | border-bottom: $transparent-border-style;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/SizeSelect/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{
9 | item.label }}
10 |
11 |
12 |
13 |
14 |
15 |
58 |
--------------------------------------------------------------------------------
/src/directive/clipboard/clipboard.js:
--------------------------------------------------------------------------------
1 | // Inspired by https://github.com/Inndy/vue-clipboard2
2 | const Clipboard = require('clipboard')
3 | if (!Clipboard) {
4 | throw new Error('you should npm install `clipboard` --save at first ')
5 | }
6 |
7 | export default {
8 | bind(el, binding) {
9 | if (binding.arg === 'success') {
10 | el._v_clipboard_success = binding.value
11 | } else if (binding.arg === 'error') {
12 | el._v_clipboard_error = binding.value
13 | } else {
14 | const clipboard = new Clipboard(el, {
15 | text() { return binding.value },
16 | action() { return binding.arg === 'cut' ? 'cut' : 'copy' }
17 | })
18 | clipboard.on('success', e => {
19 | const callback = el._v_clipboard_success
20 | callback && callback(e) // eslint-disable-line
21 | })
22 | clipboard.on('error', e => {
23 | const callback = el._v_clipboard_error
24 | callback && callback(e) // eslint-disable-line
25 | })
26 | el._v_clipboard = clipboard
27 | }
28 | },
29 | update(el, binding) {
30 | if (binding.arg === 'success') {
31 | el._v_clipboard_success = binding.value
32 | } else if (binding.arg === 'error') {
33 | el._v_clipboard_error = binding.value
34 | } else {
35 | el._v_clipboard.text = function() { return binding.value }
36 | el._v_clipboard.action = function() { return binding.arg === 'cut' ? 'cut' : 'copy' }
37 | }
38 | },
39 | unbind(el, binding) {
40 | if (binding.arg === 'success') {
41 | delete el._v_clipboard_success
42 | } else if (binding.arg === 'error') {
43 | delete el._v_clipboard_error
44 | } else {
45 | el._v_clipboard.destroy()
46 | delete el._v_clipboard
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/layout/components/Sidebar/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
57 |
--------------------------------------------------------------------------------
/src/views/dashboard/components/mixins/resize.js:
--------------------------------------------------------------------------------
1 | import { debounce } from '@/utils'
2 |
3 | export default {
4 | data() {
5 | return {
6 | $_sidebarElm: null,
7 | $_resizeHandler: null
8 | }
9 | },
10 | mounted() {
11 | this.$_resizeHandler = debounce(() => {
12 | if (this.chart) {
13 | this.chart.resize()
14 | }
15 | }, 100)
16 | this.$_initResizeEvent()
17 | this.$_initSidebarResizeEvent()
18 | },
19 | beforeDestroy() {
20 | this.$_destroyResizeEvent()
21 | this.$_destroySidebarResizeEvent()
22 | },
23 | // to fixed bug when cached by keep-alive
24 | // https://github.com/PanJiaChen/vue-element-admin/issues/2116
25 | activated() {
26 | this.$_initResizeEvent()
27 | this.$_initSidebarResizeEvent()
28 | },
29 | deactivated() {
30 | this.$_destroyResizeEvent()
31 | this.$_destroySidebarResizeEvent()
32 | },
33 | methods: {
34 | // use $_ for mixins properties
35 | // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
36 | $_initResizeEvent() {
37 | window.addEventListener('resize', this.$_resizeHandler)
38 | },
39 | $_destroyResizeEvent() {
40 | window.removeEventListener('resize', this.$_resizeHandler)
41 | },
42 | $_sidebarResizeHandler(e) {
43 | if (e.propertyName === 'width') {
44 | this.$_resizeHandler()
45 | }
46 | },
47 | $_initSidebarResizeEvent() {
48 | this.$_sidebarElm = document.getElementsByClassName('sidebar-container')[0]
49 | this.$_sidebarElm && this.$_sidebarElm.addEventListener('transitionend', this.$_sidebarResizeHandler)
50 | },
51 | $_destroySidebarResizeEvent() {
52 | this.$_sidebarElm && this.$_sidebarElm.removeEventListener('transitionend', this.$_sidebarResizeHandler)
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/Tinymce/dynamicLoadScript.js:
--------------------------------------------------------------------------------
1 | let callbacks = []
2 |
3 | function loadedTinymce() {
4 | // to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2144
5 | // check is successfully downloaded script
6 | return window.tinymce
7 | }
8 |
9 | const dynamicLoadScript = (src, callback) => {
10 | const existingScript = document.getElementById(src)
11 | const cb = callback || function() {}
12 |
13 | if (!existingScript) {
14 | const script = document.createElement('script')
15 | script.src = src // src url for the third-party library being loaded.
16 | script.id = src
17 | document.body.appendChild(script)
18 | callbacks.push(cb)
19 | const onEnd = 'onload' in script ? stdOnEnd : ieOnEnd
20 | onEnd(script)
21 | }
22 |
23 | if (existingScript && cb) {
24 | if (loadedTinymce()) {
25 | cb(null, existingScript)
26 | } else {
27 | callbacks.push(cb)
28 | }
29 | }
30 |
31 | function stdOnEnd(script) {
32 | script.onload = function() {
33 | // this.onload = null here is necessary
34 | // because even IE9 works not like others
35 | this.onerror = this.onload = null
36 | for (const cb of callbacks) {
37 | cb(null, script)
38 | }
39 | callbacks = null
40 | }
41 | script.onerror = function() {
42 | this.onerror = this.onload = null
43 | cb(new Error('Failed to load ' + src), script)
44 | }
45 | }
46 |
47 | function ieOnEnd(script) {
48 | script.onreadystatechange = function() {
49 | if (this.readyState !== 'complete' && this.readyState !== 'loaded') return
50 | this.onreadystatechange = null
51 | for (const cb of callbacks) {
52 | cb(null, script) // there is no way to catch loading errors in IE8
53 | }
54 | callbacks = null
55 | }
56 | }
57 | }
58 |
59 | export default dynamicLoadScript
60 |
--------------------------------------------------------------------------------
/src/filters/index.js:
--------------------------------------------------------------------------------
1 | // import parseTime, formatTime and set to filter
2 | export { parseTime, formatTime } from '@/utils'
3 |
4 | /**
5 | * Show plural label if time is plural number
6 | * @param {number} time
7 | * @param {string} label
8 | * @return {string}
9 | */
10 | function pluralize(time, label) {
11 | if (time === 1) {
12 | return time + label
13 | }
14 | return time + label + 's'
15 | }
16 |
17 | /**
18 | * @param {number} time
19 | */
20 | export function timeAgo(time) {
21 | const between = Date.now() / 1000 - Number(time)
22 | if (between < 3600) {
23 | return pluralize(~~(between / 60), ' minute')
24 | } else if (between < 86400) {
25 | return pluralize(~~(between / 3600), ' hour')
26 | } else {
27 | return pluralize(~~(between / 86400), ' day')
28 | }
29 | }
30 |
31 | /**
32 | * Number formatting
33 | * like 10000 => 10k
34 | * @param {number} num
35 | * @param {number} digits
36 | */
37 | export function numberFormatter(num, digits) {
38 | const si = [
39 | { value: 1E18, symbol: 'E' },
40 | { value: 1E15, symbol: 'P' },
41 | { value: 1E12, symbol: 'T' },
42 | { value: 1E9, symbol: 'G' },
43 | { value: 1E6, symbol: 'M' },
44 | { value: 1E3, symbol: 'k' }
45 | ]
46 | for (let i = 0; i < si.length; i++) {
47 | if (num >= si[i].value) {
48 | return (num / si[i].value).toFixed(digits).replace(/\.0+$|(\.[0-9]*[1-9])0+$/, '$1') + si[i].symbol
49 | }
50 | }
51 | return num.toString()
52 | }
53 |
54 | /**
55 | * 10000 => "10,000"
56 | * @param {number} num
57 | */
58 | export function toThousandFilter(num) {
59 | return (+num || 0).toString().replace(/^-?\d+/g, m => m.replace(/(?=(?!\b)(\d{3})+$)/g, ','))
60 | }
61 |
62 | /**
63 | * Upper case first char
64 | * @param {String} string
65 | */
66 | export function uppercaseFirst(string) {
67 | return string.charAt(0).toUpperCase() + string.slice(1)
68 | }
69 |
--------------------------------------------------------------------------------
/src/styles/btn.scss:
--------------------------------------------------------------------------------
1 | @import './variables.scss';
2 |
3 | @mixin colorBtn($color) {
4 | background: $color;
5 |
6 | &:hover {
7 | color: $color;
8 |
9 | &:before,
10 | &:after {
11 | background: $color;
12 | }
13 | }
14 | }
15 |
16 | .blue-btn {
17 | @include colorBtn($blue)
18 | }
19 |
20 | .light-blue-btn {
21 | @include colorBtn($light-blue)
22 | }
23 |
24 | .red-btn {
25 | @include colorBtn($red)
26 | }
27 |
28 | .pink-btn {
29 | @include colorBtn($pink)
30 | }
31 |
32 | .green-btn {
33 | @include colorBtn($green)
34 | }
35 |
36 | .tiffany-btn {
37 | @include colorBtn($tiffany)
38 | }
39 |
40 | .yellow-btn {
41 | @include colorBtn($yellow)
42 | }
43 |
44 | .pan-btn {
45 | font-size: 14px;
46 | color: #fff;
47 | padding: 14px 36px;
48 | border-radius: 8px;
49 | border: none;
50 | outline: none;
51 | transition: 600ms ease all;
52 | position: relative;
53 | display: inline-block;
54 |
55 | &:hover {
56 | background: #fff;
57 |
58 | &:before,
59 | &:after {
60 | width: 100%;
61 | transition: 600ms ease all;
62 | }
63 | }
64 |
65 | &:before,
66 | &:after {
67 | content: '';
68 | position: absolute;
69 | top: 0;
70 | right: 0;
71 | height: 2px;
72 | width: 0;
73 | transition: 400ms ease all;
74 | }
75 |
76 | &::after {
77 | right: inherit;
78 | top: inherit;
79 | left: 0;
80 | bottom: 0;
81 | }
82 | }
83 |
84 | .custom-button {
85 | display: inline-block;
86 | line-height: 1;
87 | white-space: nowrap;
88 | cursor: pointer;
89 | background: #fff;
90 | color: #fff;
91 | -webkit-appearance: none;
92 | text-align: center;
93 | box-sizing: border-box;
94 | outline: 0;
95 | margin: 0;
96 | padding: 10px 15px;
97 | font-size: 14px;
98 | border-radius: 4px;
99 | }
100 |
--------------------------------------------------------------------------------
/src/utils/scroll-to.js:
--------------------------------------------------------------------------------
1 | Math.easeInOutQuad = function(t, b, c, d) {
2 | t /= d / 2
3 | if (t < 1) {
4 | return c / 2 * t * t + b
5 | }
6 | t--
7 | return -c / 2 * (t * (t - 2) - 1) + b
8 | }
9 |
10 | // requestAnimationFrame for Smart Animating http://goo.gl/sx5sts
11 | var requestAnimFrame = (function() {
12 | return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }
13 | })()
14 |
15 | /**
16 | * Because it's so fucking difficult to detect the scrolling element, just move them all
17 | * @param {number} amount
18 | */
19 | function move(amount) {
20 | document.documentElement.scrollTop = amount
21 | document.body.parentNode.scrollTop = amount
22 | document.body.scrollTop = amount
23 | }
24 |
25 | function position() {
26 | return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop
27 | }
28 |
29 | /**
30 | * @param {number} to
31 | * @param {number} duration
32 | * @param {Function} callback
33 | */
34 | export function scrollTo(to, duration, callback) {
35 | const start = position()
36 | const change = to - start
37 | const increment = 20
38 | let currentTime = 0
39 | duration = (typeof (duration) === 'undefined') ? 500 : duration
40 | var animateScroll = function() {
41 | // increment the time
42 | currentTime += increment
43 | // find the value with the quadratic in-out easing function
44 | var val = Math.easeInOutQuad(currentTime, start, change, duration)
45 | // move the document.body
46 | move(val)
47 | // do the animation unless its over
48 | if (currentTime < duration) {
49 | requestAnimFrame(animateScroll)
50 | } else {
51 | if (callback && typeof (callback) === 'function') {
52 | // the animation is done so lets callback
53 | callback()
54 | }
55 | }
56 | }
57 | animateScroll()
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/GithubCorner/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
17 |
22 |
23 |
24 |
25 |
26 |
55 |
--------------------------------------------------------------------------------
/src/views/message/system/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 我的消息
5 |
6 |
7 |
8 |
9 | {{ UtilsDateFormat.fromParse(scope.row.dateTime).toDateTimeString() }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
70 |
71 |
74 |
--------------------------------------------------------------------------------
/src/components/IconSelect/index.vue:
--------------------------------------------------------------------------------
1 |
2 | 这是一个vue项目的一部分,我希望当span中的item内容过长时显示为...,以下是代码
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {{ item }}
12 |
13 |
14 |
15 |
16 |
17 |
45 |
46 |
73 |
--------------------------------------------------------------------------------
/src/layout/components/Sidebar/Logo.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
33 |
34 |
83 |
--------------------------------------------------------------------------------
/src/icons/svg/qq.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/icons/svg/network-backup-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/icons/svg/arrow-circle-down-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | headscale-panel-ui
2 |
3 | This project is developed based on the go-web-mini project, using a management system scaffold built with Go + Vue. It follows a front-end and back-end separation approach, only including the necessary parts for project development. It incorporates role-based access control (RBAC), is well-structured with reasonable packages, and is concise and easy to expand. The backend in Go includes the use of gin, gorm, jwt, and casbin, while the frontend in Vue is based on vue-element-admin
4 |
5 | About Me
6 | -------------------------
7 | This is my first official open source project, and I have little experience with it,
8 | so if you have good advice or techniques, I look forward to talking to you, and you can start by submitting an issue.
9 | If there is an architecture that doesn't fit or changes are significant, I will refactor in due course.
10 |
11 | ## Installation method
12 | 1. Download this project
13 | 2. Run `npm install`
14 | 3. Edit the `.env.production` with your backend host and port
15 | 4. Build `npm run build:prod`
16 |
17 | > Note: If you encounter the following problem, please execute the command `export NODE_OPTIONS=--openssl-legacy-provider` before executing step 4.
18 | 
19 |
20 | ## Deployment
21 | Copy files in `dist` to your web server
22 |
23 | Front-end supports separate front- and back-end deployments
24 | If you need to deploy the front-end and back-end on the same machine you can use nginx as the web server, see [here](./docs/nginx.md) for configuration
25 |
26 | Project Screenshots
27 | -------------------
28 |
29 | 
30 | 
31 | 
32 | 
33 | 
34 | 
35 | 
36 |
37 | Backend Project
38 | --------------------
39 | [https://github.com/QianheYu/headscale-panel.git](https://github.com/QianheYu/headscale-panel.git)
40 |
41 | ## MIT License
42 |
43 | Copyright (c) 2023 QianheYu
44 |
45 |
--------------------------------------------------------------------------------
/src/components/YamlEditor/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
65 |
66 |
87 |
--------------------------------------------------------------------------------
/src/utils/validate.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by PanJiaChen on 16/11/18.
3 | */
4 |
5 | /**
6 | * @param {string} path
7 | * @returns {Boolean}
8 | */
9 | export function isExternal(path) {
10 | return /^(https?:|mailto:|tel:)/.test(path)
11 | }
12 |
13 | /**
14 | * @param {string} str
15 | * @returns {Boolean}
16 | */
17 | export function validUsername(str) {
18 | return str.length >= 2
19 | }
20 |
21 | /**
22 | * @param {string} url
23 | * @returns {Boolean}
24 | */
25 | export function validURL(url) {
26 | const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
27 | return reg.test(url)
28 | }
29 |
30 | /**
31 | * @param {string} str
32 | * @returns {Boolean}
33 | */
34 | export function validLowerCase(str) {
35 | const reg = /^[a-z]+$/
36 | return reg.test(str)
37 | }
38 |
39 | /**
40 | * @param {string} str
41 | * @returns {Boolean}
42 | */
43 | export function validUpperCase(str) {
44 | const reg = /^[A-Z]+$/
45 | return reg.test(str)
46 | }
47 |
48 | /**
49 | * @param {string} str
50 | * @returns {Boolean}
51 | */
52 | export function validAlphabets(str) {
53 | const reg = /^[A-Za-z]+$/
54 | return reg.test(str)
55 | }
56 |
57 | /**
58 | * @param {string} email
59 | * @returns {Boolean}
60 | */
61 | export function validEmail(email) {
62 | const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
63 | return reg.test(email)
64 | }
65 |
66 | /**
67 | * @param {string} str
68 | * @returns {Boolean}
69 | */
70 | export function isString(str) {
71 | if (typeof str === 'string' || str instanceof String) {
72 | return true
73 | }
74 | return false
75 | }
76 |
77 | /**
78 | * @param {Array} arg
79 | * @returns {Boolean}
80 | */
81 | export function isArray(arg) {
82 | if (typeof Array.isArray === 'undefined') {
83 | return Object.prototype.toString.call(arg) === '[object Array]'
84 | }
85 | return Array.isArray(arg)
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/Echarts/PieChart.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
85 |
--------------------------------------------------------------------------------
/src/components/JsonEditor/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
62 |
63 |
84 |
--------------------------------------------------------------------------------
/src/components/Sticky/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
92 |
--------------------------------------------------------------------------------
/src/views/console/setting/components/Card.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ label }}
5 |
6 |
7 |
8 | Hidden Expired Keys
9 |
10 |
11 | Hidden Used Keys
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | 操作
28 |
29 |
30 |
31 | 编辑
32 | 删除
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
49 |
50 |
53 |
--------------------------------------------------------------------------------
/src/components/Pagination/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
92 |
93 |
102 |
--------------------------------------------------------------------------------
/src/components/ErrorLog/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Error Log
12 | Clear All
13 |
14 |
15 |
16 |
17 |
18 | Msg:
19 |
20 | {{ row.err.message }}
21 |
22 |
23 |
24 |
25 | Info:
26 |
27 | {{ row.vm.$vnode.tag }} error in {{ row.info }}
28 |
29 |
30 |
31 |
32 | Url:
33 |
34 | {{ row.url }}
35 |
36 |
37 |
38 |
39 |
40 |
41 | {{ scope.row.err.stack }}
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
70 |
71 |
79 |
--------------------------------------------------------------------------------
/src/directive/waves/waves.js:
--------------------------------------------------------------------------------
1 | import './waves.css'
2 |
3 | const context = '@@wavesContext'
4 |
5 | function handleClick(el, binding) {
6 | function handle(e) {
7 | const customOpts = Object.assign({}, binding.value)
8 | const opts = Object.assign({
9 | ele: el, // 波纹作用元素
10 | type: 'hit', // hit 点击位置扩散 center中心点扩展
11 | color: 'rgba(0, 0, 0, 0.15)' // 波纹颜色
12 | },
13 | customOpts
14 | )
15 | const target = opts.ele
16 | if (target) {
17 | target.style.position = 'relative'
18 | target.style.overflow = 'hidden'
19 | const rect = target.getBoundingClientRect()
20 | let ripple = target.querySelector('.waves-ripple')
21 | if (!ripple) {
22 | ripple = document.createElement('span')
23 | ripple.className = 'waves-ripple'
24 | ripple.style.height = ripple.style.width = Math.max(rect.width, rect.height) + 'px'
25 | target.appendChild(ripple)
26 | } else {
27 | ripple.className = 'waves-ripple'
28 | }
29 | switch (opts.type) {
30 | case 'center':
31 | ripple.style.top = rect.height / 2 - ripple.offsetHeight / 2 + 'px'
32 | ripple.style.left = rect.width / 2 - ripple.offsetWidth / 2 + 'px'
33 | break
34 | default:
35 | ripple.style.top =
36 | (e.pageY - rect.top - ripple.offsetHeight / 2 - document.documentElement.scrollTop ||
37 | document.body.scrollTop) + 'px'
38 | ripple.style.left =
39 | (e.pageX - rect.left - ripple.offsetWidth / 2 - document.documentElement.scrollLeft ||
40 | document.body.scrollLeft) + 'px'
41 | }
42 | ripple.style.backgroundColor = opts.color
43 | ripple.className = 'waves-ripple z-active'
44 | return false
45 | }
46 | }
47 |
48 | if (!el[context]) {
49 | el[context] = {
50 | removeHandle: handle
51 | }
52 | } else {
53 | el[context].removeHandle = handle
54 | }
55 |
56 | return handle
57 | }
58 |
59 | export default {
60 | bind(el, binding) {
61 | el.addEventListener('click', handleClick(el, binding), false)
62 | },
63 | update(el, binding) {
64 | el.removeEventListener('click', el[context].removeHandle, false)
65 | el.addEventListener('click', handleClick(el, binding), false)
66 | },
67 | unbind(el) {
68 | el.removeEventListener('click', el[context].removeHandle, false)
69 | el[context] = null
70 | delete el[context]
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/views/console/setting/components/mixins/dataFormat.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-unused-vars
2 |
3 | import { today } from '@/utils/date'
4 |
5 | export class PreAuthKey {
6 | constructor(id = '', key = '', reusable = false, ephemeral = false, used = false, expire = '', create_at = '', acl_tags = []) {
7 | this.id = id
8 | this.key = key
9 | this.reusable = reusable
10 | this.ephemeral = ephemeral
11 | this.used = used
12 | this.expire = expire
13 | this.create_at = create_at
14 | this.acl_tags = acl_tags
15 | }
16 | id = '';
17 | key = '';
18 | reusable = true;
19 | ephemeral = true;
20 | used = true;
21 | expire = '';
22 | create_at = '';
23 | acl_tags = [];
24 | toJson() {
25 | return {
26 | id: this.id,
27 | key: this.key,
28 | reusable: this.reusable,
29 | ephemeral: this.ephemeral,
30 | used: this.used,
31 | expire: this.expire,
32 | create_at: this.create_at,
33 | acl_tags: this.acl_tags
34 | }
35 | }
36 | static fromJson(json = {}) {
37 | return new PreAuthKey(
38 | json.id,
39 | json.key,
40 | json.reusable,
41 | json.ephemeral,
42 | json.used,
43 | json.expire,
44 | json.create_at,
45 | json.acl_tags
46 | )
47 | }
48 | }
49 | export class NewPreAuthKey {
50 | reusable = false;
51 | ephemeral = false;
52 | expire = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59).toISOString();
53 | acl_tags = [];
54 | // expire = '';
55 | toJson() {
56 | return {
57 | expire: this.expire,
58 | reusable: this.reusable,
59 | ephemeral: this.ephemeral,
60 | acl_tags: this.acl_tags
61 | }
62 | }
63 | rule() {
64 | var validate = (rule, value, callback) => {
65 | // value format example "2023-04-07T16:00:00.000Z"
66 | console.debug('validate date')
67 | if (value.length <= 0) {
68 | return callback(new Error('日期不可为空'))
69 | }
70 | const reg = /^[0-9]{4}-[0|1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9].[0-9]{3}Z$/
71 | if (!reg.test(value)) {
72 | return callback(new Error('日期格式错误'))
73 | }
74 | const date = Date.parse(value)
75 | if (date < Date.now()) {
76 | return callback(new Error('日期不可在当前日期之前'))
77 | }
78 | return callback
79 | }
80 | return {
81 | expire: [
82 | { require: true, validate: validate, trigger: 'blur' }
83 | ]
84 | }
85 | }
86 | }
87 |
88 |
--------------------------------------------------------------------------------
/src/components/Breadcrumb/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ item.meta.title }}
6 | {{ item.meta.title }}
7 |
8 |
9 |
10 |
11 |
12 |
69 |
70 |
83 |
--------------------------------------------------------------------------------
/src/views/error-page/401.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ $t('page401.back') }}
5 |
6 |
7 |
8 |
9 | Oops!
10 |
11 | {{ $t('page401.noPermission') }}
12 | {{ $t('page401.contactLeader') }}
13 |
14 | {{ $t('page401.orYouCanGoTo') }}
15 |
16 |
17 | {{ $t('page401.homePage') }}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
55 |
56 |
95 |
--------------------------------------------------------------------------------
/public/webmini.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/svg/menu1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Share/DropdownMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
39 |
40 |
104 |
--------------------------------------------------------------------------------
/src/directive/el-drag-dialog/drag.js:
--------------------------------------------------------------------------------
1 | export default {
2 | bind(el, binding, vnode) {
3 | const dialogHeaderEl = el.querySelector('.el-dialog__header')
4 | const dragDom = el.querySelector('.el-dialog')
5 | dialogHeaderEl.style.cssText += ';cursor:move;'
6 | dragDom.style.cssText += ';top:0px;'
7 |
8 | // 获取原有属性 ie dom元素.currentStyle 火狐谷歌 window.getComputedStyle(dom元素, null);
9 | const getStyle = (function() {
10 | if (window.document.currentStyle) {
11 | return (dom, attr) => dom.currentStyle[attr]
12 | } else {
13 | return (dom, attr) => getComputedStyle(dom, false)[attr]
14 | }
15 | })()
16 |
17 | dialogHeaderEl.onmousedown = (e) => {
18 | // 鼠标按下,计算当前元素距离可视区的距离
19 | const disX = e.clientX - dialogHeaderEl.offsetLeft
20 | const disY = e.clientY - dialogHeaderEl.offsetTop
21 |
22 | const dragDomWidth = dragDom.offsetWidth
23 | const dragDomHeight = dragDom.offsetHeight
24 |
25 | const screenWidth = document.body.clientWidth
26 | const screenHeight = document.body.clientHeight
27 |
28 | const minDragDomLeft = dragDom.offsetLeft
29 | const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth
30 |
31 | const minDragDomTop = dragDom.offsetTop
32 | const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomHeight
33 |
34 | // 获取到的值带px 正则匹配替换
35 | let styL = getStyle(dragDom, 'left')
36 | let styT = getStyle(dragDom, 'top')
37 |
38 | if (styL.includes('%')) {
39 | styL = +document.body.clientWidth * (+styL.replace(/\%/g, '') / 100)
40 | styT = +document.body.clientHeight * (+styT.replace(/\%/g, '') / 100)
41 | } else {
42 | styL = +styL.replace(/\px/g, '')
43 | styT = +styT.replace(/\px/g, '')
44 | }
45 |
46 | document.onmousemove = function(e) {
47 | // 通过事件委托,计算移动的距离
48 | let left = e.clientX - disX
49 | let top = e.clientY - disY
50 |
51 | // 边界处理
52 | if (-(left) > minDragDomLeft) {
53 | left = -minDragDomLeft
54 | } else if (left > maxDragDomLeft) {
55 | left = maxDragDomLeft
56 | }
57 |
58 | if (-(top) > minDragDomTop) {
59 | top = -minDragDomTop
60 | } else if (top > maxDragDomTop) {
61 | top = maxDragDomTop
62 | }
63 |
64 | // 移动当前元素
65 | dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`
66 |
67 | // emit onDrag event
68 | vnode.child.$emit('dragDialog')
69 | }
70 |
71 | document.onmouseup = function(e) {
72 | document.onmousemove = null
73 | document.onmouseup = null
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/views/console/setting/components/CardTab.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Hidden Expired Keys
8 |
9 |
10 | Hidden Used Keys
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | 操作
31 |
32 | 编辑
33 | 删除
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
52 |
53 |
56 |
--------------------------------------------------------------------------------
/src/layout/components/Settings/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Page style setting
5 |
6 |
7 | Theme Color
8 |
9 |
10 |
11 |
12 | Open Tags-View
13 |
14 |
15 |
16 |
17 | Fixed Header
18 |
19 |
20 |
21 |
22 | Sidebar Logo
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
83 |
84 |
109 |
--------------------------------------------------------------------------------
/src/views/console/acl/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ this.$t('console.acl.title') }}
5 |
6 |
7 |
8 |
9 |
10 | {{ this.$t('console.acl.save') }}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
83 |
84 |
87 |
--------------------------------------------------------------------------------
/src/directive/sticky.js:
--------------------------------------------------------------------------------
1 | const vueSticky = {}
2 | let listenAction
3 | vueSticky.install = Vue => {
4 | Vue.directive('sticky', {
5 | inserted(el, binding) {
6 | const params = binding.value || {}
7 | const stickyTop = params.stickyTop || 0
8 | const zIndex = params.zIndex || 1000
9 | const elStyle = el.style
10 |
11 | elStyle.position = '-webkit-sticky'
12 | elStyle.position = 'sticky'
13 | // if the browser support css sticky(Currently Safari, Firefox and Chrome Canary)
14 | // if (~elStyle.position.indexOf('sticky')) {
15 | // elStyle.top = `${stickyTop}px`;
16 | // elStyle.zIndex = zIndex;
17 | // return
18 | // }
19 | const elHeight = el.getBoundingClientRect().height
20 | const elWidth = el.getBoundingClientRect().width
21 | elStyle.cssText = `top: ${stickyTop}px; z-index: ${zIndex}`
22 |
23 | const parentElm = el.parentNode || document.documentElement
24 | const placeholder = document.createElement('div')
25 | placeholder.style.display = 'none'
26 | placeholder.style.width = `${elWidth}px`
27 | placeholder.style.height = `${elHeight}px`
28 | parentElm.insertBefore(placeholder, el)
29 |
30 | let active = false
31 |
32 | const getScroll = (target, top) => {
33 | const prop = top ? 'pageYOffset' : 'pageXOffset'
34 | const method = top ? 'scrollTop' : 'scrollLeft'
35 | let ret = target[prop]
36 | if (typeof ret !== 'number') {
37 | ret = window.document.documentElement[method]
38 | }
39 | return ret
40 | }
41 |
42 | const sticky = () => {
43 | if (active) {
44 | return
45 | }
46 | if (!elStyle.height) {
47 | elStyle.height = `${el.offsetHeight}px`
48 | }
49 |
50 | elStyle.position = 'fixed'
51 | elStyle.width = `${elWidth}px`
52 | placeholder.style.display = 'inline-block'
53 | active = true
54 | }
55 |
56 | const reset = () => {
57 | if (!active) {
58 | return
59 | }
60 |
61 | elStyle.position = ''
62 | placeholder.style.display = 'none'
63 | active = false
64 | }
65 |
66 | const check = () => {
67 | const scrollTop = getScroll(window, true)
68 | const offsetTop = el.getBoundingClientRect().top
69 | if (offsetTop < stickyTop) {
70 | sticky()
71 | } else {
72 | if (scrollTop < elHeight + stickyTop) {
73 | reset()
74 | }
75 | }
76 | }
77 | listenAction = () => {
78 | check()
79 | }
80 |
81 | window.addEventListener('scroll', listenAction)
82 | },
83 |
84 | unbind() {
85 | window.removeEventListener('scroll', listenAction)
86 | }
87 | })
88 | }
89 |
90 | export default vueSticky
91 |
92 |
--------------------------------------------------------------------------------
/src/components/Echarts/BarChart.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
107 |
--------------------------------------------------------------------------------