From 85e8e2c830ff0b6bcaafbbb7174c8f15e330d4e2 Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 16 Mar 2026 09:44:30 +0800 Subject: [PATCH] web/frpc: redesign frpc dashboard with sidebar nav, proxy/visitor list and detail views (#5237) --- .gitignore | 1 + client/api_router.go | 2 + client/config_manager.go | 42 + client/configmgmt/types.go | 3 + client/http/controller.go | 38 + client/http/controller_test.go | 131 ++ client/service.go | 11 + client/visitor/visitor_manager.go | 7 + web/frpc/components.d.ts | 39 +- web/frpc/index.html | 1 + web/frpc/package-lock.json | 136 ++ web/frpc/package.json | 1 + web/frpc/src/App.vue | 567 +++++--- web/frpc/src/api/frpc.ts | 15 +- web/frpc/src/assets/css/_form-layout.scss | 33 + web/frpc/src/assets/css/_index.scss | 2 + web/frpc/src/assets/css/_mixins.scss | 49 + web/frpc/src/assets/css/_variables.scss | 61 + web/frpc/src/assets/css/custom.css | 105 -- web/frpc/src/assets/css/dark.css | 223 ++-- web/frpc/src/assets/css/var.css | 117 ++ web/frpc/src/components/ActionButton.vue | 144 ++ web/frpc/src/components/BaseDialog.vue | 142 ++ web/frpc/src/components/ConfigField.vue | 249 ++++ web/frpc/src/components/ConfigSection.vue | 185 +++ web/frpc/src/components/ConfirmDialog.vue | 84 ++ web/frpc/src/components/FilterDropdown.vue | 102 ++ web/frpc/src/components/KeyValueEditor.vue | 113 +- web/frpc/src/components/PopoverMenu.vue | 303 +++++ web/frpc/src/components/PopoverMenuItem.vue | 125 ++ web/frpc/src/components/ProxyCard.vue | 493 ++----- web/frpc/src/components/StatCard.vue | 202 --- web/frpc/src/components/StatusPills.vue | 103 ++ web/frpc/src/components/StringListEditor.vue | 141 ++ .../proxy-form/ProxyAuthSection.vue | 40 + .../proxy-form/ProxyBackendSection.vue | 149 +++ .../proxy-form/ProxyBaseSection.vue | 51 + .../components/proxy-form/ProxyFormLayout.vue | 50 + .../proxy-form/ProxyHealthSection.vue | 52 + .../proxy-form/ProxyHttpSection.vue | 32 + .../proxy-form/ProxyLoadBalanceSection.vue | 31 + .../proxy-form/ProxyMetadataSection.vue | 29 + .../components/proxy-form/ProxyNatSection.vue | 29 + .../proxy-form/ProxyRemoteSection.vue | 41 + .../proxy-form/ProxyTransportSection.vue | 39 + .../visitor-form/VisitorBaseSection.vue | 40 + .../visitor-form/VisitorConnectionSection.vue | 43 + .../visitor-form/VisitorFormLayout.vue | 33 + .../visitor-form/VisitorTransportSection.vue | 32 + .../visitor-form/VisitorXtcpSection.vue | 47 + web/frpc/src/composables/useResponsive.ts | 8 + web/frpc/src/main.ts | 4 +- web/frpc/src/router/index.ts | 49 +- web/frpc/src/stores/client.ts | 28 + web/frpc/src/stores/proxy.ts | 132 ++ web/frpc/src/stores/visitor.ts | 68 + web/frpc/src/types/constants.ts | 32 + web/frpc/src/types/index.ts | 5 + .../types/{proxy.ts => proxy-converters.ts} | 298 +---- web/frpc/src/types/proxy-form.ts | 167 +++ web/frpc/src/types/proxy-status.ts | 13 + web/frpc/src/types/proxy-store.ts | 30 + web/frpc/src/views/ClientConfigure.vue | 442 ++----- web/frpc/src/views/Overview.vue | 1160 ---------------- web/frpc/src/views/ProxyDetail.vue | 303 +++++ web/frpc/src/views/ProxyEdit.vue | 1175 ++--------------- web/frpc/src/views/ProxyList.vue | 439 ++++++ web/frpc/src/views/VisitorDetail.vue | 206 +++ web/frpc/src/views/VisitorEdit.vue | 554 ++------ web/frpc/src/views/VisitorList.vue | 371 ++++++ web/frpc/vite.config.mts | 8 + 71 files changed, 5908 insertions(+), 4292 deletions(-) create mode 100644 web/frpc/src/assets/css/_form-layout.scss create mode 100644 web/frpc/src/assets/css/_index.scss create mode 100644 web/frpc/src/assets/css/_mixins.scss create mode 100644 web/frpc/src/assets/css/_variables.scss delete mode 100644 web/frpc/src/assets/css/custom.css create mode 100644 web/frpc/src/assets/css/var.css create mode 100644 web/frpc/src/components/ActionButton.vue create mode 100644 web/frpc/src/components/BaseDialog.vue create mode 100644 web/frpc/src/components/ConfigField.vue create mode 100644 web/frpc/src/components/ConfigSection.vue create mode 100644 web/frpc/src/components/ConfirmDialog.vue create mode 100644 web/frpc/src/components/FilterDropdown.vue create mode 100644 web/frpc/src/components/PopoverMenu.vue create mode 100644 web/frpc/src/components/PopoverMenuItem.vue delete mode 100644 web/frpc/src/components/StatCard.vue create mode 100644 web/frpc/src/components/StatusPills.vue create mode 100644 web/frpc/src/components/StringListEditor.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyAuthSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyBackendSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyBaseSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyFormLayout.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyHealthSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyHttpSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyLoadBalanceSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyMetadataSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyNatSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyRemoteSection.vue create mode 100644 web/frpc/src/components/proxy-form/ProxyTransportSection.vue create mode 100644 web/frpc/src/components/visitor-form/VisitorBaseSection.vue create mode 100644 web/frpc/src/components/visitor-form/VisitorConnectionSection.vue create mode 100644 web/frpc/src/components/visitor-form/VisitorFormLayout.vue create mode 100644 web/frpc/src/components/visitor-form/VisitorTransportSection.vue create mode 100644 web/frpc/src/components/visitor-form/VisitorXtcpSection.vue create mode 100644 web/frpc/src/composables/useResponsive.ts create mode 100644 web/frpc/src/stores/client.ts create mode 100644 web/frpc/src/stores/proxy.ts create mode 100644 web/frpc/src/stores/visitor.ts create mode 100644 web/frpc/src/types/constants.ts create mode 100644 web/frpc/src/types/index.ts rename web/frpc/src/types/{proxy.ts => proxy-converters.ts} (65%) create mode 100644 web/frpc/src/types/proxy-form.ts create mode 100644 web/frpc/src/types/proxy-status.ts create mode 100644 web/frpc/src/types/proxy-store.ts delete mode 100644 web/frpc/src/views/Overview.vue create mode 100644 web/frpc/src/views/ProxyDetail.vue create mode 100644 web/frpc/src/views/ProxyList.vue create mode 100644 web/frpc/src/views/VisitorDetail.vue create mode 100644 web/frpc/src/views/VisitorList.vue diff --git a/.gitignore b/.gitignore index 47fb34ad..161c114e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ client.key # AI .claude/ .sisyphus/ +.superpowers/ diff --git a/client/api_router.go b/client/api_router.go index ceab4fbc..82b73046 100644 --- a/client/api_router.go +++ b/client/api_router.go @@ -38,6 +38,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut) + subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet) + subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet) if svr.storeSource != nil { subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet) diff --git a/client/config_manager.go b/client/config_manager.go index 1d3fb0ec..24512e9f 100644 --- a/client/config_manager.go +++ b/client/config_manager.go @@ -80,6 +80,48 @@ func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus { return m.svr.getAllProxyStatus() } +func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) { + // Try running proxy manager first + ws, ok := m.svr.getProxyStatus(name) + if ok { + return ws.Cfg, true + } + + // Fallback to store + m.svr.reloadMu.Lock() + storeSource := m.svr.storeSource + m.svr.reloadMu.Unlock() + + if storeSource != nil { + cfg := storeSource.GetProxy(name) + if cfg != nil { + return cfg, true + } + } + return nil, false +} + +func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) { + // Try running visitor manager first + cfg, ok := m.svr.getVisitorCfg(name) + if ok { + return cfg, true + } + + // Fallback to store + m.svr.reloadMu.Lock() + storeSource := m.svr.storeSource + m.svr.reloadMu.Unlock() + + if storeSource != nil { + vcfg := storeSource.GetVisitor(name) + if vcfg != nil { + return vcfg, true + } + } + return nil, false +} + func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool { if name == "" { return false diff --git a/client/configmgmt/types.go b/client/configmgmt/types.go index 090d0b7b..5a51a5e5 100644 --- a/client/configmgmt/types.go +++ b/client/configmgmt/types.go @@ -26,6 +26,9 @@ type ConfigManager interface { IsStoreProxyEnabled(name string) bool StoreEnabled() bool + GetProxyConfig(name string) (v1.ProxyConfigurer, bool) + GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) + ListStoreProxies() ([]v1.ProxyConfigurer, error) GetStoreProxy(name string) (v1.ProxyConfigurer, error) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) diff --git a/client/http/controller.go b/client/http/controller.go index d06396dc..57e8165d 100644 --- a/client/http/controller.go +++ b/client/http/controller.go @@ -162,6 +162,44 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro return psr } +// GetProxyConfig handles GET /api/proxy/{name}/config +func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") + } + + cfg, ok := c.manager.GetProxyConfig(name) + if !ok { + return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name)) + } + + payload, err := model.ProxyDefinitionFromConfigurer(cfg) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return payload, nil +} + +// GetVisitorConfig handles GET /api/visitor/{name}/config +func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") + } + + cfg, ok := c.manager.GetVisitorConfig(name) + if !ok { + return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name)) + } + + payload, err := model.VisitorDefinitionFromConfigurer(cfg) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return payload, nil +} + func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) { proxies, err := c.manager.ListStoreProxies() if err != nil { diff --git a/client/http/controller_test.go b/client/http/controller_test.go index aa88c545..719fbcd4 100644 --- a/client/http/controller_test.go +++ b/client/http/controller_test.go @@ -26,6 +26,8 @@ type fakeConfigManager struct { getProxyStatusFn func() []*proxy.WorkingStatus isStoreProxyEnabledFn func(name string) bool storeEnabledFn func() bool + getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool) + getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool) listStoreProxiesFn func() ([]v1.ProxyConfigurer, error) getStoreProxyFn func(name string) (v1.ProxyConfigurer, error) @@ -82,6 +84,20 @@ func (m *fakeConfigManager) StoreEnabled() bool { return false } +func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) { + if m.getProxyConfigFn != nil { + return m.getProxyConfigFn(name) + } + return nil, false +} + +func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) { + if m.getVisitorConfigFn != nil { + return m.getVisitorConfigFn(name) + } + return nil, false +} + func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) { if m.listStoreProxiesFn != nil { return m.listStoreProxiesFn() @@ -529,3 +545,118 @@ func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) { t.Fatalf("unexpected response payload: %#v", payload) } } + +func TestGetProxyConfigFromManager(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) { + if name == "ssh" { + cfg := &v1.TCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: "ssh", + Type: "tcp", + ProxyBackend: v1.ProxyBackend{ + LocalPort: 22, + }, + }, + } + return cfg, true + } + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "ssh"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.GetProxyConfig(ctx) + if err != nil { + t.Fatalf("get proxy config: %v", err) + } + payload, ok := resp.(model.ProxyDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestGetProxyConfigNotFound(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) { + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "missing"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + _, err := controller.GetProxyConfig(ctx) + if err == nil { + t.Fatal("expected error") + } + assertHTTPCode(t, err, http.StatusNotFound) +} + +func TestGetVisitorConfigFromManager(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) { + if name == "my-stcp" { + cfg := &v1.STCPVisitorConfig{ + VisitorBaseConfig: v1.VisitorBaseConfig{ + Name: "my-stcp", + Type: "stcp", + ServerName: "server1", + BindPort: 9000, + }, + } + return cfg, true + } + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.GetVisitorConfig(ctx) + if err != nil { + t.Fatalf("get visitor config: %v", err) + } + payload, ok := resp.(model.VisitorDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestGetVisitorConfigNotFound(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) { + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "missing"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + _, err := controller.GetVisitorConfig(ctx) + if err == nil { + t.Fatal("expected error") + } + assertHTTPCode(t, err, http.StatusNotFound) +} diff --git a/client/service.go b/client/service.go index f419b068..5c51fd67 100644 --- a/client/service.go +++ b/client/service.go @@ -521,6 +521,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) { return ctl.pm.GetProxyStatus(name) } +func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) { + svr.ctlMu.RLock() + ctl := svr.ctl + svr.ctlMu.RUnlock() + + if ctl == nil { + return nil, false + } + return ctl.vm.GetVisitorCfg(name) +} + func (svr *Service) StatusExporter() StatusExporter { return &statusExporterImpl{ getProxyStatusFunc: svr.getProxyStatus, diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go index b3539c69..b60f7047 100644 --- a/client/visitor/visitor_manager.go +++ b/client/visitor/visitor_manager.go @@ -191,6 +191,13 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error { return v.AcceptConn(conn) } +func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) { + vm.mu.RLock() + defer vm.mu.RUnlock() + cfg, ok := vm.cfgs[name] + return cfg, ok +} + type visitorHelperImpl struct { connectServerFn func() (net.Conn, error) msgTransporter transport.MessageTransporter diff --git a/web/frpc/components.d.ts b/web/frpc/components.d.ts index 700f3c42..4236f460 100644 --- a/web/frpc/components.d.ts +++ b/web/frpc/components.d.ts @@ -7,28 +7,45 @@ export {} declare module 'vue' { export interface GlobalComponents { - ElButton: typeof import('element-plus/es')['ElButton'] - ElCard: typeof import('element-plus/es')['ElCard'] - ElCol: typeof import('element-plus/es')['ElCol'] - ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] + ActionButton: typeof import('./src/components/ActionButton.vue')['default'] + BaseDialog: typeof import('./src/components/BaseDialog.vue')['default'] + ConfigField: typeof import('./src/components/ConfigField.vue')['default'] + ConfigSection: typeof import('./src/components/ConfigSection.vue')['default'] + ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default'] + ElDialog: typeof import('element-plus/es')['ElDialog'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] - ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] - ElOption: typeof import('element-plus/es')['ElOption'] + ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - ElRow: typeof import('element-plus/es')['ElRow'] - ElSelect: typeof import('element-plus/es')['ElSelect'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] - ElTag: typeof import('element-plus/es')['ElTag'] - ElTooltip: typeof import('element-plus/es')['ElTooltip'] + FilterDropdown: typeof import('./src/components/FilterDropdown.vue')['default'] KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default'] + PopoverMenu: typeof import('./src/components/PopoverMenu.vue')['default'] + PopoverMenuItem: typeof import('./src/components/PopoverMenuItem.vue')['default'] + ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default'] + ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default'] + ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] + ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default'] + ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default'] + ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default'] + ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default'] + ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default'] + ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default'] + ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default'] + ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] - StatCard: typeof import('./src/components/StatCard.vue')['default'] + StatusPills: typeof import('./src/components/StatusPills.vue')['default'] + StringListEditor: typeof import('./src/components/StringListEditor.vue')['default'] + VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default'] + VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default'] + VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default'] + VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default'] + VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default'] } export interface ComponentCustomProperties { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/web/frpc/index.html b/web/frpc/index.html index 0c7caa7b..a893c9c1 100644 --- a/web/frpc/index.html +++ b/web/frpc/index.html @@ -3,6 +3,7 @@ + frp client diff --git a/web/frpc/package-lock.json b/web/frpc/package-lock.json index 204e6bf6..3ee94d8b 100644 --- a/web/frpc/package-lock.json +++ b/web/frpc/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "element-plus": "^2.13.0", + "pinia": "^3.0.4", "vue": "^3.5.26", "vue-router": "^4.6.4" }, @@ -2072,6 +2073,36 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-kit/node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/eslint-config-prettier": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", @@ -2430,6 +2461,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2713,6 +2753,21 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4223,6 +4278,12 @@ "node": ">= 0.4" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -4701,6 +4762,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -4974,6 +5047,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -5582,6 +5661,36 @@ "node": ">=4" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -5859,6 +5968,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rolldown-string": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/rolldown-string/-/rolldown-string-0.2.1.tgz", @@ -6267,6 +6382,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6395,6 +6519,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/web/frpc/package.json b/web/frpc/package.json index 14eb419e..6e20d893 100644 --- a/web/frpc/package.json +++ b/web/frpc/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "element-plus": "^2.13.0", + "pinia": "^3.0.4", "vue": "^3.5.26", "vue-router": "^4.6.4" }, diff --git a/web/frpc/src/App.vue b/web/frpc/src/App.vue index 44ebcbc9..b75c8a69 100644 --- a/web/frpc/src/App.vue +++ b/web/frpc/src/App.vue @@ -2,140 +2,160 @@
-
-
-
- -
- / - frp - Client - {{ - currentRouteName - }} -
- -
- - - - +
+ +
+
+ / + frp + Client
- + + + +
-
- -
+
+ +
- diff --git a/web/frpc/src/api/frpc.ts b/web/frpc/src/api/frpc.ts index 213c04fa..c7af90c0 100644 --- a/web/frpc/src/api/frpc.ts +++ b/web/frpc/src/api/frpc.ts @@ -5,7 +5,7 @@ import type { ProxyDefinition, VisitorListResp, VisitorDefinition, -} from '../types/proxy' +} from '../types' export const getStatus = () => { return http.get('/api/status') @@ -23,6 +23,19 @@ export const reloadConfig = () => { return http.get('/api/reload') } +// Config lookup API (any source) +export const getProxyConfig = (name: string) => { + return http.get( + `/api/proxy/${encodeURIComponent(name)}/config`, + ) +} + +export const getVisitorConfig = (name: string) => { + return http.get( + `/api/visitor/${encodeURIComponent(name)}/config`, + ) +} + // Store API - Proxies export const listStoreProxies = () => { return http.get('/api/store/proxies') diff --git a/web/frpc/src/assets/css/_form-layout.scss b/web/frpc/src/assets/css/_form-layout.scss new file mode 100644 index 00000000..a43fab6e --- /dev/null +++ b/web/frpc/src/assets/css/_form-layout.scss @@ -0,0 +1,33 @@ +@use './mixins' as *; + +/* Shared form layout styles for proxy/visitor form sections */ +.field-row { + display: grid; + gap: 16px; + align-items: start; +} + +.field-row.two-col { + grid-template-columns: 1fr 1fr; +} + +.field-row.three-col { + grid-template-columns: 1fr 1fr 1fr; +} + +.field-grow { + min-width: 0; +} + +.switch-field :deep(.el-form-item__content) { + min-height: 32px; + display: flex; + align-items: center; +} + +@include mobile { + .field-row.two-col, + .field-row.three-col { + grid-template-columns: 1fr; + } +} diff --git a/web/frpc/src/assets/css/_index.scss b/web/frpc/src/assets/css/_index.scss new file mode 100644 index 00000000..b70de87b --- /dev/null +++ b/web/frpc/src/assets/css/_index.scss @@ -0,0 +1,2 @@ +@forward './variables'; +@forward './mixins'; diff --git a/web/frpc/src/assets/css/_mixins.scss b/web/frpc/src/assets/css/_mixins.scss new file mode 100644 index 00000000..3861971b --- /dev/null +++ b/web/frpc/src/assets/css/_mixins.scss @@ -0,0 +1,49 @@ +@use './variables' as vars; + +@mixin mobile { + @media (max-width: #{vars.$breakpoint-mobile - 1px}) { + @content; + } +} + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +@mixin page-scroll { + height: 100%; + overflow-y: auto; + padding: vars.$spacing-xl 40px; + + > * { + max-width: 960px; + margin: 0 auto; + } + + @include mobile { + padding: vars.$spacing-xl; + } +} + +@mixin custom-scrollbar { + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #d1d1d1; + border-radius: 3px; + } +} diff --git a/web/frpc/src/assets/css/_variables.scss b/web/frpc/src/assets/css/_variables.scss new file mode 100644 index 00000000..3df1ef19 --- /dev/null +++ b/web/frpc/src/assets/css/_variables.scss @@ -0,0 +1,61 @@ +// Typography +$font-size-xs: 11px; +$font-size-sm: 13px; +$font-size-md: 14px; +$font-size-lg: 15px; +$font-size-xl: 18px; + +$font-weight-normal: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; + +// Colors - Text +$color-text-primary: var(--color-text-primary); +$color-text-secondary: var(--color-text-secondary); +$color-text-muted: var(--color-text-muted); +$color-text-light: var(--color-text-light); + +// Colors - Background +$color-bg-primary: var(--color-bg-primary); +$color-bg-secondary: var(--color-bg-secondary); +$color-bg-tertiary: var(--color-bg-tertiary); +$color-bg-muted: var(--color-bg-muted); +$color-bg-hover: var(--color-bg-hover); +$color-bg-active: var(--color-bg-active); + +// Colors - Border +$color-border: var(--color-border); +$color-border-light: var(--color-border-light); +$color-border-lighter: var(--color-border-lighter); + +// Colors - Status +$color-primary: var(--color-primary); +$color-danger: var(--color-danger); +$color-danger-dark: var(--color-danger-dark); +$color-danger-light: var(--color-danger-light); + +// Colors - Button +$color-btn-primary: var(--color-btn-primary); +$color-btn-primary-hover: var(--color-btn-primary-hover); + +// Spacing +$spacing-xs: 4px; +$spacing-sm: 8px; +$spacing-md: 12px; +$spacing-lg: 16px; +$spacing-xl: 20px; + +// Border Radius +$radius-sm: 6px; +$radius-md: 8px; + +// Transitions +$transition-fast: 0.15s ease; +$transition-medium: 0.2s ease; + +// Layout +$header-height: 50px; +$sidebar-width: 200px; + +// Breakpoints +$breakpoint-mobile: 768px; diff --git a/web/frpc/src/assets/css/custom.css b/web/frpc/src/assets/css/custom.css deleted file mode 100644 index ed128996..00000000 --- a/web/frpc/src/assets/css/custom.css +++ /dev/null @@ -1,105 +0,0 @@ -/* Modern Base Styles */ -* { - box-sizing: border-box; -} - -/* Smooth transitions for Element Plus components */ -.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.08); -} - -/* 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; -} - -/* 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; - } -} - -/* Input enhancements */ -.el-input__wrapper { - transition: all 0.2s ease; -} - -.el-input__wrapper:hover { - box-shadow: 0 0 0 1px var(--el-border-color-hover) inset; -} - -/* Button enhancements */ -.el-button { - font-weight: 500; -} - -/* Tag enhancements */ -.el-tag { - font-weight: 500; -} - -/* Card enhancements */ -.el-card__header { - padding: 16px 20px; - border-bottom: 1px solid var(--el-border-color-lighter); -} - -.el-card__body { - padding: 20px; -} - -/* Table enhancements */ -.el-table { - font-size: 14px; -} - -.el-table th { - font-weight: 600; -} - -/* Empty state */ -.el-empty__description { - margin-top: 16px; - font-size: 14px; -} - -/* Loading state */ -.el-loading-mask { - border-radius: 12px; -} diff --git a/web/frpc/src/assets/css/dark.css b/web/frpc/src/assets/css/dark.css index 7c118fc3..1d090aec 100644 --- a/web/frpc/src/assets/css/dark.css +++ b/web/frpc/src/assets/css/dark.css @@ -1,48 +1,51 @@ -/* Dark Mode Theme */ +/* Dark mode styles */ html.dark { - --el-bg-color: #1e1e2e; - --el-bg-color-page: #1a1a2e; - --el-bg-color-overlay: #27293d; - --el-fill-color-blank: #1e1e2e; - background-color: #1a1a2e; + --el-bg-color: #212121; + --el-bg-color-page: #181818; + --el-bg-color-overlay: #303030; + --el-fill-color-blank: #212121; + --el-border-color: #404040; + --el-border-color-light: #353535; + --el-border-color-lighter: #2a2a2a; + --el-text-color-primary: #e5e7eb; + --el-text-color-secondary: #888888; + --el-text-color-placeholder: #afafaf; + background-color: #212121; + color-scheme: dark; } -html.dark body { - background-color: #1a1a2e; - color: #e5e7eb; +/* Scrollbar */ +html.dark ::-webkit-scrollbar { + width: 6px; + height: 6px; } -/* Dark mode scrollbar */ html.dark ::-webkit-scrollbar-track { - background: #27293d; + background: #303030; } html.dark ::-webkit-scrollbar-thumb { - background: #3a3d5c; + background: #404040; + border-radius: 3px; } html.dark ::-webkit-scrollbar-thumb:hover { - background: #4a4d6c; + background: #505050; } -/* Dark mode cards */ -html.dark .el-card { - background-color: #27293d; - border-color: #3a3d5c; +/* Form */ +html.dark .el-form-item__label { + color: #e5e7eb; } -html.dark .el-card__header { - border-bottom-color: #3a3d5c; -} - -/* Dark mode inputs */ +/* Input */ html.dark .el-input__wrapper { - background-color: #27293d; - box-shadow: 0 0 0 1px #3a3d5c inset; + background: var(--color-bg-input); + box-shadow: 0 0 0 1px #404040 inset; } html.dark .el-input__wrapper:hover { - box-shadow: 0 0 0 1px #4a4d6c inset; + box-shadow: 0 0 0 1px #505050 inset; } html.dark .el-input__wrapper.is-focus { @@ -54,71 +57,44 @@ html.dark .el-input__inner { } html.dark .el-input__inner::placeholder { - color: #6b7280; + color: #afafaf; } -/* Dark mode textarea */ html.dark .el-textarea__inner { - background-color: #1e1e2d; - border-color: #3a3d5c; + background: var(--color-bg-input); + box-shadow: 0 0 0 1px #404040 inset; color: #e5e7eb; } -html.dark .el-textarea__inner::placeholder { - color: #6b7280; +html.dark .el-textarea__inner:hover { + box-shadow: 0 0 0 1px #505050 inset; } -/* Dark mode table */ -html.dark .el-table { - background-color: #27293d; +html.dark .el-textarea__inner:focus { + box-shadow: 0 0 0 1px var(--el-color-primary) inset; +} + +/* Select */ +html.dark .el-select__wrapper { + background: var(--color-bg-input); + box-shadow: 0 0 0 1px #404040 inset; +} + +html.dark .el-select__wrapper:hover { + box-shadow: 0 0 0 1px #505050 inset; +} + +html.dark .el-select__selected-item { color: #e5e7eb; } -html.dark .el-table th.el-table__cell { - 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.el-table__cell { - background-color: #1e1e2e; -} - -html.dark .el-table__row:hover > td.el-table__cell { - background-color: #2a2a3c !important; -} - -/* Dark mode tags */ -html.dark .el-tag--info { - background-color: #3a3d5c; - border-color: #3a3d5c; - color: #e5e7eb; -} - -/* Dark mode buttons */ -html.dark .el-button--default { - background-color: #27293d; - border-color: #3a3d5c; - color: #e5e7eb; -} - -html.dark .el-button--default:hover { - background-color: #2a2a3c; - border-color: #4a4d6c; - color: #fff; -} - -/* Dark mode select */ -html.dark .el-select .el-input__wrapper { - background-color: #27293d; +html.dark .el-select__placeholder { + color: #afafaf; } html.dark .el-select-dropdown { - background-color: #27293d; - border-color: #3a3d5c; + background: #303030; + border-color: #404040; } html.dark .el-select-dropdown__item { @@ -126,55 +102,92 @@ html.dark .el-select-dropdown__item { } html.dark .el-select-dropdown__item:hover { - background-color: #2a2a3c; + background: #3a3a3a; } -/* Dark mode dialog */ +html.dark .el-select-dropdown__item.is-selected { + color: var(--el-color-primary); +} + +html.dark .el-select-dropdown__item.is-disabled { + color: #666666; +} + +/* Tag */ +html.dark .el-tag--info { + background: #303030; + border-color: #404040; + color: #b0b0b0; +} + +/* Button */ +html.dark .el-button--default { + background: #303030; + border-color: #404040; + color: #e5e7eb; +} + +html.dark .el-button--default:hover { + background: #3a3a3a; + border-color: #505050; + color: #e5e7eb; +} + +/* Card */ +html.dark .el-card { + background: #212121; + border-color: #353535; + color: #b0b0b0; +} + +html.dark .el-card__header { + border-bottom-color: #353535; + color: #e5e7eb; +} + +/* Dialog */ html.dark .el-dialog { - background-color: #27293d; -} - -html.dark .el-dialog__header { - border-bottom-color: #3a3d5c; + background: #212121; } html.dark .el-dialog__title { color: #e5e7eb; } -html.dark .el-dialog__body { - color: #e5e7eb; +/* Message */ +html.dark .el-message { + background: #303030; + border-color: #404040; } -/* Dark mode message box */ -html.dark .el-message-box { - background-color: #27293d; - border-color: #3a3d5c; +html.dark .el-message--success { + background: #1e3d2e; + border-color: #3d6b4f; } -html.dark .el-message-box__title { - color: #e5e7eb; +html.dark .el-message--warning { + background: #3d3020; + border-color: #6b5020; } -html.dark .el-message-box__message { - color: #e5e7eb; +html.dark .el-message--error { + background: #3d2027; + border-color: #5c2d2d; } -/* Dark mode empty */ -html.dark .el-empty__description { - color: #9ca3af; -} - -/* Dark mode loading */ +/* Loading */ html.dark .el-loading-mask { - background-color: rgba(30, 30, 46, 0.9); + background-color: rgba(33, 33, 33, 0.9); } -html.dark .el-loading-text { - color: #e5e7eb; +/* Overlay */ +html.dark .el-overlay { + background-color: rgba(0, 0, 0, 0.6); } -/* Dark mode tooltip */ -html.dark .el-tooltip__trigger { - color: #e5e7eb; +/* Tooltip */ +html.dark .el-tooltip__popper { + background: #303030 !important; + border-color: #404040 !important; + color: #e5e7eb !important; } diff --git a/web/frpc/src/assets/css/var.css b/web/frpc/src/assets/css/var.css new file mode 100644 index 00000000..388a0521 --- /dev/null +++ b/web/frpc/src/assets/css/var.css @@ -0,0 +1,117 @@ +:root { + /* Text colors */ + --color-text-primary: #303133; + --color-text-secondary: #606266; + --color-text-muted: #909399; + --color-text-light: #c0c4cc; + --color-text-placeholder: #a8abb2; + + /* Background colors */ + --color-bg-primary: #ffffff; + --color-bg-secondary: #f9f9f9; + --color-bg-tertiary: #fafafa; + --color-bg-surface: #ffffff; + --color-bg-muted: #f4f4f5; + --color-bg-input: #ffffff; + --color-bg-hover: #efefef; + --color-bg-active: #eaeaea; + + /* Border colors */ + --color-border: #dcdfe6; + --color-border-light: #e4e7ed; + --color-border-lighter: #ebeef5; + --color-border-extra-light: #f2f6fc; + + /* Status colors */ + --color-primary: #409eff; + --color-primary-light: #ecf5ff; + --color-success: #67c23a; + --color-warning: #e6a23c; + --color-danger: #f56c6c; + --color-danger-dark: #c45656; + --color-danger-light: #fef0f0; + --color-info: #909399; + + /* Button colors */ + --color-btn-primary: #303133; + --color-btn-primary-hover: #4a4d5c; + + /* Element Plus mapping */ + --el-color-primary: var(--color-primary); + --el-color-success: var(--color-success); + --el-color-warning: var(--color-warning); + --el-color-danger: var(--color-danger); + --el-color-info: var(--color-info); + + --el-text-color-primary: var(--color-text-primary); + --el-text-color-regular: var(--color-text-secondary); + --el-text-color-secondary: var(--color-text-muted); + --el-text-color-placeholder: var(--color-text-placeholder); + + --el-bg-color: var(--color-bg-primary); + --el-bg-color-page: var(--color-bg-secondary); + --el-bg-color-overlay: var(--color-bg-primary); + + --el-border-color: var(--color-border); + --el-border-color-light: var(--color-border-light); + --el-border-color-lighter: var(--color-border-lighter); + --el-border-color-extra-light: var(--color-border-extra-light); + + --el-fill-color-blank: var(--color-bg-primary); + --el-fill-color-light: var(--color-bg-tertiary); + --el-fill-color: var(--color-bg-tertiary); + --el-fill-color-dark: var(--color-bg-hover); + --el-fill-color-darker: var(--color-bg-active); + + /* Input */ + --el-input-bg-color: var(--color-bg-input); + --el-input-border-color: var(--color-border); + --el-input-hover-border-color: var(--color-border-light); + + /* Dialog */ + --el-dialog-bg-color: var(--color-bg-primary); + --el-overlay-color: rgba(0, 0, 0, 0.5); +} + +html.dark { + /* Text colors */ + --color-text-primary: #e5e7eb; + --color-text-secondary: #b0b0b0; + --color-text-muted: #888888; + --color-text-light: #666666; + --color-text-placeholder: #afafaf; + + /* Background colors */ + --color-bg-primary: #212121; + --color-bg-secondary: #181818; + --color-bg-tertiary: #303030; + --color-bg-surface: #303030; + --color-bg-muted: #303030; + --color-bg-input: #2f2f2f; + --color-bg-hover: #3a3a3a; + --color-bg-active: #454545; + + /* Border colors */ + --color-border: #404040; + --color-border-light: #353535; + --color-border-lighter: #2a2a2a; + --color-border-extra-light: #222222; + + /* Status colors */ + --color-primary: #409eff; + --color-danger: #f87171; + --color-danger-dark: #f87171; + --color-danger-light: #3d2027; + --color-info: #888888; + + /* Button colors */ + --color-btn-primary: #404040; + --color-btn-primary-hover: #505050; + + /* Dark overrides */ + --el-text-color-regular: var(--color-text-primary); + --el-overlay-color: rgba(0, 0, 0, 0.7); + + background-color: #181818; + color-scheme: dark; +} diff --git a/web/frpc/src/components/ActionButton.vue b/web/frpc/src/components/ActionButton.vue new file mode 100644 index 00000000..6bbaae82 --- /dev/null +++ b/web/frpc/src/components/ActionButton.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/web/frpc/src/components/BaseDialog.vue b/web/frpc/src/components/BaseDialog.vue new file mode 100644 index 00000000..b1e85b5b --- /dev/null +++ b/web/frpc/src/components/BaseDialog.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/web/frpc/src/components/ConfigField.vue b/web/frpc/src/components/ConfigField.vue new file mode 100644 index 00000000..a6c2cdd4 --- /dev/null +++ b/web/frpc/src/components/ConfigField.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/web/frpc/src/components/ConfigSection.vue b/web/frpc/src/components/ConfigSection.vue new file mode 100644 index 00000000..1a795afb --- /dev/null +++ b/web/frpc/src/components/ConfigSection.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/web/frpc/src/components/ConfirmDialog.vue b/web/frpc/src/components/ConfirmDialog.vue new file mode 100644 index 00000000..cb23900e --- /dev/null +++ b/web/frpc/src/components/ConfirmDialog.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/web/frpc/src/components/FilterDropdown.vue b/web/frpc/src/components/FilterDropdown.vue new file mode 100644 index 00000000..a4db62db --- /dev/null +++ b/web/frpc/src/components/FilterDropdown.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/web/frpc/src/components/KeyValueEditor.vue b/web/frpc/src/components/KeyValueEditor.vue index 3472ebb4..9b467670 100644 --- a/web/frpc/src/components/KeyValueEditor.vue +++ b/web/frpc/src/components/KeyValueEditor.vue @@ -1,42 +1,51 @@ @@ -50,11 +59,13 @@ interface Props { modelValue: KVEntry[] keyPlaceholder?: string valuePlaceholder?: string + readonly?: boolean } const props = withDefaults(defineProps(), { keyPlaceholder: 'Key', valuePlaceholder: 'Value', + readonly: false, }) const emit = defineEmits<{ @@ -129,25 +140,45 @@ html.dark .kv-remove-btn:hover { display: inline-flex; align-items: center; gap: 6px; - padding: 6px 14px; - border: 1px dashed var(--el-border-color); - border-radius: 8px; + padding: 5px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; background: transparent; - color: var(--el-text-color-secondary); + color: var(--color-text-secondary); font-size: 13px; cursor: pointer; - transition: all 0.2s; + transition: all 0.15s; align-self: flex-start; } .kv-add-btn svg { - width: 14px; - height: 14px; + width: 13px; + height: 13px; } .kv-add-btn:hover { - color: var(--el-color-primary); - border-color: var(--el-color-primary); - background: var(--el-color-primary-light-9); + background: var(--color-bg-hover); +} + +.kv-empty { + color: var(--el-text-color-secondary); + font-size: 13px; +} + +.kv-readonly-row { + display: flex; + gap: 8px; + padding: 4px 0; + font-size: 13px; +} + +.kv-readonly-key { + color: var(--el-text-color-secondary); + min-width: 100px; +} + +.kv-readonly-value { + color: var(--el-text-color-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } diff --git a/web/frpc/src/components/PopoverMenu.vue b/web/frpc/src/components/PopoverMenu.vue new file mode 100644 index 00000000..8abd91ed --- /dev/null +++ b/web/frpc/src/components/PopoverMenu.vue @@ -0,0 +1,303 @@ + + + + + + + + + diff --git a/web/frpc/src/components/PopoverMenuItem.vue b/web/frpc/src/components/PopoverMenuItem.vue new file mode 100644 index 00000000..8df1e49b --- /dev/null +++ b/web/frpc/src/components/PopoverMenuItem.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/web/frpc/src/components/ProxyCard.vue b/web/frpc/src/components/ProxyCard.vue index f253ae15..9269c3a1 100644 --- a/web/frpc/src/components/ProxyCard.vue +++ b/web/frpc/src/components/ProxyCard.vue @@ -1,104 +1,49 @@ - diff --git a/web/frpc/src/views/Overview.vue b/web/frpc/src/views/Overview.vue deleted file mode 100644 index c8dfcdba..00000000 --- a/web/frpc/src/views/Overview.vue +++ /dev/null @@ -1,1160 +0,0 @@ - - - - - diff --git a/web/frpc/src/views/ProxyDetail.vue b/web/frpc/src/views/ProxyDetail.vue new file mode 100644 index 00000000..d87b72e8 --- /dev/null +++ b/web/frpc/src/views/ProxyDetail.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/web/frpc/src/views/ProxyEdit.vue b/web/frpc/src/views/ProxyEdit.vue index e94dce88..04183d2c 100644 --- a/web/frpc/src/views/ProxyEdit.vue +++ b/web/frpc/src/views/ProxyEdit.vue @@ -1,772 +1,71 @@ - - - diff --git a/web/frpc/src/views/ProxyList.vue b/web/frpc/src/views/ProxyList.vue new file mode 100644 index 00000000..5ac02463 --- /dev/null +++ b/web/frpc/src/views/ProxyList.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/web/frpc/src/views/VisitorDetail.vue b/web/frpc/src/views/VisitorDetail.vue new file mode 100644 index 00000000..5816a373 --- /dev/null +++ b/web/frpc/src/views/VisitorDetail.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/web/frpc/src/views/VisitorEdit.vue b/web/frpc/src/views/VisitorEdit.vue index 429ed4ad..6a25785d 100644 --- a/web/frpc/src/views/VisitorEdit.vue +++ b/web/frpc/src/views/VisitorEdit.vue @@ -1,16 +1,18 @@ - - - diff --git a/web/frpc/src/views/VisitorList.vue b/web/frpc/src/views/VisitorList.vue new file mode 100644 index 00000000..b856c59e --- /dev/null +++ b/web/frpc/src/views/VisitorList.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/web/frpc/vite.config.mts b/web/frpc/vite.config.mts index cb8de991..39eb4d17 100644 --- a/web/frpc/vite.config.mts +++ b/web/frpc/vite.config.mts @@ -27,6 +27,14 @@ export default defineConfig({ '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, + css: { + preprocessorOptions: { + scss: { + api: 'modern', + additionalData: `@use "@/assets/css/_index.scss" as *;`, + }, + }, + }, build: { assetsDir: '', chunkSizeWarningLimit: 1000,