diff --git a/web/frps/package.json b/web/frps/package.json
index cb78bbdf..f7d41e83 100644
--- a/web/frps/package.json
+++ b/web/frps/package.json
@@ -13,6 +13,7 @@
"echarts": "^4.9.0",
"element-ui": "^2.14.1",
"humanize-plus": "^1.8.2",
+ "js-cookie": "^2.2.1",
"vue": "^2.6.12",
"vue-router": "^3.4.9",
"vuex": "^3.5.1",
@@ -36,6 +37,7 @@
"less-loader": "^7.1.0",
"node-sass": "^5.0.0",
"sass-loader": "^10.1.0",
+ "svg-sprite-loader": "^5.0.0",
"vue-template-compiler": "^2.6.12"
},
"browserslist": [
diff --git a/web/frps/src/App.vue b/web/frps/src/App.vue
index 3ebd41f9..d3e2cd89 100644
--- a/web/frps/src/App.vue
+++ b/web/frps/src/App.vue
@@ -1,87 +1,5 @@
-
-
-
-
-
- Overview
-
- Proxies
- TCP
- UDP
- HTTP
- HTTPS
- STCP
-
- Help
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
diff --git a/web/frps/src/components/AdminLayout/components/AppMain.vue b/web/frps/src/components/AdminLayout/components/AppMain.vue
new file mode 100644
index 00000000..f6a3286f
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/AppMain.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/components/Navbar.vue b/web/frps/src/components/AdminLayout/components/Navbar.vue
new file mode 100644
index 00000000..8c86649a
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Navbar.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/components/Sidebar/FixiOSBug.js b/web/frps/src/components/AdminLayout/components/Sidebar/FixiOSBug.js
new file mode 100644
index 00000000..bc14856f
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Sidebar/FixiOSBug.js
@@ -0,0 +1,26 @@
+export default {
+ computed: {
+ device() {
+ return this.$store.state.app.device
+ }
+ },
+ mounted() {
+ // In order to fix the click on menu on the ios device will trigger the mouseleave bug
+ // https://github.com/PanJiaChen/vue-element-admin/issues/1135
+ this.fixBugIniOS()
+ },
+ methods: {
+ fixBugIniOS() {
+ const $subMenu = this.$refs.subMenu
+ if ($subMenu) {
+ const handleMouseleave = $subMenu.handleMouseleave
+ $subMenu.handleMouseleave = (e) => {
+ if (this.device === 'mobile') {
+ return
+ }
+ handleMouseleave(e)
+ }
+ }
+ }
+ }
+}
diff --git a/web/frps/src/components/AdminLayout/components/Sidebar/Item.vue b/web/frps/src/components/AdminLayout/components/Sidebar/Item.vue
new file mode 100644
index 00000000..b515f615
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Sidebar/Item.vue
@@ -0,0 +1,29 @@
+
diff --git a/web/frps/src/components/AdminLayout/components/Sidebar/Link.vue b/web/frps/src/components/AdminLayout/components/Sidebar/Link.vue
new file mode 100644
index 00000000..eb4dd107
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Sidebar/Link.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/components/Sidebar/Logo.vue b/web/frps/src/components/AdminLayout/components/Sidebar/Logo.vue
new file mode 100644
index 00000000..92bd8dc0
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Sidebar/Logo.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/components/Sidebar/SidebarItem.vue b/web/frps/src/components/AdminLayout/components/Sidebar/SidebarItem.vue
new file mode 100644
index 00000000..a418c3d7
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Sidebar/SidebarItem.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/components/Sidebar/index.vue b/web/frps/src/components/AdminLayout/components/Sidebar/index.vue
new file mode 100644
index 00000000..c46ec89f
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/Sidebar/index.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/components/index.js b/web/frps/src/components/AdminLayout/components/index.js
new file mode 100644
index 00000000..97ee3cd1
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/components/index.js
@@ -0,0 +1,3 @@
+export { default as Navbar } from './Navbar'
+export { default as Sidebar } from './Sidebar'
+export { default as AppMain } from './AppMain'
diff --git a/web/frps/src/components/AdminLayout/index.vue b/web/frps/src/components/AdminLayout/index.vue
new file mode 100644
index 00000000..acd641b7
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/index.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/AdminLayout/mixin/ResizeHandler.js b/web/frps/src/components/AdminLayout/mixin/ResizeHandler.js
new file mode 100644
index 00000000..e8d0df8c
--- /dev/null
+++ b/web/frps/src/components/AdminLayout/mixin/ResizeHandler.js
@@ -0,0 +1,45 @@
+import store from '@/store'
+
+const { body } = document
+const WIDTH = 992 // refer to Bootstrap's responsive design
+
+export default {
+ watch: {
+ $route(route) {
+ if (this.device === 'mobile' && this.sidebar.opened) {
+ store.dispatch('app/closeSideBar', { withoutAnimation: false })
+ }
+ }
+ },
+ beforeMount() {
+ window.addEventListener('resize', this.$_resizeHandler)
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.$_resizeHandler)
+ },
+ mounted() {
+ const isMobile = this.$_isMobile()
+ if (isMobile) {
+ store.dispatch('app/toggleDevice', 'mobile')
+ store.dispatch('app/closeSideBar', { withoutAnimation: true })
+ }
+ },
+ methods: {
+ // use $_ for mixins properties
+ // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
+ $_isMobile() {
+ const rect = body.getBoundingClientRect()
+ return rect.width - 1 < WIDTH
+ },
+ $_resizeHandler() {
+ if (!document.hidden) {
+ const isMobile = this.$_isMobile()
+ store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
+
+ if (isMobile) {
+ store.dispatch('app/closeSideBar', { withoutAnimation: true })
+ }
+ }
+ }
+ }
+}
diff --git a/web/frps/src/components/Breadcrumb/index.vue b/web/frps/src/components/Breadcrumb/index.vue
new file mode 100644
index 00000000..50d2d3b0
--- /dev/null
+++ b/web/frps/src/components/Breadcrumb/index.vue
@@ -0,0 +1,65 @@
+
+
+
+
+ {{ item.meta.title }}
+ {{ item.meta.title }}
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/Hamburger/index.vue b/web/frps/src/components/Hamburger/index.vue
new file mode 100644
index 00000000..56a6ad11
--- /dev/null
+++ b/web/frps/src/components/Hamburger/index.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
diff --git a/web/frps/src/components/SvgIcon/index.vue b/web/frps/src/components/SvgIcon/index.vue
new file mode 100644
index 00000000..41583bbb
--- /dev/null
+++ b/web/frps/src/components/SvgIcon/index.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
diff --git a/web/frps/src/icons/index.js b/web/frps/src/icons/index.js
new file mode 100644
index 00000000..c3859d53
--- /dev/null
+++ b/web/frps/src/icons/index.js
@@ -0,0 +1,9 @@
+import Vue from 'vue'
+import SvgIcon from '@/components/SvgIcon' // svg component
+
+// register globally
+Vue.component('SvgIcon', SvgIcon)
+
+const req = require.context('./svg', false, /\.svg$/)
+const requireAll = requireContext => requireContext.keys().map(requireContext)
+requireAll(req)
diff --git a/web/frps/src/icons/svg/dashboard.svg b/web/frps/src/icons/svg/dashboard.svg
new file mode 100644
index 00000000..5317d370
--- /dev/null
+++ b/web/frps/src/icons/svg/dashboard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/frps/src/icons/svg/help.svg b/web/frps/src/icons/svg/help.svg
new file mode 100644
index 00000000..52545879
--- /dev/null
+++ b/web/frps/src/icons/svg/help.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/frps/src/icons/svg/proxy.svg b/web/frps/src/icons/svg/proxy.svg
new file mode 100644
index 00000000..67129b02
--- /dev/null
+++ b/web/frps/src/icons/svg/proxy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/frps/src/icons/svgo.yml b/web/frps/src/icons/svgo.yml
new file mode 100644
index 00000000..d11906ae
--- /dev/null
+++ b/web/frps/src/icons/svgo.yml
@@ -0,0 +1,22 @@
+# replace default config
+
+# multipass: true
+# full: true
+
+plugins:
+
+ # - name
+ #
+ # or:
+ # - name: false
+ # - name: true
+ #
+ # or:
+ # - name:
+ # param1: 1
+ # param2: 2
+
+- removeAttrs:
+ attrs:
+ - 'fill'
+ - 'fill-rule'
diff --git a/web/frps/src/main.js b/web/frps/src/main.js
index 9fb7bd0a..010a6cd3 100644
--- a/web/frps/src/main.js
+++ b/web/frps/src/main.js
@@ -1,10 +1,11 @@
import Vue from 'vue'
-// import ElementUI from 'element-ui'
-import { Button, Form, FormItem, Row, Col, Table, TableColumn, Popover, Menu, Submenu, MenuItem, Tag, Message } from 'element-ui'
+import ElementUI from 'element-ui'
import lang from 'element-ui/lib/locale/lang/en'
import locale from 'element-ui/lib/locale'
import 'element-ui/lib/theme-chalk/index.css'
import './utils/less/custom.less'
+import '@/icons'
+import '@/styles/index.scss'
import App from './App.vue'
import router from './router'
@@ -13,19 +14,7 @@ import 'whatwg-fetch'
locale.use(lang)
-Vue.use(Button)
-Vue.use(Form)
-Vue.use(FormItem)
-Vue.use(Row)
-Vue.use(Col)
-Vue.use(Table)
-Vue.use(TableColumn)
-Vue.use(Popover)
-Vue.use(Menu)
-Vue.use(Submenu)
-Vue.use(MenuItem)
-Vue.use(Tag)
-Vue.prototype.$message = Message
+Vue.use(ElementUI)
import fetch from '@/utils/fetch'
Vue.prototype.$fetch = fetch
diff --git a/web/frps/src/router/index.js b/web/frps/src/router/index.js
index ab421a1f..2ea91b00 100644
--- a/web/frps/src/router/index.js
+++ b/web/frps/src/router/index.js
@@ -1,45 +1,91 @@
import Vue from 'vue'
import Router from 'vue-router'
-import Overview from '../components/Overview.vue'
-import ProxiesTcp from '../components/ProxiesTcp.vue'
-import ProxiesUdp from '../components/ProxiesUdp.vue'
-import ProxiesHttp from '../components/ProxiesHttp.vue'
-import ProxiesHttps from '../components/ProxiesHttps.vue'
-import ProxiesStcp from '../components/ProxiesStcp.vue'
+import AdminLayout from '@/components/AdminLayout'
Vue.use(Router)
-export default new Router({
- routes: [
- {
- path: '/',
- name: 'Overview',
- component: Overview
+export const routes = [
+ {
+ path: '/',
+ component: AdminLayout,
+ meta: {
+ icon: 'dashboard'
},
- {
- path: '/proxies/tcp',
- name: 'ProxiesTcp',
- component: ProxiesTcp
+ children: [
+ {
+ path: '/',
+ component: () => import('@/views/Overview'),
+ name: 'Overview',
+ meta: {
+ title: 'Overview'
+ }
+ }
+ ]
+ },
+ {
+ path: '/proxies',
+ component: AdminLayout,
+ meta: {
+ title: 'Proxies',
+ icon: 'proxy'
},
- {
- path: '/proxies/udp',
- name: 'ProxiesUdp',
- component: ProxiesUdp
- },
- {
- path: '/proxies/http',
- name: 'ProxiesHttp',
- component: ProxiesHttp
- },
- {
- path: '/proxies/https',
- name: 'ProxiesHttps',
- component: ProxiesHttps
- },
- {
- path: '/proxies/stcp',
- name: 'ProxiesStcp',
- component: ProxiesStcp
- }
- ]
-})
+ children: [
+ {
+ path: 'tcp',
+ component: () => import('@/views/ProxiesTcp'),
+ name: 'ProxiesTcp',
+ meta: {
+ title: 'TCP'
+ }
+ },
+ {
+ path: 'udp',
+ component: () => import('@/views/ProxiesUdp'),
+ name: 'ProxiesUdp',
+ meta: {
+ title: 'UDP'
+ }
+ },
+ {
+ path: 'http',
+ component: () => import('@/views/ProxiesHttp'),
+ name: 'ProxiesHttp',
+ meta: {
+ title: 'HTTP'
+ }
+ },
+ {
+ path: 'https',
+ component: () => import('@/views/ProxiesHttps'),
+ name: 'ProxiesHttps',
+ meta: {
+ title: 'HTTPS'
+ }
+ },
+ {
+ path: 'stcp',
+ component: () => import('@/views/ProxiesStcp'),
+ name: 'ProxiesStcp',
+ meta: {
+ title: 'STCP'
+ }
+ }
+ ]
+ },
+ {
+ path: 'help',
+ component: AdminLayout,
+ children: [
+ {
+ path: 'https://github.com/fatedier/frp',
+ component: () => import('@/views/Overview'),
+ meta: {
+ title: 'Help',
+ icon: 'help'
+ }
+ }
+ ]
+ }
+]
+
+export default new Router({ routes })
diff --git a/web/frps/src/store/index.js b/web/frps/src/store/index.js
index 437c206d..d3680779 100644
--- a/web/frps/src/store/index.js
+++ b/web/frps/src/store/index.js
@@ -1,24 +1,19 @@
import Vue from 'vue'
import Vuex from 'vuex'
-import fetch from '@/utils/fetch'
+
Vue.use(Vuex)
+const modulesFiles = require.context('./modules', true, /\.js$/)
+
+const modules = modulesFiles.keys().reduce((modules, modulePath) => {
+ const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
+ const value = modulesFiles(modulePath)
+ modules[moduleName] = value.default
+ return modules
+}, {})
+
const store = new Vuex.Store({
- state: {
- serverInfo: null
- },
- mutations: {
- SET_SERVER_INFO(state, serverInfo) {
- state.serverInfo = serverInfo
- }
- },
- actions: {
- async fetchServerInfo({ commit }) {
- const json = await fetch('serverinfo')
- commit('SET_SERVER_INFO', json || null)
- return json
- }
- }
+ modules
})
export default store
diff --git a/web/frps/src/store/modules/app.js b/web/frps/src/store/modules/app.js
new file mode 100644
index 00000000..7ea7e332
--- /dev/null
+++ b/web/frps/src/store/modules/app.js
@@ -0,0 +1,48 @@
+import Cookies from 'js-cookie'
+
+const state = {
+ sidebar: {
+ opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
+ withoutAnimation: false
+ },
+ device: 'desktop'
+}
+
+const mutations = {
+ TOGGLE_SIDEBAR: state => {
+ state.sidebar.opened = !state.sidebar.opened
+ state.sidebar.withoutAnimation = false
+ if (state.sidebar.opened) {
+ Cookies.set('sidebarStatus', 1)
+ } else {
+ Cookies.set('sidebarStatus', 0)
+ }
+ },
+ CLOSE_SIDEBAR: (state, withoutAnimation) => {
+ Cookies.set('sidebarStatus', 0)
+ state.sidebar.opened = false
+ state.sidebar.withoutAnimation = withoutAnimation
+ },
+ TOGGLE_DEVICE: (state, device) => {
+ state.device = device
+ }
+}
+
+const actions = {
+ toggleSideBar({ commit }) {
+ commit('TOGGLE_SIDEBAR')
+ },
+ closeSideBar({ commit }, { withoutAnimation }) {
+ commit('CLOSE_SIDEBAR', withoutAnimation)
+ },
+ toggleDevice({ commit }, device) {
+ commit('TOGGLE_DEVICE', device)
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/web/frps/src/store/modules/server.js b/web/frps/src/store/modules/server.js
new file mode 100644
index 00000000..6ef4ee7d
--- /dev/null
+++ b/web/frps/src/store/modules/server.js
@@ -0,0 +1,29 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import fetch from '@/utils/fetch'
+Vue.use(Vuex)
+
+const state = {
+ serverInfo: null
+}
+
+const mutations = {
+ SET_SERVER_INFO(state, serverInfo) {
+ state.serverInfo = serverInfo
+ }
+}
+
+const actions = {
+ async fetchServerInfo({ commit }) {
+ const json = await fetch('serverinfo')
+ commit('SET_SERVER_INFO', json || null)
+ return json
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/web/frps/src/styles/element-ui.scss b/web/frps/src/styles/element-ui.scss
new file mode 100644
index 00000000..00624119
--- /dev/null
+++ b/web/frps/src/styles/element-ui.scss
@@ -0,0 +1,49 @@
+// cover some element-ui styles
+
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+ font-weight: 400 !important;
+}
+
+.el-upload {
+ input[type="file"] {
+ display: none !important;
+ }
+}
+
+.el-upload__input {
+ display: none;
+}
+
+
+// to fixed https://github.com/ElemeFE/element/issues/2461
+.el-dialog {
+ transform: none;
+ left: 0;
+ position: relative;
+ margin: 0 auto;
+}
+
+// refine element ui upload
+.upload-container {
+ .el-upload {
+ width: 100%;
+
+ .el-upload-dragger {
+ width: 100%;
+ height: 200px;
+ }
+ }
+}
+
+// dropdown
+.el-dropdown-menu {
+ a {
+ display: block
+ }
+}
+
+// to fix el-date-picker css style
+.el-range-separator {
+ box-sizing: content-box;
+}
diff --git a/web/frps/src/styles/index.scss b/web/frps/src/styles/index.scss
new file mode 100644
index 00000000..3619ecf3
--- /dev/null
+++ b/web/frps/src/styles/index.scss
@@ -0,0 +1,68 @@
+@import "./variables.scss";
+@import "./mixin.scss";
+@import "./transition.scss";
+@import "./element-ui.scss";
+@import "./sidebar.scss";
+@import "./layout.scss";
+
+body {
+ height: 100%;
+ margin: 0;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB,
+ Microsoft YaHei, Arial, sans-serif;
+}
+
+label {
+ font-weight: 700;
+}
+
+html {
+ height: 100%;
+ box-sizing: border-box;
+}
+
+#app {
+ height: 100%;
+}
+
+*,
+*:before,
+*:after {
+ box-sizing: inherit;
+}
+
+a:focus,
+a:active {
+ outline: none;
+}
+
+a,
+a:focus,
+a:hover {
+ cursor: pointer;
+ color: inherit;
+ text-decoration: none;
+}
+
+div:focus {
+ outline: none;
+}
+
+.clearfix {
+ &:after {
+ visibility: hidden;
+ display: block;
+ font-size: 0;
+ content: " ";
+ clear: both;
+ height: 0;
+ }
+}
+
+// main-container global css
+.app-container {
+ padding: 20px;
+}
diff --git a/web/frps/src/styles/layout.scss b/web/frps/src/styles/layout.scss
new file mode 100644
index 00000000..9a2a6cc1
--- /dev/null
+++ b/web/frps/src/styles/layout.scss
@@ -0,0 +1,525 @@
+/*
+ * @Description: 布局(来源:ColorUI)
+ * @Author: wwh
+ * @Date: 2020-07-12 22:36:59
+ * @LastEditTime: 2020-07-12 23:04:49
+ * @LastEditors: wwh
+ * @FilePath: \web\src\styles\layout.scss
+ */
+
+/* -- flex弹性布局 -- */
+
+.flex {
+ display: flex;
+}
+
+.basis-xs {
+ flex-basis: 20%;
+}
+
+.basis-sm {
+ flex-basis: 40%;
+}
+
+.basis-df {
+ flex-basis: 50%;
+}
+
+.basis-lg {
+ flex-basis: 60%;
+}
+
+.basis-xl {
+ flex-basis: 80%;
+}
+
+.flex-sub {
+ flex: 1;
+}
+
+.flex-twice {
+ flex: 2;
+}
+
+.flex-treble {
+ flex: 3;
+}
+
+.flex-direction {
+ flex-direction: column;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.align-start {
+ align-items: flex-start;
+}
+
+.align-end {
+ align-items: flex-end;
+}
+
+.align-center {
+ align-items: center;
+}
+
+.align-stretch {
+ align-items: stretch;
+}
+
+.self-start {
+ align-self: flex-start;
+}
+
+.self-center {
+ align-self: flex-center;
+}
+
+.self-end {
+ align-self: flex-end;
+}
+
+.self-stretch {
+ align-self: stretch;
+}
+
+.align-stretch {
+ align-items: stretch;
+}
+
+.justify-start {
+ justify-content: flex-start;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.justify-around {
+ justify-content: space-around;
+}
+
+/* grid布局 */
+
+.grid {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.grid.grid-square {
+ overflow: hidden;
+}
+
+.grid.grid-square .cu-tag {
+ position: absolute;
+ right: 0;
+ top: 0;
+ border-bottom-left-radius: 6px;
+ padding: 6px 12px;
+ height: auto;
+ background-color: rgba(0, 0, 0, 0.5);
+}
+
+.grid.grid-square > view > text[class*="cuIcon-"] {
+ font-size: 52px;
+ position: absolute;
+ color: #8799a3;
+ margin: auto;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+}
+
+.grid.grid-square > view {
+ margin-right: 20px;
+ margin-bottom: 20px;
+ border-radius: 6px;
+ position: relative;
+ overflow: hidden;
+}
+.grid.grid-square > view.bg-img image {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+}
+.grid.col-1.grid-square > view {
+ padding-bottom: 100%;
+ height: 0;
+ margin-right: 0;
+}
+
+.grid.col-2.grid-square > view {
+ padding-bottom: calc((100% - 20px) / 2);
+ height: 0;
+ width: calc((100% - 20px) / 2);
+}
+
+.grid.col-3.grid-square > view {
+ padding-bottom: calc((100% - 40px) / 3);
+ height: 0;
+ width: calc((100% - 40px) / 3);
+}
+
+.grid.col-4.grid-square > view {
+ padding-bottom: calc((100% - 60px) / 4);
+ height: 0;
+ width: calc((100% - 60px) / 4);
+}
+
+.grid.col-5.grid-square > view {
+ padding-bottom: calc((100% - 80px) / 5);
+ height: 0;
+ width: calc((100% - 80px) / 5);
+}
+
+.grid.col-2.grid-square > view:nth-child(2n),
+.grid.col-3.grid-square > view:nth-child(3n),
+.grid.col-4.grid-square > view:nth-child(4n),
+.grid.col-5.grid-square > view:nth-child(5n) {
+ margin-right: 0;
+}
+
+.grid.col-1 > view {
+ width: 100%;
+}
+
+.grid.col-2 > view {
+ width: 50%;
+}
+
+.grid.col-3 > view {
+ width: 33.33%;
+}
+
+.grid.col-4 > view {
+ width: 25%;
+}
+
+.grid.col-5 > view {
+ width: 20%;
+}
+
+/* -- 内外边距 -- */
+
+.margin-0 {
+ margin: 0;
+}
+
+.margin-xs {
+ margin: 10px;
+}
+
+.margin-sm {
+ margin: 20px;
+}
+
+.margin {
+ margin: 30px;
+}
+
+.margin-lg {
+ margin: 40px;
+}
+
+.margin-xl {
+ margin: 50px;
+}
+
+.margin-top-xs {
+ margin-top: 10px;
+}
+
+.margin-top-sm {
+ margin-top: 20px;
+}
+
+.margin-top {
+ margin-top: 30px;
+}
+
+.margin-top-lg {
+ margin-top: 40px;
+}
+
+.margin-top-xl {
+ margin-top: 50px;
+}
+
+.margin-right-xs {
+ margin-right: 10px;
+}
+
+.margin-right-sm {
+ margin-right: 20px;
+}
+
+.margin-right {
+ margin-right: 30px;
+}
+
+.margin-right-lg {
+ margin-right: 40px;
+}
+
+.margin-right-xl {
+ margin-right: 50px;
+}
+
+.margin-bottom-xs {
+ margin-bottom: 10px;
+}
+
+.margin-bottom-sm {
+ margin-bottom: 20px;
+}
+
+.margin-bottom {
+ margin-bottom: 30px;
+}
+
+.margin-bottom-lg {
+ margin-bottom: 40px;
+}
+
+.margin-bottom-xl {
+ margin-bottom: 50px;
+}
+
+.margin-left-xs {
+ margin-left: 10px;
+}
+
+.margin-left-sm {
+ margin-left: 20px;
+}
+
+.margin-left {
+ margin-left: 30px;
+}
+
+.margin-left-lg {
+ margin-left: 40px;
+}
+
+.margin-left-xl {
+ margin-left: 50px;
+}
+
+.margin-lr-xs {
+ margin-left: 10px;
+ margin-right: 10px;
+}
+
+.margin-lr-sm {
+ margin-left: 20px;
+ margin-right: 20px;
+}
+
+.margin-lr {
+ margin-left: 30px;
+ margin-right: 30px;
+}
+
+.margin-lr-lg {
+ margin-left: 40px;
+ margin-right: 40px;
+}
+
+.margin-lr-xl {
+ margin-left: 50px;
+ margin-right: 50px;
+}
+
+.margin-tb-xs {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.margin-tb-sm {
+ margin-top: 20px;
+ margin-bottom: 20px;
+}
+
+.margin-tb {
+ margin-top: 30px;
+ margin-bottom: 30px;
+}
+
+.margin-tb-lg {
+ margin-top: 40px;
+ margin-bottom: 40px;
+}
+
+.margin-tb-xl {
+ margin-top: 50px;
+ margin-bottom: 50px;
+}
+
+.padding-0 {
+ padding: 0;
+}
+
+.padding-xs {
+ padding: 10px;
+}
+
+.padding-sm {
+ padding: 20px;
+}
+
+.padding {
+ padding: 30px;
+}
+
+.padding-lg {
+ padding: 40px;
+}
+
+.padding-xl {
+ padding: 50px;
+}
+
+.padding-top-xs {
+ padding-top: 10px;
+}
+
+.padding-top-sm {
+ padding-top: 20px;
+}
+
+.padding-top {
+ padding-top: 30px;
+}
+
+.padding-top-lg {
+ padding-top: 40px;
+}
+
+.padding-top-xl {
+ padding-top: 50px;
+}
+
+.padding-right-xs {
+ padding-right: 10px;
+}
+
+.padding-right-sm {
+ padding-right: 20px;
+}
+
+.padding-right {
+ padding-right: 30px;
+}
+
+.padding-right-lg {
+ padding-right: 40px;
+}
+
+.padding-right-xl {
+ padding-right: 50px;
+}
+
+.padding-bottom-xs {
+ padding-bottom: 10px;
+}
+
+.padding-bottom-sm {
+ padding-bottom: 20px;
+}
+
+.padding-bottom {
+ padding-bottom: 30px;
+}
+
+.padding-bottom-lg {
+ padding-bottom: 40px;
+}
+
+.padding-bottom-xl {
+ padding-bottom: 50px;
+}
+
+.padding-left-xs {
+ padding-left: 10px;
+}
+
+.padding-left-sm {
+ padding-left: 20px;
+}
+
+.padding-left {
+ padding-left: 30px;
+}
+
+.padding-left-lg {
+ padding-left: 40px;
+}
+
+.padding-left-xl {
+ padding-left: 50px;
+}
+
+.padding-lr-xs {
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+.padding-lr-sm {
+ padding-left: 20px;
+ padding-right: 20px;
+}
+
+.padding-lr {
+ padding-left: 30px;
+ padding-right: 30px;
+}
+
+.padding-lr-lg {
+ padding-left: 40px;
+ padding-right: 40px;
+}
+
+.padding-lr-xl {
+ padding-left: 50px;
+ padding-right: 50px;
+}
+
+.padding-tb-xs {
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+.padding-tb-sm {
+ padding-top: 20px;
+ padding-bottom: 20px;
+}
+
+.padding-tb {
+ padding-top: 30px;
+ padding-bottom: 30px;
+}
+
+.padding-tb-lg {
+ padding-top: 40px;
+ padding-bottom: 40px;
+}
+
+.padding-tb-xl {
+ padding-top: 50px;
+ padding-bottom: 50px;
+}
diff --git a/web/frps/src/styles/mixin.scss b/web/frps/src/styles/mixin.scss
new file mode 100644
index 00000000..36b74bbd
--- /dev/null
+++ b/web/frps/src/styles/mixin.scss
@@ -0,0 +1,28 @@
+@mixin clearfix {
+ &:after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+}
+
+@mixin scrollBar {
+ &::-webkit-scrollbar-track-piece {
+ background: #d3dce6;
+ }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #99a9bf;
+ border-radius: 20px;
+ }
+}
+
+@mixin relative {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
diff --git a/web/frps/src/styles/sidebar.scss b/web/frps/src/styles/sidebar.scss
new file mode 100644
index 00000000..f50daf7f
--- /dev/null
+++ b/web/frps/src/styles/sidebar.scss
@@ -0,0 +1,209 @@
+#app {
+
+ .main-container {
+ min-height: 100%;
+ transition: margin-left .28s;
+ margin-left: $sideBarWidth;
+ position: relative;
+ }
+
+ .sidebar-container {
+ transition: width 0.28s;
+ width: $sideBarWidth !important;
+ background-color: $menuBg;
+ height: 100%;
+ position: fixed;
+ font-size: 0px;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1001;
+ overflow: hidden;
+
+ // reset element-ui css
+ .horizontal-collapse-transition {
+ transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
+ }
+
+ .scrollbar-wrapper {
+ overflow-x: hidden !important;
+ }
+
+ .el-scrollbar__bar.is-vertical {
+ right: 0px;
+ }
+
+ .el-scrollbar {
+ height: 100%;
+ }
+
+ &.has-logo {
+ .el-scrollbar {
+ height: calc(100% - 50px);
+ }
+ }
+
+ .is-horizontal {
+ display: none;
+ }
+
+ a {
+ display: inline-block;
+ width: 100%;
+ overflow: hidden;
+ }
+
+ .svg-icon {
+ margin-right: 16px;
+ }
+
+ .el-menu {
+ border: none;
+ height: 100%;
+ width: 100% !important;
+ }
+
+ // menu hover
+ .submenu-title-noDropdown,
+ .el-submenu__title {
+ &:hover {
+ background-color: $menuHover !important;
+ }
+ }
+
+ .is-active>.el-submenu__title {
+ color: $subMenuActiveText !important;
+ }
+
+ & .nest-menu .el-submenu>.el-submenu__title,
+ & .el-submenu .el-menu-item {
+ min-width: $sideBarWidth !important;
+ background-color: $subMenuBg !important;
+
+ &:hover {
+ background-color: $subMenuHover !important;
+ }
+ }
+ }
+
+ .hideSidebar {
+ .sidebar-container {
+ width: 54px !important;
+ }
+
+ .main-container {
+ margin-left: 54px;
+ }
+
+ .submenu-title-noDropdown {
+ padding: 0 !important;
+ position: relative;
+
+ .el-tooltip {
+ padding: 0 !important;
+
+ .svg-icon {
+ margin-left: 20px;
+ }
+ }
+ }
+
+ .el-submenu {
+ overflow: hidden;
+
+ &>.el-submenu__title {
+ padding: 0 !important;
+
+ .svg-icon {
+ margin-left: 20px;
+ }
+
+ .el-submenu__icon-arrow {
+ display: none;
+ }
+ }
+ }
+
+ .el-menu--collapse {
+ .el-submenu {
+ &>.el-submenu__title {
+ &>span {
+ height: 0;
+ width: 0;
+ overflow: hidden;
+ visibility: hidden;
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+
+ .el-menu--collapse .el-menu .el-submenu {
+ min-width: $sideBarWidth !important;
+ }
+
+ // mobile responsive
+ .mobile {
+ .main-container {
+ margin-left: 0px;
+ }
+
+ .sidebar-container {
+ transition: transform .28s;
+ width: $sideBarWidth !important;
+ }
+
+ &.hideSidebar {
+ .sidebar-container {
+ pointer-events: none;
+ transition-duration: 0.3s;
+ transform: translate3d(-$sideBarWidth, 0, 0);
+ }
+ }
+ }
+
+ .withoutAnimation {
+
+ .main-container,
+ .sidebar-container {
+ transition: none;
+ }
+ }
+}
+
+// when menu collapsed
+.el-menu--vertical {
+ &>.el-menu {
+ .svg-icon {
+ margin-right: 12px;
+ }
+ }
+
+ .nest-menu .el-submenu>.el-submenu__title,
+ .el-menu-item {
+ &:hover {
+ // you can use $subMenuHover
+ background-color: $menuHover !important;
+ }
+ }
+
+ // the scroll bar appears when the subMenu is too long
+ >.el-menu--popup {
+ max-height: 100vh;
+ overflow-y: auto;
+
+ &::-webkit-scrollbar-track-piece {
+ background: #d3dce6;
+ }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #99a9bf;
+ border-radius: 20px;
+ }
+ }
+}
diff --git a/web/frps/src/styles/transition.scss b/web/frps/src/styles/transition.scss
new file mode 100644
index 00000000..4cb27cc8
--- /dev/null
+++ b/web/frps/src/styles/transition.scss
@@ -0,0 +1,48 @@
+// global transition css
+
+/* fade */
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.28s;
+}
+
+.fade-enter,
+.fade-leave-active {
+ opacity: 0;
+}
+
+/* fade-transform */
+.fade-transform-leave-active,
+.fade-transform-enter-active {
+ transition: all .5s;
+}
+
+.fade-transform-enter {
+ opacity: 0;
+ transform: translateX(-30px);
+}
+
+.fade-transform-leave-to {
+ opacity: 0;
+ transform: translateX(30px);
+}
+
+/* breadcrumb transition */
+.breadcrumb-enter-active,
+.breadcrumb-leave-active {
+ transition: all .5s;
+}
+
+.breadcrumb-enter,
+.breadcrumb-leave-active {
+ opacity: 0;
+ transform: translateX(20px);
+}
+
+.breadcrumb-move {
+ transition: all .5s;
+}
+
+.breadcrumb-leave-active {
+ position: absolute;
+}
diff --git a/web/frps/src/styles/variables.scss b/web/frps/src/styles/variables.scss
new file mode 100644
index 00000000..be557726
--- /dev/null
+++ b/web/frps/src/styles/variables.scss
@@ -0,0 +1,25 @@
+// sidebar
+$menuText:#bfcbd9;
+$menuActiveText:#409EFF;
+$subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951
+
+$menuBg:#304156;
+$menuHover:#263445;
+
+$subMenuBg:#1f2d3d;
+$subMenuHover:#001528;
+
+$sideBarWidth: 210px;
+
+// the :export directive is the magic sauce for webpack
+// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
+:export {
+ menuText: $menuText;
+ menuActiveText: $menuActiveText;
+ subMenuActiveText: $subMenuActiveText;
+ menuBg: $menuBg;
+ menuHover: $menuHover;
+ subMenuBg: $subMenuBg;
+ subMenuHover: $subMenuHover;
+ sideBarWidth: $sideBarWidth;
+}
diff --git a/web/frps/src/utils/validate.js b/web/frps/src/utils/validate.js
new file mode 100644
index 00000000..befc09ea
--- /dev/null
+++ b/web/frps/src/utils/validate.js
@@ -0,0 +1,7 @@
+/**
+ * @param {string} path
+ * @returns {Boolean}
+ */
+export function isExternal(path) {
+ return /^(https?:|mailto:|tel:)/.test(path)
+}
diff --git a/web/frps/src/components/Overview.vue b/web/frps/src/views/Overview.vue
similarity index 97%
rename from web/frps/src/components/Overview.vue
rename to web/frps/src/views/Overview.vue
index fc0cc732..c377601f 100644
--- a/web/frps/src/components/Overview.vue
+++ b/web/frps/src/views/Overview.vue
@@ -72,15 +72,15 @@ export default {
},
computed: {
serverInfo() {
- return this.$store.state.serverInfo
+ return this.$store.state.server.serverInfo
}
},
- mounted() {
+ async mounted() {
+ await this.$store.dispatch('server/fetchServerInfo')
this.initData()
},
methods: {
initData() {
- console.log(!!this.serverInfo, this.serverInfo)
if (!this.serverInfo) return
this.version = this.serverInfo.version
diff --git a/web/frps/src/components/ProxiesHttp.vue b/web/frps/src/views/ProxiesHttp.vue
similarity index 95%
rename from web/frps/src/components/ProxiesHttp.vue
rename to web/frps/src/views/ProxiesHttp.vue
index 1a9d44a3..d9a2f98b 100644
--- a/web/frps/src/components/ProxiesHttp.vue
+++ b/web/frps/src/views/ProxiesHttp.vue
@@ -60,7 +60,7 @@
diff --git a/web/frps/vue.config.js b/web/frps/vue.config.js
index e08236a3..c23de08c 100644
--- a/web/frps/vue.config.js
+++ b/web/frps/vue.config.js
@@ -1,3 +1,9 @@
+const path = require('path')
+
+function resolve(dir) {
+ return path.join(__dirname, dir)
+}
+
module.exports = {
publicPath: './',
devServer: {
@@ -12,5 +18,28 @@ module.exports = {
}
}
}
+ },
+ chainWebpack(config) {
+ config.plugins.delete('preload') // TODO: need test
+ config.plugins.delete('prefetch') // TODO: need test
+
+ // set svg-sprite-loader
+ config.module
+ .rule('svg')
+ .exclude.add(resolve('src/icons'))
+ .end()
+ config.module
+ .rule('icons')
+ .test(/\.svg$/)
+ .include.add(resolve('src/icons'))
+ .end()
+ .use('svg-sprite-loader')
+ .loader('svg-sprite-loader')
+ .options({
+ symbolId: 'icon-[name]'
+ })
+ .end()
+
+ config.when(process.env.NODE_ENV === 'development', config => config.devtool('eval-source-map'))
}
}