Compare commits

..

2 Commits

Author SHA1 Message Date
fatedier
8d1ab7d585 test/e2e: guard Process against double-Start and Stop-before-Start
Add started flag to prevent double-Start panics and allow Stop to
return immediately when the process was never started. Use sync.Once
for closing the done channel as defense-in-depth against double close.
2026-03-09 01:44:54 +08:00
fatedier
f32bec9f4d test/e2e: optimize RunFrps/RunFrpc with process exit detection
Refactor Process to track subprocess lifecycle via a done channel,
replacing direct cmd.Wait() in Stop() to avoid double-Wait races.
RunFrps/RunFrpc now use select on the done channel instead of fixed
sleeps, allowing short-lived processes (verify, startup failures) to
return immediately while preserving existing timeout behavior for
long-running daemons.
2026-03-09 00:41:00 +08:00
108 changed files with 12951 additions and 8506 deletions

6
.gitignore vendored
View File

@@ -25,12 +25,10 @@ dist/
client.crt
client.key
node_modules/
# Cache
*.swp
# AI
.claude/
CLAUDE.md
AGENTS.md
.sisyphus/
.superpowers/

View File

@@ -90,7 +90,6 @@ linters:
- third_party$
- builtin$
- examples$
- node_modules
formatters:
enable:
- gci
@@ -113,7 +112,6 @@ formatters:
- third_party$
- builtin$
- examples$
- node_modules
issues:
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,39 +0,0 @@
# AGENTS.md
## Development Commands
### Build
- `make build` - Build both frps and frpc binaries
- `make frps` - Build server binary only
- `make frpc` - Build client binary only
- `make all` - Build everything with formatting
### Testing
- `make test` - Run unit tests
- `make e2e` - Run end-to-end tests
- `make e2e-trace` - Run e2e tests with trace logging
- `make alltest` - Run all tests including vet, unit tests, and e2e
### Code Quality
- `make fmt` - Run go fmt
- `make fmt-more` - Run gofumpt for more strict formatting
- `make gci` - Run gci import organizer
- `make vet` - Run go vet
- `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml)
### Assets
- `make web` - Build web dashboards (frps and frpc)
### Cleanup
- `make clean` - Remove built binaries and temporary files
## Testing
- E2E tests using Ginkgo/Gomega framework
- Mock servers in `/test/e2e/mock/`
- Run: `make e2e` or `make alltest`
## Agent Runbooks
Operational procedures for agents are in `doc/agents/`:
- `doc/agents/release.md` - Release process

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -7,4 +7,3 @@
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.
* OIDC auth now caches the access token and refreshes it before expiry, avoiding a new token request on every heartbeat. Falls back to per-request fetch when the provider omits `expires_in`.

View File

@@ -38,8 +38,6 @@ 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)

View File

@@ -80,48 +80,6 @@ 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

View File

@@ -26,9 +26,6 @@ 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)

View File

@@ -162,44 +162,6 @@ 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 {

View File

@@ -26,8 +26,6 @@ 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)
@@ -84,20 +82,6 @@ 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()
@@ -545,118 +529,3 @@ 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)
}

View File

@@ -19,7 +19,6 @@ import (
"errors"
"fmt"
"net"
"net/http"
"os"
"runtime"
"sync"
@@ -163,6 +162,15 @@ func NewService(options ServiceOptions) (*Service, error) {
return nil, err
}
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
ws, err := httppkg.NewServer(options.Common.WebServer)
if err != nil {
return nil, err
}
webServer = ws
}
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
if err != nil {
return nil, err
@@ -183,17 +191,6 @@ func NewService(options ServiceOptions) (*Service, error) {
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
// Create the web server after all fallible steps so its listener is not
// leaked when an earlier error causes NewService to return.
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
ws, err := httppkg.NewServer(options.Common.WebServer)
if err != nil {
return nil, err
}
webServer = ws
}
s := &Service{
ctx: context.Background(),
auth: authRuntime,
@@ -232,25 +229,22 @@ func (svr *Service) Run(ctx context.Context) error {
}
if svr.vnetController != nil {
vnetController := svr.vnetController
if err := svr.vnetController.Init(); err != nil {
log.Errorf("init virtual network controller error: %v", err)
svr.stop()
return err
}
go func() {
log.Infof("virtual network controller start...")
if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {
if err := svr.vnetController.Run(); err != nil {
log.Warnf("virtual network controller exit with error: %v", err)
}
}()
}
if svr.webServer != nil {
webServer := svr.webServer
go func() {
log.Infof("admin server listen on %s", webServer.Address())
if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Infof("admin server listen on %s", svr.webServer.Address())
if err := svr.webServer.Run(); err != nil {
log.Warnf("admin server exit with error: %v", err)
}
}()
@@ -261,7 +255,6 @@ func (svr *Service) Run(ctx context.Context) error {
if svr.ctl == nil {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
svr.stop()
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
}
@@ -504,10 +497,6 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
if svr.vnetController != nil {
_ = svr.vnetController.Stop()
svr.vnetController = nil
}
}
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
@@ -521,17 +510,6 @@ 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,

View File

@@ -1,120 +1,14 @@
package client
import (
"context"
"errors"
"net"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type failingConnector struct {
err error
}
func (c *failingConnector) Open() error {
return c.err
}
func (c *failingConnector) Connect() (net.Conn, error) {
return nil, c.err
}
func (c *failingConnector) Close() error {
return nil
}
func getFreeTCPPort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on ephemeral port: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
port := getFreeTCPPort(t)
agg := source.NewAggregator(source.NewConfigSource())
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: lo.ToPtr(true),
WebServer: v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
},
},
ConfigSourceAggregator: agg,
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
return &failingConnector{err: errors.New("login boom")}
},
})
if err != nil {
t.Fatalf("new service: %v", err)
}
err = svr.Run(context.Background())
if err == nil {
t.Fatal("expected run error, got nil")
}
if !strings.Contains(err.Error(), "login boom") {
t.Fatalf("unexpected error: %v", err)
}
if svr.webServer != nil {
t.Fatal("expected web server to be cleaned up after initial login failure")
}
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
t.Fatalf("expected admin port to be released: %v", err)
}
_ = ln.Close()
}
func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {
port := getFreeTCPPort(t)
agg := source.NewAggregator(source.NewConfigSource())
_, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
Auth: v1.AuthClientConfig{
Method: v1.AuthMethodOIDC,
OIDC: v1.AuthOIDCClientConfig{
TokenEndpointURL: "://bad",
},
},
WebServer: v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
},
},
ConfigSourceAggregator: agg,
})
if err == nil {
t.Fatal("expected new service error, got nil")
}
if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") {
t.Fatalf("unexpected error: %v", err)
}
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
t.Fatalf("expected admin port to remain free: %v", err)
}
_ = ln.Close()
}
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
newCommon := &v1.ClientCommonConfig{User: "new-user"}

View File

@@ -191,13 +191,6 @@ 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

View File

@@ -1,80 +0,0 @@
# Release Process
## 1. Update Release Notes
Edit `Release.md` in the project root with the changes for this version:
```markdown
## Features
* ...
## Improvements
* ...
## Fixes
* ...
```
This file is used by GoReleaser as the GitHub Release body.
## 2. Bump Version
Update the version string in `pkg/util/version/version.go`:
```go
var version = "0.X.0"
```
Commit and push to `dev`:
```bash
git add pkg/util/version/version.go Release.md
git commit -m "bump version to vX.Y.Z"
git push origin dev
```
## 3. Merge dev → master
Create a PR from `dev` to `master`:
```bash
gh pr create --base master --head dev --title "bump version"
```
Wait for CI to pass, then merge using **merge commit** (not squash).
## 4. Tag the Release
```bash
git checkout master
git pull origin master
git tag -a vX.Y.Z -m "bump version"
git push origin vX.Y.Z
```
## 5. Trigger GoReleaser
Manually trigger the `goreleaser` workflow in GitHub Actions:
```bash
gh workflow run goreleaser --ref master
```
GoReleaser will:
1. Run `package.sh` to cross-compile all platforms and create archives
2. Create a GitHub Release with all packages, using `Release.md` as release notes
## Key Files
| File | Purpose |
|------|---------|
| `pkg/util/version/version.go` | Version string |
| `Release.md` | Release notes (read by GoReleaser) |
| `.goreleaser.yml` | GoReleaser config |
| `package.sh` | Cross-compile and packaging script |
| `.github/workflows/goreleaser.yml` | GitHub Actions workflow (manual trigger) |
## Versioning
- Minor release: `v0.X.0`
- Patch release: `v0.X.Y` (e.g., `v0.62.1`)

View File

@@ -30,7 +30,6 @@ import (
"golang.org/x/oauth2/clientcredentials"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
)
@@ -76,64 +75,14 @@ func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyUR
return &http.Client{Transport: transport}, nil
}
// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh
// token on every call. This is used as a fallback when the OIDC provider
// does not return expires_in, which would cause a caching TokenSource to
// hold onto a stale token forever.
type nonCachingTokenSource struct {
cfg *clientcredentials.Config
ctx context.Context
}
func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {
return s.cfg.Token(s.ctx)
}
// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first
// successful Token() call, checks whether the provider returns an expiry.
// If not, it permanently switches to nonCachingTokenSource so that a fresh
// token is fetched every time. This avoids an eager network call at
// construction time, letting the login retry loop handle transient IdP
// outages.
type oidcTokenSource struct {
mu sync.Mutex
initialized bool
source oauth2.TokenSource
fallbackCfg *clientcredentials.Config
fallbackCtx context.Context
}
func (s *oidcTokenSource) Token() (*oauth2.Token, error) {
s.mu.Lock()
if !s.initialized {
token, err := s.source.Token()
if err != nil {
s.mu.Unlock()
return nil, err
}
if token.Expiry.IsZero() {
s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}
}
s.initialized = true
s.mu.Unlock()
return token, nil
}
source := s.source
s.mu.Unlock()
return source.Token()
}
type OidcAuthProvider struct {
additionalAuthScopes []v1.AuthScope
tokenSource oauth2.TokenSource
tokenGenerator *clientcredentials.Config
httpClient *http.Client
}
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {
return nil, err
}
eps := make(map[string][]string)
for k, v := range cfg.AdditionalEndpointParams {
eps[k] = []string{v}
@@ -151,42 +100,30 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
EndpointParams: eps,
}
// Build the context that TokenSource will use for all future HTTP requests.
// context.Background() is appropriate here because the token source is
// long-lived and outlives any single request.
ctx := context.Background()
// Create custom HTTP client if needed
var httpClient *http.Client
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
var err error
httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
}
// Create a persistent TokenSource that caches the token and refreshes
// it before expiry. This avoids making a new HTTP request to the OIDC
// provider on every heartbeat/ping.
//
// We wrap it in an oidcTokenSource so that the first Token() call
// (deferred to SetLogin inside the login retry loop) probes whether the
// provider returns expires_in. If not, it switches to a non-caching
// source. This avoids an eager network call at construction time, which
// would prevent loopLoginUntilSuccess from retrying on transient IdP
// outages.
cachingSource := tokenGenerator.TokenSource(ctx)
return &OidcAuthProvider{
additionalAuthScopes: additionalAuthScopes,
tokenSource: &oidcTokenSource{
source: cachingSource,
fallbackCfg: tokenGenerator,
fallbackCtx: ctx,
},
tokenGenerator: tokenGenerator,
httpClient: httpClient,
}, nil
}
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
tokenObj, err := auth.tokenSource.Token()
ctx := context.Background()
if auth.httpClient != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
}
tokenObj, err := auth.tokenGenerator.Token(ctx)
if err != nil {
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
}

View File

@@ -2,10 +2,6 @@ package auth_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
@@ -66,188 +62,3 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
r.Error(err)
r.Contains(err.Error(), "received different OIDC subject in login and ping")
}
func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
r := require.New(t)
var requestCount atomic.Int32
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
requestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
"access_token": "fresh-test-token",
"token_type": "Bearer",
})
}))
defer tokenServer.Close()
provider, err := auth.NewOidcAuthSetter(
[]v1.AuthScope{v1.AuthScopeHeartBeats},
v1.AuthOIDCClientConfig{
ClientID: "test-client",
ClientSecret: "test-secret",
TokenEndpointURL: tokenServer.URL,
},
)
r.NoError(err)
// Constructor no longer fetches a token eagerly.
// The first SetLogin triggers the adaptive probe.
r.Equal(int32(0), requestCount.Load())
loginMsg := &msg.Login{}
err = provider.SetLogin(loginMsg)
r.NoError(err)
r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
for range 3 {
pingMsg := &msg.Ping{}
err = provider.SetPing(pingMsg)
r.NoError(err)
r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
}
// 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)
r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
}
func TestOidcAuthProviderCachesToken(t *testing.T) {
r := require.New(t)
var requestCount atomic.Int32
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
requestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
"access_token": "cached-test-token",
"token_type": "Bearer",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
provider, err := auth.NewOidcAuthSetter(
[]v1.AuthScope{v1.AuthScopeHeartBeats},
v1.AuthOIDCClientConfig{
ClientID: "test-client",
ClientSecret: "test-secret",
TokenEndpointURL: tokenServer.URL,
},
)
r.NoError(err)
// Constructor no longer fetches eagerly; first SetLogin triggers the probe.
r.Equal(int32(0), requestCount.Load())
// SetLogin triggers the adaptive probe and caches the token.
loginMsg := &msg.Login{}
err = provider.SetLogin(loginMsg)
r.NoError(err)
r.Equal("cached-test-token", loginMsg.PrivilegeKey)
r.Equal(int32(1), requestCount.Load())
// Subsequent calls should also reuse the cached token
for range 5 {
pingMsg := &msg.Ping{}
err = provider.SetPing(pingMsg)
r.NoError(err)
r.Equal("cached-test-token", pingMsg.PrivilegeKey)
}
r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
}
func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {
r := require.New(t)
var requestCount atomic.Int32
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
n := requestCount.Add(1)
// The oauth2 library retries once internally, so we need two
// consecutive failures to surface an error to the caller.
if n <= 2 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "temporarily_unavailable",
"error_description": "service is starting up",
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
"access_token": "retry-test-token",
"token_type": "Bearer",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
// Constructor succeeds even though the IdP is "down".
provider, err := auth.NewOidcAuthSetter(
[]v1.AuthScope{v1.AuthScopeHeartBeats},
v1.AuthOIDCClientConfig{
ClientID: "test-client",
ClientSecret: "test-secret",
TokenEndpointURL: tokenServer.URL,
},
)
r.NoError(err)
r.Equal(int32(0), requestCount.Load())
// First SetLogin hits the IdP, which returns an error (after internal retry).
loginMsg := &msg.Login{}
err = provider.SetLogin(loginMsg)
r.Error(err)
r.Equal(int32(2), requestCount.Load())
// Second SetLogin retries and succeeds.
err = provider.SetLogin(loginMsg)
r.NoError(err)
r.Equal("retry-test-token", loginMsg.PrivilegeKey)
r.Equal(int32(3), requestCount.Load())
// Subsequent calls use cached token.
pingMsg := &msg.Ping{}
err = provider.SetPing(pingMsg)
r.NoError(err)
r.Equal("retry-test-token", pingMsg.PrivilegeKey)
r.Equal(int32(3), requestCount.Load())
}
func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {
r := require.New(t)
tokenServer := httptest.NewServer(http.NotFoundHandler())
defer tokenServer.Close()
_, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: "://bad",
})
r.Error(err)
r.Contains(err.Error(), "auth.oidc.tokenEndpointURL")
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
TokenEndpointURL: tokenServer.URL,
})
r.Error(err)
r.Contains(err.Error(), "auth.oidc.clientID is required")
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: tokenServer.URL,
AdditionalEndpointParams: map[string]string{
"scope": "profile",
},
})
r.Error(err)
r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: tokenServer.URL,
Audience: "api",
AdditionalEndpointParams: map[string]string{"audience": "override"},
})
r.Error(err)
r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
}

View File

@@ -88,11 +88,6 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
}
if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {
if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
}
}
return nil, errs
}

View File

@@ -1,57 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"errors"
"net/url"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
var errs []string
if c.ClientID == "" {
errs = append(errs, "auth.oidc.clientID is required")
}
if c.TokenEndpointURL == "" {
errs = append(errs, "auth.oidc.tokenEndpointURL is required")
} else {
tokenURL, err := url.Parse(c.TokenEndpointURL)
if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
} else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
}
}
if _, ok := c.AdditionalEndpointParams["scope"]; ok {
errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
}
if c.Audience != "" {
if _, ok := c.AdditionalEndpointParams["audience"]; ok {
errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
}
}
if len(errs) == 0 {
return nil
}
return errors.New(strings.Join(errs, "; "))
}

View File

@@ -1,78 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
tokenServer := httptest.NewServer(http.NotFoundHandler())
defer tokenServer.Close()
t.Run("valid", func(t *testing.T) {
require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: tokenServer.URL,
AdditionalEndpointParams: map[string]string{
"resource": "api",
},
}))
})
t.Run("invalid token endpoint url", func(t *testing.T) {
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: "://bad",
})
require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
})
t.Run("missing client id", func(t *testing.T) {
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
TokenEndpointURL: tokenServer.URL,
})
require.ErrorContains(t, err, "auth.oidc.clientID is required")
})
t.Run("scope endpoint param is not allowed", func(t *testing.T) {
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: tokenServer.URL,
AdditionalEndpointParams: map[string]string{
"scope": "email",
},
})
require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
})
t.Run("audience conflict", func(t *testing.T) {
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
ClientID: "test-client",
TokenEndpointURL: tokenServer.URL,
Audience: "api",
AdditionalEndpointParams: map[string]string{
"audience": "override",
},
})
require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
})
}

View File

@@ -100,11 +100,7 @@ func (s *Server) Run() error {
}
func (s *Server) Close() error {
err := s.hs.Close()
if s.ln != nil {
_ = s.ln.Close()
}
return err
return s.hs.Close()
}
type RouterRegisterHelper struct {

View File

@@ -131,9 +131,6 @@ func (c *Controller) handlePacket(buf []byte) {
}
func (c *Controller) Stop() error {
if c.tun == nil {
return nil
}
return c.tun.Close()
}

View File

@@ -9,7 +9,6 @@ import (
"strconv"
"time"
"github.com/fatedier/frp/pkg/config"
flog "github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/pkg/process"
@@ -62,22 +61,9 @@ func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string
err = p.Start()
ExpectNoError(err)
}
// Wait for each client's proxies to register with frps.
// If any client has no proxies (e.g. visitor-only), fall back to sleep
// for the remaining time since visitors have no deterministic readiness signal.
allConfirmed := len(clientProcesses) > 0
start := time.Now()
for i, p := range clientProcesses {
configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]
if !waitForClientProxyReady(configPath, p, 5*time.Second) {
allConfirmed = false
}
}
if len(clientProcesses) > 0 && !allConfirmed {
remaining := 1500*time.Millisecond - time.Since(start)
if remaining > 0 {
time.Sleep(remaining)
}
// frpc needs time to connect and register proxies with frps.
if len(clientProcesses) > 0 {
time.Sleep(1500 * time.Millisecond)
}
return serverProcess, clientProcesses
@@ -119,55 +105,6 @@ func (f *Framework) GenerateConfigFile(content string) string {
return path
}
// waitForClientProxyReady parses the client config to extract proxy names,
// then waits for each proxy's "start proxy success" log in the process output.
// Returns true only if proxies were expected and all registered successfully.
func waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool {
_, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false)
if err != nil || len(proxyCfgs) == 0 {
return false
}
// Use a single deadline so the total wait across all proxies does not exceed timeout.
deadline := time.Now().Add(timeout)
for _, cfg := range proxyCfgs {
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
name := cfg.GetBaseConfig().Name
pattern := fmt.Sprintf("[%s] start proxy success", name)
if err := p.WaitForOutput(pattern, 1, remaining); err != nil {
return false
}
}
return true
}
// WaitForTCPUnreachable polls a TCP address until a connection fails or timeout.
func WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error {
if interval <= 0 {
return fmt.Errorf("invalid interval for TCP unreachable on %s: interval must be positive", addr)
}
if timeout <= 0 {
return fmt.Errorf("invalid timeout for TCP unreachable on %s: timeout must be positive", addr)
}
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return fmt.Errorf("timeout waiting for TCP unreachable on %s", addr)
}
dialTimeout := min(interval, remaining)
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return nil
}
conn.Close()
time.Sleep(min(interval, time.Until(deadline)))
}
}
// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.
func WaitForTCPReady(addr string, timeout time.Duration) error {
if timeout <= 0 {

View File

@@ -1,258 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package oidcserver provides a minimal mock OIDC server for e2e testing.
// It implements three endpoints:
// - /.well-known/openid-configuration (discovery)
// - /jwks (JSON Web Key Set)
// - /token (client_credentials grant)
package oidcserver
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net"
"net/http"
"strconv"
"sync/atomic"
"time"
)
type Server struct {
bindAddr string
bindPort int
l net.Listener
hs *http.Server
privateKey *rsa.PrivateKey
kid string
clientID string
clientSecret string
audience string
subject string
expiresIn int // seconds; 0 means omit expires_in from token response
tokenRequestCount atomic.Int64
}
type Option func(*Server)
func WithBindPort(port int) Option {
return func(s *Server) { s.bindPort = port }
}
func WithClientCredentials(id, secret string) Option {
return func(s *Server) {
s.clientID = id
s.clientSecret = secret
}
}
func WithAudience(aud string) Option {
return func(s *Server) { s.audience = aud }
}
func WithSubject(sub string) Option {
return func(s *Server) { s.subject = sub }
}
func WithExpiresIn(seconds int) Option {
return func(s *Server) { s.expiresIn = seconds }
}
func New(options ...Option) *Server {
s := &Server{
bindAddr: "127.0.0.1",
kid: "test-key-1",
clientID: "test-client",
clientSecret: "test-secret",
audience: "frps",
subject: "test-service",
expiresIn: 3600,
}
for _, opt := range options {
opt(s)
}
return s
}
func (s *Server) Run() error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("generate RSA key: %w", err)
}
s.privateKey = key
s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))
if err != nil {
return err
}
s.bindPort = s.l.Addr().(*net.TCPAddr).Port
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
mux.HandleFunc("/jwks", s.handleJWKS)
mux.HandleFunc("/token", s.handleToken)
s.hs = &http.Server{
Handler: mux,
ReadHeaderTimeout: time.Minute,
}
go func() { _ = s.hs.Serve(s.l) }()
return nil
}
func (s *Server) Close() error {
if s.hs != nil {
return s.hs.Close()
}
return nil
}
func (s *Server) BindAddr() string { return s.bindAddr }
func (s *Server) BindPort() int { return s.bindPort }
func (s *Server) Issuer() string {
return fmt.Sprintf("http://%s:%d", s.bindAddr, s.bindPort)
}
func (s *Server) TokenEndpoint() string {
return s.Issuer() + "/token"
}
// TokenRequestCount returns the number of successful token requests served.
func (s *Server) TokenRequestCount() int64 {
return s.tokenRequestCount.Load()
}
func (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
issuer := s.Issuer()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer,
"token_endpoint": issuer + "/token",
"jwks_uri": issuer + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
}
func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
pub := &s.privateKey.PublicKey
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": s.kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
},
},
})
}
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "invalid_request",
})
return
}
if r.FormValue("grant_type") != "client_credentials" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "unsupported_grant_type",
})
return
}
// Accept credentials from Basic Auth or form body.
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
clientID = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if clientID != s.clientID || clientSecret != s.clientSecret {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "invalid_client",
})
return
}
token, err := s.signJWT()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]any{
"access_token": token,
"token_type": "Bearer",
}
if s.expiresIn > 0 {
resp["expires_in"] = s.expiresIn
}
s.tokenRequestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func (s *Server) signJWT() (string, error) {
now := time.Now()
header, _ := json.Marshal(map[string]string{
"alg": "RS256",
"kid": s.kid,
"typ": "JWT",
})
claims, _ := json.Marshal(map[string]any{
"iss": s.Issuer(),
"sub": s.subject,
"aud": s.audience,
"iat": now.Unix(),
"exp": now.Add(1 * time.Hour).Unix(),
})
headerB64 := base64.RawURLEncoding.EncodeToString(header)
claimsB64 := base64.RawURLEncoding.EncodeToString(claims)
signingInput := headerB64 + "." + claimsB64
h := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:])
if err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}

View File

@@ -4,37 +4,15 @@ import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
// SafeBuffer is a thread-safe wrapper around bytes.Buffer.
// It is safe to call Write and String concurrently.
type SafeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *SafeBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *SafeBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
type Process struct {
cmd *exec.Cmd
cancel context.CancelFunc
errorOutput *SafeBuffer
stdOutput *SafeBuffer
errorOutput *bytes.Buffer
stdOutput *bytes.Buffer
done chan struct{}
closeOne sync.Once
@@ -58,8 +36,8 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
cancel: cancel,
done: make(chan struct{}),
}
p.errorOutput = &SafeBuffer{}
p.stdOutput = &SafeBuffer{}
p.errorOutput = bytes.NewBufferString("")
p.stdOutput = bytes.NewBufferString("")
cmd.Stderr = p.errorOutput
cmd.Stdout = p.stdOutput
return p
@@ -120,34 +98,6 @@ func (p *Process) Output() string {
return p.stdOutput.String() + p.errorOutput.String()
}
// CountOutput returns how many times pattern appears in the current accumulated output.
func (p *Process) CountOutput(pattern string) int {
return strings.Count(p.Output(), pattern)
}
func (p *Process) SetBeforeStopHandler(fn func()) {
p.beforeStopHandler = fn
}
// WaitForOutput polls the combined process output until the pattern is found
// count time(s) or the timeout is reached. It also returns early if the process exits.
func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
output := p.Output()
if strings.Count(output, pattern) >= count {
return nil
}
select {
case <-p.Done():
// Process exited, check one last time.
output = p.Output()
if strings.Count(output, pattern) >= count {
return nil
}
return fmt.Errorf("process exited before %d occurrence(s) of %q found", count, pattern)
case <-time.After(25 * time.Millisecond):
}
}
return fmt.Errorf("timeout waiting for %d occurrence(s) of %q", count, pattern)
}

View File

@@ -1,192 +0,0 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package basic
import (
"fmt"
"time"
"github.com/onsi/ginkgo/v2"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/mock/server/oidcserver"
"github.com/fatedier/frp/test/e2e/pkg/port"
)
var _ = ginkgo.Describe("[Feature: OIDC]", func() {
f := framework.NewDefaultFramework()
ginkgo.It("should work with OIDC authentication", func() {
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
f.RunServer("", oidcSrv)
portName := port.GenName("TCP")
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, oidcSrv.Issuer())
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "test-secret"
auth.oidc.tokenEndpointURL = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("should authenticate heartbeats with OIDC", func() {
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
f.RunServer("", oidcSrv)
serverPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := fmt.Sprintf(`
bindAddr = "0.0.0.0"
bindPort = %d
log.level = "trace"
auth.method = "oidc"
auth.additionalScopes = ["HeartBeats"]
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, serverPort, oidcSrv.Issuer())
clientConf := fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
log.level = "trace"
auth.method = "oidc"
auth.additionalScopes = ["HeartBeats"]
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "test-secret"
auth.oidc.tokenEndpointURL = "%s"
transport.heartbeatInterval = 1
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort)
serverConfigPath := f.GenerateConfigFile(serverConf)
clientConfigPath := f.GenerateConfigFile(clientConf)
_, _, err := f.RunFrps("-c", serverConfigPath)
framework.ExpectNoError(err)
clientProcess, _, err := f.RunFrpc("-c", clientConfigPath)
framework.ExpectNoError(err)
// Wait for several authenticated heartbeat cycles instead of a fixed sleep.
err = clientProcess.WaitForOutput("send heartbeat to server", 3, 10*time.Second)
framework.ExpectNoError(err)
// Proxy should still work: heartbeat auth has not failed.
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
ginkgo.It("should work when token has no expires_in", func() {
oidcSrv := oidcserver.New(
oidcserver.WithBindPort(f.AllocPort()),
oidcserver.WithExpiresIn(0),
)
f.RunServer("", oidcSrv)
portName := port.GenName("TCP")
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, oidcSrv.Issuer())
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.additionalScopes = ["HeartBeats"]
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "test-secret"
auth.oidc.tokenEndpointURL = "%s"
transport.heartbeatInterval = 1
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
countAfterLogin := oidcSrv.TokenRequestCount()
// Wait for several heartbeat cycles instead of a fixed sleep.
// Each heartbeat fetches a fresh token in non-caching mode.
err := clientProcesses[0].WaitForOutput("send heartbeat to server", 3, 10*time.Second)
framework.ExpectNoError(err)
framework.NewRequestExpect(f).PortName(portName).Ensure()
// Each heartbeat should have fetched a new token (non-caching mode).
countAfterHeartbeats := oidcSrv.TokenRequestCount()
framework.ExpectTrue(
countAfterHeartbeats > countAfterLogin,
"expected additional token requests for heartbeats, got %d before and %d after",
countAfterLogin, countAfterHeartbeats,
)
})
ginkgo.It("should reject invalid OIDC credentials", func() {
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
f.RunServer("", oidcSrv)
portName := port.GenName("TCP")
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.issuer = "%s"
auth.oidc.audience = "frps"
`, oidcSrv.Issuer())
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
auth.method = "oidc"
auth.oidc.clientID = "test-client"
auth.oidc.clientSecret = "wrong-secret"
auth.oidc.tokenEndpointURL = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
})
})

View File

@@ -41,24 +41,24 @@ var _ = ginkgo.Describe("[Feature: Chaos]", func() {
// 2. stop frps, expect request failed
_ = ps.Stop()
time.Sleep(200 * time.Millisecond)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 3. restart frps, expect request success
successCount := pc.CountOutput("[tcp] start proxy success")
_, _, err = f.RunFrps("-c", serverConfigPath)
framework.ExpectNoError(err)
framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*time.Second))
time.Sleep(2 * time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
// 4. stop frpc, expect request failed
_ = pc.Stop()
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
time.Sleep(200 * time.Millisecond)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 5. restart frpc, expect request success
newPc, _, err := f.RunFrpc("-c", clientConfigPath)
_, _, err = f.RunFrpc("-c", clientConfigPath)
framework.ExpectNoError(err)
framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*time.Second))
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
})

View File

@@ -286,7 +286,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.intervalSeconds = 1
`, fooPort, remotePort, barPort, remotePort)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
f.RunProcesses(serverConf, []string{clientConf})
// check foo and bar is ok
results := []string{}
@@ -299,17 +299,15 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
time.Sleep(2 * time.Second)
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
// resume bar server, check foo and bar is ok
successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
time.Sleep(2 * time.Second)
results = []string{}
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
@@ -359,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.path = "/healthz"
`, fooPort, barPort)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
f.RunProcesses(serverConf, []string{clientConf})
// send first HTTP request
var contents []string
@@ -389,17 +387,15 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
time.Sleep(2 * time.Second)
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo"})
framework.ExpectNotContainElements(results, []string{"bar"})
// resume bar server, check foo and bar is ok
successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
time.Sleep(2 * time.Second)
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"})
})

View File

@@ -31,7 +31,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
@@ -52,7 +52,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200
})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second))
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -72,7 +72,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200
})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*time.Second))
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort1).Ensure()
proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
@@ -107,8 +107,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200
})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second))
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second))
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
})
@@ -127,7 +126,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
@@ -148,7 +147,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200
})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second))
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
@@ -157,7 +156,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200
})
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
})
@@ -175,7 +174,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
@@ -196,6 +195,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
return resp.Code == 200
})
time.Sleep(500 * time.Millisecond)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
}).Ensure(func(resp *request.Response) bool {
@@ -225,7 +226,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
@@ -247,7 +248,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
invalidBody, _ := json.Marshal(map[string]any{
"name": "bad-proxy",
@@ -280,7 +281,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
`, adminPort, f.TempDirectory)
f.RunProcesses(serverConf, []string{clientConf})
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
time.Sleep(500 * time.Millisecond)
createBody, _ := json.Marshal(map[string]any{
"name": "proxy-a",

View File

@@ -1,7 +1,7 @@
.PHONY: dist install build preview lint
install:
@cd .. && npm install
@npm install
build: install
@npm run build

View File

@@ -7,39 +7,28 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ConfigField: typeof import('./src/components/ConfigField.vue')['default']
ConfigSection: typeof import('./src/components/ConfigSection.vue')['default']
ElDialog: typeof import('element-plus/es')['ElDialog']
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']
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']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
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']
KeyValueEditor: typeof import('./src/components/KeyValueEditor.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']
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']
StatCard: typeof import('./src/components/StatCard.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>frp client</title>
</head>

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@
},
"dependencies": {
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},

View File

@@ -2,160 +2,140 @@
<div id="app">
<header class="header">
<div class="header-content">
<div class="brand-section">
<button v-if="isMobile" class="hamburger-btn" @click="toggleSidebar" aria-label="Toggle menu">
<span class="hamburger-icon">&#9776;</span>
</button>
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
<div class="header-top">
<div class="brand-section">
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge client-badge">Client</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge">Client</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
<nav class="nav-bar">
<router-link to="/" class="nav-link" active-class="active"
>Overview</router-link
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
<router-link to="/configure" class="nav-link" active-class="active"
>Configure</router-link
>
</nav>
</div>
</header>
<div class="layout">
<!-- Mobile overlay -->
<div
v-if="isMobile && sidebarOpen"
class="sidebar-overlay"
@click="closeSidebar"
/>
<aside class="sidebar" :class="{ 'mobile-open': isMobile && sidebarOpen }">
<nav class="sidebar-nav">
<router-link
to="/proxies"
class="sidebar-link"
:class="{ active: route.path.startsWith('/proxies') }"
@click="closeSidebar"
>
Proxies
</router-link>
<router-link
to="/visitors"
class="sidebar-link"
:class="{ active: route.path.startsWith('/visitors') }"
@click="closeSidebar"
>
Visitors
</router-link>
<router-link
to="/config"
class="sidebar-link"
:class="{ active: route.path === '/config' }"
@click="closeSidebar"
>
Config
</router-link>
</nav>
</aside>
<main id="content">
<router-view></router-view>
</main>
</div>
<main id="content">
<router-view></router-view>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component'
import { useResponsive } from './composables/useResponsive'
const route = useRoute()
const isDark = useDark()
const { isMobile } = useResponsive()
const sidebarOpen = ref(false)
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const closeSidebar = () => {
sidebarOpen.value = false
}
// Auto-close sidebar on route change
watch(() => route.path, () => {
if (isMobile.value) {
closeSidebar()
}
const currentRouteName = computed(() => {
if (route.path === '/') return 'Overview'
if (route.path === '/configure') return 'Configure'
if (route.path === '/proxies/create') return 'Create Proxy'
if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit'))
return 'Edit Proxy'
if (route.path === '/visitors/create') return 'Create Visitor'
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit'))
return 'Edit Visitor'
return ''
})
</script>
<style lang="scss">
<style>
:root {
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica,
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
}
*,
:after,
:before {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
overflow: hidden;
}
#app {
height: 100vh;
height: 100dvh;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: $color-bg-secondary;
background-color: var(--el-bg-color-page);
}
// Header
.header {
flex-shrink: 0;
background: $color-bg-primary;
border-bottom: 1px solid $color-border-light;
height: $header-height;
position: sticky;
top: 0;
z-index: 100;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
}
.header-top {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 $spacing-xl;
}
.brand-section {
display: flex;
align-items: center;
gap: $spacing-md;
gap: 12px;
}
.logo-wrapper {
@@ -164,30 +144,41 @@ html, body {
}
.logo-icon {
width: 28px;
height: 28px;
width: 32px;
height: 32px;
}
.divider {
color: $color-border;
font-size: 22px;
color: var(--header-border);
font-size: 24px;
font-weight: 200;
}
.brand-name {
font-weight: $font-weight-semibold;
font-size: $font-size-xl;
color: $color-text-primary;
font-weight: 600;
font-size: 18px;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.badge {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $color-text-muted;
background: $color-bg-muted;
font-size: 12px;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 4px;
border-radius: 99px;
border: 1px solid var(--header-border);
}
.badge.client-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
font-weight: 500;
}
html.dark .badge.client-badge {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
.header-controls {
@@ -197,17 +188,17 @@ html, body {
}
.github-link {
@include flex-center;
width: 28px;
height: 28px;
border-radius: $radius-sm;
color: $color-text-secondary;
transition: all $transition-fast;
&:hover {
background: $color-bg-hover;
color: $color-text-primary;
}
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-primary);
transition: background 0.2s;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.github-icon {
@@ -215,10 +206,15 @@ html, body {
height: 18px;
}
.github-link:hover {
background: var(--hover-bg);
border-color: var(--header-border);
}
.theme-switch {
--el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2;
--el-switch-border-color: var(--color-border-light);
--el-switch-border-color: var(--header-border);
}
html.dark .theme-switch {
@@ -229,300 +225,47 @@ html.dark .theme-switch {
color: #909399 !important;
}
// Layout
.layout {
flex: 1;
.nav-bar {
height: 48px;
display: flex;
overflow: hidden;
align-items: center;
gap: 24px;
}
.sidebar {
width: $sidebar-width;
flex-shrink: 0;
border-right: 1px solid $color-border-light;
padding: $spacing-lg $spacing-md;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar-nav {
@include flex-column;
gap: 2px;
}
.sidebar-link {
display: block;
.nav-link {
text-decoration: none;
font-size: $font-size-lg;
color: $color-text-secondary;
padding: 10px $spacing-md;
border-radius: $radius-sm;
transition: all $transition-fast;
&:hover {
color: $color-text-primary;
background: $color-bg-hover;
}
&.active {
color: $color-text-primary;
background: $color-bg-hover;
font-weight: $font-weight-medium;
}
font-size: 14px;
color: var(--text-secondary);
padding: 8px 0;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
// Hamburger button (mobile only)
.hamburger-btn {
@include flex-center;
width: 36px;
height: 36px;
border: none;
border-radius: $radius-sm;
background: transparent;
cursor: pointer;
padding: 0;
transition: background $transition-fast;
&:hover {
background: $color-bg-hover;
}
.nav-link:hover {
color: var(--text-primary);
}
.hamburger-icon {
font-size: 20px;
line-height: 1;
color: $color-text-primary;
}
// Mobile overlay
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
.nav-link.active {
color: var(--active-link);
border-bottom-color: var(--active-link);
}
#content {
flex: 1;
min-width: 0;
overflow: hidden;
background: $color-bg-primary;
width: 100%;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
}
// Common page styles
.page-title {
font-size: $font-size-xl + 2px;
font-weight: $font-weight-semibold;
color: $color-text-primary;
margin: 0;
}
.page-subtitle {
font-size: $font-size-md;
color: $color-text-muted;
margin: $spacing-sm 0 0;
}
.icon-btn {
@include flex-center;
width: 32px;
height: 32px;
border: none;
border-radius: $radius-sm;
background: transparent;
color: $color-text-muted;
cursor: pointer;
transition: all $transition-fast;
&:hover {
background: $color-bg-hover;
color: $color-text-primary;
}
}
.search-input {
width: 200px;
.el-input__wrapper {
border-radius: 10px;
background: $color-bg-tertiary;
box-shadow: 0 0 0 1px $color-border inset;
&.is-focus {
box-shadow: 0 0 0 1px $color-text-light inset;
}
}
.el-input__inner {
color: $color-text-primary;
}
.el-input__prefix {
color: $color-text-muted;
}
@include mobile {
flex: 1;
width: auto;
}
}
// Element Plus global overrides
.el-button {
font-weight: $font-weight-medium;
}
.el-tag {
font-weight: $font-weight-medium;
}
.el-switch {
--el-switch-on-color: #606266;
--el-switch-off-color: #dcdfe6;
}
html.dark .el-switch {
--el-switch-on-color: #b0b0b0;
--el-switch-off-color: #404040;
}
.el-radio {
--el-radio-text-color: var(--color-text-primary) !important;
--el-radio-input-border-color-hover: #606266 !important;
--el-color-primary: #606266 !important;
}
.el-form-item {
margin-bottom: 16px;
}
.el-loading-mask {
border-radius: $radius-md;
}
// Select overrides
.el-select__wrapper {
border-radius: $radius-md !important;
box-shadow: 0 0 0 1px $color-border-light inset !important;
transition: all $transition-fast;
&:hover {
box-shadow: 0 0 0 1px $color-border inset !important;
}
&.is-focused {
box-shadow: 0 0 0 1px $color-border inset !important;
}
}
.el-select-dropdown {
border-radius: 12px !important;
border: 1px solid $color-border-light !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
padding: 4px !important;
}
.el-select-dropdown__item {
border-radius: $radius-sm;
margin: 2px 0;
transition: background $transition-fast;
&.is-selected {
color: $color-text-primary;
font-weight: $font-weight-medium;
}
}
// Input overrides
.el-input__wrapper {
border-radius: $radius-md !important;
box-shadow: 0 0 0 1px $color-border-light inset !important;
transition: all $transition-fast;
&:hover {
box-shadow: 0 0 0 1px $color-border inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px $color-border inset !important;
}
}
// Status pill (shared)
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
padding: 3px 10px;
border-radius: 10px;
text-transform: capitalize;
&.running {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.error {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
&.waiting {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
&.disabled {
background: $color-bg-muted;
color: $color-text-light;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
}
// Mobile
@include mobile {
@media (max-width: 768px) {
.header-content {
padding: 0 $spacing-lg;
}
.sidebar {
position: fixed;
top: $header-height;
left: 0;
bottom: 0;
z-index: 100;
background: $color-bg-primary;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border-right: 1px solid $color-border-light;
&.mobile-open {
transform: translateX(0);
}
}
.sidebar-nav {
flex-direction: column;
gap: 2px;
padding: 0 20px;
}
#content {
width: 100%;
}
// Select dropdown overflow prevention
.el-select-dropdown {
max-width: calc(100vw - 32px);
padding: 20px;
}
}
</style>

View File

@@ -5,7 +5,7 @@ import type {
ProxyDefinition,
VisitorListResp,
VisitorDefinition,
} from '../types'
} from '../types/proxy'
export const getStatus = () => {
return http.get<StatusResponse>('/api/status')
@@ -23,19 +23,6 @@ export const reloadConfig = () => {
return http.get<void>('/api/reload')
}
// Config lookup API (any source)
export const getProxyConfig = (name: string) => {
return http.get<ProxyDefinition>(
`/api/proxy/${encodeURIComponent(name)}/config`,
)
}
export const getVisitorConfig = (name: string) => {
return http.get<VisitorDefinition>(
`/api/visitor/${encodeURIComponent(name)}/config`,
)
}
// Store API - Proxies
export const listStoreProxies = () => {
return http.get<ProxyListResp>('/api/store/proxies')

View File

@@ -1,33 +0,0 @@
@use '@shared/css/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;
}
}

View File

@@ -0,0 +1,105 @@
/* 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;
}

View File

@@ -1,51 +1,48 @@
/* Dark mode styles */
/* Dark Mode Theme */
html.dark {
--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;
--el-bg-color: #1e1e2e;
--el-bg-color-page: #1a1a2e;
--el-bg-color-overlay: #27293d;
--el-fill-color-blank: #1e1e2e;
background-color: #1a1a2e;
}
/* Scrollbar */
html.dark ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
html.dark ::-webkit-scrollbar-track {
background: #303030;
}
html.dark ::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 3px;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #505050;
}
/* Form */
html.dark .el-form-item__label {
html.dark body {
background-color: #1a1a2e;
color: #e5e7eb;
}
/* Input */
/* 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;
}
html.dark .el-card__header {
border-bottom-color: #3a3d5c;
}
/* Dark mode inputs */
html.dark .el-input__wrapper {
background: var(--color-bg-input);
box-shadow: 0 0 0 1px #404040 inset;
background-color: #27293d;
box-shadow: 0 0 0 1px #3a3d5c inset;
}
html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px #505050 inset;
box-shadow: 0 0 0 1px #4a4d6c inset;
}
html.dark .el-input__wrapper.is-focus {
@@ -57,44 +54,71 @@ html.dark .el-input__inner {
}
html.dark .el-input__inner::placeholder {
color: #afafaf;
color: #6b7280;
}
/* Dark mode textarea */
html.dark .el-textarea__inner {
background: var(--color-bg-input);
box-shadow: 0 0 0 1px #404040 inset;
background-color: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-textarea__inner:hover {
box-shadow: 0 0 0 1px #505050 inset;
html.dark .el-textarea__inner::placeholder {
color: #6b7280;
}
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 {
/* Dark mode table */
html.dark .el-table {
background-color: #27293d;
color: #e5e7eb;
}
html.dark .el-select__placeholder {
color: #afafaf;
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-dropdown {
background: #303030;
border-color: #404040;
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-select-dropdown__item {
@@ -102,92 +126,55 @@ html.dark .el-select-dropdown__item {
}
html.dark .el-select-dropdown__item:hover {
background: #3a3a3a;
background-color: #2a2a3c;
}
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 */
/* Dark mode dialog */
html.dark .el-dialog {
background: #212121;
background-color: #27293d;
}
html.dark .el-dialog__header {
border-bottom-color: #3a3d5c;
}
html.dark .el-dialog__title {
color: #e5e7eb;
}
/* Message */
html.dark .el-message {
background: #303030;
border-color: #404040;
html.dark .el-dialog__body {
color: #e5e7eb;
}
html.dark .el-message--success {
background: #1e3d2e;
border-color: #3d6b4f;
/* Dark mode message box */
html.dark .el-message-box {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-message--warning {
background: #3d3020;
border-color: #6b5020;
html.dark .el-message-box__title {
color: #e5e7eb;
}
html.dark .el-message--error {
background: #3d2027;
border-color: #5c2d2d;
html.dark .el-message-box__message {
color: #e5e7eb;
}
/* Loading */
/* Dark mode empty */
html.dark .el-empty__description {
color: #9ca3af;
}
/* Dark mode loading */
html.dark .el-loading-mask {
background-color: rgba(33, 33, 33, 0.9);
background-color: rgba(30, 30, 46, 0.9);
}
/* Overlay */
html.dark .el-overlay {
background-color: rgba(0, 0, 0, 0.6);
html.dark .el-loading-text {
color: #e5e7eb;
}
/* Tooltip */
html.dark .el-tooltip__popper {
background: #303030 !important;
border-color: #404040 !important;
color: #e5e7eb !important;
/* Dark mode tooltip */
html.dark .el-tooltip__trigger {
color: #e5e7eb;
}

View File

@@ -1,117 +0,0 @@
: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;
}

View File

@@ -1,249 +0,0 @@
<template>
<!-- Edit mode: use el-form-item for validation -->
<el-form-item v-if="!readonly" :label="label" :prop="prop" :class="($attrs.class as string)">
<!-- text -->
<el-input
v-if="type === 'text'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- number -->
<el-input
v-else-if="type === 'number'"
:model-value="modelValue != null ? String(modelValue) : ''"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="handleNumberInput($event)"
/>
<!-- switch -->
<div v-else-if="type === 'switch'" class="config-field-switch-wrap">
<el-switch
:model-value="modelValue"
:disabled="disabled"
size="small"
@update:model-value="$emit('update:modelValue', $event)"
/>
<span v-if="tip" class="config-field-switch-tip">{{ tip }}</span>
</div>
<!-- select -->
<PopoverMenu
v-else-if="type === 'select'"
:model-value="modelValue"
:display-value="selectDisplayValue"
:disabled="disabled"
:width="selectWidth"
selectable
full-width
filterable
:filter-placeholder="placeholder || 'Select...'"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #default="{ filterText }">
<PopoverMenuItem
v-for="opt in filteredOptions(filterText)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</PopoverMenuItem>
</template>
</PopoverMenu>
<!-- password -->
<el-input
v-else-if="type === 'password'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
type="password"
show-password
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- kv -->
<KeyValueEditor
v-else-if="type === 'kv'"
:model-value="modelValue"
:key-placeholder="keyPlaceholder"
:value-placeholder="valuePlaceholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- tags (string array) -->
<StringListEditor
v-else-if="type === 'tags'"
:model-value="modelValue || []"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<div v-if="tip && type !== 'switch'" class="config-field-tip">{{ tip }}</div>
</el-form-item>
<!-- Readonly mode: plain display -->
<div v-else class="config-field-readonly" :class="($attrs.class as string)">
<div class="config-field-label">{{ label }}</div>
<!-- switch readonly -->
<el-switch
v-if="type === 'switch'"
:model-value="modelValue"
disabled
size="small"
/>
<!-- kv readonly -->
<KeyValueEditor
v-else-if="type === 'kv'"
:model-value="modelValue || []"
:key-placeholder="keyPlaceholder"
:value-placeholder="valuePlaceholder"
readonly
/>
<!-- tags readonly -->
<StringListEditor
v-else-if="type === 'tags'"
:model-value="modelValue || []"
readonly
/>
<!-- text/number/select/password readonly -->
<el-input
v-else
:model-value="displayValue"
disabled
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import KeyValueEditor from './KeyValueEditor.vue'
import StringListEditor from './StringListEditor.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
const props = withDefaults(
defineProps<{
label: string
type?: 'text' | 'number' | 'switch' | 'select' | 'password' | 'kv' | 'tags'
readonly?: boolean
modelValue?: any
placeholder?: string
disabled?: boolean
tip?: string
prop?: string
options?: Array<{ label: string; value: string | number }>
min?: number
max?: number
keyPlaceholder?: string
valuePlaceholder?: string
}>(),
{
type: 'text',
readonly: false,
modelValue: undefined,
placeholder: '',
disabled: false,
tip: '',
prop: '',
options: () => [],
min: undefined,
max: undefined,
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
},
)
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const handleNumberInput = (val: string) => {
if (val === '') {
emit('update:modelValue', undefined)
return
}
const num = Number(val)
if (!isNaN(num)) {
let clamped = num
if (props.min != null && clamped < props.min) clamped = props.min
if (props.max != null && clamped > props.max) clamped = props.max
emit('update:modelValue', clamped)
}
}
const selectDisplayValue = computed(() => {
const opt = props.options.find((o) => o.value === props.modelValue)
return opt ? opt.label : ''
})
const selectWidth = computed(() => {
return Math.max(160, ...props.options.map((o) => o.label.length * 10 + 60))
})
const filteredOptions = (filterText: string) => {
if (!filterText) return props.options
const lower = filterText.toLowerCase()
return props.options.filter((o) => o.label.toLowerCase().includes(lower))
}
const displayValue = computed(() => {
if (props.modelValue == null || props.modelValue === '') return '—'
if (props.type === 'select') {
const opt = props.options.find((o) => o.value === props.modelValue)
return opt ? opt.label : String(props.modelValue)
}
if (props.type === 'password') {
return props.modelValue ? '••••••' : '—'
}
return String(props.modelValue)
})
</script>
<style scoped>
.config-field-switch-wrap {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
width: 100%;
}
.config-field-switch-tip {
font-size: 12px;
color: var(--color-text-muted);
}
.config-field-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.config-field-readonly {
margin-bottom: 16px;
}
.config-field-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 6px;
line-height: 1;
}
.config-field-readonly :deep(*) {
cursor: default !important;
}
.config-field-readonly :deep(.el-input.is-disabled .el-input__wrapper) {
background: var(--color-bg-tertiary);
box-shadow: 0 0 0 1px var(--color-border-lighter) inset;
}
.config-field-readonly :deep(.el-input.is-disabled .el-input__inner) {
color: var(--color-text-primary);
-webkit-text-fill-color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.config-field-readonly :deep(.el-switch.is-disabled) {
opacity: 1;
}
</style>

View File

@@ -1,185 +0,0 @@
<template>
<div class="config-section-card">
<!-- Collapsible: header is a separate clickable area -->
<template v-if="collapsible">
<div
v-if="title"
class="section-header clickable"
@click="handleToggle"
>
<h3 class="section-title">{{ title }}</h3>
<div class="section-header-right">
<span v-if="readonly && !hasValue" class="not-configured-badge">
Not configured
</span>
<el-icon v-if="canToggle" class="collapse-arrow" :class="{ expanded }">
<ArrowDown />
</el-icon>
</div>
</div>
<div class="collapse-wrapper" :class="{ expanded }">
<div class="collapse-inner">
<div class="section-body">
<slot />
</div>
</div>
</div>
</template>
<!-- Non-collapsible: title and content in one area -->
<template v-else>
<div class="section-body">
<h3 v-if="title" class="section-title section-title-inline">{{ title }}</h3>
<slot />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
const props = withDefaults(
defineProps<{
title?: string
collapsible?: boolean
readonly?: boolean
hasValue?: boolean
}>(),
{
title: '',
collapsible: false,
readonly: false,
hasValue: true,
},
)
const computeInitial = () => {
if (!props.collapsible) return true
return props.hasValue
}
const expanded = ref(computeInitial())
// Only auto-expand when hasValue goes from false to true (async data loaded)
// Never auto-collapse — don't override user interaction
watch(
() => props.hasValue,
(newVal, oldVal) => {
if (newVal && !oldVal && props.collapsible) {
expanded.value = true
}
},
)
const canToggle = computed(() => {
if (!props.collapsible) return false
if (props.readonly && !props.hasValue) return false
return true
})
const handleToggle = () => {
if (canToggle.value) {
expanded.value = !expanded.value
}
}
</script>
<style scoped lang="scss">
.config-section-card {
background: var(--el-bg-color);
border: 1px solid var(--color-border-lighter);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
/* Collapsible header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.section-header.clickable {
cursor: pointer;
transition: background 0.15s;
}
.section-header.clickable:hover {
background: var(--color-bg-hover);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
/* Inline title for non-collapsible sections */
.section-title-inline {
margin-bottom: 16px;
}
.section-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.not-configured-badge {
font-size: 11px;
color: var(--color-text-light);
background: var(--color-bg-muted);
padding: 2px 8px;
border-radius: 4px;
}
.collapse-arrow {
transition: transform 0.3s;
color: var(--color-text-muted);
}
.collapse-arrow.expanded {
transform: rotate(-180deg);
}
/* Grid-based collapse animation */
.collapse-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.collapse-wrapper.expanded {
grid-template-rows: 1fr;
}
.collapse-inner {
overflow: hidden;
}
.section-body {
padding: 20px 20px 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-body :deep(.el-form-item) {
margin-bottom: 0;
}
.section-body :deep(.config-field-readonly) {
margin-bottom: 0;
}
@include mobile {
.section-body {
padding: 16px;
}
}
</style>

View File

@@ -1,51 +1,42 @@
<template>
<div class="kv-editor">
<template v-if="readonly">
<div v-if="modelValue.length === 0" class="kv-empty"></div>
<div v-for="(entry, index) in modelValue" :key="index" class="kv-readonly-row">
<span class="kv-readonly-key">{{ entry.key }}</span>
<span class="kv-readonly-value">{{ entry.value }}</span>
</div>
</template>
<template v-else>
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
<el-input
:model-value="entry.key"
:placeholder="keyPlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'key', $event)"
/>
<el-input
:model-value="entry.value"
:placeholder="valuePlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'value', $event)"
/>
<button class="kv-remove-btn" @click="removeEntry(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div>
<button class="kv-add-btn" @click="addEntry">
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
<el-input
:model-value="entry.key"
:placeholder="keyPlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'key', $event)"
/>
<el-input
:model-value="entry.value"
:placeholder="valuePlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'value', $event)"
/>
<button class="kv-remove-btn" @click="removeEntry(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
Add
</button>
</template>
</div>
<button class="kv-add-btn" @click="addEntry">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
fill="currentColor"
/>
</svg>
Add
</button>
</div>
</template>
@@ -59,13 +50,11 @@ interface Props {
modelValue: KVEntry[]
keyPlaceholder?: string
valuePlaceholder?: string
readonly?: boolean
}
const props = withDefaults(defineProps<Props>(), {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
readonly: false,
})
const emit = defineEmits<{
@@ -140,45 +129,25 @@ html.dark .kv-remove-btn:hover {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 6px 14px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
background: transparent;
color: var(--color-text-secondary);
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
transition: all 0.2s;
align-self: flex-start;
}
.kv-add-btn svg {
width: 13px;
height: 13px;
width: 14px;
height: 14px;
}
.kv-add-btn:hover {
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;
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
</style>

View File

@@ -1,49 +1,104 @@
<template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }" @click="$emit('click', proxy)">
<div
class="proxy-card"
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
>
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
<span class="status-pill" :class="statusClass">
<span class="status-dot"></span>
{{ proxy.status }}
<span class="type-tag" :class="`type-${proxy.type}`">{{
proxy.type.toUpperCase()
}}</span>
<span v-if="isStore" class="source-tag">
<svg
class="store-icon"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
fill="currentColor"
/>
<path
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
fill="currentColor"
/>
</svg>
Store
</span>
</div>
<div class="card-address">
<template v-if="proxy.remote_addr && localDisplay">
{{ proxy.remote_addr }} {{ localDisplay }}
</template>
<template v-else-if="proxy.remote_addr">{{ proxy.remote_addr }}</template>
<template v-else-if="localDisplay">{{ localDisplay }}</template>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local</span>
<span class="meta-value code">{{ proxy.local_addr }}</span>
</span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div>
</div>
<div class="card-right">
<span v-if="showSource" class="source-label">{{ displaySource }}</span>
<div v-if="showActions" @click.stop>
<PopoverMenu :width="120" placement="bottom-end">
<template #trigger>
<ActionButton variant="outline" size="small">
<el-icon><MoreFilled /></el-icon>
</ActionButton>
</template>
<PopoverMenuItem v-if="proxy.status === 'disabled'" @click="$emit('toggle', proxy, true)">
<el-icon><Open /></el-icon>
Enable
</PopoverMenuItem>
<PopoverMenuItem v-else @click="$emit('toggle', proxy, false)">
<el-icon><TurnOff /></el-icon>
Disable
</PopoverMenuItem>
<PopoverMenuItem @click="$emit('edit', proxy)">
<el-icon><Edit /></el-icon>
Edit
</PopoverMenuItem>
<PopoverMenuItem danger @click="$emit('delete', proxy)">
<el-icon><Delete /></el-icon>
Delete
</PopoverMenuItem>
</PopoverMenu>
<div v-if="proxy.err" class="error-info">
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
<div class="error-badge">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">Error</span>
</div>
</el-tooltip>
</div>
<div class="status-badge" :class="statusClass">
<span class="status-dot"></span>
{{ proxy.status }}
</div>
<!-- Store actions -->
<div v-if="isStore" class="card-actions">
<button
class="action-btn edit-btn"
@click.stop="$emit('edit', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
fill="currentColor"
/>
</svg>
</button>
<button
class="action-btn delete-btn"
@click.stop="$emit('delete', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
</div>
@@ -52,40 +107,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MoreFilled, Edit, Delete, Open, TurnOff } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import type { ProxyStatus } from '../types'
import { Warning } from '@element-plus/icons-vue'
import type { ProxyStatus } from '../types/proxy'
interface Props {
proxy: ProxyStatus
showSource?: boolean
showActions?: boolean
deleting?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showSource: false,
showActions: false,
deleting: false,
})
const props = defineProps<Props>()
defineEmits<{
click: [proxy: ProxyStatus]
edit: [proxy: ProxyStatus]
delete: [proxy: ProxyStatus]
toggle: [proxy: ProxyStatus, enabled: boolean]
}>()
const displaySource = computed(() => {
return props.proxy.source === 'store' ? 'store' : 'config'
})
const localDisplay = computed(() => {
if (props.proxy.plugin) return `plugin:${props.proxy.plugin}`
return props.proxy.local_addr || ''
})
const isStore = computed(() => props.proxy.source === 'store')
const statusClass = computed(() => {
switch (props.proxy.status) {
@@ -93,43 +129,53 @@ const statusClass = computed(() => {
return 'running'
case 'error':
return 'error'
case 'disabled':
return 'disabled'
default:
return 'waiting'
}
})
</script>
<style scoped lang="scss">
<style scoped>
.proxy-card {
background: $color-bg-primary;
border: 1px solid $color-border-lighter;
border-radius: $radius-md;
padding: 14px 20px;
cursor: pointer;
transition: all $transition-medium;
position: relative;
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border-color: $color-border;
}
.proxy-card:hover {
border-color: var(--el-border-color);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
}
&.has-error {
border-color: rgba(245, 108, 108, 0.3);
}
.proxy-card.has-error {
border-color: var(--el-color-danger-light-5);
}
html.dark .proxy-card.has-error {
border-color: var(--el-color-danger-dark-2);
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-lg;
padding: 18px 20px;
gap: 20px;
min-height: 76px;
}
/* Left Section */
.card-left {
@include flex-column;
gap: $spacing-sm;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
flex: 1;
min-width: 0;
}
@@ -137,68 +183,311 @@ const statusClass = computed(() => {
.card-header {
display: flex;
align-items: center;
gap: $spacing-sm;
gap: 8px;
flex-wrap: wrap;
}
.proxy-name {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.3;
letter-spacing: -0.01em;
}
.type-tag {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: $color-bg-muted;
color: $color-text-secondary;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-address {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: $font-size-sm;
color: $color-text-muted;
.type-tag.type-tcp {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.type-tag.type-udp {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.type-tag.type-http {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.type-tag.type-https {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
.type-tag.type-stcp,
.type-tag.type-sudp,
.type-tag.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-tag.type-tcpmux {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
html.dark .type-tag.type-tcp {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .type-tag.type-udp {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
html.dark .type-tag.type-http {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
html.dark .type-tag.type-https {
background: rgba(52, 211, 153, 0.2);
color: #34d399;
}
html.dark .type-tag.type-stcp,
html.dark .type-tag.type-sudp,
html.dark .type-tag.type-xtcp {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
html.dark .type-tag.type-tcpmux {
background: rgba(244, 114, 182, 0.15);
color: #f472b6;
}
.source-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.1) 0%,
rgba(118, 75, 162, 0.1) 100%
);
color: #764ba2;
}
html.dark .source-tag {
background: linear-gradient(
135deg,
rgba(129, 140, 248, 0.15) 0%,
rgba(167, 139, 250, 0.15) 100%
);
color: #a78bfa;
}
.store-icon {
width: 12px;
height: 12px;
}
.card-meta {
display: flex;
align-items: center;
gap: $spacing-sm;
gap: 16px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 500;
}
.meta-value {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 3px 7px;
border-radius: 5px;
font-size: 11px;
letter-spacing: -0.02em;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: $spacing-md;
gap: 12px;
flex-shrink: 0;
}
.source-label {
font-size: $font-size-xs;
color: $color-text-light;
.error-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: var(--el-color-danger-light-9);
cursor: help;
}
.error-icon {
color: var(--el-color-danger);
font-size: 14px;
}
.error-text {
font-size: 11px;
font-weight: 500;
color: var(--el-color-danger);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.running .status-dot {
background: var(--el-color-success);
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
animation: pulse 2s infinite;
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.error .status-dot {
background: var(--el-color-danger);
}
@include mobile {
.status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
.status-badge.waiting .status-dot {
background: var(--el-color-warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Action buttons */
.card-actions {
display: flex;
gap: 4px;
}
@media (hover: hover) and (pointer: fine) {
.card-actions {
display: none;
}
.proxy-card.is-store:hover .status-badge {
display: none;
}
.proxy-card:hover .card-actions {
display: flex;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn svg {
width: 14px;
height: 14px;
}
.action-btn:hover {
transform: scale(1.05);
}
.edit-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
html.dark .edit-btn:hover {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .delete-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: $spacing-sm;
gap: 14px;
padding: 14px 16px;
}
.card-right {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.card-address {
word-break: break-all;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 14px;
}
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<el-card
class="stat-card"
:class="{ clickable: !!to }"
:body-style="{ padding: '20px' }"
shadow="hover"
@click="handleClick"
>
<div class="stat-card-content">
<div class="stat-icon" :class="`icon-${type}`">
<component :is="iconComponent" class="icon" />
</div>
<div class="stat-info">
<div class="stat-value">{{ value }}</div>
<div class="stat-label">{{ label }}</div>
</div>
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
</div>
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
Connection,
CircleCheck,
Warning,
Setting,
ArrowRight,
} from '@element-plus/icons-vue'
interface Props {
label: string
value: string | number
type?: 'proxies' | 'running' | 'error' | 'config'
subtitle?: string
to?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'proxies',
})
const router = useRouter()
const iconComponent = computed(() => {
switch (props.type) {
case 'proxies':
return Connection
case 'running':
return CircleCheck
case 'error':
return Warning
case 'config':
return Setting
default:
return Connection
}
})
const handleClick = () => {
if (props.to) {
router.push(props.to)
}
}
</script>
<style scoped>
.stat-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.stat-card.clickable:hover .arrow-icon {
transform: translateX(4px);
}
html.dark .stat-card {
border-color: #3a3d5c;
background: #27293d;
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
}
.arrow-icon {
color: #909399;
font-size: 18px;
transition: transform 0.2s ease;
flex-shrink: 0;
}
html.dark .arrow-icon {
color: #9ca3af;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon .icon {
width: 28px;
height: 28px;
}
.icon-proxies {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.icon-running {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
.icon-error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.icon-config {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
html.dark .icon-proxies {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
html.dark .icon-running {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
html.dark .icon-error {
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
}
html.dark .icon-config {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 500;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;
}
html.dark .stat-value {
color: #e5e7eb;
}
.stat-label {
font-size: 14px;
color: #909399;
font-weight: 500;
}
html.dark .stat-label {
color: #9ca3af;
}
.stat-subtitle {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
font-size: 12px;
color: #909399;
}
html.dark .stat-subtitle {
border-top-color: #3a3d5c;
color: #9ca3af;
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<div class="status-pills">
<button
v-for="pill in pills"
:key="pill.status"
class="pill"
:class="{ active: modelValue === pill.status, [pill.status || 'all']: true }"
@click="emit('update:modelValue', pill.status)"
>
{{ pill.label }} {{ pill.count }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
items: Array<{ status: string }>
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const pills = computed(() => {
const counts = { running: 0, error: 0, waiting: 0 }
for (const item of props.items) {
const s = item.status as keyof typeof counts
if (s in counts) {
counts[s]++
}
}
return [
{ status: '', label: 'All', count: props.items.length },
{ status: 'running', label: 'Running', count: counts.running },
{ status: 'error', label: 'Error', count: counts.error },
{ status: 'waiting', label: 'Waiting', count: counts.waiting },
]
})
</script>
<style scoped lang="scss">
.status-pills {
display: flex;
gap: $spacing-sm;
}
.pill {
border: none;
border-radius: 12px;
padding: $spacing-xs $spacing-md;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
cursor: pointer;
background: $color-bg-muted;
color: $color-text-secondary;
transition: all $transition-fast;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
&.active {
&.all {
background: $color-bg-muted;
color: $color-text-secondary;
}
&.running {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.error {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
&.waiting {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
}
}
@include mobile {
.status-pills {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
</style>

View File

@@ -1,141 +0,0 @@
<template>
<div class="string-list-editor">
<template v-if="readonly">
<div v-if="!modelValue || modelValue.length === 0" class="list-empty"></div>
<div v-for="(item, index) in modelValue" :key="index" class="list-readonly-item">
{{ item }}
</div>
</template>
<template v-else>
<div v-for="(item, index) in modelValue" :key="index" class="item-row">
<el-input
:model-value="item"
:placeholder="placeholder"
@update:model-value="updateItem(index, $event)"
/>
<button class="item-remove" @click="removeItem(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" fill="currentColor"/>
</svg>
</button>
</div>
<button class="list-add-btn" @click="addItem">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" fill="currentColor"/>
</svg>
Add
</button>
</template>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue: string[]
placeholder?: string
readonly?: boolean
}>(),
{
placeholder: 'Enter value',
readonly: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const addItem = () => {
emit('update:modelValue', [...(props.modelValue || []), ''])
}
const removeItem = (index: number) => {
const newValue = [...props.modelValue]
newValue.splice(index, 1)
emit('update:modelValue', newValue)
}
const updateItem = (index: number, value: string) => {
const newValue = [...props.modelValue]
newValue[index] = value
emit('update:modelValue', newValue)
}
</script>
<style scoped>
.string-list-editor {
width: 100%;
}
.item-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.item-row .el-input {
flex: 1;
}
.item-remove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.item-remove svg {
width: 14px;
height: 14px;
}
.item-remove:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.list-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.list-add-btn svg {
width: 13px;
height: 13px;
}
.list-add-btn:hover {
background: var(--color-bg-hover);
}
.list-empty {
color: var(--color-text-muted);
font-size: 13px;
}
.list-readonly-item {
font-size: 13px;
color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 2px 0;
}
</style>

View File

@@ -1,40 +0,0 @@
<template>
<ConfigSection title="Authentication" :readonly="readonly">
<template v-if="['http', 'tcpmux'].includes(form.type)">
<div class="field-row three-col">
<ConfigField label="HTTP User" type="text" v-model="form.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.httpPassword" :readonly="readonly" />
<ConfigField label="Route By HTTP User" type="text" v-model="form.routeByHTTPUser" :readonly="readonly" />
</div>
</template>
<template v-if="['stcp', 'sudp', 'xtcp'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Secret Key" type="password" v-model="form.secretKey" prop="secretKey" :readonly="readonly" />
<ConfigField label="Allow Users" type="tags" v-model="form.allowUsers" placeholder="username" :readonly="readonly" />
</div>
</template>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,149 +0,0 @@
<template>
<!-- Backend Mode -->
<template v-if="!readonly">
<el-form-item label="Backend Mode">
<el-radio-group v-model="backendMode">
<el-radio value="direct">Direct</el-radio>
<el-radio value="plugin">Plugin</el-radio>
</el-radio-group>
</el-form-item>
</template>
<!-- Direct mode -->
<template v-if="backendMode === 'direct'">
<div class="field-row two-col">
<ConfigField label="Local IP" type="text" v-model="form.localIP" placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Local Port" type="number" v-model="form.localPort" :min="0" :max="65535" prop="localPort" :readonly="readonly" />
</div>
</template>
<!-- Plugin mode -->
<template v-else>
<div class="field-row two-col">
<ConfigField label="Plugin Type" type="select" v-model="form.pluginType"
:options="PLUGIN_LIST.map((p) => ({ label: p, value: p }))" :readonly="readonly" />
<div></div>
</div>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http', 'tls2raw'].includes(form.pluginType)">
<div class="field-row two-col">
<ConfigField label="Local Address" type="text" v-model="form.pluginConfig.localAddr" placeholder="127.0.0.1:8080" :readonly="readonly" />
<ConfigField v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)"
label="Host Header Rewrite" type="text" v-model="form.pluginConfig.hostHeaderRewrite" :readonly="readonly" />
<div v-else></div>
</div>
</template>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)">
<ConfigField label="Request Headers" type="kv" v-model="pluginRequestHeaders"
key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</template>
<template v-if="['https2http', 'https2https', 'tls2raw'].includes(form.pluginType)">
<div class="field-row two-col">
<ConfigField label="Certificate Path" type="text" v-model="form.pluginConfig.crtPath" placeholder="/path/to/cert.pem" :readonly="readonly" />
<ConfigField label="Key Path" type="text" v-model="form.pluginConfig.keyPath" placeholder="/path/to/key.pem" :readonly="readonly" />
</div>
</template>
<template v-if="['https2http', 'https2https'].includes(form.pluginType)">
<ConfigField label="Enable HTTP/2" type="switch" v-model="form.pluginConfig.enableHTTP2" :readonly="readonly" />
</template>
<template v-if="form.pluginType === 'http_proxy'">
<div class="field-row two-col">
<ConfigField label="HTTP User" type="text" v-model="form.pluginConfig.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.pluginConfig.httpPassword" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'socks5'">
<div class="field-row two-col">
<ConfigField label="Username" type="text" v-model="form.pluginConfig.username" :readonly="readonly" />
<ConfigField label="Password" type="password" v-model="form.pluginConfig.password" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'static_file'">
<div class="field-row two-col">
<ConfigField label="Local Path" type="text" v-model="form.pluginConfig.localPath" placeholder="/path/to/files" :readonly="readonly" />
<ConfigField label="Strip Prefix" type="text" v-model="form.pluginConfig.stripPrefix" :readonly="readonly" />
</div>
<div class="field-row two-col">
<ConfigField label="HTTP User" type="text" v-model="form.pluginConfig.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.pluginConfig.httpPassword" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'unix_domain_socket'">
<ConfigField label="Unix Socket Path" type="text" v-model="form.pluginConfig.unixPath" placeholder="/tmp/socket.sock" :readonly="readonly" />
</template>
</template>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const PLUGIN_LIST = [
'http2https', 'http_proxy', 'https2http', 'https2https', 'http2http',
'socks5', 'static_file', 'unix_domain_socket', 'tls2raw', 'virtual_net',
]
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const backendMode = ref<'direct' | 'plugin'>(form.value.pluginType ? 'plugin' : 'direct')
const isHydrating = ref(false)
const pluginRequestHeaders = computed({
get() {
const set = form.value.pluginConfig?.requestHeaders?.set
if (!set || typeof set !== 'object') return []
return Object.entries(set).map(([key, value]) => ({ key, value: String(value) }))
},
set(val: Array<{ key: string; value: string }>) {
if (!form.value.pluginConfig) form.value.pluginConfig = {}
if (val.length === 0) {
delete form.value.pluginConfig.requestHeaders
} else {
form.value.pluginConfig.requestHeaders = {
set: Object.fromEntries(val.map((e) => [e.key, e.value])),
}
}
},
})
watch(() => form.value.pluginType, (newType, oldType) => {
if (isHydrating.value) return
if (!oldType || !newType || newType === oldType) return
if (form.value.pluginConfig && Object.keys(form.value.pluginConfig).length > 0) {
form.value.pluginConfig = {}
}
})
watch(backendMode, (mode) => {
if (mode === 'direct') {
form.value.pluginType = ''
form.value.pluginConfig = {}
} else if (!form.value.pluginType) {
form.value.pluginType = 'http2https'
}
})
const hydrate = () => {
isHydrating.value = true
backendMode.value = form.value.pluginType ? 'plugin' : 'direct'
nextTick(() => { isHydrating.value = false })
}
watch(() => props.modelValue, () => { hydrate() })
onMounted(() => { hydrate() })
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,51 +0,0 @@
<template>
<!-- Name / Type / Enabled -->
<div v-if="!readonly" class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input
v-model="form.name"
:disabled="editing || readonly"
placeholder="my-proxy"
/>
</el-form-item>
<ConfigField
label="Type"
type="select"
v-model="form.type"
:disabled="editing"
:options="PROXY_TYPES.map((t) => ({ label: t.toUpperCase(), value: t }))"
prop="type"
/>
<el-form-item label="Enabled" class="switch-field">
<el-switch v-model="form.enabled" size="small" />
</el-form-item>
</div>
<div v-else class="field-row three-col">
<ConfigField label="Name" type="text" :model-value="form.name" readonly class="field-grow" />
<ConfigField label="Type" type="text" :model-value="form.type.toUpperCase()" readonly />
<ConfigField label="Enabled" type="switch" :model-value="form.enabled" readonly />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { PROXY_TYPES, type ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,50 +0,0 @@
<template>
<div class="proxy-form-layout">
<ConfigSection :readonly="readonly">
<ProxyBaseSection v-model="form" :readonly="readonly" :editing="editing" />
<ProxyRemoteSection
v-if="['tcp', 'udp', 'http', 'https', 'tcpmux'].includes(form.type)"
v-model="form" :readonly="readonly" />
<ProxyBackendSection v-model="form" :readonly="readonly" />
</ConfigSection>
<ProxyAuthSection
v-if="['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'].includes(form.type)"
v-model="form" :readonly="readonly" />
<ProxyHttpSection v-if="form.type === 'http'" v-model="form" :readonly="readonly" />
<ProxyTransportSection v-model="form" :readonly="readonly" />
<ProxyHealthSection v-model="form" :readonly="readonly" />
<ProxyLoadBalanceSection v-model="form" :readonly="readonly" />
<ProxyNatSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
<ProxyMetadataSection v-model="form" :readonly="readonly" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ProxyBaseSection from './ProxyBaseSection.vue'
import ProxyRemoteSection from './ProxyRemoteSection.vue'
import ProxyBackendSection from './ProxyBackendSection.vue'
import ProxyAuthSection from './ProxyAuthSection.vue'
import ProxyHttpSection from './ProxyHttpSection.vue'
import ProxyTransportSection from './ProxyTransportSection.vue'
import ProxyHealthSection from './ProxyHealthSection.vue'
import ProxyLoadBalanceSection from './ProxyLoadBalanceSection.vue'
import ProxyNatSection from './ProxyNatSection.vue'
import ProxyMetadataSection from './ProxyMetadataSection.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>

View File

@@ -1,52 +0,0 @@
<template>
<ConfigSection title="Health Check" collapsible :readonly="readonly" :has-value="!!form.healthCheckType">
<div class="field-row two-col">
<ConfigField label="Type" type="select" v-model="form.healthCheckType"
:options="[{ label: 'Disabled', value: '' }, { label: 'TCP', value: 'tcp' }, { label: 'HTTP', value: 'http' }]" :readonly="readonly" />
<div></div>
</div>
<template v-if="form.healthCheckType">
<div class="field-row three-col">
<ConfigField label="Timeout (s)" type="number" v-model="form.healthCheckTimeoutSeconds" :min="1" :readonly="readonly" />
<ConfigField label="Max Failed" type="number" v-model="form.healthCheckMaxFailed" :min="1" :readonly="readonly" />
<ConfigField label="Interval (s)" type="number" v-model="form.healthCheckIntervalSeconds" :min="1" :readonly="readonly" />
</div>
<template v-if="form.healthCheckType === 'http'">
<ConfigField label="Path" type="text" v-model="form.healthCheckPath" prop="healthCheckPath" placeholder="/health" :readonly="readonly" />
<ConfigField label="HTTP Headers" type="kv" v-model="healthCheckHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</template>
</template>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const healthCheckHeaders = computed({
get() {
return form.value.healthCheckHTTPHeaders.map((h) => ({ key: h.name, value: h.value }))
},
set(val: Array<{ key: string; value: string }>) {
form.value.healthCheckHTTPHeaders = val.map((h) => ({ name: h.key, value: h.value }))
},
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,32 +0,0 @@
<template>
<ConfigSection title="HTTP Options" collapsible :readonly="readonly"
:has-value="form.locations.length > 0 || !!form.hostHeaderRewrite || form.requestHeaders.length > 0 || form.responseHeaders.length > 0">
<ConfigField label="Locations" type="tags" v-model="form.locations" placeholder="/path" :readonly="readonly" />
<ConfigField label="Host Header Rewrite" type="text" v-model="form.hostHeaderRewrite" :readonly="readonly" />
<ConfigField label="Request Headers" type="kv" v-model="form.requestHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
<ConfigField label="Response Headers" type="kv" v-model="form.responseHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,31 +0,0 @@
<template>
<ConfigSection title="Load Balancer" collapsible :readonly="readonly" :has-value="!!form.loadBalancerGroup">
<div class="field-row two-col">
<ConfigField label="Group" type="text" v-model="form.loadBalancerGroup" placeholder="Group name" :readonly="readonly" />
<ConfigField label="Group Key" type="text" v-model="form.loadBalancerGroupKey" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,29 +0,0 @@
<template>
<ConfigSection title="Metadata" collapsible :readonly="readonly" :has-value="form.metadatas.length > 0 || form.annotations.length > 0">
<ConfigField label="Metadatas" type="kv" v-model="form.metadatas" :readonly="readonly" />
<ConfigField label="Annotations" type="kv" v-model="form.annotations" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,29 +0,0 @@
<template>
<ConfigSection title="NAT Traversal" collapsible :readonly="readonly" :has-value="form.natTraversalDisableAssistedAddrs">
<ConfigField label="Disable Assisted Addresses" type="switch" v-model="form.natTraversalDisableAssistedAddrs"
tip="Only use STUN-discovered public addresses" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,41 +0,0 @@
<template>
<template v-if="['tcp', 'udp'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Remote Port" type="number" v-model="form.remotePort"
:min="0" :max="65535" prop="remotePort" tip="Use 0 for random port assignment" :readonly="readonly" />
<div></div>
</div>
</template>
<template v-if="['http', 'https', 'tcpmux'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Custom Domains" type="tags" v-model="form.customDomains"
prop="customDomains" placeholder="example.com" :readonly="readonly" />
<ConfigField v-if="form.type !== 'tcpmux'" label="Subdomain" type="text"
v-model="form.subdomain" placeholder="test" :readonly="readonly" />
<ConfigField v-if="form.type === 'tcpmux'" label="Multiplexer" type="select"
v-model="form.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" :readonly="readonly" />
</div>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,39 +0,0 @@
<template>
<ConfigSection title="Transport" collapsible :readonly="readonly"
:has-value="form.useEncryption || form.useCompression || !!form.bandwidthLimit || (!!form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || !!form.proxyProtocolVersion">
<div class="field-row two-col">
<ConfigField label="Use Encryption" type="switch" v-model="form.useEncryption" :readonly="readonly" />
<ConfigField label="Use Compression" type="switch" v-model="form.useCompression" :readonly="readonly" />
</div>
<div class="field-row three-col">
<ConfigField label="Bandwidth Limit" type="text" v-model="form.bandwidthLimit" placeholder="1MB" tip="e.g., 1MB, 500KB" :readonly="readonly" />
<ConfigField label="Bandwidth Limit Mode" type="select" v-model="form.bandwidthLimitMode"
:options="[{ label: 'Client', value: 'client' }, { label: 'Server', value: 'server' }]" :readonly="readonly" />
<ConfigField label="Proxy Protocol Version" type="select" v-model="form.proxyProtocolVersion"
:options="[{ label: 'None', value: '' }, { label: 'v1', value: 'v1' }, { label: 'v2', value: 'v2' }]" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,40 +0,0 @@
<template>
<div v-if="!readonly" class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input v-model="form.name" :disabled="editing || readonly" placeholder="my-visitor" />
</el-form-item>
<ConfigField label="Type" type="select" v-model="form.type" :disabled="editing"
:options="[{ label: 'STCP', value: 'stcp' }, { label: 'SUDP', value: 'sudp' }, { label: 'XTCP', value: 'xtcp' }]" prop="type" />
<el-form-item label="Enabled" class="switch-field">
<el-switch v-model="form.enabled" size="small" />
</el-form-item>
</div>
<div v-else class="field-row three-col">
<ConfigField label="Name" type="text" :model-value="form.name" readonly class="field-grow" />
<ConfigField label="Type" type="text" :model-value="form.type.toUpperCase()" readonly />
<ConfigField label="Enabled" type="switch" :model-value="form.enabled" readonly />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,43 +0,0 @@
<template>
<ConfigSection title="Connection" :readonly="readonly">
<div class="field-row two-col">
<ConfigField label="Server Name" type="text" v-model="form.serverName" prop="serverName"
placeholder="Name of the proxy to visit" :readonly="readonly" />
<ConfigField label="Server User" type="text" v-model="form.serverUser"
placeholder="Leave empty for same user" :readonly="readonly" />
</div>
<ConfigField label="Secret Key" type="password" v-model="form.secretKey"
placeholder="Shared secret" :readonly="readonly" />
<div class="field-row two-col">
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Bind Port" type="number" v-model="form.bindPort"
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,33 +0,0 @@
<template>
<div class="visitor-form-layout">
<ConfigSection :readonly="readonly">
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
</ConfigSection>
<VisitorConnectionSection v-model="form" :readonly="readonly" />
<VisitorTransportSection v-model="form" :readonly="readonly" />
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import VisitorBaseSection from './VisitorBaseSection.vue'
import VisitorConnectionSection from './VisitorConnectionSection.vue'
import VisitorTransportSection from './VisitorTransportSection.vue'
import VisitorXtcpSection from './VisitorXtcpSection.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>

View File

@@ -1,32 +0,0 @@
<template>
<ConfigSection title="Transport Options" collapsible :readonly="readonly"
:has-value="form.useEncryption || form.useCompression">
<div class="field-row two-col">
<ConfigField label="Use Encryption" type="switch" v-model="form.useEncryption" :readonly="readonly" />
<ConfigField label="Use Compression" type="switch" v-model="form.useCompression" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,47 +0,0 @@
<template>
<!-- XTCP Options -->
<ConfigSection title="XTCP Options" collapsible :readonly="readonly"
:has-value="form.protocol !== 'quic' || form.keepTunnelOpen || form.maxRetriesAnHour != null || form.minRetryInterval != null || !!form.fallbackTo || form.fallbackTimeoutMs != null">
<ConfigField label="Protocol" type="select" v-model="form.protocol"
:options="[{ label: 'QUIC', value: 'quic' }, { label: 'KCP', value: 'kcp' }]" :readonly="readonly" />
<ConfigField label="Keep Tunnel Open" type="switch" v-model="form.keepTunnelOpen" :readonly="readonly" />
<div class="field-row two-col">
<ConfigField label="Max Retries per Hour" type="number" v-model="form.maxRetriesAnHour" :min="0" :readonly="readonly" />
<ConfigField label="Min Retry Interval (s)" type="number" v-model="form.minRetryInterval" :min="0" :readonly="readonly" />
</div>
<div class="field-row two-col">
<ConfigField label="Fallback To" type="text" v-model="form.fallbackTo" placeholder="Fallback visitor name" :readonly="readonly" />
<ConfigField label="Fallback Timeout (ms)" type="number" v-model="form.fallbackTimeoutMs" :min="0" :readonly="readonly" />
</div>
</ConfigSection>
<!-- NAT Traversal -->
<ConfigSection title="NAT Traversal" collapsible :readonly="readonly"
:has-value="form.natTraversalDisableAssistedAddrs">
<ConfigField label="Disable Assisted Addresses" type="switch" v-model="form.natTraversalDisableAssistedAddrs"
tip="Only use STUN-discovered public addresses" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -1,8 +0,0 @@
import { useBreakpoints } from '@vueuse/core'
const breakpoints = useBreakpoints({ mobile: 0, desktop: 768 })
export function useResponsive() {
const isMobile = breakpoints.smaller('desktop') // < 768px
return { isMobile }
}

View File

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

View File

@@ -1,26 +1,23 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import Overview from '../views/Overview.vue'
import ClientConfigure from '../views/ClientConfigure.vue'
import ProxyEdit from '../views/ProxyEdit.vue'
import VisitorEdit from '../views/VisitorEdit.vue'
import { useProxyStore } from '../stores/proxy'
import { listStoreProxies } from '../api/frpc'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: '/proxies',
name: 'Overview',
component: Overview,
},
{
path: '/proxies',
name: 'ProxyList',
component: () => import('../views/ProxyList.vue'),
},
{
path: '/proxies/detail/:name',
name: 'ProxyDetail',
component: () => import('../views/ProxyDetail.vue'),
path: '/configure',
name: 'ClientConfigure',
component: ClientConfigure,
},
{
path: '/proxies/create',
@@ -34,16 +31,6 @@ const router = createRouter({
component: ProxyEdit,
meta: { requiresStore: true },
},
{
path: '/visitors',
name: 'VisitorList',
component: () => import('../views/VisitorList.vue'),
},
{
path: '/visitors/detail/:name',
name: 'VisitorDetail',
component: () => import('../views/VisitorDetail.vue'),
},
{
path: '/visitors/create',
name: 'VisitorCreate',
@@ -56,21 +43,27 @@ const router = createRouter({
component: VisitorEdit,
meta: { requiresStore: true },
},
{
path: '/config',
name: 'ClientConfigure',
component: ClientConfigure,
},
],
})
const isStoreEnabled = async () => {
try {
await listStoreProxies()
return true
} catch (err: any) {
if (err?.status === 404) {
return false
}
return true
}
}
router.beforeEach(async (to) => {
if (!to.matched.some((record) => record.meta.requiresStore)) {
return true
}
const proxyStore = useProxyStore()
const enabled = await proxyStore.checkStoreEnabled()
const enabled = await isStoreEnabled()
if (enabled) {
return true
}
@@ -78,7 +71,7 @@ router.beforeEach(async (to) => {
ElMessage.warning(
'Store is disabled. Enable Store in frpc config to create or edit store entries.',
)
return { name: 'ProxyList' }
return { name: 'Overview' }
})
export default router

View File

@@ -1,28 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
export const useClientStore = defineStore('client', () => {
const config = ref('')
const loading = ref(false)
const fetchConfig = async () => {
loading.value = true
try {
config.value = await getConfig()
} finally {
loading.value = false
}
}
const saveConfig = async (text: string) => {
await putConfig(text)
config.value = text
}
const reload = async () => {
await reloadConfig()
}
return { config, loading, fetchConfig, saveConfig, reload }
})

View File

@@ -1,132 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ProxyStatus, ProxyDefinition } from '../types'
import {
getStatus,
listStoreProxies,
getStoreProxy,
createStoreProxy,
updateStoreProxy,
deleteStoreProxy,
} from '../api/frpc'
export const useProxyStore = defineStore('proxy', () => {
const proxies = ref<ProxyStatus[]>([])
const storeProxies = ref<ProxyDefinition[]>([])
const storeEnabled = ref(false)
const storeChecked = ref(false)
const loading = ref(false)
const storeLoading = ref(false)
const error = ref<string | null>(null)
const fetchStatus = async () => {
loading.value = true
error.value = null
try {
const json = await getStatus()
const list: ProxyStatus[] = []
for (const key in json) {
for (const ps of json[key]) {
list.push(ps)
}
}
proxies.value = list
} catch (err: any) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
const fetchStoreProxies = async () => {
storeLoading.value = true
try {
const res = await listStoreProxies()
storeProxies.value = res.proxies || []
storeEnabled.value = true
storeChecked.value = true
} catch (err: any) {
if (err?.status === 404) {
storeEnabled.value = false
}
storeChecked.value = true
} finally {
storeLoading.value = false
}
}
const checkStoreEnabled = async () => {
if (storeChecked.value) return storeEnabled.value
await fetchStoreProxies()
return storeEnabled.value
}
const createProxy = async (data: ProxyDefinition) => {
await createStoreProxy(data)
await fetchStoreProxies()
}
const updateProxy = async (name: string, data: ProxyDefinition) => {
await updateStoreProxy(name, data)
await fetchStoreProxies()
}
const deleteProxy = async (name: string) => {
await deleteStoreProxy(name)
await fetchStoreProxies()
}
const toggleProxy = async (name: string, enabled: boolean) => {
const def = await getStoreProxy(name)
const block = (def as any)[def.type]
if (block) {
block.enabled = enabled
}
await updateStoreProxy(name, def)
await fetchStatus()
await fetchStoreProxies()
}
const storeProxyWithStatus = (def: ProxyDefinition): ProxyStatus => {
const block = (def as any)[def.type]
const enabled = block?.enabled !== false
const localIP = block?.localIP || '127.0.0.1'
const localPort = block?.localPort
const local_addr = localPort != null ? `${localIP}:${localPort}` : ''
const remotePort = block?.remotePort
const remote_addr = remotePort != null ? `:${remotePort}` : ''
const plugin = block?.plugin?.type || ''
const status = proxies.value.find((p) => p.name === def.name)
return {
name: def.name,
type: def.type,
status: !enabled ? 'disabled' : (status?.status || 'waiting'),
err: status?.err || '',
local_addr: status?.local_addr || local_addr,
remote_addr: status?.remote_addr || remote_addr,
plugin: status?.plugin || plugin,
source: 'store',
}
}
return {
proxies,
storeProxies,
storeEnabled,
storeChecked,
loading,
storeLoading,
error,
fetchStatus,
fetchStoreProxies,
checkStoreEnabled,
createProxy,
updateProxy,
deleteProxy,
toggleProxy,
storeProxyWithStatus,
}
})

View File

@@ -1,68 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { VisitorDefinition } from '../types'
import {
listStoreVisitors,
createStoreVisitor,
updateStoreVisitor,
deleteStoreVisitor,
} from '../api/frpc'
export const useVisitorStore = defineStore('visitor', () => {
const storeVisitors = ref<VisitorDefinition[]>([])
const storeEnabled = ref(false)
const storeChecked = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const fetchStoreVisitors = async () => {
loading.value = true
try {
const res = await listStoreVisitors()
storeVisitors.value = res.visitors || []
storeEnabled.value = true
storeChecked.value = true
} catch (err: any) {
if (err?.status === 404) {
storeEnabled.value = false
}
storeChecked.value = true
} finally {
loading.value = false
}
}
const checkStoreEnabled = async () => {
if (storeChecked.value) return storeEnabled.value
await fetchStoreVisitors()
return storeEnabled.value
}
const createVisitor = async (data: VisitorDefinition) => {
await createStoreVisitor(data)
await fetchStoreVisitors()
}
const updateVisitor = async (name: string, data: VisitorDefinition) => {
await updateStoreVisitor(name, data)
await fetchStoreVisitors()
}
const deleteVisitor = async (name: string) => {
await deleteStoreVisitor(name)
await fetchStoreVisitors()
}
return {
storeVisitors,
storeEnabled,
storeChecked,
loading,
error,
fetchStoreVisitors,
checkStoreEnabled,
createVisitor,
updateVisitor,
deleteVisitor,
}
})

View File

@@ -1,32 +0,0 @@
export const PROXY_TYPES = [
'tcp',
'udp',
'http',
'https',
'tcpmux',
'stcp',
'sudp',
'xtcp',
] as const
export type ProxyType = (typeof PROXY_TYPES)[number]
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
export type VisitorType = (typeof VISITOR_TYPES)[number]
export const PLUGIN_TYPES = [
'',
'http2https',
'http_proxy',
'https2http',
'https2https',
'http2http',
'socks5',
'static_file',
'unix_domain_socket',
'tls2raw',
'virtual_net',
] as const
export type PluginType = (typeof PLUGIN_TYPES)[number]

View File

@@ -1,5 +0,0 @@
export * from './constants'
export * from './proxy-status'
export * from './proxy-store'
export * from './proxy-form'
export * from './proxy-converters'

View File

@@ -1,167 +0,0 @@
import type { ProxyType, VisitorType } from './constants'
export interface ProxyFormData {
// Base fields (ProxyBaseConfig)
name: string
type: ProxyType
enabled: boolean
// Backend (ProxyBackend)
localIP: string
localPort: number | undefined
pluginType: string
pluginConfig: Record<string, any>
// Transport (ProxyTransport)
useEncryption: boolean
useCompression: boolean
bandwidthLimit: string
bandwidthLimitMode: string
proxyProtocolVersion: string
// Load Balancer (LoadBalancerConfig)
loadBalancerGroup: string
loadBalancerGroupKey: string
// Health Check (HealthCheckConfig)
healthCheckType: string
healthCheckTimeoutSeconds: number | undefined
healthCheckMaxFailed: number | undefined
healthCheckIntervalSeconds: number | undefined
healthCheckPath: string
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
// Metadata & Annotations
metadatas: Array<{ key: string; value: string }>
annotations: Array<{ key: string; value: string }>
// TCP/UDP specific
remotePort: number | undefined
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
customDomains: string[]
subdomain: string
// HTTP specific (HTTPProxyConfig)
locations: string[]
httpUser: string
httpPassword: string
hostHeaderRewrite: string
requestHeaders: Array<{ key: string; value: string }>
responseHeaders: Array<{ key: string; value: string }>
routeByHTTPUser: string
// TCPMux specific
multiplexer: string
// STCP/SUDP/XTCP specific
secretKey: string
allowUsers: string[]
// XTCP specific (NatTraversalConfig)
natTraversalDisableAssistedAddrs: boolean
}
export interface VisitorFormData {
// Base fields (VisitorBaseConfig)
name: string
type: VisitorType
enabled: boolean
// Transport (VisitorTransport)
useEncryption: boolean
useCompression: boolean
// Connection
secretKey: string
serverUser: string
serverName: string
bindAddr: string
bindPort: number | undefined
// XTCP specific (XTCPVisitorConfig)
protocol: string
keepTunnelOpen: boolean
maxRetriesAnHour: number | undefined
minRetryInterval: number | undefined
fallbackTo: string
fallbackTimeoutMs: number | undefined
natTraversalDisableAssistedAddrs: boolean
}
export function createDefaultProxyForm(): ProxyFormData {
return {
name: '',
type: 'tcp',
enabled: true,
localIP: '127.0.0.1',
localPort: undefined,
pluginType: '',
pluginConfig: {},
useEncryption: false,
useCompression: false,
bandwidthLimit: '',
bandwidthLimitMode: 'client',
proxyProtocolVersion: '',
loadBalancerGroup: '',
loadBalancerGroupKey: '',
healthCheckType: '',
healthCheckTimeoutSeconds: undefined,
healthCheckMaxFailed: undefined,
healthCheckIntervalSeconds: undefined,
healthCheckPath: '',
healthCheckHTTPHeaders: [],
metadatas: [],
annotations: [],
remotePort: undefined,
customDomains: [],
subdomain: '',
locations: [],
httpUser: '',
httpPassword: '',
hostHeaderRewrite: '',
requestHeaders: [],
responseHeaders: [],
routeByHTTPUser: '',
multiplexer: 'httpconnect',
secretKey: '',
allowUsers: [],
natTraversalDisableAssistedAddrs: false,
}
}
export function createDefaultVisitorForm(): VisitorFormData {
return {
name: '',
type: 'stcp',
enabled: true,
useEncryption: false,
useCompression: false,
secretKey: '',
serverUser: '',
serverName: '',
bindAddr: '127.0.0.1',
bindPort: undefined,
protocol: 'quic',
keepTunnelOpen: false,
maxRetriesAnHour: undefined,
minRetryInterval: undefined,
fallbackTo: '',
fallbackTimeoutMs: undefined,
natTraversalDisableAssistedAddrs: false,
}
}

View File

@@ -1,13 +0,0 @@
export interface ProxyStatus {
name: string
type: string
status: string
err: string
local_addr: string
plugin: string
remote_addr: string
source?: 'store' | 'config'
[key: string]: any
}
export type StatusResponse = Record<string, ProxyStatus[]>

View File

@@ -1,30 +0,0 @@
import type { ProxyType, VisitorType } from './constants'
export interface ProxyDefinition {
name: string
type: ProxyType
tcp?: Record<string, any>
udp?: Record<string, any>
http?: Record<string, any>
https?: Record<string, any>
tcpmux?: Record<string, any>
stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
}
export interface VisitorDefinition {
name: string
type: VisitorType
stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
}
export interface ProxyListResp {
proxies: ProxyDefinition[]
}
export interface VisitorListResp {
visitors: VisitorDefinition[]
}

View File

@@ -1,7 +1,264 @@
import type { ProxyType, VisitorType } from './constants'
import type { ProxyFormData, VisitorFormData } from './proxy-form'
import { createDefaultProxyForm, createDefaultVisitorForm } from './proxy-form'
import type { ProxyDefinition, VisitorDefinition } from './proxy-store'
// ========================================
// RUNTIME STATUS TYPES (from /api/status)
// ========================================
export interface ProxyStatus {
name: string
type: string
status: string
err: string
local_addr: string
plugin: string
remote_addr: string
source?: 'store' | 'config'
[key: string]: any
}
export type StatusResponse = Record<string, ProxyStatus[]>
// ========================================
// STORE API TYPES
// ========================================
export interface ProxyDefinition {
name: string
type: ProxyType
tcp?: Record<string, any>
udp?: Record<string, any>
http?: Record<string, any>
https?: Record<string, any>
tcpmux?: Record<string, any>
stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
}
export interface VisitorDefinition {
name: string
type: VisitorType
stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
}
export interface ProxyListResp {
proxies: ProxyDefinition[]
}
export interface VisitorListResp {
visitors: VisitorDefinition[]
}
// ========================================
// CONSTANTS
// ========================================
export const PROXY_TYPES = [
'tcp',
'udp',
'http',
'https',
'stcp',
'sudp',
'xtcp',
'tcpmux',
] as const
export type ProxyType = (typeof PROXY_TYPES)[number]
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
export type VisitorType = (typeof VISITOR_TYPES)[number]
export const PLUGIN_TYPES = [
'',
'http2https',
'http_proxy',
'https2http',
'https2https',
'http2http',
'socks5',
'static_file',
'unix_domain_socket',
'tls2raw',
'virtual_net',
] as const
export type PluginType = (typeof PLUGIN_TYPES)[number]
// ========================================
// FORM DATA INTERFACES
// ========================================
export interface ProxyFormData {
// Base fields (ProxyBaseConfig)
name: string
type: ProxyType
enabled: boolean
// Backend (ProxyBackend)
localIP: string
localPort: number | undefined
pluginType: string
pluginConfig: Record<string, any>
// Transport (ProxyTransport)
useEncryption: boolean
useCompression: boolean
bandwidthLimit: string
bandwidthLimitMode: string
proxyProtocolVersion: string
// Load Balancer (LoadBalancerConfig)
loadBalancerGroup: string
loadBalancerGroupKey: string
// Health Check (HealthCheckConfig)
healthCheckType: string
healthCheckTimeoutSeconds: number | undefined
healthCheckMaxFailed: number | undefined
healthCheckIntervalSeconds: number | undefined
healthCheckPath: string
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
// Metadata & Annotations
metadatas: Array<{ key: string; value: string }>
annotations: Array<{ key: string; value: string }>
// TCP/UDP specific
remotePort: number | undefined
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
customDomains: string
subdomain: string
// HTTP specific (HTTPProxyConfig)
locations: string
httpUser: string
httpPassword: string
hostHeaderRewrite: string
requestHeaders: Array<{ key: string; value: string }>
responseHeaders: Array<{ key: string; value: string }>
routeByHTTPUser: string
// TCPMux specific
multiplexer: string
// STCP/SUDP/XTCP specific
secretKey: string
allowUsers: string
// XTCP specific (NatTraversalConfig)
natTraversalDisableAssistedAddrs: boolean
}
export interface VisitorFormData {
// Base fields (VisitorBaseConfig)
name: string
type: VisitorType
enabled: boolean
// Transport (VisitorTransport)
useEncryption: boolean
useCompression: boolean
// Connection
secretKey: string
serverUser: string
serverName: string
bindAddr: string
bindPort: number | undefined
// XTCP specific (XTCPVisitorConfig)
protocol: string
keepTunnelOpen: boolean
maxRetriesAnHour: number | undefined
minRetryInterval: number | undefined
fallbackTo: string
fallbackTimeoutMs: number | undefined
natTraversalDisableAssistedAddrs: boolean
}
// ========================================
// DEFAULT FORM CREATORS
// ========================================
export function createDefaultProxyForm(): ProxyFormData {
return {
name: '',
type: 'tcp',
enabled: true,
localIP: '127.0.0.1',
localPort: undefined,
pluginType: '',
pluginConfig: {},
useEncryption: false,
useCompression: false,
bandwidthLimit: '',
bandwidthLimitMode: 'client',
proxyProtocolVersion: '',
loadBalancerGroup: '',
loadBalancerGroupKey: '',
healthCheckType: '',
healthCheckTimeoutSeconds: undefined,
healthCheckMaxFailed: undefined,
healthCheckIntervalSeconds: undefined,
healthCheckPath: '',
healthCheckHTTPHeaders: [],
metadatas: [],
annotations: [],
remotePort: undefined,
customDomains: '',
subdomain: '',
locations: '',
httpUser: '',
httpPassword: '',
hostHeaderRewrite: '',
requestHeaders: [],
responseHeaders: [],
routeByHTTPUser: '',
multiplexer: 'httpconnect',
secretKey: '',
allowUsers: '',
natTraversalDisableAssistedAddrs: false,
}
}
export function createDefaultVisitorForm(): VisitorFormData {
return {
name: '',
type: 'stcp',
enabled: true,
useEncryption: false,
useCompression: false,
secretKey: '',
serverUser: '',
serverName: '',
bindAddr: '127.0.0.1',
bindPort: undefined,
protocol: 'quic',
keepTunnelOpen: false,
maxRetriesAnHour: undefined,
minRetryInterval: undefined,
fallbackTo: '',
fallbackTimeoutMs: undefined,
natTraversalDisableAssistedAddrs: false,
}
}
// ========================================
// CONVERTERS: Form -> Store API
@@ -102,8 +359,11 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
}
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
if (form.customDomains.length > 0) {
block.customDomains = form.customDomains.filter(Boolean)
if (form.customDomains) {
block.customDomains = form.customDomains
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.subdomain) {
block.subdomain = form.subdomain
@@ -111,8 +371,11 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
}
if (form.type === 'http') {
if (form.locations.length > 0) {
block.locations = form.locations.filter(Boolean)
if (form.locations) {
block.locations = form.locations
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.httpUser) block.httpUser = form.httpUser
if (form.httpPassword) block.httpPassword = form.httpPassword
@@ -146,8 +409,11 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
if (form.secretKey) block.secretKey = form.secretKey
if (form.allowUsers.length > 0) {
block.allowUsers = form.allowUsers.filter(Boolean)
if (form.allowUsers) {
block.allowUsers = form.allowUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
}
@@ -378,17 +644,17 @@ export function storeProxyToForm(config: ProxyDefinition): ProxyFormData {
// Domain config
if (Array.isArray(c.customDomains)) {
form.customDomains = c.customDomains
form.customDomains = c.customDomains.join(', ')
} else if (c.customDomains) {
form.customDomains = [c.customDomains]
form.customDomains = c.customDomains
}
form.subdomain = c.subdomain || ''
// HTTP specific
if (Array.isArray(c.locations)) {
form.locations = c.locations
form.locations = c.locations.join(', ')
} else if (c.locations) {
form.locations = [c.locations]
form.locations = c.locations
}
form.httpUser = c.httpUser || ''
form.httpPassword = c.httpPassword || ''
@@ -413,9 +679,9 @@ export function storeProxyToForm(config: ProxyDefinition): ProxyFormData {
// Secure types
form.secretKey = c.secretKey || ''
if (Array.isArray(c.allowUsers)) {
form.allowUsers = c.allowUsers
form.allowUsers = c.allowUsers.join(', ')
} else if (c.allowUsers) {
form.allowUsers = [c.allowUsers]
form.allowUsers = c.allowUsers
}
// XTCP NAT traversal

View File

@@ -2,68 +2,127 @@
<div class="configure-page">
<div class="page-header">
<div class="title-section">
<h1 class="page-title">Config</h1>
<h1 class="page-title">Configuration</h1>
<p class="page-subtitle">
Edit and manage your frpc configuration file
</p>
</div>
</div>
<div class="editor-header">
<div class="header-left">
<a
href="https://github.com/fatedier/frp#configuration-files"
target="_blank"
class="docs-link"
>
<el-icon><Link /></el-icon>
Documentation
</a>
</div>
<div class="header-actions">
<ActionButton @click="handleUpload">Update & Reload</ActionButton>
</div>
</div>
<el-row :gutter="20">
<el-col :xs="24" :lg="16">
<el-card class="editor-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Configuration Editor</span>
<el-tag size="small" type="success">TOML</el-tag>
</div>
<div class="header-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 & Reload
</el-button>
</div>
</div>
</template>
<div class="editor-wrapper">
<el-input
type="textarea"
:autosize="false"
v-model="configContent"
placeholder="# frpc configuration file content...
<div class="editor-wrapper">
<el-input
type="textarea"
:autosize="{ minRows: 20, maxRows: 40 }"
v-model="configContent"
placeholder="# frpc configuration file content...
serverAddr = &quot;127.0.0.1&quot;
serverPort = 7000"
class="code-editor"
></el-input>
</div>
[common]
server_addr = 127.0.0.1
server_port = 7000"
class="code-editor"
></el-input>
</div>
</el-card>
</el-col>
<ConfirmDialog
v-model="confirmVisible"
title="Confirm Update"
message="This operation will update your frpc configuration and reload it. Do you want to continue?"
confirm-text="Update"
:loading="uploading"
:is-mobile="isMobile"
@confirm="doUpload"
/>
<el-col :xs="24" :lg="8">
<el-card class="help-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Quick Reference</span>
</div>
</template>
<div class="help-content">
<div class="help-section">
<h4 class="help-section-title">Common Settings</h4>
<div class="help-items">
<div class="help-item">
<code>serverAddr</code>
<span>Server address</span>
</div>
<div class="help-item">
<code>serverPort</code>
<span>Server port (default: 7000)</span>
</div>
<div class="help-item">
<code>auth.token</code>
<span>Authentication token</span>
</div>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Proxy Types</h4>
<div class="proxy-type-tags">
<el-tag type="primary" effect="plain">TCP</el-tag>
<el-tag type="success" effect="plain">UDP</el-tag>
<el-tag type="warning" effect="plain">HTTP</el-tag>
<el-tag type="danger" effect="plain">HTTPS</el-tag>
<el-tag type="info" effect="plain">STCP</el-tag>
<el-tag effect="plain">XTCP</el-tag>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Example Proxy</h4>
<pre class="code-example">
[[proxies]]
name = "web"
type = "http"
localPort = 80
customDomains = ["example.com"]</pre
>
</div>
<div class="help-section">
<a
href="https://github.com/fatedier/frp#configuration-files"
target="_blank"
class="docs-link"
>
<el-icon><Link /></el-icon>
View Full Documentation
</a>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Link } from '@element-plus/icons-vue'
import { useClientStore } from '../stores/client'
import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
import { useResponsive } from '../composables/useResponsive'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Upload, Link } from '@element-plus/icons-vue'
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
const { isMobile } = useResponsive()
const clientStore = useClientStore()
const configContent = ref('')
const fetchData = async () => {
try {
await clientStore.fetchConfig()
configContent.value = clientStore.config
const text = await getConfig()
configContent.value = text
} catch (err: any) {
ElMessage({
showClose: true,
@@ -73,116 +132,256 @@ const fetchData = async () => {
}
}
const confirmVisible = ref(false)
const uploading = ref(false)
const handleUpload = () => {
confirmVisible.value = true
}
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
}
const doUpload = async () => {
if (!configContent.value.trim()) {
ElMessage.warning('Configuration content cannot be empty!')
return
}
uploading.value = true
try {
await clientStore.saveConfig(configContent.value)
await clientStore.reload()
ElMessage.success('Configuration updated and reloaded successfully')
confirmVisible.value = false
} catch (err: any) {
ElMessage.error('Update failed: ' + err.message)
} finally {
uploading.value = false
}
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 lang="scss">
<style scoped>
.configure-page {
height: 100%;
overflow: hidden;
padding: $spacing-xl 40px;
max-width: 960px;
margin: 0 auto;
@include flex-column;
gap: $spacing-sm;
display: flex;
flex-direction: column;
gap: 24px;
}
.editor-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.page-header {
@include flex-column;
gap: $spacing-sm;
margin-bottom: $spacing-sm;
display: flex;
flex-direction: column;
gap: 8px;
}
.title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.editor-header {
.page-title {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
}
.page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.editor-card,
.help-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
}
html.dark .editor-card,
html.dark .help-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: $spacing-sm;
gap: 12px;
}
.card-title {
font-size: 16px;
font-weight: 500;
color: #303133;
}
html.dark .card-title {
color: #e5e7eb;
}
.editor-wrapper {
position: relative;
}
.code-editor :deep(.el-textarea__inner) {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
padding: 16px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #e4e7ed;
resize: none;
}
html.dark .code-editor :deep(.el-textarea__inner) {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.code-editor :deep(.el-textarea__inner:focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
/* Help Card */
.help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.help-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.help-section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.help-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.help-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
font-size: 13px;
}
html.dark .help-item {
background: #1e1e2d;
}
.help-item code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.help-item span {
color: var(--el-text-color-secondary);
flex: 1;
}
.proxy-type-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.code-example {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
margin: 0;
overflow-x: auto;
white-space: pre;
}
html.dark .code-example {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.docs-link {
display: flex;
align-items: center;
gap: $spacing-xs;
color: $color-text-muted;
gap: 8px;
color: var(--el-color-primary);
text-decoration: none;
font-size: $font-size-sm;
transition: color $transition-fast;
&:hover {
color: $color-text-primary;
}
font-size: 14px;
font-weight: 500;
padding: 12px 16px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
transition: all 0.2s;
}
.code-editor {
height: 100%;
:deep(.el-textarea__inner) {
height: 100% !important;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: $font-size-sm;
line-height: 1.6;
padding: $spacing-lg;
border-radius: $radius-md;
background: $color-bg-tertiary;
border: 1px solid $color-border-light;
resize: none;
&:focus {
border-color: $color-text-light;
box-shadow: none;
}
}
.docs-link:hover {
background: var(--el-color-primary-light-8);
}
@include mobile {
.configure-page {
padding: $spacing-xl $spacing-lg;
@media (max-width: 768px) {
.card-header {
flex-direction: column;
align-items: stretch;
}
.header-left {
@@ -193,4 +392,10 @@ fetchData()
justify-content: flex-end;
}
}
@media (max-width: 992px) {
.help-card {
margin-top: 20px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,303 +0,0 @@
<template>
<div class="proxy-detail-page">
<!-- Fixed Header -->
<div class="detail-top">
<nav class="breadcrumb">
<router-link :to="isStore ? '/proxies?tab=store' : '/proxies'" class="breadcrumb-link">Proxies</router-link>
<span class="breadcrumb-sep">&rsaquo;</span>
<span class="breadcrumb-current">{{ proxyName }}</span>
</nav>
<template v-if="proxy">
<div class="detail-header">
<div>
<div class="header-title-row">
<h2 class="detail-title">{{ proxy.name }}</h2>
<span class="status-pill" :class="statusClass">
<span class="status-dot"></span>
{{ proxy.status }}
</span>
</div>
<p class="header-subtitle">
Source: {{ displaySource }} &middot; Type:
{{ proxy.type.toUpperCase() }}
</p>
</div>
<div v-if="isStore" class="header-actions">
<ActionButton variant="outline" size="small" @click="handleEdit">
Edit
</ActionButton>
</div>
</div>
</template>
</div>
<!-- Scrollable Content -->
<div v-if="notFound" class="not-found">
<p class="empty-text">Proxy not found</p>
<p class="empty-hint">The proxy "{{ proxyName }}" does not exist.</p>
<ActionButton variant="outline" @click="router.push('/proxies')">
Back to Proxies
</ActionButton>
</div>
<div v-else-if="proxy" v-loading="loading" class="detail-content">
<!-- Error Banner -->
<div v-if="proxy.err" class="error-banner">
<el-icon class="error-icon"><Warning /></el-icon>
<div>
<div class="error-title">Connection Error</div>
<div class="error-message">{{ proxy.err }}</div>
</div>
</div>
<!-- Config Sections -->
<ProxyFormLayout
v-if="formData"
:model-value="formData"
readonly
/>
</div>
<div v-else v-loading="loading" class="loading-area"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Warning } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import ProxyFormLayout from '../components/proxy-form/ProxyFormLayout.vue'
import { getProxyConfig, getStoreProxy } from '../api/frpc'
import { useProxyStore } from '../stores/proxy'
import { storeProxyToForm } from '../types'
import type { ProxyStatus, ProxyDefinition, ProxyFormData } from '../types'
const route = useRoute()
const router = useRouter()
const proxyStore = useProxyStore()
const proxyName = route.params.name as string
const proxy = ref<ProxyStatus | null>(null)
const proxyConfig = ref<ProxyDefinition | null>(null)
const loading = ref(true)
const notFound = ref(false)
const isStore = ref(false)
onMounted(async () => {
try {
// Try status API first
await proxyStore.fetchStatus()
const found = proxyStore.proxies.find((p) => p.name === proxyName)
// Try config API (works for any source)
let configDef: ProxyDefinition | null = null
try {
configDef = await getProxyConfig(proxyName)
proxyConfig.value = configDef
} catch {
// Config not available
}
// Check if proxy is from the store (for Edit/Delete buttons)
try {
await getStoreProxy(proxyName)
isStore.value = true
} catch {
// Not a store proxy
}
if (found) {
proxy.value = found
} else if (configDef) {
// Proxy not in status (e.g. disabled), build from config definition
const block = (configDef as any)[configDef.type]
const localIP = block?.localIP || '127.0.0.1'
const localPort = block?.localPort
const enabled = block?.enabled !== false
proxy.value = {
name: configDef.name,
type: configDef.type,
status: enabled ? 'waiting' : 'disabled',
err: '',
local_addr: localPort != null ? `${localIP}:${localPort}` : '',
remote_addr: block?.remotePort != null ? `:${block.remotePort}` : '',
plugin: block?.plugin?.type || '',
}
} else {
notFound.value = true
}
} catch (err: any) {
ElMessage.error('Failed to load proxy: ' + err.message)
} finally {
loading.value = false
}
})
const displaySource = computed(() =>
isStore.value ? 'store' : 'config',
)
const statusClass = computed(() => {
const s = proxy.value?.status
if (s === 'running') return 'running'
if (s === 'error') return 'error'
if (s === 'disabled') return 'disabled'
return 'waiting'
})
const formData = computed((): ProxyFormData | null => {
if (!proxyConfig.value) return null
return storeProxyToForm(proxyConfig.value)
})
const handleEdit = () => {
router.push('/proxies/' + encodeURIComponent(proxyName) + '/edit')
}
</script>
<style scoped lang="scss">
.proxy-detail-page {
display: flex;
flex-direction: column;
height: 100%;
max-width: 960px;
margin: 0 auto;
}
.detail-top {
flex-shrink: 0;
padding: $spacing-xl 24px 0;
}
.detail-content {
flex: 1;
overflow-y: auto;
padding: 0 24px 160px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: $font-size-md;
margin-bottom: $spacing-lg;
}
.breadcrumb-link {
color: $color-text-secondary;
text-decoration: none;
&:hover {
color: $color-text-primary;
}
}
.breadcrumb-sep {
color: $color-text-light;
}
.breadcrumb-current {
color: $color-text-primary;
font-weight: $font-weight-medium;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-xl;
}
.header-title-row {
display: flex;
align-items: center;
gap: $spacing-md;
margin-bottom: $spacing-sm;
}
.detail-title {
margin: 0;
font-size: 22px;
font-weight: $font-weight-semibold;
color: $color-text-primary;
}
.header-subtitle {
font-size: $font-size-sm;
color: $color-text-muted;
margin: 0;
}
.header-actions {
display: flex;
gap: $spacing-sm;
}
.error-banner {
display: flex;
align-items: flex-start;
gap: $spacing-sm;
padding: 12px 16px;
background: var(--color-danger-light);
border: 1px solid rgba(245, 108, 108, 0.2);
border-radius: $radius-md;
margin-bottom: $spacing-xl;
.error-icon {
color: $color-danger;
font-size: 18px;
margin-top: 2px;
}
.error-title {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-danger;
margin-bottom: 2px;
}
.error-message {
font-size: $font-size-sm;
color: $color-text-muted;
}
}
.not-found,
.loading-area {
text-align: center;
padding: 60px $spacing-xl;
}
.empty-text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $color-text-secondary;
margin: 0 0 $spacing-xs;
}
.empty-hint {
font-size: $font-size-sm;
color: $color-text-muted;
margin: 0 0 $spacing-lg;
}
@include mobile {
.detail-top {
padding: $spacing-xl $spacing-lg 0;
}
.detail-content {
padding: 0 $spacing-lg $spacing-xl;
}
.detail-header {
flex-direction: column;
gap: $spacing-md;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,440 +0,0 @@
<template>
<div class="proxies-page">
<!-- Fixed top area -->
<div class="page-top">
<!-- Header -->
<div class="page-header">
<h2 class="page-title">Proxies</h2>
</div>
<!-- Tabs -->
<div class="tab-bar">
<div class="tab-buttons">
<button class="tab-btn" :class="{ active: activeTab === 'status' }" @click="switchTab('status')">Status</button>
<button class="tab-btn" :class="{ active: activeTab === 'store' }" @click="switchTab('store')">Store</button>
</div>
<div class="tab-actions">
<ActionButton variant="outline" size="small" @click="refreshData">
<el-icon><Refresh /></el-icon>
</ActionButton>
<ActionButton v-if="activeTab === 'store' && proxyStore.storeEnabled" size="small" @click="handleCreate">
+ New Proxy
</ActionButton>
</div>
</div>
<!-- Status Tab Filters -->
<template v-if="activeTab === 'status'">
<StatusPills v-if="!isMobile" :items="proxyStore.proxies" v-model="statusFilter" />
<div class="filter-bar">
<el-input v-model="searchText" placeholder="Search..." clearable class="search-input">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<FilterDropdown v-model="sourceFilter" label="Source" :options="sourceOptions" :min-width="140" :is-mobile="isMobile" />
<FilterDropdown v-model="typeFilter" label="Type" :options="typeOptions" :min-width="140" :is-mobile="isMobile" />
</div>
</template>
<!-- Store Tab Filters -->
<template v-if="activeTab === 'store' && proxyStore.storeEnabled">
<div class="filter-bar">
<el-input v-model="storeSearch" placeholder="Search..." clearable class="search-input">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<FilterDropdown v-model="storeTypeFilter" label="Type" :options="storeTypeOptions" :min-width="140" :is-mobile="isMobile" />
</div>
</template>
</div>
<!-- Scrollable list area -->
<div class="page-content">
<!-- Status Tab List -->
<div v-if="activeTab === 'status'" v-loading="proxyStore.loading">
<div v-if="filteredStatus.length > 0" class="proxy-list">
<ProxyCard
v-for="p in filteredStatus"
:key="p.name"
:proxy="p"
showSource
@click="goToDetail(p.name)"
/>
</div>
<div v-else-if="!proxyStore.loading" class="empty-state">
<p class="empty-text">No proxies found</p>
<p class="empty-hint">Proxies will appear here once configured and connected.</p>
</div>
</div>
<!-- Store Tab List -->
<div v-if="activeTab === 'store'" v-loading="proxyStore.storeLoading">
<div v-if="!proxyStore.storeEnabled" class="store-disabled">
<p>Store is not enabled. Add the following to your frpc configuration:</p>
<pre class="config-hint">[store]
path = "./frpc_store.json"</pre>
</div>
<template v-else>
<div v-if="filteredStoreProxies.length > 0" class="proxy-list">
<ProxyCard
v-for="p in filteredStoreProxies"
:key="p.name"
:proxy="proxyStore.storeProxyWithStatus(p)"
showActions
@click="goToDetail(p.name)"
@edit="handleEdit"
@toggle="handleToggleProxy"
@delete="handleDeleteProxy(p.name)"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No store proxies</p>
<p class="empty-hint">Click "New Proxy" to create one.</p>
</div>
</template>
</div>
</div>
<ConfirmDialog
v-model="deleteDialog.visible"
title="Delete Proxy"
:message="deleteDialog.message"
confirm-text="Delete"
danger
:loading="deleteDialog.loading"
:is-mobile="isMobile"
@confirm="doDelete"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import StatusPills from '../components/StatusPills.vue'
import FilterDropdown from '@shared/components/FilterDropdown.vue'
import ProxyCard from '../components/ProxyCard.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
import { useProxyStore } from '../stores/proxy'
import { useResponsive } from '../composables/useResponsive'
import type { ProxyStatus } from '../types'
const { isMobile } = useResponsive()
const route = useRoute()
const router = useRouter()
const proxyStore = useProxyStore()
// Tab
const activeTab = computed(() => {
const tab = route.query.tab as string
return tab === 'store' ? 'store' : 'status'
})
const switchTab = (tab: string) => {
router.replace({ query: tab === 'status' ? {} : { tab } })
}
// Filters (local UI state)
const statusFilter = ref('')
const typeFilter = ref('')
const sourceFilter = ref('')
const searchText = ref('')
const storeSearch = ref('')
const storeTypeFilter = ref('')
// Delete dialog
const deleteDialog = reactive({
visible: false,
title: 'Delete Proxy',
message: '',
loading: false,
name: '',
})
// Source handling
const displaySource = (proxy: ProxyStatus): string => {
return proxy.source === 'store' ? 'store' : 'config'
}
// Filter options
const sourceOptions = computed(() => {
const sources = new Set<string>()
sources.add('config')
sources.add('store')
proxyStore.proxies.forEach((p) => {
sources.add(displaySource(p))
})
return Array.from(sources)
.sort()
.map((s) => ({ label: s, value: s }))
})
const PROXY_TYPE_ORDER = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp']
const sortByTypeOrder = (types: string[]) => {
return types.sort((a, b) => {
const ia = PROXY_TYPE_ORDER.indexOf(a)
const ib = PROXY_TYPE_ORDER.indexOf(b)
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
})
}
const typeOptions = computed(() => {
const types = new Set<string>()
proxyStore.proxies.forEach((p) => types.add(p.type))
return sortByTypeOrder(Array.from(types))
.map((t) => ({ label: t.toUpperCase(), value: t }))
})
const storeTypeOptions = computed(() => {
const types = new Set<string>()
proxyStore.storeProxies.forEach((p) => types.add(p.type))
return sortByTypeOrder(Array.from(types))
.map((t) => ({ label: t.toUpperCase(), value: t }))
})
// Filtered computeds — Status tab uses proxyStore.proxies (runtime only)
const filteredStatus = computed(() => {
let result = proxyStore.proxies as ProxyStatus[]
if (statusFilter.value) {
result = result.filter((p) => p.status === statusFilter.value)
}
if (typeFilter.value) {
result = result.filter((p) => p.type === typeFilter.value)
}
if (sourceFilter.value) {
result = result.filter((p) => displaySource(p) === sourceFilter.value)
}
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search),
)
}
return result
})
const filteredStoreProxies = computed(() => {
let list = proxyStore.storeProxies
if (storeTypeFilter.value) {
list = list.filter((p) => p.type === storeTypeFilter.value)
}
if (storeSearch.value) {
const q = storeSearch.value.toLowerCase()
list = list.filter((p) => p.name.toLowerCase().includes(q))
}
return list
})
// Data fetching
const refreshData = () => {
proxyStore.fetchStatus().catch((err: any) => {
ElMessage.error('Failed to get status: ' + err.message)
})
proxyStore.fetchStoreProxies()
}
// Navigation
const goToDetail = (name: string) => {
router.push('/proxies/detail/' + encodeURIComponent(name))
}
const handleCreate = () => {
router.push('/proxies/create')
}
const handleEdit = (proxy: ProxyStatus) => {
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
}
const handleToggleProxy = async (proxy: ProxyStatus, enabled: boolean) => {
try {
await proxyStore.toggleProxy(proxy.name, enabled)
ElMessage.success(enabled ? 'Proxy enabled' : 'Proxy disabled')
} catch (err: any) {
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
}
}
const handleDeleteProxy = (name: string) => {
deleteDialog.name = name
deleteDialog.message = `Are you sure you want to delete "${name}"? This action cannot be undone.`
deleteDialog.visible = true
}
const doDelete = async () => {
deleteDialog.loading = true
try {
await proxyStore.deleteProxy(deleteDialog.name)
ElMessage.success('Proxy deleted')
deleteDialog.visible = false
proxyStore.fetchStatus()
} catch (err: any) {
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
} finally {
deleteDialog.loading = false
}
}
onMounted(() => {
refreshData()
})
</script>
<style scoped lang="scss">
.proxies-page {
display: flex;
flex-direction: column;
height: 100%;
max-width: 960px;
margin: 0 auto;
}
.page-top {
flex-shrink: 0;
padding: $spacing-xl 40px 0;
}
.page-content {
flex: 1;
overflow-y: auto;
padding: 0 40px $spacing-xl;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-xl;
}
.tab-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $color-border-lighter;
margin-bottom: $spacing-xl;
}
.tab-buttons {
display: flex;
}
.tab-actions {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.tab-btn {
background: none;
border: none;
padding: $spacing-sm $spacing-xl;
font-size: $font-size-md;
color: $color-text-muted;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all $transition-fast;
&:hover { color: $color-text-primary; }
&.active {
color: $color-text-primary;
border-bottom-color: $color-text-primary;
font-weight: $font-weight-medium;
}
}
.filter-bar {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-top: $spacing-lg;
padding-bottom: $spacing-lg;
:deep(.search-input) {
flex: 1;
min-width: 150px;
}
}
.proxy-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.empty-state {
text-align: center;
padding: 60px $spacing-xl;
}
.empty-text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $color-text-secondary;
margin: 0 0 $spacing-xs;
}
.empty-hint {
font-size: $font-size-sm;
color: $color-text-muted;
margin: 0;
}
.store-disabled {
padding: 32px;
text-align: center;
color: $color-text-muted;
}
.config-hint {
display: inline-block;
text-align: left;
background: $color-bg-hover;
padding: 12px 20px;
border-radius: $radius-sm;
font-size: $font-size-sm;
margin-top: $spacing-md;
}
@include mobile {
.page-top {
padding: $spacing-lg $spacing-lg 0;
}
.page-content {
padding: 0 $spacing-lg $spacing-lg;
}
.page-header {
flex-direction: column;
align-items: stretch;
gap: $spacing-md;
}
.header-actions {
justify-content: flex-end;
}
.filter-bar {
:deep(.search-input) {
flex: 1;
min-width: 0;
}
}
}
</style>

View File

@@ -1,206 +0,0 @@
<template>
<div class="visitor-detail-page">
<!-- Fixed Header -->
<div class="detail-top">
<nav class="breadcrumb">
<router-link to="/visitors" class="breadcrumb-link">Visitors</router-link>
<span class="breadcrumb-sep">&rsaquo;</span>
<span class="breadcrumb-current">{{ visitorName }}</span>
</nav>
<template v-if="visitor">
<div class="detail-header">
<div>
<h2 class="detail-title">{{ visitor.name }}</h2>
<p class="header-subtitle">Type: {{ visitor.type.toUpperCase() }}</p>
</div>
<div v-if="isStore" class="header-actions">
<ActionButton variant="outline" size="small" @click="handleEdit">
Edit
</ActionButton>
</div>
</div>
</template>
</div>
<div v-if="notFound" class="not-found">
<p class="empty-text">Visitor not found</p>
<p class="empty-hint">The visitor "{{ visitorName }}" does not exist.</p>
<ActionButton variant="outline" @click="router.push('/visitors')">
Back to Visitors
</ActionButton>
</div>
<div v-else-if="visitor" v-loading="loading" class="detail-content">
<VisitorFormLayout
v-if="formData"
:model-value="formData"
readonly
/>
</div>
<div v-else v-loading="loading" class="loading-area"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import ActionButton from '@shared/components/ActionButton.vue'
import VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'
import { getVisitorConfig, getStoreVisitor } from '../api/frpc'
import type { VisitorDefinition, VisitorFormData } from '../types'
import { storeVisitorToForm } from '../types'
const route = useRoute()
const router = useRouter()
const visitorName = route.params.name as string
const visitor = ref<VisitorDefinition | null>(null)
const loading = ref(true)
const notFound = ref(false)
const isStore = ref(false)
onMounted(async () => {
try {
const config = await getVisitorConfig(visitorName)
visitor.value = config
// Check if visitor is from the store (for Edit/Delete buttons)
try {
await getStoreVisitor(visitorName)
isStore.value = true
} catch {
// Not a store visitor — Edit/Delete not available
}
} catch (err: any) {
if (err?.status === 404 || err?.response?.status === 404) {
notFound.value = true
} else {
notFound.value = true
ElMessage.error('Failed to load visitor: ' + err.message)
}
} finally {
loading.value = false
}
})
const formData = computed<VisitorFormData | null>(() => {
if (!visitor.value) return null
return storeVisitorToForm(visitor.value)
})
const handleEdit = () => {
router.push('/visitors/' + encodeURIComponent(visitorName) + '/edit')
}
</script>
<style scoped lang="scss">
.visitor-detail-page {
display: flex;
flex-direction: column;
height: 100%;
max-width: 960px;
margin: 0 auto;
}
.detail-top {
flex-shrink: 0;
padding: $spacing-xl 24px 0;
}
.detail-content {
flex: 1;
overflow-y: auto;
padding: 0 24px 160px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: $font-size-md;
margin-bottom: $spacing-lg;
}
.breadcrumb-link {
color: $color-text-secondary;
text-decoration: none;
&:hover {
color: $color-text-primary;
}
}
.breadcrumb-sep {
color: $color-text-light;
}
.breadcrumb-current {
color: $color-text-primary;
font-weight: $font-weight-medium;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-xl;
}
.detail-title {
margin: 0;
font-size: 22px;
font-weight: $font-weight-semibold;
color: $color-text-primary;
margin-bottom: $spacing-sm;
}
.header-subtitle {
font-size: $font-size-sm;
color: $color-text-muted;
margin: 0;
}
.header-actions {
display: flex;
gap: $spacing-sm;
}
.not-found,
.loading-area {
text-align: center;
padding: 60px $spacing-xl;
}
.empty-text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $color-text-secondary;
margin: 0 0 $spacing-xs;
}
.empty-hint {
font-size: $font-size-sm;
color: $color-text-muted;
margin: 0 0 $spacing-lg;
}
@include mobile {
.detail-top {
padding: $spacing-xl $spacing-lg 0;
}
.detail-content {
padding: 0 $spacing-lg $spacing-xl;
}
.detail-header {
flex-direction: column;
gap: $spacing-md;
}
}
</style>

View File

@@ -1,18 +1,16 @@
<template>
<div class="visitor-edit-page">
<div class="edit-header">
<nav class="breadcrumb">
<router-link to="/visitors" class="breadcrumb-item">Visitors</router-link>
<span class="breadcrumb-separator"></span>
<span class="breadcrumb-current">{{ isEditing ? 'Edit Visitor' : 'New Visitor' }}</span>
</nav>
<div class="header-actions">
<ActionButton variant="outline" size="small" @click="goBack">Cancel</ActionButton>
<ActionButton size="small" :loading="saving" @click="handleSave">
{{ isEditing ? 'Update' : 'Create' }}
</ActionButton>
</div>
</div>
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<router-link to="/" class="breadcrumb-item">Overview</router-link>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{
isEditing ? 'Edit Visitor' : 'Create Visitor'
}}</span>
</nav>
<div v-loading="pageLoading" class="edit-content">
<el-form
@@ -22,52 +20,254 @@
label-position="top"
@submit.prevent
>
<VisitorFormLayout v-model="form" :editing="isEditing" />
<!-- Header Card -->
<div class="form-card header-card">
<div class="card-body">
<div class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input
v-model="form.name"
:disabled="isEditing"
placeholder="my-visitor"
/>
</el-form-item>
<el-form-item label="Type" prop="type">
<el-select
v-model="form.type"
:disabled="isEditing"
:fit-input-width="false"
popper-class="visitor-type-dropdown"
class="type-select"
>
<el-option value="stcp" label="STCP">
<div class="type-option">
<span class="type-tag-inline type-stcp">STCP</span>
<span class="type-desc">Secure TCP Visitor</span>
</div>
</el-option>
<el-option value="sudp" label="SUDP">
<div class="type-option">
<span class="type-tag-inline type-sudp">SUDP</span>
<span class="type-desc">Secure UDP Visitor</span>
</div>
</el-option>
<el-option value="xtcp" label="XTCP">
<div class="type-option">
<span class="type-tag-inline type-xtcp">XTCP</span>
<span class="type-desc">P2P (NAT traversal)</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="Enabled">
<el-switch v-model="form.enabled" />
</el-form-item>
</div>
</div>
</div>
<!-- Connection -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">Connection</h3>
</div>
<div class="card-body">
<div class="field-row two-col">
<el-form-item label="Server Name" prop="serverName">
<el-input
v-model="form.serverName"
placeholder="Name of the proxy to visit"
/>
</el-form-item>
<el-form-item label="Server User">
<el-input
v-model="form.serverUser"
placeholder="Leave empty for same user"
/>
</el-form-item>
</div>
<el-form-item label="Secret Key">
<el-input
v-model="form.secretKey"
type="password"
show-password
placeholder="Shared secret"
/>
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Bind Address">
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
</el-form-item>
<el-form-item label="Bind Port" prop="bindPort">
<el-input-number
v-model="form.bindPort"
:min="bindPortMin"
:max="65535"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
</div>
</div>
<!-- Transport Options (collapsible) -->
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="transportExpanded = !transportExpanded"
>
<h3 class="card-title">Transport Options</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: transportExpanded }"
><ArrowDown
/></el-icon>
</div>
<el-collapse-transition>
<div v-show="transportExpanded" class="card-body">
<div class="field-row two-col">
<el-form-item label="Use Encryption">
<el-switch v-model="form.useEncryption" />
</el-form-item>
<el-form-item label="Use Compression">
<el-switch v-model="form.useCompression" />
</el-form-item>
</div>
</div>
</el-collapse-transition>
</div>
<!-- XTCP Options (collapsible, xtcp only) -->
<template v-if="form.type === 'xtcp'">
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="xtcpExpanded = !xtcpExpanded"
>
<h3 class="card-title">XTCP Options</h3>
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"
><ArrowDown
/></el-icon>
</div>
<el-collapse-transition>
<div v-show="xtcpExpanded" class="card-body">
<el-form-item label="Protocol">
<el-select v-model="form.protocol" class="full-width">
<el-option value="quic" label="QUIC" />
<el-option value="kcp" label="KCP" />
</el-select>
</el-form-item>
<el-form-item label="Keep Tunnel Open">
<el-switch v-model="form.keepTunnelOpen" />
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Max Retries per Hour">
<el-input-number
v-model="form.maxRetriesAnHour"
:min="0"
controls-position="right"
class="full-width"
/>
</el-form-item>
<el-form-item label="Min Retry Interval (s)">
<el-input-number
v-model="form.minRetryInterval"
:min="0"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
<div class="field-row two-col">
<el-form-item label="Fallback To">
<el-input
v-model="form.fallbackTo"
placeholder="Fallback visitor name"
/>
</el-form-item>
<el-form-item label="Fallback Timeout (ms)">
<el-input-number
v-model="form.fallbackTimeoutMs"
:min="0"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
</div>
</el-collapse-transition>
</div>
<!-- NAT Traversal (collapsible, xtcp only) -->
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="natExpanded = !natExpanded"
>
<h3 class="card-title">NAT Traversal</h3>
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"
><ArrowDown
/></el-icon>
</div>
<el-collapse-transition>
<div v-show="natExpanded" class="card-body">
<el-form-item label="Disable Assisted Addresses">
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
<div class="form-tip">
Only use STUN-discovered public addresses
</div>
</el-form-item>
</div>
</el-collapse-transition>
</div>
</template>
</el-form>
</div>
<ConfirmDialog
v-model="leaveDialogVisible"
title="Unsaved Changes"
message="You have unsaved changes. Are you sure you want to leave?"
:is-mobile="isMobile"
@confirm="handleLeaveConfirm"
@cancel="handleLeaveCancel"
/>
<!-- Sticky Footer -->
<div class="sticky-footer">
<div class="footer-content">
<el-button @click="goBack">Cancel</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEditing ? 'Update' : 'Create' }}
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
import VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'
import { useResponsive } from '../composables/useResponsive'
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import {
type VisitorFormData,
createDefaultVisitorForm,
formToStoreVisitor,
storeVisitorToForm,
} from '../types'
import { getStoreVisitor } from '../api/frpc'
import { useVisitorStore } from '../stores/visitor'
} from '../types/proxy'
import {
getStoreVisitor,
createStoreVisitor,
updateStoreVisitor,
} from '../api/frpc'
const { isMobile } = useResponsive()
const route = useRoute()
const router = useRouter()
const visitorStore = useVisitorStore()
const isEditing = computed(() => !!route.params.name)
const pageLoading = ref(false)
const saving = ref(false)
const formRef = ref<FormInstance>()
const form = ref<VisitorFormData>(createDefaultVisitorForm())
const dirty = ref(false)
const formSaved = ref(false)
const trackChanges = ref(false)
const transportExpanded = ref(false)
const xtcpExpanded = ref(false)
const natExpanded = ref(false)
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
const formRules: FormRules = {
name: [
@@ -110,60 +310,22 @@ const formRules: FormRules = {
}
const goBack = () => {
router.back()
}
watch(
() => form.value,
() => {
if (trackChanges.value) {
dirty.value = true
}
},
{ deep: true },
)
const leaveDialogVisible = ref(false)
const leaveResolve = ref<((value: boolean) => void) | null>(null)
onBeforeRouteLeave(async () => {
if (dirty.value && !formSaved.value) {
leaveDialogVisible.value = true
return new Promise<boolean>((resolve) => {
leaveResolve.value = resolve
})
}
})
const handleLeaveConfirm = () => {
leaveDialogVisible.value = false
leaveResolve.value?.(true)
}
const handleLeaveCancel = () => {
leaveDialogVisible.value = false
leaveResolve.value?.(false)
router.push('/')
}
const loadVisitor = async () => {
const name = route.params.name as string
if (!name) return
trackChanges.value = false
dirty.value = false
pageLoading.value = true
try {
const res = await getStoreVisitor(name)
form.value = storeVisitorToForm(res)
await nextTick()
} catch (err: any) {
ElMessage.error('Failed to load visitor: ' + err.message)
router.push('/visitors')
router.push('/')
} finally {
pageLoading.value = false
nextTick(() => {
trackChanges.value = true
})
}
}
@@ -181,14 +343,13 @@ const handleSave = async () => {
try {
const data = formToStoreVisitor(form.value)
if (isEditing.value) {
await visitorStore.updateVisitor(form.value.name, data)
await updateStoreVisitor(form.value.name, data)
ElMessage.success('Visitor updated')
} else {
await visitorStore.createVisitor(data)
await createStoreVisitor(data)
ElMessage.success('Visitor created')
}
formSaved.value = true
router.push('/visitors')
router.push('/')
} catch (err: any) {
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
} finally {
@@ -199,8 +360,6 @@ const handleSave = async () => {
onMounted(() => {
if (isEditing.value) {
loadVisitor()
} else {
trackChanges.value = true
}
})
@@ -212,48 +371,14 @@ watch(
loadVisitor()
return
}
trackChanges.value = false
form.value = createDefaultVisitorForm()
dirty.value = false
nextTick(() => {
trackChanges.value = true
})
},
)
</script>
<style scoped lang="scss">
<style scoped>
.visitor-edit-page {
display: flex;
flex-direction: column;
height: 100%;
max-width: 960px;
margin: 0 auto;
}
/* Header */
.edit-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 20px 24px;
}
.edit-content {
flex: 1;
overflow-y: auto;
padding: 0 24px 160px;
> * {
max-width: 960px;
margin: 0 auto;
}
}
.header-actions {
display: flex;
gap: 8px;
padding-bottom: 80px;
}
/* Breadcrumb */
@@ -262,6 +387,20 @@ watch(
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 24px;
}
.breadcrumb-link {
display: flex;
align-items: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
margin-right: 4px;
}
.breadcrumb-link:hover {
color: var(--text-primary);
}
.breadcrumb-item {
@@ -283,13 +422,185 @@ watch(
font-weight: 500;
}
@include mobile {
.edit-header {
padding: 20px 16px;
/* Form Cards */
.form-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
html.dark .form-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--header-border);
}
html.dark .card-header {
border-bottom-color: #3a3d5c;
}
.card-header.clickable {
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.card-header.clickable:hover {
background: var(--hover-bg);
}
.collapsible-card .card-header {
border-bottom: none;
}
.collapsible-card .card-body {
border-top: 1px solid var(--header-border);
}
html.dark .collapsible-card .card-body {
border-top-color: #3a3d5c;
}
.card-title {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.collapse-icon {
transition: transform 0.3s;
color: var(--text-secondary);
}
.collapse-icon.expanded {
transform: rotate(-180deg);
}
.card-body {
padding: 20px 24px;
}
/* Field Rows */
.field-row {
display: grid;
gap: 16px;
}
.field-row.two-col {
grid-template-columns: 1fr 1fr;
}
.field-row.three-col {
grid-template-columns: 1fr auto auto;
align-items: start;
}
.field-grow {
min-width: 0;
}
.full-width {
width: 100%;
}
.type-select {
width: 180px;
}
.type-option {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.type-tag-inline {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
}
.type-tag-inline.type-stcp,
.type-tag-inline.type-sudp,
.type-tag-inline.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
/* Sticky Footer */
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid var(--header-border);
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 40px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Responsive */
@media (max-width: 768px) {
.field-row.two-col,
.field-row.three-col {
grid-template-columns: 1fr;
}
.edit-content {
padding: 0 16px 160px;
.type-select {
width: 100%;
}
.card-body {
padding: 16px;
}
.footer-content {
padding: 12px 20px;
}
}
</style>
<style>
.visitor-type-dropdown {
min-width: 300px !important;
}
.visitor-type-dropdown .el-select-dropdown__item {
height: auto;
padding: 8px 16px;
line-height: 1.4;
}
</style>

View File

@@ -1,373 +0,0 @@
<template>
<div class="visitors-page">
<!-- Header -->
<div class="page-header">
<h2 class="page-title">Visitors</h2>
</div>
<!-- Tab bar -->
<div class="tab-bar">
<div class="tab-buttons">
<button class="tab-btn active">Store</button>
</div>
<div class="tab-actions">
<ActionButton variant="outline" size="small" @click="fetchData">
<el-icon><Refresh /></el-icon>
</ActionButton>
<ActionButton v-if="visitorStore.storeEnabled" size="small" @click="handleCreate">
+ New Visitor
</ActionButton>
</div>
</div>
<div v-loading="visitorStore.loading">
<div v-if="!visitorStore.storeEnabled" class="store-disabled">
<p>Store is not enabled. Add the following to your frpc configuration:</p>
<pre class="config-hint">[store]
path = "./frpc_store.json"</pre>
</div>
<template v-else>
<div class="filter-bar">
<el-input v-model="searchText" placeholder="Search..." clearable class="search-input">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<FilterDropdown v-model="typeFilter" label="Type" :options="typeOptions" :min-width="140" :is-mobile="isMobile" />
</div>
<div v-if="filteredVisitors.length > 0" class="visitor-list">
<div v-for="v in filteredVisitors" :key="v.name" class="visitor-card" @click="goToDetail(v.name)">
<div class="card-left">
<div class="card-header">
<span class="visitor-name">{{ v.name }}</span>
<span class="type-tag">{{ v.type.toUpperCase() }}</span>
</div>
<div v-if="getServerName(v)" class="card-meta">{{ getServerName(v) }}</div>
</div>
<div class="card-right">
<div @click.stop>
<PopoverMenu :width="120" placement="bottom-end">
<template #trigger>
<ActionButton variant="outline" size="small">
<el-icon><MoreFilled /></el-icon>
</ActionButton>
</template>
<PopoverMenuItem @click="handleEdit(v)">
<el-icon><Edit /></el-icon>
Edit
</PopoverMenuItem>
<PopoverMenuItem danger @click="handleDelete(v.name)">
<el-icon><Delete /></el-icon>
Delete
</PopoverMenuItem>
</PopoverMenu>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<p class="empty-text">No visitors found</p>
<p class="empty-hint">Click "New Visitor" to create one.</p>
</div>
</template>
</div>
<ConfirmDialog v-model="deleteDialog.visible" title="Delete Visitor"
:message="deleteDialog.message" confirm-text="Delete" danger
:loading="deleteDialog.loading" :is-mobile="isMobile" @confirm="doDelete" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search, Refresh, MoreFilled, Edit, Delete } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import FilterDropdown from '@shared/components/FilterDropdown.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
import { useVisitorStore } from '../stores/visitor'
import { useResponsive } from '../composables/useResponsive'
import type { VisitorDefinition } from '../types'
const { isMobile } = useResponsive()
const router = useRouter()
const visitorStore = useVisitorStore()
const searchText = ref('')
const typeFilter = ref('')
const deleteDialog = reactive({
visible: false,
message: '',
loading: false,
name: '',
})
const typeOptions = computed(() => {
return [
{ label: 'STCP', value: 'stcp' },
{ label: 'SUDP', value: 'sudp' },
{ label: 'XTCP', value: 'xtcp' },
]
})
const filteredVisitors = computed(() => {
let list = visitorStore.storeVisitors
if (typeFilter.value) {
list = list.filter((v) => v.type === typeFilter.value)
}
if (searchText.value) {
const q = searchText.value.toLowerCase()
list = list.filter((v) => v.name.toLowerCase().includes(q))
}
return list
})
const getServerName = (v: VisitorDefinition): string => {
const block = (v as any)[v.type]
return block?.serverName || ''
}
const fetchData = () => {
visitorStore.fetchStoreVisitors()
}
const handleCreate = () => {
router.push('/visitors/create')
}
const handleEdit = (v: VisitorDefinition) => {
router.push('/visitors/' + encodeURIComponent(v.name) + '/edit')
}
const goToDetail = (name: string) => {
router.push('/visitors/detail/' + encodeURIComponent(name))
}
const handleDelete = (name: string) => {
deleteDialog.name = name
deleteDialog.message = `Are you sure you want to delete visitor "${name}"? This action cannot be undone.`
deleteDialog.visible = true
}
const doDelete = async () => {
deleteDialog.loading = true
try {
await visitorStore.deleteVisitor(deleteDialog.name)
ElMessage.success('Visitor deleted')
deleteDialog.visible = false
fetchData()
} catch (err: any) {
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
} finally {
deleteDialog.loading = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.visitors-page {
height: 100%;
overflow-y: auto;
padding: $spacing-xl 40px;
max-width: 960px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-xl;
}
.tab-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $color-border-lighter;
margin-bottom: $spacing-xl;
}
.tab-buttons {
display: flex;
}
.tab-btn {
background: none;
border: none;
padding: $spacing-sm $spacing-xl;
font-size: $font-size-md;
color: $color-text-muted;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all $transition-fast;
&:hover {
color: $color-text-primary;
}
&.active {
color: $color-text-primary;
border-bottom-color: $color-text-primary;
font-weight: $font-weight-medium;
}
}
.tab-actions {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.filter-bar {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-xl;
:deep(.search-input) {
flex: 1;
min-width: 150px;
}
}
.visitor-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.visitor-card {
display: flex;
justify-content: space-between;
align-items: center;
background: $color-bg-primary;
border: 1px solid $color-border-lighter;
border-radius: $radius-md;
padding: 14px 20px;
cursor: pointer;
transition: all $transition-medium;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border-color: $color-border;
}
}
.card-left {
@include flex-column;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.visitor-name {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
}
.type-tag {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
padding: 2px 8px;
border-radius: 4px;
background: $color-bg-muted;
color: $color-text-secondary;
}
.card-meta {
font-size: $font-size-sm;
color: $color-text-muted;
}
.card-right {
display: flex;
align-items: center;
gap: $spacing-md;
flex-shrink: 0;
}
.store-disabled {
padding: 32px;
text-align: center;
color: $color-text-muted;
}
.config-hint {
display: inline-block;
text-align: left;
background: $color-bg-hover;
padding: 12px 20px;
border-radius: $radius-sm;
font-size: $font-size-sm;
margin-top: $spacing-md;
}
.empty-state {
text-align: center;
padding: 60px $spacing-xl;
}
.empty-text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $color-text-secondary;
margin: 0 0 $spacing-xs;
}
.empty-hint {
font-size: $font-size-sm;
color: $color-text-muted;
margin: 0;
}
@include mobile {
.visitors-page {
padding: $spacing-lg;
}
.page-header {
flex-direction: column;
align-items: stretch;
gap: $spacing-md;
}
.filter-bar {
flex-wrap: wrap;
:deep(.search-input) {
flex: 1 1 100%;
}
}
.visitor-card {
flex-direction: column;
align-items: stretch;
gap: $spacing-sm;
}
.card-right {
justify-content: flex-end;
}
}
</style>

View File

@@ -18,12 +18,8 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../shared/*"]
}
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../shared/**/*.ts", "../shared/**/*.vue"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -25,20 +25,6 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
},
dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],
modules: [
fileURLToPath(new URL('../node_modules', import.meta.url)),
'node_modules',
],
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
additionalData: `@use "@shared/css/_index.scss" as *;`,
},
},
},
build: {

View File

@@ -1,7 +1,7 @@
.PHONY: dist install build preview lint
install:
@cd .. && npm install
@npm install
build: install
@npm run build

View File

@@ -11,12 +11,13 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
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']

7646
web/frps/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,195 +2,135 @@
<div id="app">
<header class="header">
<div class="header-content">
<div class="brand-section">
<button
v-if="isMobile"
class="hamburger-btn"
@click="toggleSidebar"
aria-label="Toggle menu"
>
<span class="hamburger-icon">&#9776;</span>
</button>
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
<div class="header-top">
<div class="brand-section">
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge server-badge">Server</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge server-badge">Server</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
<nav class="nav-bar">
<router-link to="/" class="nav-link" active-class="active"
>Overview</router-link
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
<router-link to="/clients" class="nav-link" active-class="active"
>Clients</router-link
>
<router-link
to="/proxies"
class="nav-link"
:class="{ active: route.path.startsWith('/proxies') }"
>Proxies</router-link
>
</nav>
</div>
</header>
<div class="layout">
<!-- Mobile overlay -->
<div
v-if="isMobile && sidebarOpen"
class="sidebar-overlay"
@click="closeSidebar"
/>
<aside
class="sidebar"
:class="{ 'mobile-open': isMobile && sidebarOpen }"
>
<nav class="sidebar-nav">
<router-link
to="/"
class="sidebar-link"
:class="{ active: route.path === '/' }"
@click="closeSidebar"
>
Overview
</router-link>
<router-link
to="/clients"
class="sidebar-link"
:class="{ active: route.path.startsWith('/clients') }"
@click="closeSidebar"
>
Clients
</router-link>
<router-link
to="/proxies"
class="sidebar-link"
:class="{
active:
route.path.startsWith('/proxies') ||
route.path.startsWith('/proxy'),
}"
@click="closeSidebar"
>
Proxies
</router-link>
</nav>
</aside>
<main id="content">
<router-view></router-view>
</main>
</div>
<main id="content">
<router-view></router-view>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component'
import { useResponsive } from './composables/useResponsive'
const route = useRoute()
const isDark = useDark()
const { isMobile } = useResponsive()
const sidebarOpen = ref(false)
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const closeSidebar = () => {
sidebarOpen.value = false
}
// Auto-close sidebar on route change
watch(
() => route.path,
() => {
if (isMobile.value) {
closeSidebar()
}
},
)
const currentRouteName = computed(() => {
if (route.path === '/') return 'Overview'
if (route.path.startsWith('/clients')) return 'Clients'
if (route.path.startsWith('/proxies')) return 'Proxies'
return ''
})
</script>
<style>
:root {
--header-height: 50px;
--sidebar-width: 200px;
--header-bg: #ffffff;
--header-border: #e4e7ed;
--sidebar-bg: #ffffff;
--text-primary: #303133;
--text-secondary: #606266;
--text-muted: #909399;
--hover-bg: #efefef;
--content-bg: #f9f9f9;
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: #1e1e2e;
--header-border: #3a3d5c;
--sidebar-bg: #1e1e2e;
--text-primary: #e5e7eb;
--text-secondary: #b0b0b0;
--text-muted: #888888;
--hover-bg: #2a2a3e;
--content-bg: #181825;
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body {
margin: 0;
font-family:
ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica, Arial,
sans-serif;
}
*,
:after,
:before {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
height: 100%;
overflow: hidden;
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
}
#app {
height: 100vh;
height: 100dvh;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--content-bg);
background-color: var(--el-bg-color-page);
}
/* Header */
.header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 100;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
height: var(--header-height);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
}
.header-top {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 20px;
}
.brand-section {
@@ -205,13 +145,13 @@ body {
}
.logo-icon {
width: 28px;
height: 28px;
width: 32px;
height: 32px;
}
.divider {
color: var(--header-border);
font-size: 22px;
font-size: 24px;
font-weight: 200;
}
@@ -223,12 +163,12 @@ body {
}
.badge {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-size: 12px;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 4px;
border-radius: 99px;
border: 1px solid var(--header-border);
}
.badge.server-badge {
@@ -249,20 +189,17 @@ html.dark .badge.server-badge {
}
.github-link {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
transition: all 0.15s ease;
text-decoration: none;
}
.github-link:hover {
background: var(--hover-bg);
border-radius: 50%;
color: var(--text-primary);
transition: background 0.2s;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.github-icon {
@@ -270,6 +207,11 @@ html.dark .badge.server-badge {
height: 18px;
}
.github-link:hover {
background: var(--hover-bg);
border-color: var(--header-border);
}
.theme-switch {
--el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2;
@@ -284,235 +226,46 @@ html.dark .theme-switch {
color: #909399 !important;
}
/* Layout */
.layout {
flex: 1;
display: flex;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--sidebar-bg);
border-right: 1px solid var(--header-border);
padding: 16px 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-link {
display: block;
text-decoration: none;
font-size: 15px;
color: var(--text-secondary);
padding: 10px 12px;
border-radius: 6px;
transition: all 0.15s ease;
}
.sidebar-link:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
.sidebar-link.active {
color: var(--text-primary);
background: var(--hover-bg);
font-weight: 500;
}
/* Hamburger button (mobile only) */
.hamburger-btn {
.nav-bar {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
padding: 0;
transition: background 0.15s ease;
gap: 24px;
}
.hamburger-btn:hover {
background: var(--hover-bg);
.nav-link {
text-decoration: none;
font-size: 14px;
color: var(--text-secondary);
padding: 8px 0;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.hamburger-icon {
font-size: 20px;
line-height: 1;
.nav-link:hover {
color: var(--text-primary);
}
/* Mobile overlay */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
.nav-link.active {
color: var(--active-link);
border-bottom-color: var(--active-link);
}
/* Content */
#content {
flex: 1;
min-width: 0;
overflow-y: auto;
width: 100%;
padding: 40px;
}
#content > * {
max-width: 1024px;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
}
/* Common page styles */
.page-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text-primary, var(--text-primary));
margin: 0;
}
.page-subtitle {
font-size: 14px;
color: var(--color-text-muted, var(--text-muted));
margin: 8px 0 0;
}
/* Element Plus global overrides */
.el-button {
font-weight: 500;
}
.el-tag {
font-weight: 500;
}
.el-switch {
--el-switch-on-color: #606266;
--el-switch-off-color: #dcdfe6;
}
html.dark .el-switch {
--el-switch-on-color: #b0b0b0;
--el-switch-off-color: #3a3d5c;
}
.el-form-item {
margin-bottom: 16px;
}
.el-loading-mask {
border-radius: 8px;
}
/* Select overrides */
.el-select__wrapper {
border-radius: 8px !important;
box-shadow: 0 0 0 1px var(--color-border-light, #e4e7ed) inset !important;
transition: all 0.15s ease;
}
.el-select__wrapper:hover {
box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;
}
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;
}
.el-select-dropdown {
border-radius: 12px !important;
border: 1px solid var(--color-border-light, #e4e7ed) !important;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
padding: 4px !important;
}
.el-select-dropdown__item {
border-radius: 6px;
margin: 2px 0;
transition: background 0.15s ease;
}
.el-select-dropdown__item.is-selected {
color: var(--color-text-primary, var(--text-primary));
font-weight: 500;
}
/* Input overrides */
.el-input__wrapper {
border-radius: 8px !important;
box-shadow: 0 0 0 1px var(--color-border-light, #e4e7ed) inset !important;
transition: all 0.15s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--color-border, #dcdfe6) inset !important;
}
/* Card overrides */
.el-card {
border-radius: 12px;
border: 1px solid var(--color-border-light, #e4e7ed);
transition: all 0.2s ease;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d1d1;
border-radius: 3px;
}
/* Mobile */
@media (max-width: 767px) {
@media (max-width: 768px) {
.header-content {
padding: 0 16px;
}
.sidebar {
position: fixed;
top: var(--header-height);
left: 0;
bottom: 0;
z-index: 100;
background: var(--sidebar-bg);
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border-right: 1px solid var(--header-border);
}
.sidebar.mobile-open {
transform: translateX(0);
padding: 0 20px;
}
#content {
width: 100%;
padding: 20px;
}
}

View File

@@ -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: 500;
}
/* 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;
}
}

View File

@@ -1,153 +1,46 @@
/* Dark mode styles */
html.dark {
--el-bg-color: #1e1e2e;
--el-bg-color-page: #181825;
--el-bg-color-overlay: #27293d;
--el-fill-color-blank: #1e1e2e;
--el-border-color: #3a3d5c;
--el-border-color-light: #313348;
--el-border-color-lighter: #2a2a3e;
--el-text-color-primary: #e5e7eb;
--el-text-color-secondary: #888888;
--el-text-color-placeholder: #afafaf;
background-color: #1e1e2e;
color-scheme: dark;
}
/* Scrollbar */
html.dark ::-webkit-scrollbar {
width: 6px;
height: 6px;
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;
border-radius: 3px;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #4a4d6c;
}
/* Form */
html.dark .el-form-item__label {
color: #e5e7eb;
/* Dark mode cards */
html.dark .el-card {
background-color: #27293d;
border-color: #3a3d5c;
}
/* Input */
/* Dark mode inputs */
html.dark .el-input__wrapper {
background: var(--color-bg-input);
box-shadow: 0 0 0 1px #3a3d5c inset;
}
html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px #4a4d6c inset;
}
html.dark .el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-input__inner {
color: #e5e7eb;
}
html.dark .el-input__inner::placeholder {
color: #afafaf;
}
html.dark .el-textarea__inner {
background: var(--color-bg-input);
box-shadow: 0 0 0 1px #3a3d5c inset;
color: #e5e7eb;
}
html.dark .el-textarea__inner:hover {
box-shadow: 0 0 0 1px #4a4d6c inset;
}
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 #3a3d5c inset;
}
html.dark .el-select__wrapper:hover {
box-shadow: 0 0 0 1px #4a4d6c inset;
}
html.dark .el-select__selected-item {
color: #e5e7eb;
}
html.dark .el-select__placeholder {
color: #afafaf;
}
html.dark .el-select-dropdown {
background: #27293d;
border-color: #3a3d5c;
}
html.dark .el-select-dropdown__item {
color: #e5e7eb;
}
html.dark .el-select-dropdown__item:hover {
background: #2a2a3e;
}
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: #27293d;
border-color: #3a3d5c;
color: #b0b0b0;
}
/* Button */
html.dark .el-button--default {
background: #27293d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-button--default:hover {
background: #2a2a3e;
border-color: #4a4d6c;
color: #e5e7eb;
}
/* Card */
html.dark .el-card {
background: #1e1e2e;
border-color: #3a3d5c;
color: #b0b0b0;
}
html.dark .el-card__header {
border-bottom-color: #3a3d5c;
color: #e5e7eb;
}
/* Table */
/* Dark mode table */
html.dark .el-table {
background-color: #1e1e2e;
background-color: #27293d;
color: #e5e7eb;
}
@@ -157,56 +50,9 @@ html.dark .el-table th {
}
html.dark .el-table tr {
background-color: #1e1e2e;
background-color: #27293d;
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: #181825;
}
/* Dialog */
html.dark .el-dialog {
background: #1e1e2e;
}
html.dark .el-dialog__title {
color: #e5e7eb;
}
/* Message */
html.dark .el-message {
background: #27293d;
border-color: #3a3d5c;
}
html.dark .el-message--success {
background: #1e3d2e;
border-color: #3d6b4f;
}
html.dark .el-message--warning {
background: #3d3020;
border-color: #6b5020;
}
html.dark .el-message--error {
background: #3d2027;
border-color: #5c2d2d;
}
/* Loading */
html.dark .el-loading-mask {
background-color: rgba(30, 30, 46, 0.9);
}
/* Overlay */
html.dark .el-overlay {
background-color: rgba(0, 0, 0, 0.6);
}
/* Tooltip */
html.dark .el-tooltip__popper {
background: #27293d !important;
border-color: #3a3d5c !important;
color: #e5e7eb !important;
background-color: #1e1e2e;
}

View File

@@ -1,109 +0,0 @@
: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;
/* 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: #1e1e2e;
--color-bg-secondary: #181825;
--color-bg-tertiary: #27293d;
--color-bg-surface: #27293d;
--color-bg-muted: #27293d;
--color-bg-input: #27293d;
--color-bg-hover: #2a2a3e;
--color-bg-active: #353550;
/* Border colors */
--color-border: #3a3d5c;
--color-border-light: #313348;
--color-border-lighter: #2a2a3e;
--color-border-extra-light: #222233;
/* Status colors */
--color-primary: #409eff;
--color-danger: #f87171;
--color-danger-dark: #f87171;
--color-danger-light: #3d2027;
--color-info: #888888;
/* Dark overrides */
--el-text-color-regular: var(--color-text-primary);
--el-overlay-color: rgba(0, 0, 0, 0.7);
background-color: #181825;
color-scheme: dark;
}

View File

@@ -1,8 +0,0 @@
import { useBreakpoints } from '@vueuse/core'
const breakpoints = useBreakpoints({ mobile: 0, desktop: 768 })
export function useResponsive() {
const isMobile = breakpoints.smaller('desktop') // < 768px
return { isMobile }
}

View File

@@ -3,7 +3,7 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import './assets/css/var.css'
import './assets/css/custom.css'
import './assets/css/dark.css'
const app = createApp(App)

View File

@@ -8,13 +8,23 @@
</div>
<div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData">
Refresh
</ActionButton>
<el-button :icon="Refresh" class="action-btn" @click="fetchData"
>Refresh</el-button
>
<ActionButton variant="outline" size="small" danger @click="showClearDialog = true">
Clear Offline
</ActionButton>
<el-popconfirm
title="Clear all offline proxies?"
width="220"
confirm-button-text="Clear"
cancel-button-text="Cancel"
@confirm="clearOfflineProxies"
>
<template #reference>
<el-button :icon="Delete" class="action-btn" type="danger" plain
>Clear Offline</el-button
>
</template>
</el-popconfirm>
</div>
</div>
@@ -28,35 +38,28 @@
class="main-search"
/>
<PopoverMenu
<el-select
:model-value="selectedClientKey"
:width="220"
placement="bottom-end"
selectable
filterable
filter-placeholder="Search clients..."
:display-value="selectedClientLabel"
placeholder="All Clients"
clearable
class="client-filter"
@update:model-value="onClientFilterChange($event as string)"
filterable
class="client-select"
@change="onClientFilterChange"
>
<template #default="{ filterText }">
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
<PopoverMenuItem
v-if="clientIDFilter && !selectedClientInList"
:value="selectedClientKey"
>
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
</PopoverMenuItem>
<PopoverMenuItem
v-for="client in filteredClientOptions(filterText)"
:key="client.key"
:value="client.key"
>
{{ client.label }}
</PopoverMenuItem>
</template>
</PopoverMenu>
<el-option label="All Clients" value="" />
<el-option
v-if="clientIDFilter && !selectedClientInList"
:label="`${userFilter ? userFilter + '.' : ''}${clientIDFilter} (not found)`"
:value="selectedClientKey"
style="color: var(--el-color-warning); font-style: italic"
/>
<el-option
v-for="client in clientOptions"
:key="client.key"
:label="client.label"
:value="client.key"
/>
</el-select>
</div>
<div class="type-tabs">
@@ -85,15 +88,6 @@
<el-empty description="No proxies found" />
</div>
</div>
<ConfirmDialog
v-model="showClearDialog"
title="Clear Offline"
message="Are you sure you want to clear all offline proxies?"
confirm-text="Clear"
danger
@confirm="handleClearConfirm"
/>
</div>
</template>
@@ -101,9 +95,7 @@
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
import { Search, Refresh, Delete } from '@element-plus/icons-vue'
import {
BaseProxy,
TCPProxy,
@@ -115,8 +107,6 @@ import {
SUDPProxy,
} from '../utils/proxy'
import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import {
getProxiesByType,
clearOfflineProxies as apiClearOfflineProxies,
@@ -143,7 +133,6 @@ const proxies = ref<BaseProxy[]>([])
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '')
@@ -168,20 +157,6 @@ const selectedClientKey = computed(() => {
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
})
const selectedClientLabel = computed(() => {
if (!clientIDFilter.value) return 'All Clients'
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
})
const filteredClientOptions = (filterText: string) => {
if (!filterText) return clientOptions.value
const search = filterText.toLowerCase()
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
}
// Check if the filtered client exists in the client list
const selectedClientInList = computed(() => {
if (!clientIDFilter.value) return true
@@ -300,11 +275,6 @@ const fetchData = async () => {
}
}
const handleClearConfirm = async () => {
showClearDialog.value = false
await clearOfflineProxies()
}
const clearOfflineProxies = async () => {
try {
await apiClearOfflineProxies()
@@ -387,6 +357,12 @@ fetchClients()
gap: 12px;
}
.action-btn {
border-radius: 8px;
padding: 8px 16px;
height: 36px;
font-weight: 500;
}
.filter-section {
display: flex;
@@ -406,16 +382,37 @@ fetchClients()
flex: 1;
}
.main-search :deep(.el-input__wrapper),
.client-filter :deep(.el-input__wrapper) {
height: 32px;
border-radius: 8px;
.main-search,
.client-select {
height: 44px;
}
.client-filter {
.main-search :deep(.el-input__wrapper),
.client-select :deep(.el-input__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 0 16px;
height: 100%;
border: 1px solid var(--el-border-color);
}
.main-search :deep(.el-input__wrapper) {
font-size: 15px;
}
.client-select {
width: 240px;
}
.client-select :deep(.el-select__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 0 12px;
height: 44px;
min-height: 44px;
border: 1px solid var(--el-border-color);
}
.type-tabs {
display: flex;
gap: 8px;
@@ -465,7 +462,7 @@ fetchClients()
flex-direction: column;
}
.client-filter {
.client-select {
width: 100%;
}
}

View File

@@ -51,41 +51,98 @@
<router-link
v-if="proxy.clientID"
:to="clientLink"
class="meta-link"
class="client-link"
>
<el-icon><Monitor /></el-icon>
<span>{{
proxy.user
? `${proxy.user}.${proxy.clientID}`
: proxy.clientID
}}</span>
<span
>Client:
{{
proxy.user
? `${proxy.user}.${proxy.clientID}`
: proxy.clientID
}}</span
>
</router-link>
<span v-if="proxy.lastStartTime" class="meta-text">
<span class="meta-sep">·</span>
Last Started {{ proxy.lastStartTime }}
</span>
<span v-if="proxy.lastCloseTime" class="meta-text">
<span class="meta-sep">·</span>
Last Closed {{ proxy.lastCloseTime }}
</span>
</div>
</div>
</div>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div v-if="proxy.port" class="stats-item">
<span class="stats-label">Port</span>
<span class="stats-value">{{ proxy.port }}</span>
<!-- Stats Cards -->
<div class="stats-grid">
<div v-if="proxy.port" class="stat-card">
<div class="stat-header">
<span class="stat-label">Port</span>
<div class="stat-icon port">
<el-icon><Connection /></el-icon>
</div>
</div>
<div class="stat-value">{{ proxy.port }}</div>
</div>
<div class="stats-item">
<span class="stats-label">Connections</span>
<span class="stats-value">{{ proxy.conns }}</span>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Connections</span>
<div class="stat-icon connections">
<el-icon><DataLine /></el-icon>
</div>
</div>
<div class="stat-value">{{ proxy.conns }}</div>
</div>
<div class="stats-item">
<span class="stats-label">Traffic</span>
<span class="stats-value"> {{ formatTrafficValue(proxy.trafficIn) }} <small>{{ formatTrafficUnit(proxy.trafficIn) }}</small> / {{ formatTrafficValue(proxy.trafficOut) }} <small>{{ formatTrafficUnit(proxy.trafficOut) }}</small></span>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Traffic In</span>
<div class="stat-icon traffic-in">
<el-icon><Bottom /></el-icon>
</div>
</div>
<div class="stat-value">
<span class="value-number">{{
formatTrafficValue(proxy.trafficIn)
}}</span>
<span class="value-unit">{{
formatTrafficUnit(proxy.trafficIn)
}}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Traffic Out</span>
<div class="stat-icon traffic-out">
<el-icon><Top /></el-icon>
</div>
</div>
<div class="stat-value">
<span class="value-number">{{
formatTrafficValue(proxy.trafficOut)
}}</span>
<span class="value-unit">{{
formatTrafficUnit(proxy.trafficOut)
}}</span>
</div>
</div>
</div>
<!-- Status Timeline -->
<div class="timeline-card">
<div class="timeline-header">
<el-icon><DataLine /></el-icon>
<h2>Status Timeline</h2>
</div>
<div class="timeline-body">
<div class="timeline-grid">
<div class="timeline-item">
<span class="timeline-label">Last Start Time</span>
<span class="timeline-value">{{
proxy.lastStartTime || '-'
}}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Last Close Time</span>
<span class="timeline-value">{{
proxy.lastCloseTime || '-'
}}</span>
</div>
</div>
</div>
</div>
@@ -231,6 +288,9 @@ import {
ArrowLeft,
Monitor,
Connection,
DataLine,
Bottom,
Top,
Link,
Lock,
Promotion,
@@ -533,71 +593,176 @@ html.dark .status-badge.online {
.header-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
gap: 16px;
}
.meta-link {
.client-link {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
font-size: 14px;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.meta-link:hover {
.client-link:hover {
color: var(--el-color-primary);
}
.meta-text {
color: var(--text-muted);
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.meta-sep {
margin: 0 4px;
}
/* Stats Bar */
.stats-bar {
display: flex;
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 10px;
margin-bottom: 20px;
border-radius: 12px;
padding: 20px;
}
.stats-item {
flex: 1;
.stat-header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 20px;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.stats-item + .stats-item {
border-left: 1px solid var(--header-border);
}
.stats-label {
font-size: 12px;
.stat-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.stats-value {
.stat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.stat-icon.port {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.stat-icon.connections {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.stat-icon.traffic-in {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.stat-icon.traffic-out {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
html.dark .stat-icon.port {
background: rgba(139, 92, 246, 0.15);
}
html.dark .stat-icon.connections {
background: rgba(168, 85, 247, 0.15);
}
html.dark .stat-icon.traffic-in {
background: rgba(59, 130, 246, 0.15);
}
html.dark .stat-icon.traffic-out {
background: rgba(34, 197, 94, 0.15);
}
.stat-value {
display: flex;
align-items: baseline;
gap: 6px;
}
.value-number {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
line-height: 1;
}
.stat-value:not(:has(.value-number)) {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
}
.stats-value small {
font-size: 12px;
.value-unit {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
/* Timeline Card */
.timeline-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
.timeline-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
color: var(--text-secondary);
}
.timeline-header h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.timeline-body {
padding: 20px;
padding-top: 0;
}
.timeline-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
background: var(--el-fill-color-light);
border-radius: 10px;
padding: 20px 24px;
}
.timeline-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.timeline-value {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
/* Card Base */
.traffic-card {
@@ -791,22 +956,16 @@ html.dark .config-item-icon.route {
}
/* Responsive */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.config-grid {
grid-template-columns: 1fr;
}
.stats-bar {
flex-wrap: wrap;
}
.stats-item {
flex: 1 1 40%;
}
.stats-item:nth-child(n+3) {
border-top: 1px solid var(--header-border);
}
}
@media (max-width: 640px) {
@@ -815,5 +974,12 @@ html.dark .config-item-icon.route {
gap: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.timeline-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -18,12 +18,8 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../shared/*"]
}
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../shared/**/*.ts", "../shared/**/*.vue"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -25,20 +25,6 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
},
dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'],
modules: [
fileURLToPath(new URL('../node_modules', import.meta.url)),
'node_modules',
],
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
additionalData: `@use "@shared/css/_index.scss" as *;`,
},
},
},
build: {

View File

@@ -1,5 +0,0 @@
{
"name": "frp-web",
"private": true,
"workspaces": ["shared", "frpc", "frps"]
}

View File

@@ -1,144 +0,0 @@
<template>
<button
type="button"
class="action-button"
:class="[variant, size, { 'is-loading': loading, 'is-danger': danger }]"
:disabled="disabled || loading"
@click="handleClick"
>
<div v-if="loading" class="spinner"></div>
<span v-if="loading && loadingText">{{ loadingText }}</span>
<slot v-else />
</button>
</template>
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'small' | 'medium' | 'large'
disabled?: boolean
loading?: boolean
loadingText?: string
danger?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'medium',
disabled: false,
loading: false,
loadingText: '',
danger: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped lang="scss">
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
border-radius: $radius-md;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-fast;
border: 1px solid transparent;
white-space: nowrap;
.spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
&.small {
padding: 5px $spacing-md;
font-size: $font-size-sm;
}
&.medium {
padding: $spacing-sm $spacing-lg;
font-size: $font-size-md;
}
&.large {
padding: 10px $spacing-xl;
font-size: $font-size-lg;
}
&.primary {
background: $color-btn-primary;
border-color: $color-btn-primary;
color: #fff;
&:hover:not(:disabled) {
background: $color-btn-primary-hover;
border-color: $color-btn-primary-hover;
}
}
&.secondary {
background: $color-bg-hover;
border-color: $color-border-light;
color: $color-text-primary;
&:hover:not(:disabled) {
border-color: $color-border;
}
}
&.outline {
background: transparent;
border-color: $color-border;
color: $color-text-primary;
&:hover:not(:disabled) {
background: $color-bg-hover;
}
}
&.is-danger {
&.primary {
background: $color-danger;
border-color: $color-danger;
&:hover:not(:disabled) {
background: $color-danger-dark;
border-color: $color-danger-dark;
}
}
&.outline, &.secondary {
color: $color-danger;
&:hover:not(:disabled) {
border-color: $color-danger;
background: rgba(239, 68, 68, 0.08);
}
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

View File

@@ -1,141 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
:width="dialogWidth"
:destroy-on-close="destroyOnClose"
:close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape"
:append-to-body="appendToBody"
:top="dialogTop"
:fullscreen="isMobile"
class="base-dialog"
:class="{ 'mobile-dialog': isMobile }"
>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
title: string
width?: string
destroyOnClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
appendToBody?: boolean
top?: string
isMobile?: boolean
}>(),
{
width: '480px',
destroyOnClose: true,
closeOnClickModal: true,
closeOnPressEscape: true,
appendToBody: false,
top: '15vh',
isMobile: false,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const dialogWidth = computed(() => {
if (props.isMobile) return '100%'
return props.width
})
const dialogTop = computed(() => {
if (props.isMobile) return '0'
return props.top
})
</script>
<style lang="scss">
.base-dialog.el-dialog {
border-radius: 16px;
.el-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
min-height: 42px;
margin: 0;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
height: 1px;
background: $color-border-lighter;
}
}
.el-dialog__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
.el-dialog__body {
padding: 16px 8px;
}
.el-dialog__headerbtn {
position: static;
width: 32px;
height: 32px;
@include flex-center;
border-radius: $radius-sm;
transition: background $transition-fast;
&:hover {
background: $color-bg-hover;
}
}
.el-dialog__footer {
padding: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
&.mobile-dialog {
border-radius: 0;
margin: 0;
height: 100%;
max-height: 100dvh;
display: flex;
flex-direction: column;
.el-dialog__body {
flex: 1;
overflow-y: auto;
padding: 16px 12px;
}
.el-dialog__footer {
padding: 8px 12px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More