Просмотр исходного кода

web/frpc: refactor dashboard with improved structure and API layer (#5117)

fatedier 3 дней назад
Родитель
Сommit
479e9f50c2

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
assets/frpc/static/index-BAsh6RH1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 4
assets/frpc/static/index-HyKZ_pht.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
assets/frpc/static/index-JCcyRUo1.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
assets/frpc/static/index-iuf46MlF.css


+ 3 - 3
assets/frpc/static/index.html

@@ -3,9 +3,9 @@
 
 <head>
     <meta charset="utf-8">
-    <title>frp client admin UI</title>
-  <script type="module" crossorigin src="./index-HyKZ_pht.js"></script>
-  <link rel="stylesheet" crossorigin href="./index-iuf46MlF.css">
+    <title>frp client</title>
+  <script type="module" crossorigin src="./index-BAsh6RH1.js"></script>
+  <link rel="stylesheet" crossorigin href="./index-JCcyRUo1.css">
 </head>
 
 <body>

+ 1 - 0
client/admin_api.go

@@ -168,6 +168,7 @@ func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
 	log.Infof("http request [/api/status]")
 	defer func() {
 		log.Infof("http response [/api/status]")
+		w.Header().Set("Content-Type", "application/json")
 		buf, _ = json.Marshal(&res)
 		_, _ = w.Write(buf)
 	}()

+ 6 - 0
server/dashboard_api.go

@@ -123,6 +123,7 @@ func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
 	}()
 
 	log.Infof("http request: [%s]", r.URL.Path)
+	w.Header().Set("Content-Type", "application/json")
 	serverStats := mem.StatsCollector.GetServer()
 	svrResp := serverInfoResp{
 		Version:               version.Full(),
@@ -155,6 +156,7 @@ func (svr *Service) apiClientList(w http.ResponseWriter, r *http.Request) {
 	res := GeneralResponse{Code: 200}
 	defer func() {
 		log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
+		w.Header().Set("Content-Type", "application/json")
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
@@ -212,6 +214,7 @@ func (svr *Service) apiClientDetail(w http.ResponseWriter, r *http.Request) {
 	res := GeneralResponse{Code: 200}
 	defer func() {
 		log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
+		w.Header().Set("Content-Type", "application/json")
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
@@ -332,6 +335,7 @@ func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
 
 	defer func() {
 		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
+		w.Header().Set("Content-Type", "application/json")
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
@@ -404,6 +408,7 @@ func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request
 
 	defer func() {
 		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
+		w.Header().Set("Content-Type", "application/json")
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))
@@ -472,6 +477,7 @@ func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
 
 	defer func() {
 		log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
+		w.Header().Set("Content-Type", "application/json")
 		w.WriteHeader(res.Code)
 		if len(res.Msg) > 0 {
 			_, _ = w.Write([]byte(res.Msg))

+ 6 - 4
web/frpc/components.d.ts

@@ -7,18 +7,20 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
-    ClientConfigure: typeof import('./src/components/ClientConfigure.vue')['default']
     ElButton: typeof import('element-plus/es')['ElButton']
-    ElCol: typeof import('element-plus/es')['ElCol']
+    ElCard: typeof import('element-plus/es')['ElCard']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
-    ElRow: typeof import('element-plus/es')['ElRow']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
-    Overview: typeof import('./src/components/Overview.vue')['default']
+    ElTag: typeof import('element-plus/es')['ElTag']
+    ElTooltip: typeof import('element-plus/es')['ElTooltip']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
   }
+  export interface ComponentCustomProperties {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
 }

+ 1 - 1
web/frpc/index.html

@@ -3,7 +3,7 @@
 
 <head>
     <meta charset="utf-8">
-    <title>frp client admin UI</title>
+    <title>frp client</title>
 </head>
 
 <body>

+ 18 - 13
web/frpc/package.json

@@ -11,25 +11,30 @@
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
   },
   "dependencies": {
-    "element-plus": "^2.5.3",
-    "vue": "^3.4.15",
-    "vue-router": "^4.2.5"
+    "element-plus": "^2.13.0",
+    "vue": "^3.5.26",
+    "vue-router": "^4.6.4"
   },
   "devDependencies": {
-    "@rushstack/eslint-patch": "^1.7.2",
-    "@types/node": "^18.11.12",
-    "@vitejs/plugin-vue": "^5.0.3",
+    "@rushstack/eslint-patch": "^1.15.0",
+    "@types/node": "24",
+    "@vitejs/plugin-vue": "^6.0.3",
     "@vue/eslint-config-prettier": "^9.0.0",
     "@vue/eslint-config-typescript": "^12.0.0",
-    "@vue/tsconfig": "^0.5.1",
+    "@vue/tsconfig": "^0.8.1",
+    "@vueuse/core": "^14.1.0",
     "eslint": "^8.56.0",
-    "eslint-plugin-vue": "^9.21.0",
+    "eslint-plugin-vue": "^9.33.0",
     "npm-run-all": "^4.1.5",
-    "prettier": "^3.2.4",
-    "typescript": "~5.3.3",
+    "prettier": "^3.7.4",
+    "sass": "^1.97.2",
+    "terser": "^5.44.1",
+    "typescript": "^5.9.3",
     "unplugin-auto-import": "^0.17.5",
+    "unplugin-element-plus": "^0.11.2",
     "unplugin-vue-components": "^0.26.0",
-    "vite": "^5.0.12",
-    "vue-tsc": "^1.8.27"
+    "vite": "^7.3.0",
+    "vite-svg-loader": "^5.1.0",
+    "vue-tsc": "^3.2.2"
   }
-}
+}

+ 247 - 68
web/frpc/src/App.vue

@@ -1,116 +1,295 @@
 <template>
   <div id="app">
-    <header class="grid-content header-color">
-      <div class="header-content">
+    <header class="header">
+      <div class="header-top">
         <div class="brand">
-          <a href="#">frp client</a>
+          <a href="#" @click.prevent="router.push('/')">frpc</a>
         </div>
-        <div class="dark-switch">
+        <div class="header-actions">
+          <a
+            class="github-link"
+            href="https://github.com/fatedier/frp"
+            target="_blank"
+            aria-label="GitHub"
+          >
+            <GitHubIcon class="github-icon" />
+          </a>
           <el-switch
             v-model="darkmodeSwitch"
             inline-prompt
-            active-text="Dark"
-            inactive-text="Light"
+            :active-icon="Moon"
+            :inactive-icon="Sunny"
             @change="toggleDark"
-            style="
-              --el-switch-on-color: #444452;
-              --el-switch-off-color: #589ef8;
-            "
+            class="theme-switch"
           />
         </div>
       </div>
+      <nav class="header-nav">
+        <el-menu
+          :default-active="currentRoute"
+          mode="horizontal"
+          :ellipsis="false"
+          @select="handleSelect"
+          class="nav-menu"
+        >
+          <el-menu-item index="/">Overview</el-menu-item>
+          <el-menu-item index="/configure">Configure</el-menu-item>
+        </el-menu>
+      </nav>
     </header>
-    <section>
-      <el-row>
-        <el-col id="side-nav" :xs="24" :md="4">
-          <el-menu
-            default-active="1"
-            mode="vertical"
-            theme="light"
-            router="false"
-            @select="handleSelect"
-          >
-            <el-menu-item index="/">Overview</el-menu-item>
-            <el-menu-item index="/configure">Configure</el-menu-item>
-            <el-menu-item index="">Help</el-menu-item>
-          </el-menu>
-        </el-col>
-
-        <el-col :xs="24" :md="20">
-          <div id="content">
-            <router-view></router-view>
-          </div>
-        </el-col>
-      </el-row>
-    </section>
-    <footer></footer>
+    <main id="content">
+      <router-view></router-view>
+    </main>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
 import { useDark, useToggle } from '@vueuse/core'
+import { Moon, Sunny } from '@element-plus/icons-vue'
+import GitHubIcon from './assets/icons/github.svg?component'
 
+const router = useRouter()
+const route = useRoute()
 const isDark = useDark()
 const darkmodeSwitch = ref(isDark)
 const toggleDark = useToggle(isDark)
 
+const currentRoute = computed(() => {
+  return route.path
+})
+
 const handleSelect = (key: string) => {
-  if (key == '') {
-    window.open('https://github.com/fatedier/frp')
-  }
+  router.push(key)
 }
 </script>
 
 <style>
 body {
-  margin: 0px;
-  font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, sans-serif;
+  margin: 0;
+  font-family:
+    -apple-system,
+    BlinkMacSystemFont,
+    Helvetica Neue,
+    sans-serif;
 }
 
-header {
-  width: 100%;
-  height: 60px;
+#app {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background: #f2f2f2;
+}
+
+html.dark #app {
+  background: #1a1a2e;
 }
 
-.header-color {
-  background: #58b7ff;
+.header {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background: #fff;
 }
 
-html.dark .header-color {
-  background: #395c74;
+html.dark .header {
+  background: #1e1e2d;
 }
 
-.header-content {
+.header-top {
   display: flex;
   align-items: center;
+  justify-content: space-between;
+  height: 48px;
+  padding: 0 32px;
 }
 
-#content {
-  margin-top: 20px;
-  padding-right: 40px;
+.brand a {
+  color: #303133;
+  font-size: 20px;
+  font-weight: 700;
+  text-decoration: none;
+  letter-spacing: -0.5px;
 }
 
-.brand {
-  display: flex;
-  justify-content: flex-start;
+html.dark .brand a {
+  color: #e5e7eb;
 }
 
-.brand a {
-  color: #fff;
-  background-color: transparent;
-  margin-left: 20px;
-  line-height: 25px;
-  font-size: 25px;
-  padding: 15px 15px;
-  height: 30px;
-  text-decoration: none;
+.brand a:hover {
+  color: #409eff;
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 16px;
 }
 
-.dark-switch {
+.github-link {
   display: flex;
-  justify-content: flex-end;
-  flex-grow: 1;
-  padding-right: 40px;
+  align-items: center;
+  padding: 6px;
+  border-radius: 6px;
+  transition: all 0.2s;
+}
+
+.github-link:hover {
+  background: #f2f3f5;
+}
+
+html.dark .github-link:hover {
+  background: #2a2a3c;
+}
+
+.github-icon {
+  width: 20px;
+  height: 20px;
+  color: #606266;
+  transition: color 0.2s;
+}
+
+.github-link:hover .github-icon {
+  color: #303133;
+}
+
+html.dark .github-icon {
+  color: #a0a3ad;
+}
+
+html.dark .github-link:hover .github-icon {
+  color: #e5e7eb;
+}
+
+.theme-switch {
+  --el-switch-on-color: #2c2c3a;
+  --el-switch-off-color: #f2f2f2;
+  --el-switch-border-color: #dcdfe6;
+}
+
+.theme-switch .el-switch__core .el-switch__inner .el-icon {
+  color: #909399 !important;
+}
+
+.header-nav {
+  position: relative;
+  padding: 0 32px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+html.dark .header-nav {
+  border-bottom-color: #3a3d5c;
+}
+
+.nav-menu {
+  background: transparent !important;
+  border-bottom: none !important;
+  height: 46px;
+}
+
+.nav-menu .el-menu-item,
+.nav-menu .el-sub-menu__title {
+  position: relative;
+  height: 32px !important;
+  line-height: 32px !important;
+  border-bottom: none !important;
+  border-radius: 6px !important;
+  color: #666 !important;
+  font-weight: 400;
+  font-size: 14px;
+  padding: 0 12px !important;
+  margin: 7px 0;
+  transition:
+    background 0.15s ease,
+    color 0.15s ease;
+}
+
+.nav-menu > .el-menu-item,
+.nav-menu > .el-sub-menu {
+  margin-right: 4px;
+}
+
+.nav-menu > .el-sub-menu {
+  padding: 0 !important;
+}
+
+html.dark .nav-menu .el-menu-item,
+html.dark .nav-menu .el-sub-menu__title {
+  color: #888 !important;
+}
+
+.nav-menu .el-menu-item:hover,
+.nav-menu .el-sub-menu__title:hover {
+  background: #f2f2f2 !important;
+  color: #171717 !important;
+}
+
+html.dark .nav-menu .el-menu-item:hover,
+html.dark .nav-menu .el-sub-menu__title:hover {
+  background: #2a2a3c !important;
+  color: #e5e7eb !important;
+}
+
+.nav-menu .el-menu-item.is-active {
+  background: transparent !important;
+  color: #171717 !important;
+  font-weight: 500;
+}
+
+.nav-menu .el-menu-item.is-active::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: -3px;
+  height: 2px;
+  background: #171717;
+  border-radius: 1px;
+}
+
+.nav-menu .el-menu-item.is-active:hover {
+  background: #f2f2f2 !important;
+}
+
+html.dark .nav-menu .el-menu-item.is-active {
+  background: transparent !important;
+  color: #e5e7eb !important;
+  font-weight: 500;
+}
+
+html.dark .nav-menu .el-menu-item.is-active::after {
+  background: #e5e7eb;
+}
+
+html.dark .nav-menu .el-menu-item.is-active:hover {
+  background: #2a2a3c !important;
+}
+
+#content {
+  flex: 1;
+  padding: 24px 40px;
+  max-width: 1400px;
+  margin: 0 auto;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+@media (max-width: 768px) {
+  .header-top {
+    padding: 0 16px;
+  }
+
+  .header-nav {
+    padding: 0 16px;
+  }
+
+  #content {
+    padding: 16px;
+  }
+
+  .brand a {
+    font-size: 18px;
+  }
 }
-</style>
+</style>

+ 18 - 0
web/frpc/src/api/frpc.ts

@@ -0,0 +1,18 @@
+import { http } from './http'
+import type { StatusResponse } from '../types/proxy'
+
+export const getStatus = () => {
+  return http.get<StatusResponse>('/api/status')
+}
+
+export const getConfig = () => {
+  return http.get<string>('/api/config')
+}
+
+export const putConfig = (content: string) => {
+  return http.put<void>('/api/config', content)
+}
+
+export const reloadConfig = () => {
+  return http.get<void>('/api/reload')
+}

+ 76 - 0
web/frpc/src/api/http.ts

@@ -0,0 +1,76 @@
+// http.ts - Base HTTP client
+
+class HTTPError extends Error {
+  status: number
+  statusText: string
+
+  constructor(status: number, statusText: string, message?: string) {
+    super(message || statusText)
+    this.status = status
+    this.statusText = statusText
+  }
+}
+
+async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
+  const defaultOptions: RequestInit = {
+    credentials: 'include',
+  }
+
+  const response = await fetch(url, { ...defaultOptions, ...options })
+
+  if (!response.ok) {
+    throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
+  }
+
+  // Handle empty response (e.g. 204 No Content)
+  if (response.status === 204) {
+    return {} as T
+  }
+
+  const contentType = response.headers.get('content-type')
+  if (contentType && contentType.includes('application/json')) {
+    return response.json()
+  }
+  return response.text() as unknown as T
+}
+
+export const http = {
+  get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
+  post: <T>(url: string, body?: any, options?: RequestInit) => {
+    const headers: HeadersInit = { ...options?.headers }
+    let requestBody = body
+
+    if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
+        if (!('Content-Type' in headers)) {
+             (headers as any)['Content-Type'] = 'application/json'
+        }
+        requestBody = JSON.stringify(body)
+    }
+
+    return request<T>(url, { 
+      ...options, 
+      method: 'POST', 
+      headers,
+      body: requestBody
+    })
+  },
+  put: <T>(url: string, body?: any, options?: RequestInit) => {
+    const headers: HeadersInit = { ...options?.headers }
+    let requestBody = body
+
+    if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
+        if (!('Content-Type' in headers)) {
+             (headers as any)['Content-Type'] = 'application/json'
+        }
+        requestBody = JSON.stringify(body)
+    }
+
+    return request<T>(url, { 
+      ...options, 
+      method: 'PUT', 
+      headers,
+      body: requestBody
+    })
+  },
+  delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
+}

+ 89 - 0
web/frpc/src/assets/css/custom.css

@@ -0,0 +1,89 @@
+.el-form-item span {
+  margin-left: 15px;
+}
+
+.proxy-table-expand {
+  font-size: 0;
+}
+
+.proxy-table-expand .el-form-item__label{
+  width: 90px;
+  color: #99a9bf;
+}
+
+.proxy-table-expand .el-form-item {
+  margin-right: 0;
+  margin-bottom: 0;
+  width: 50%;
+}
+
+.el-table .el-table__expanded-cell {
+  padding: 20px 50px;
+}
+
+/* Modern styles */
+* {
+  box-sizing: border-box;
+}
+
+/* Smooth transitions */
+.el-button,
+.el-card,
+.el-input,
+.el-select,
+.el-tag {
+  transition: all 0.3s ease;
+}
+
+/* Card hover effects */
+.el-card:hover {
+  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+}
+
+/* Better scrollbar */
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+::-webkit-scrollbar-track {
+  background: #f1f1f1;
+  border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+  background: #c1c1c1;
+  border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: #a8a8a8;
+}
+
+/* Page headers */
+.el-page-header {
+  padding: 16px 0;
+}
+
+.el-page-header__title {
+  font-size: 20px;
+  font-weight: 600;
+}
+
+/* Better form layouts */
+.el-form-item {
+  margin-bottom: 18px;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+  .el-row {
+    margin-left: 0 !important;
+    margin-right: 0 !important;
+  }
+
+  .el-col {
+    padding-left: 10px !important;
+    padding-right: 10px !important;
+  }
+}

+ 58 - 0
web/frpc/src/assets/css/dark.css

@@ -0,0 +1,58 @@
+html.dark {
+  --el-bg-color: #1e1e2e;
+  --el-fill-color-blank: #1e1e2e;
+  background-color: #1e1e2e;
+}
+
+html.dark body {
+  background-color: #1e1e2e;
+  color: #e5e7eb;
+}
+
+/* Dark mode scrollbar */
+html.dark ::-webkit-scrollbar-track {
+  background: #27293d;
+}
+
+html.dark ::-webkit-scrollbar-thumb {
+  background: #3a3d5c;
+}
+
+html.dark ::-webkit-scrollbar-thumb:hover {
+  background: #4a4d6c;
+}
+
+/* Dark mode cards */
+html.dark .el-card {
+  background-color: #27293d;
+  border-color: #3a3d5c;
+}
+
+/* Dark mode inputs */
+html.dark .el-input__wrapper {
+  background-color: #27293d;
+  border-color: #3a3d5c;
+}
+
+html.dark .el-input__inner {
+  color: #e5e7eb;
+}
+
+/* Dark mode table */
+html.dark .el-table {
+  background-color: #27293d;
+  color: #e5e7eb;
+}
+
+html.dark .el-table th {
+  background-color: #1e1e2e;
+  color: #e5e7eb;
+}
+
+html.dark .el-table tr {
+  background-color: #27293d;
+}
+
+html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
+  background-color: #1e1e2e;
+}

+ 0 - 5
web/frpc/src/assets/dark.css

@@ -1,5 +0,0 @@
-html.dark {
-  --el-bg-color: #343432;
-  --el-fill-color-blank: #343432;
-  background-color: #343432;
-}

+ 3 - 0
web/frpc/src/assets/icons/github.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
+  <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
+</svg>

+ 0 - 102
web/frpc/src/components/ClientConfigure.vue

@@ -1,102 +0,0 @@
-<template>
-  <div>
-    <el-row id="head">
-      <el-button type="primary" @click="fetchData">Refresh</el-button>
-      <el-button type="primary" @click="uploadConfig">Upload</el-button>
-    </el-row>
-    <el-input
-      type="textarea"
-      autosize
-      v-model="textarea"
-      placeholder="frpc configure file, can not be empty..."
-    ></el-input>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-
-let textarea = ref('')
-
-const fetchData = () => {
-  fetch('/api/config', { credentials: 'include' })
-    .then((res) => {
-      return res.text()
-    })
-    .then((text) => {
-      textarea.value = text
-    })
-    .catch(() => {
-      ElMessage({
-        showClose: true,
-        message: 'Get configure content from frpc failed!',
-        type: 'warning',
-      })
-    })
-}
-
-const uploadConfig = () => {
-  ElMessageBox.confirm(
-    'This operation will upload your frpc configure file content and hot reload it, do you want to continue?',
-    'Notice',
-    {
-      confirmButtonText: 'Yes',
-      cancelButtonText: 'No',
-      type: 'warning',
-    }
-  )
-    .then(() => {
-      if (textarea.value == '') {
-        ElMessage({
-          message: 'Configure content can not be empty!',
-          type: 'warning',
-        })
-        return
-      }
-
-      fetch('/api/config', {
-        credentials: 'include',
-        method: 'PUT',
-        body: textarea.value,
-      })
-        .then(() => {
-          fetch('/api/reload', { credentials: 'include' })
-            .then(() => {
-              ElMessage({
-                type: 'success',
-                message: 'Success',
-              })
-            })
-            .catch((err) => {
-              ElMessage({
-                showClose: true,
-                message: 'Reload frpc configure file error, ' + err,
-                type: 'warning',
-              })
-            })
-        })
-        .catch(() => {
-          ElMessage({
-            showClose: true,
-            message: 'Put config to frpc and hot reload failed!',
-            type: 'warning',
-          })
-        })
-    })
-    .catch(() => {
-      ElMessage({
-        message: 'Canceled',
-        type: 'info',
-      })
-    })
-}
-
-fetchData()
-</script>
-
-<style>
-#head {
-  margin-bottom: 30px;
-}
-</style>

+ 0 - 85
web/frpc/src/components/Overview.vue

@@ -1,85 +0,0 @@
-<template>
-  <div>
-    <el-row>
-      <el-col :md="24">
-        <div>
-          <el-table
-            :data="status"
-            stripe
-            style="width: 100%"
-            :default-sort="{ prop: 'type', order: 'ascending' }"
-          >
-            <el-table-column
-              prop="name"
-              label="name"
-              sortable
-            ></el-table-column>
-            <el-table-column
-              prop="type"
-              label="type"
-              width="150"
-              sortable
-            ></el-table-column>
-            <el-table-column
-              prop="local_addr"
-              label="local address"
-              width="200"
-              sortable
-            ></el-table-column>
-            <el-table-column
-              prop="plugin"
-              label="plugin"
-              width="200"
-              sortable
-            ></el-table-column>
-            <el-table-column
-              prop="remote_addr"
-              label="remote address"
-              sortable
-            ></el-table-column>
-            <el-table-column
-              prop="status"
-              label="status"
-              width="150"
-              sortable
-            ></el-table-column>
-            <el-table-column prop="err" label="info"></el-table-column>
-          </el-table>
-        </div>
-      </el-col>
-    </el-row>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { ref } from 'vue'
-import { ElMessage } from 'element-plus'
-
-let status = ref<any[]>([])
-
-const fetchData = () => {
-  fetch('/api/status', { credentials: 'include' })
-    .then((res) => {
-      return res.json()
-    })
-    .then((json) => {
-      status.value = new Array()
-      for (let key in json) {
-        for (let ps of json[key]) {
-          console.log(ps)
-          status.value.push(ps)
-        }
-      }
-    })
-    .catch((err) => {
-      ElMessage({
-        showClose: true,
-        message: 'Get status info from frpc failed!' + err,
-        type: 'warning',
-      })
-    })
-}
-fetchData()
-</script>
-
-<style></style>

+ 3 - 3
web/frpc/src/main.ts

@@ -1,13 +1,13 @@
 import { createApp } from 'vue'
-import 'element-plus/dist/index.css'
 import 'element-plus/theme-chalk/dark/css-vars.css'
 import App from './App.vue'
 import router from './router'
 
-import './assets/dark.css'
+import './assets/css/custom.css'
+import './assets/css/dark.css'
 
 const app = createApp(App)
 
 app.use(router)
 
-app.mount('#app')
+app.mount('#app')

+ 3 - 3
web/frpc/src/router/index.ts

@@ -1,6 +1,6 @@
 import { createRouter, createWebHashHistory } from 'vue-router'
-import Overview from '../components/Overview.vue'
-import ClientConfigure from '../components/ClientConfigure.vue'
+import Overview from '../views/Overview.vue'
+import ClientConfigure from '../views/ClientConfigure.vue'
 
 const router = createRouter({
   history: createWebHashHistory(),
@@ -18,4 +18,4 @@ const router = createRouter({
   ],
 })
 
-export default router
+export default router

+ 5 - 0
web/frpc/src/svg.d.ts

@@ -0,0 +1,5 @@
+declare module '*.svg?component' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<object, object, unknown>
+  export default component
+}

+ 12 - 0
web/frpc/src/types/proxy.ts

@@ -0,0 +1,12 @@
+export interface ProxyStatus {
+    name: string
+    type: string
+    status: string
+    err: string
+    local_addr: string
+    plugin: string
+    remote_addr: string
+    [key: string]: any
+}
+
+export type StatusResponse = Record<string, ProxyStatus[]>

+ 33 - 0
web/frpc/src/utils/format.ts

@@ -0,0 +1,33 @@
+export function formatDistanceToNow(date: Date): string {
+  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
+
+  let interval = seconds / 31536000
+  if (interval > 1) return Math.floor(interval) + ' years ago'
+
+  interval = seconds / 2592000
+  if (interval > 1) return Math.floor(interval) + ' months ago'
+
+  interval = seconds / 86400
+  if (interval > 1) return Math.floor(interval) + ' days ago'
+
+  interval = seconds / 3600
+  if (interval > 1) return Math.floor(interval) + ' hours ago'
+
+  interval = seconds / 60
+  if (interval > 1) return Math.floor(interval) + ' minutes ago'
+
+  return Math.floor(seconds) + ' seconds ago'
+}
+
+export function formatFileSize(bytes: number): string {
+  if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
+  if (bytes === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  // Prevent index out of bounds for extremely large numbers
+  const unit = sizes[i] || sizes[sizes.length - 1]
+  const val = bytes / Math.pow(k, i)
+  
+  return parseFloat(val.toFixed(2)) + ' ' + unit
+}

+ 115 - 0
web/frpc/src/views/ClientConfigure.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="configure-page">
+    <el-card class="main-card" shadow="never">
+      <div class="toolbar-header">
+        <h2 class="card-title">Client Configuration</h2>
+        <div class="toolbar-actions">
+          <el-tooltip content="Refresh" placement="top">
+            <el-button :icon="Refresh" circle @click="fetchData" />
+          </el-tooltip>
+          <el-button type="primary" :icon="Upload" @click="handleUpload">Update</el-button>
+        </div>
+      </div>
+
+      <div class="config-editor">
+        <el-input
+          type="textarea"
+          :autosize="{ minRows: 10, maxRows: 30 }"
+          v-model="configContent"
+          placeholder="frpc configuration file content..."
+          class="code-input"
+        ></el-input>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Refresh, Upload } from '@element-plus/icons-vue'
+import { getConfig, putConfig, reloadConfig } from '../api/frpc'
+
+const configContent = ref('')
+
+const fetchData = async () => {
+  try {
+    const text = await getConfig()
+    configContent.value = text
+  } catch (err: any) {
+    ElMessage({
+      showClose: true,
+      message: 'Get configuration failed: ' + err.message,
+      type: 'warning',
+    })
+  }
+}
+
+const handleUpload = () => {
+  ElMessageBox.confirm(
+    'This operation will update your frpc configuration and reload it. Do you want to continue?',
+    'Confirm Update',
+    {
+      confirmButtonText: 'Update',
+      cancelButtonText: 'Cancel',
+      type: 'warning',
+    }
+  )
+    .then(async () => {
+      if (!configContent.value.trim()) {
+        ElMessage({
+          message: 'Configuration content cannot be empty!',
+          type: 'warning',
+        })
+        return
+      }
+
+      try {
+        await putConfig(configContent.value)
+        await reloadConfig()
+        ElMessage({
+          type: 'success',
+          message: 'Configuration updated and reloaded successfully',
+        })
+      } catch (err: any) {
+        ElMessage({
+          showClose: true,
+          message: 'Update failed: ' + err.message,
+          type: 'error',
+        })
+      }
+    })
+    .catch(() => {
+        // cancelled
+    })
+}
+
+fetchData()
+</script>
+
+<style scoped>
+.main-card {
+  border-radius: 12px;
+  border: none;
+}
+
+.toolbar-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  padding-bottom: 16px;
+}
+
+.card-title {
+    margin: 0;
+    font-size: 18px;
+    font-weight: 600;
+}
+
+.code-input {
+    font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+    font-size: 14px;
+}
+</style>

+ 215 - 0
web/frpc/src/views/Overview.vue

@@ -0,0 +1,215 @@
+<template>
+  <div class="overview-page">
+    <el-card class="main-card" shadow="never">
+      <div class="toolbar-header">
+        <h2 class="card-title">Proxy Status</h2>
+        <div class="toolbar-actions">
+          <el-input
+            v-model="searchText"
+            placeholder="Search..."
+            :prefix-icon="Search"
+            clearable
+            class="search-input"
+          />
+          <el-tooltip content="Refresh" placement="top">
+            <el-button :icon="Refresh" circle @click="fetchData" />
+          </el-tooltip>
+        </div>
+      </div>
+
+      <el-table
+        v-loading="loading"
+        :data="filteredStatus"
+        :default-sort="{ prop: 'name', order: 'ascending' }"
+        stripe
+        style="width: 100%"
+        class="proxy-table"
+      >
+        <el-table-column
+          prop="name"
+          label="Name"
+          sortable
+          min-width="120"
+        ></el-table-column>
+        <el-table-column
+          prop="type"
+          label="Type"
+          width="100"
+          sortable
+        >
+          <template #default="scope">
+            <span class="type-text">{{ scope.row.type }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="local_addr"
+          label="Local Address"
+          min-width="150"
+          sortable
+          show-overflow-tooltip
+        ></el-table-column>
+        <el-table-column
+          prop="plugin"
+          label="Plugin"
+          width="120"
+          sortable
+          show-overflow-tooltip
+        ></el-table-column>
+        <el-table-column
+          prop="remote_addr"
+          label="Remote Address"
+          min-width="150"
+          sortable
+          show-overflow-tooltip
+        ></el-table-column>
+        <el-table-column
+          prop="status"
+          label="Status"
+          width="120"
+          sortable
+          align="center"
+        >
+          <template #default="scope">
+            <el-tag
+              :type="getStatusColor(scope.row.status)"
+              effect="light"
+              round
+            >
+              {{ scope.row.status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="err" label="Info" min-width="150" show-overflow-tooltip>
+             <template #default="scope">
+                <span v-if="scope.row.err" class="error-text">{{ scope.row.err }}</span>
+                <span v-else>-</span>
+             </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Search, Refresh } from '@element-plus/icons-vue'
+import { getStatus } from '../api/frpc'
+import type { ProxyStatus } from '../types/proxy'
+
+const status = ref<ProxyStatus[]>([])
+const loading = ref(false)
+const searchText = ref('')
+
+const filteredStatus = computed(() => {
+  if (!searchText.value) {
+    return status.value
+  }
+  const search = searchText.value.toLowerCase()
+  return status.value.filter(
+    (p) =>
+      p.name.toLowerCase().includes(search) ||
+      p.type.toLowerCase().includes(search) ||
+      p.local_addr.toLowerCase().includes(search) ||
+      p.remote_addr.toLowerCase().includes(search)
+  )
+})
+
+const getStatusColor = (status: string) => {
+  switch (status) {
+    case 'running':
+      return 'success'
+    case 'error':
+      return 'danger'
+    default:
+      return 'warning'
+  }
+}
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const json = await getStatus()
+    status.value = []
+    for (const key in json) {
+      // json[key] is generic array, we assume it matches ProxyStatus
+      for (const ps of json[key]) {
+        status.value.push(ps)
+      }
+    }
+  } catch (err: any) {
+    ElMessage({
+      showClose: true,
+      message: 'Get status info from frpc failed! ' + err.message,
+      type: 'warning',
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+fetchData()
+</script>
+
+<style scoped>
+.overview-page {
+  /* No special padding needed if App.vue handles content padding */
+}
+
+.main-card {
+  border-radius: 12px;
+  border: none;
+}
+
+.card-title {
+    margin: 0;
+    font-size: 18px;
+    font-weight: 600;
+}
+
+.toolbar-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+  gap: 16px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  padding-bottom: 16px;
+}
+
+.toolbar-actions {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.search-input {
+  width: 240px;
+}
+
+.error-text {
+    color: var(--el-color-danger);
+}
+
+.type-text {
+  display: inline-block;
+  padding: 2px 8px;
+  font-size: 12px;
+  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
+  background: var(--el-fill-color-light);
+  border-radius: 4px;
+  color: var(--el-text-color-regular);
+}
+
+@media (max-width: 768px) {
+  .toolbar-header {
+    flex-direction: column;
+    align-items: stretch;
+  }
+  
+  .search-input {
+    width: 100%;
+  }
+}
+</style>

+ 22 - 1
web/frpc/vite.config.mts

@@ -2,15 +2,19 @@ import { fileURLToPath, URL } from 'node:url'
 
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
+import svgLoader from 'vite-svg-loader'
 import AutoImport from 'unplugin-auto-import/vite'
 import Components from 'unplugin-vue-components/vite'
 import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+import ElementPlus from 'unplugin-element-plus/vite'
 
 // https://vitejs.dev/config/
 export default defineConfig({
   base: '',
   plugins: [
     vue(),
+    svgLoader(),
+    ElementPlus({}),
     AutoImport({
       resolvers: [ElementPlusResolver()],
     }),
@@ -25,5 +29,22 @@ export default defineConfig({
   },
   build: {
     assetsDir: '',
+    chunkSizeWarningLimit: 1000,
+    minify: 'terser',
+    terserOptions: {
+      compress: {
+        drop_console: true,
+        drop_debugger: true,
+      },
+    },
+  },
+  server: {
+    allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
+    proxy: {
+      '/api': {
+        target: process.env.VITE_API_URL || 'http://127.0.0.1:7400',
+        changeOrigin: true,
+      },
+    },
   },
-})
+})

Разница между файлами не показана из-за своего большого размера
+ 408 - 391
web/frpc/yarn.lock


+ 1 - 0
web/frps/vite.config.mts

@@ -39,6 +39,7 @@ export default defineConfig({
     },
   },
   server: {
+    allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
     proxy: {
       '/api': {
         target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',

Разница между файлами не показана из-за своего большого размера
+ 101 - 438
web/frps/yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов