Compare commits

...

15 Commits

Author SHA1 Message Date
sagit 0cb3263a2e feat(frontend): support markdown in dashboard announcements (#176)
## Summary
- render dashboard announcements with Markdown using `react-markdown` +
`remark-gfm` while sanitizing output with `rehype-sanitize`
- add styled Markdown element mappings in the announcement banner for
links, lists, code blocks, and blockquotes
- update announcement management hint text to communicate Markdown
support in the config page

## Verification
- npm run build (vite-frontend)
2026-02-21 16:50:19 +08:00
sagit fb2189c924 Merge branch 'main' into feat/announcement-markdown-support 2026-02-21 16:36:51 +08:00
sagitchu 1383174b31 refactor: Standardize empty state UI for tunnel and user pages, update announcement banner styling, and add new entries to .gitignore. 2026-02-21 16:36:36 +08:00
sagitchu c95bde7055 style: space out provider and consumer share panel title cards from tabs 2026-02-21 16:36:05 +08:00
Sagit 57f5e3a1a3 feat(frontend): support markdown in dashboard announcements 2026-02-21 05:48:24 +00:00
sagit 66ad52c199 feat(node): add stable/test release channels for install and upgrade (#175)
## Summary
- classify release tags by channel: pure numeric tags are treated as
stable, while tags containing alpha/beta/rc (or other non-numeric
formats) are treated as test releases
- add channel-aware backend APIs for install/upgrade/release listing and
pin install commands to the selected tag via `VERSION=<tag>`
- update node page UI and API clients to let users pick stable vs test
channel for install and upgrade workflows

## Verification
- `go test ./internal/http/handler/...`
- `npm run build`
2026-02-21 13:23:20 +08:00
Sagit 2081dc9658 feat(node): support stable and test release channels 2026-02-21 04:17:20 +00:00
sagit b93255df3d feat: improve forward page grouped view (#165)
Added drag-and-drop sorting and filtering to grouped view.
2026-02-20 19:52:26 +08:00
sagitchu e1aef8700e style: make filter button icon-only 2026-02-20 19:49:57 +08:00
sagitchu d5b3a39774 feat: replace inline filters with modal dialog 2026-02-20 19:45:48 +08:00
sagitchu 022e9e3807 style: tightly pack user and tunnel filters 2026-02-20 19:39:12 +08:00
sagitchu d333d463f6 style: fix dropdown filter spacing 2026-02-20 19:33:56 +08:00
sagitchu abc9f21ab9 feat: improve forward page grouped view 2026-02-20 19:27:40 +08:00
sagit d216567c02 fix(frontend): switch grouped forward view to full list (#161)
* fix(frontend): left-align announcement logo in homepage banner

* fix(frontend): keep multiselect panels floating and preserve summaries

* fix(frontend): rebalance config card header spacing

* fix(frontend): normalize formatting and fix multiselect modal behavior

* fix(frontend): polish batch actions and sharing page guidance

* fix(frontend): switch grouped forward view to full list

* fix(backend): switch diagnosis internet target to bing

* style(frontend): beautify forward group view list display

* style: remove 'x' suffix from traffic ratio input and center config alert

* fix(ui): expand exit node select upwards in modal

* style: fix vertical alignment of logo and title in announcement banner
2026-02-20 10:31:39 +00:00
sagit 45bfd35a20 fix(backend): respect tunnel IP preference in forward diagnosis (#160)
Forward diagnosis chain-hop probes now inherit the tunnel ipPreference so v6-priority tunnels test IPv6 targets instead of defaulting to IPv4. Add a contract test to lock IPv6 target selection for entry->chain and chain->exit diagnostics.
2026-02-20 05:56:12 +00:00
20 changed files with 1194 additions and 690 deletions
+3
View File
@@ -268,3 +268,6 @@ sql/
!go-backend/internal/store/postgres/sql/
!go-backend/internal/store/postgres/sql/schema.sql
!go-backend/internal/store/postgres/sql/data.sql
go-backend/gost.db-shm
.gitignore
go-backend/gost.db-wal
@@ -360,6 +360,8 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
return nil, errors.New("隧道配置不完整")
}
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
nodeCache := map[int64]*nodeRecord{}
@@ -383,7 +385,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
}, "")
}, ipPreference)
}
} else {
for _, outNode := range outNodes {
@@ -391,7 +393,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
}, "")
}, ipPreference)
}
}
}
@@ -406,7 +408,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
}, "")
}, ipPreference)
}
} else {
for _, outNode := range outNodes {
@@ -415,7 +417,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
}, "")
}, ipPreference)
}
}
}
@@ -479,7 +481,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
case 1:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
})
}
@@ -532,14 +534,14 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
for _, outNode := range outNodes {
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 3,
})
}
default:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
})
}
+21 -4
View File
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
var req struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
secret, err := h.repo.GetNodeSecret(id)
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
version, err := resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
secret, err := h.repo.GetNodeSecret(req.ID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd))
}
+130 -79
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -19,8 +20,104 @@ const (
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
var (
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
)
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func normalizeReleaseChannel(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case releaseChannelDev:
return releaseChannelDev
default:
return releaseChannelStable
}
}
func releaseChannelFromTag(tag string) string {
normalized := strings.ToLower(strings.TrimSpace(tag))
if normalized == "" {
return releaseChannelDev
}
if testKeywordPattern.MatchString(normalized) {
return releaseChannelDev
}
if stableVersionPattern.MatchString(normalized) {
return releaseChannelStable
}
return releaseChannelDev
}
func releaseChannelLabel(channel string) string {
if normalizeReleaseChannel(channel) == releaseChannelDev {
return "测试版"
}
return "正式版"
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func resolveLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int64 `json:"id"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -75,61 +174,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
}
func resolveLatestRelease() (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
return resolveLatestReleaseAPI()
}
location := resp.Header.Get("Location")
if location == "" {
return resolveLatestReleaseAPI()
}
parts := strings.Split(location, "/")
tag := parts[len(parts)-1]
if tag == "" || tag == "latest" {
return resolveLatestReleaseAPI()
}
return tag, nil
return resolveLatestReleaseByChannel(releaseChannelStable)
}
func resolveLatestReleaseAPI() (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
if strings.TrimSpace(release.TagName) == "" {
return "", fmt.Errorf("无法从GitHub获取最新版本号")
}
return release.TagName, nil
return resolveLatestReleaseByChannel(releaseChannelStable)
}
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +190,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []int64 `json:"ids"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -151,12 +201,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -212,37 +263,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
var req struct {
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
return
}
var releases []struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
return
}
type releaseItem struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
}
items := make([]releaseItem, 0, len(releases))
@@ -250,11 +292,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
itemChannel := releaseChannelFromTag(tag)
if itemChannel != channel {
continue
}
items = append(items, releaseItem{
Version: r.TagName,
Version: tag,
Name: r.Name,
PublishedAt: r.PublishedAt,
Prerelease: r.Prerelease,
Prerelease: itemChannel == releaseChannelDev,
Channel: itemChannel,
})
}
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestReleaseChannelFromTag(t *testing.T) {
tests := []struct {
name string
tag string
expects string
}{
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
{name: "empty tag", tag: "", expects: releaseChannelDev},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
}
})
}
}
func TestNormalizeReleaseChannel(t *testing.T) {
tests := []struct {
input string
expects string
}{
{input: "", expects: releaseChannelStable},
{input: "stable", expects: releaseChannelStable},
{input: "dev", expects: releaseChannelDev},
{input: "DEV", expects: releaseChannelDev},
{input: "preview", expects: releaseChannelStable},
}
for _, tc := range tests {
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
}
}
}
@@ -193,6 +193,126 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
})
}
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupDiagnosisContractRouter(t, secret)
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
insertNode := func(name, v4, v6 string) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
`, tunnelID, chainNodeID).Error; err != nil {
t.Fatalf("insert middle chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
hasEntryToChain := false
hasChainToExit := false
for _, raw := range results {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
from := valueAsInt(item["fromChainType"])
to := valueAsInt(item["toChainType"])
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
if from == 1 && to == 2 {
hasEntryToChain = true
if targetIP != "2001:db8:10::20" {
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
}
}
if from == 2 && to == 3 {
hasChainToExit = true
if targetIP != "2001:db8:10::30" {
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
}
}
}
if !hasEntryToChain || !hasChainToExit {
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
}
}
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupDiagnosisContractRouter(t, secret)
+5 -2
View File
@@ -13,6 +13,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@internationalized/date": "^3.10.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
@@ -25,8 +27,6 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@internationalized/date": "^3.10.0",
"@react-aria/i18n": "^3.12.12",
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
@@ -43,8 +43,11 @@
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "6.30.3",
"recharts": "^3.1.1",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0",
+20 -8
View File
@@ -31,6 +31,8 @@ import axios from "axios";
import Network from "./network";
export type ReleaseChannel = "stable" | "dev";
// 登陆相关接口
export interface LoginData {
username: string;
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
export const updateNode = (data: NodeMutationPayload) =>
Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) =>
Network.post<string>("/node/install", { id });
export const getNodeInstallCommand = (
id: number,
channel: ReleaseChannel = "stable",
) => Network.post<string>("/node/install", { id, channel });
export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data);
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
return Network.post("/node/check-status", params);
};
export const upgradeNode = (id: number, version?: string) =>
export const upgradeNode = (
id: number,
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post(
"/node/upgrade",
{ id, version: version || "" },
{ id, version: version || "", channel },
{ timeout: 5 * 60 * 1000 },
);
export const batchUpgradeNodes = (ids: number[], version?: string) =>
export const batchUpgradeNodes = (
ids: number[],
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post(
"/node/batch-upgrade",
{ ids, version: version || "" },
{ ids, version: version || "", channel },
{ timeout: 15 * 60 * 1000 },
);
export const getNodeReleases = () =>
Network.post<NodeReleaseApiItem[]>("/node/releases");
export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id });
+1
View File
@@ -172,6 +172,7 @@ export interface NodeReleaseApiItem {
name: string;
publishedAt: string;
prerelease: boolean;
channel: "stable" | "dev";
}
export interface UserPackageInfoApiData {
+15 -14
View File
@@ -149,7 +149,7 @@ const getInitialConfigs = (): Record<string, string> => {
initialConfigs[key] = cachedValue;
}
});
} catch {}
} catch { }
return initialConfigs;
};
@@ -528,11 +528,10 @@ export default function ConfigPage() {
<button
key={option.value}
aria-pressed={isSelected}
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
isSelected
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
}`}
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
}`}
type="button"
onClick={() => toggleTypeSelection(option.value, setTypes)}
>
@@ -546,11 +545,10 @@ export default function ConfigPage() {
size="md"
/>
<span
className={`font-medium ${
isSelected
? "text-default-900 dark:text-default-100"
: "text-default-700 dark:text-default-500"
}`}
className={`font-medium ${isSelected
? "text-default-900 dark:text-default-100"
: "text-default-700 dark:text-default-500"
}`}
>
{option.label}
</span>
@@ -649,9 +647,9 @@ export default function ConfigPage() {
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<CardBody className="py-3">
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
<span className="text-sm">
<span className="text-sm font-medium">
检测到配置变更,请记得保存您的修改
</span>
</div>
@@ -702,13 +700,16 @@ export default function ConfigPage() {
<Textarea
label="公告内容"
minRows={4}
placeholder="请输入公告内容"
placeholder="支持 Markdown,例如:**加粗**、[链接](https://example.com)、- 列表"
value={announcement.content}
variant="bordered"
onChange={(e) =>
setAnnouncement({ ...announcement, content: e.target.value })
}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
公告支持 Markdown 语法,链接会在新标签页打开
</p>
<div className="flex justify-end">
<Button
@@ -1,4 +1,7 @@
import type { AnnouncementData } from "@/api";
import ReactMarkdown from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
@@ -14,13 +17,13 @@ export const AnnouncementBanner = ({
}
return (
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
<CardBody className="p-4">
<div className="flex items-center justify-start gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<Card className="mb-4 lg:mb-6 border border-blue-100 dark:border-blue-500/20 bg-gradient-to-br from-blue-50/80 to-indigo-50/80 dark:from-blue-500/5 dark:to-indigo-500/5 shadow-sm">
<CardBody className="p-4 sm:p-5">
<div className="flex items-center gap-3.5 sm:gap-4">
<div className="w-10 h-10 sm:w-11 sm:h-11 bg-white dark:bg-blue-500/10 rounded-full flex-shrink-0 flex items-center justify-center shadow-sm border border-blue-100 dark:border-blue-500/20">
<svg
aria-hidden="true"
className="w-5 h-5 text-blue-600 dark:text-blue-400"
className="w-5 h-5 sm:w-5 sm:h-5 text-blue-500 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
@@ -32,12 +35,51 @@ export const AnnouncementBanner = ({
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
<h3 className="text-[15px] font-semibold text-blue-900 dark:text-blue-100 tracking-wide mb-1 sm:mb-1.5 mt-1.5">
公告
</h3>
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
{announcement.content}
</p>
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
<ReactMarkdown
rehypePlugins={[rehypeSanitize]}
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
a: ({ children, href }) => (
<a
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
href={href}
rel="noopener noreferrer"
target="_blank"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">{children}</ol>
),
code: ({ children }) => (
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="mb-2 border-l-2 border-blue-300/80 dark:border-blue-500/60 pl-3 italic">
{children}
</blockquote>
),
}}
>
{announcement.content}
</ReactMarkdown>
</div>
</div>
</div>
</CardBody>
+426 -256
View File
@@ -15,6 +15,7 @@ import {
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -24,6 +25,14 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@/shadcn-bridge/heroui/table";
import {
Modal,
ModalContent,
@@ -35,7 +44,6 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import {
createForward,
@@ -136,16 +144,21 @@ export default function ForwardPage() {
const savedMode = localStorage.getItem("forward-view-mode");
return (savedMode as "grouped" | "direct") || "direct";
} catch {
} catch (e) {
return "direct";
}
});
// 筛选状态
const [filterUserId, setFilterUserId] = useState<string>("all");
const [filterTunnelId, setFilterTunnelId] = useState<string>("all");
// 拖拽排序相关状态
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
@@ -235,7 +248,7 @@ export default function ForwardPage() {
saveOrder(FORWARD_ORDER_KEY, order);
}
}
} catch {}
} catch { }
};
// 加载所有数据
@@ -1177,6 +1190,15 @@ export default function ForwardPage() {
(forward) => forward.userId === tokenUserId,
);
}
} else if (viewMode === "grouped") {
if (filterUserId !== "all") {
const targetUserId = parseInt(filterUserId);
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
}
if (filterTunnelId !== "all") {
const targetTunnelId = parseInt(filterTunnelId);
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
}
}
// 确保过滤后的转发列表有效
@@ -1220,7 +1242,7 @@ export default function ForwardPage() {
}
return sortedByDb;
}, [forwards, forwardOrder, viewMode, tokenUserId]);
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
@@ -1252,6 +1274,170 @@ export default function ForwardPage() {
);
};
// 生成用作筛选项的用户和隧道列表
const uniqueUsers = useMemo(() => {
const userMap = new Map<number, { id: number; name: string }>();
forwards.forEach((f) => {
const uId = f.userId || 0;
if (!userMap.has(uId)) {
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
}
});
return Array.from(userMap.values());
}, [forwards]);
// 可拖拽的表格行组件
const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses }: any) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: forward.id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined,
};
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<TableRow ref={setNodeRef} style={style} key={forward.id}>
{selectMode && (
<TableCell>
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
/>
</TableCell>
)}
<TableCell>
<div
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
{...attributes}
{...listeners}
title="拖拽排序"
>
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
size="sm"
color="secondary"
className="border-none bg-secondary/10 px-2"
>
<span className="font-medium text-secondary-700">{forward.tunnelName}</span>
</Chip>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name}
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
showAddressModal(forward.inIp, forward.inPort, "入口端口")
}
>
{formatInAddress(forward.inIp, forward.inPort)}
</button>
</TableCell>
<TableCell className="max-w-[240px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
showAddressModal(forward.remoteAddr, null, "目标地址")
}
>
{formatRemoteAddress(forward.remoteAddr)}
</button>
</TableCell>
<TableCell>
<Chip
className="text-xs font-medium"
color={strategyDisplay.color as any}
size="sm"
variant="flat"
>
{strategyDisplay.text}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch
isDisabled={forward.status !== 1 && forward.status !== 0}
isSelected={forward.serviceRunning}
size="sm"
color="success"
onValueChange={() => handleServiceToggle(forward)}
/>
</div>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
isIconOnly
className="bg-primary/10 text-primary hover:bg-primary/20"
size="sm"
title="编辑"
onPress={() => handleEdit(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
isIconOnly
className="bg-warning/10 text-warning hover:bg-warning/20"
size="sm"
title="诊断"
onPress={() => handleDiagnose(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</Button>
<Button
isIconOnly
className="bg-danger/10 text-danger hover:bg-danger/20"
size="sm"
title="删除"
onPress={() => handleDelete(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</TableCell>
</TableRow>
);
};
// 渲染转发卡片
const renderForwardCard = (forward: Forward, listeners?: any) => {
const statusDisplay = getStatusDisplay(forward.status);
@@ -1282,11 +1468,10 @@ export default function ForwardPage() {
<div className="flex items-center gap-1.5 ml-2">
{viewMode === "direct" && (
<div
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${
isMobile
? "opacity-100" // 移动端始终显示
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
}`}
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${isMobile
? "opacity-100" // 移动端始终显示
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
}`}
{...listeners}
style={{ touchAction: "none" }}
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
@@ -1324,11 +1509,10 @@ export default function ForwardPage() {
{/* 地址信息 */}
<div className="space-y-1">
<button
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
@@ -1364,11 +1548,10 @@ export default function ForwardPage() {
</button>
<button
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
@@ -1516,14 +1699,43 @@ export default function ForwardPage() {
return <PageLoadingState message="正在加载..." />;
}
const userGroups = groupForwardsByUserAndTunnel();
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6 gap-2">
<div className="flex-1" />
<div className="flex-1"></div>
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
{/* 筛选按钮 (仅在分组视图显示) */}
{viewMode === "grouped" && (
<Button
isIconOnly
aria-label="筛选条件"
className={filterUserId !== "all" || filterTunnelId !== "all" ? "bg-primary/20 text-primary relative" : "text-default-600 relative"}
color={filterUserId !== "all" || filterTunnelId !== "all" ? "primary" : "default"}
size="sm"
title="筛选条件"
variant="flat"
onPress={() => setIsFilterModalOpen(true)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
{(filterUserId !== "all" || filterTunnelId !== "all") && (
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
)}
</Button>
)}
{/* 显示模式切换按钮 */}
<Button
isIconOnly
@@ -1584,7 +1796,7 @@ export default function ForwardPage() {
</Button>
<Button
color="default"
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
@@ -1669,204 +1881,104 @@ export default function ForwardPage() {
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
/* 按用户和隧道分组的转发列表 */
userGroups.length > 0 ? (
<div className="space-y-6">
{userGroups.map((userGroup) => (
<Card
key={userGroup.userId || "unknown"}
className="shadow-sm border border-divider w-full overflow-hidden"
sortedForwards.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label="全部转发列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0">
<svg
aria-hidden="true"
className="w-5 h-5 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
fillRule="evenodd"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<h2 className="text-base font-medium text-foreground truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
{userGroup.userName}
</h2>
<p className="text-xs text-default-500 truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
{userGroup.tunnelGroups.length} 个隧道,
{userGroup.tunnelGroups.reduce(
(total, tg) => total + tg.forwards.length,
0,
)}{" "}
个转发
</p>
</div>
</div>
<Chip
className="text-xs flex-shrink-0 ml-2"
color="primary"
size="sm"
variant="flat"
>
用户
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0 md:pt-0">
<Accordion className="px-0" variant="splitted">
{userGroup.tunnelGroups.map((tunnelGroup) => (
<AccordionItem
key={tunnelGroup.tunnelId}
aria-label={tunnelGroup.tunnelName}
className="shadow-none border border-divider"
title={
<div className="flex items-center justify-between w-full min-w-0 pr-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<svg
aria-hidden="true"
className="w-4 h-4 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 10V3L4 14h7v7l9-11h-7z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-foreground truncate max-w-[120px] sm:max-w-[200px] md:max-w-[300px] lg:max-w-[400px]">
{tunnelGroup.tunnelName}
</h3>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<Chip
className="text-xs"
size="sm"
variant="flat"
>
{
tunnelGroup.forwards.filter(
(f) => f.serviceRunning,
).length
}
/{tunnelGroup.forwards.length}
</Chip>
</div>
</div>
}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 p-4">
{tunnelGroup.forwards.map((forward) =>
renderForwardCard(forward, undefined),
)}
</div>
</AccordionItem>
))}
</Accordion>
</CardBody>
</Card>
))}
<TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
<TableColumn className="w-10 pl-4"></TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext items={sortableForwardIds} strategy={verticalListSortingStrategy} key={forward.id}>
<SortableTableRow
forward={forward}
selectMode={selectMode}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
getStrategyDisplay={getStrategyDisplay}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
handleServiceToggle={handleServiceToggle}
handleEdit={handleEdit}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
showAddressModal={showAddressModal}
hasMultipleAddresses={hasMultipleAddresses}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg
aria-hidden="true"
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无转发配置
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)
) : /* 直接显示模式 */
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => { }} // 添加空的 onDragStart 处理器
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg
aria-hidden="true"
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无转发配置
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</div>
</div>
</CardBody>
</Card>
)}
</SortableContext>
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */}
<Modal
@@ -2335,11 +2447,10 @@ export default function ForwardPage() {
{importResults.map((result, index) => (
<div
key={index}
className={`p-2 rounded border ${
result.success
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
}`}
className={`p-2 rounded border ${result.success
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
}`}
>
<div className="flex items-center gap-2">
{result.success ? (
@@ -2372,11 +2483,10 @@ export default function ForwardPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`text-xs font-medium ${
result.success
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
className={`text-xs font-medium ${result.success
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
>
{result.success ? "成功" : "失败"}
</span>
@@ -2388,11 +2498,10 @@ export default function ForwardPage() {
</code>
</div>
<div
className={`text-xs ${
result.success
? "text-success-600 dark:text-success-400"
: "text-danger-600 dark:text-danger-400"
}`}
className={`text-xs ${result.success
? "text-success-600 dark:text-success-400"
: "text-danger-600 dark:text-danger-400"
}`}
>
{result.message}
</div>
@@ -2575,20 +2684,18 @@ export default function ForwardPage() {
return (
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2630,11 +2737,10 @@ export default function ForwardPage() {
<td className="px-3 py-2 text-center">
{result.success ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`font-semibold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</span>
@@ -2748,19 +2854,17 @@ export default function ForwardPage() {
return (
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
className={`border rounded-lg p-3 ${result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2796,11 +2900,10 @@ export default function ForwardPage() {
</div>
<div className="text-center">
<div
className={`text-lg font-bold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</div>
@@ -3015,6 +3118,73 @@ export default function ForwardPage() {
)}
</ModalContent>
</Modal>
{/* 筛选模态框 */}
<Modal
isOpen={isFilterModalOpen}
onOpenChange={setIsFilterModalOpen}
placement="center"
size="md"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">筛选条件</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">按用户筛选</label>
<Select
aria-label="筛选用户"
className="w-full"
variant="bordered"
selectedKeys={[filterUserId]}
onSelectionChange={(keys) => {
const key = Array.from(keys)[0] as string;
setFilterUserId(key || "all");
}}
>
<SelectItem key="all">全部用户</SelectItem>
{uniqueUsers.map(user => (
<SelectItem key={user.id.toString()}>{user.name}</SelectItem>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">按隧道筛选</label>
<Select
aria-label="筛选隧道"
className="w-full"
variant="bordered"
selectedKeys={[filterTunnelId]}
onSelectionChange={(keys) => {
const key = Array.from(keys)[0] as string;
setFilterTunnelId(key || "all");
}}
>
<SelectItem key="all">全部隧道</SelectItem>
{tunnels.map(tunnel => (
<SelectItem key={tunnel.id.toString()}>{tunnel.name}</SelectItem>
))}
</Select>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="default" variant="flat" onPress={() => {
setFilterUserId("all");
setFilterTunnelId("all");
}}>
重置
</Button>
<Button color="primary" onPress={onClose}>
完成
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
}
+8 -28
View File
@@ -334,34 +334,14 @@ export default function LimitPage() {
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg
aria-hidden="true"
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无限速规则
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无限速规则
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
+130 -21
View File
@@ -49,6 +49,7 @@ import {
batchUpgradeNodes,
getNodeReleases,
rollbackNode,
type ReleaseChannel,
} from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import {
@@ -183,6 +184,10 @@ export default function NodePage() {
const [installCommandModal, setInstallCommandModal] = useState(false);
const [installCommand, setInstallCommand] = useState("");
const [currentNodeName, setCurrentNodeName] = useState("");
const [installSelectorOpen, setInstallSelectorOpen] = useState(false);
const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);
const [installChannel, setInstallChannel] =
useState<ReleaseChannel>("stable");
// 升级相关状态
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
@@ -198,9 +203,12 @@ export default function NodePage() {
name: string;
publishedAt: string;
prerelease: boolean;
channel: ReleaseChannel;
}>
>([]);
const [releasesLoading, setReleasesLoading] = useState(false);
const [releaseChannel, setReleaseChannel] =
useState<ReleaseChannel>("stable");
const [selectedVersion, setSelectedVersion] = useState("");
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
const [upgradeProgress, setUpgradeProgress] = useState<
@@ -609,20 +617,31 @@ export default function NodePage() {
}
};
const openInstallSelector = (node: Node) => {
setInstallTargetNode(node);
setInstallChannel("stable");
setInstallSelectorOpen(true);
};
// 复制安装命令
const handleCopyInstallCommand = async (node: Node) => {
const handleCopyInstallCommand = async (
node: Node,
channel: ReleaseChannel,
) => {
setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
);
try {
const res = await getNodeInstallCommand(node.id);
const res = await getNodeInstallCommand(node.id, channel);
if (res.code === 0 && res.data) {
const copied = await tryCopyInstallCommand(res.data);
if (copied) {
toast.success("安装命令已复制到剪贴板");
toast.success(
`${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`,
);
} else {
setInstallCommand(res.data);
setCurrentNodeName(node.name);
@@ -640,6 +659,13 @@ export default function NodePage() {
}
};
const handleConfirmInstallCommand = async () => {
if (!installTargetNode) return;
setInstallSelectorOpen(false);
await handleCopyInstallCommand(installTargetNode, installChannel);
};
// 手动复制安装命令
const handleManualCopy = async () => {
try {
@@ -651,18 +677,10 @@ export default function NodePage() {
}
};
// 打开版本选择弹窗
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setSelectedVersion("");
setUpgradeModalOpen(true);
const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {
setReleasesLoading(true);
try {
const res = await getNodeReleases();
const res = await getNodeReleases(channel);
if (res.code === 0 && Array.isArray(res.data)) {
setReleases(res.data);
@@ -674,6 +692,21 @@ export default function NodePage() {
} finally {
setReleasesLoading(false);
}
}, []);
// 打开版本选择弹窗
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
const defaultChannel: ReleaseChannel = "stable";
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setReleaseChannel(defaultChannel);
setSelectedVersion("");
setUpgradeModalOpen(true);
await loadReleasesByChannel(defaultChannel);
};
// 确认升级(从版本弹窗)
@@ -692,7 +725,11 @@ export default function NodePage() {
),
);
try {
const res = await upgradeNode(upgradeTargetNodeId, version);
const res = await upgradeNode(
upgradeTargetNodeId,
version,
releaseChannel,
);
if (res.code === 0) {
toast.success(`节点升级命令已发送,节点将自动重启`);
@@ -712,7 +749,11 @@ export default function NodePage() {
setBatchUpgradeLoading(true);
setUpgradeModalOpen(false);
try {
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
const res = await batchUpgradeNodes(
Array.from(selectedIds),
version,
releaseChannel,
);
if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
@@ -1000,7 +1041,7 @@ export default function NodePage() {
<div className="flex gap-2 items-center">
<Button
color="default"
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
@@ -1367,7 +1408,7 @@ export default function NodePage() {
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
onPress={() => openInstallSelector(node)}
>
安装
</Button>
@@ -1811,6 +1852,55 @@ export default function NodePage() {
</ModalContent>
</Modal>
<Modal
backdrop="blur"
isOpen={installSelectorOpen}
placement="center"
size="md"
onOpenChange={setInstallSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
选择安装通道
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
</h2>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Select
label="版本通道"
selectedKeys={[installChannel]}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as ReleaseChannel;
setInstallChannel(selected || "stable");
}}
>
<SelectItem key="stable" textValue="stable">
正式版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
测试版(含 alpha / beta / rc)
</SelectItem>
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button color="primary" onPress={handleConfirmInstallCommand}>
生成命令
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 安装命令模态框 */}
<Modal
backdrop="blur"
@@ -1891,9 +1981,28 @@ export default function NodePage() {
</div>
) : (
<div className="space-y-4">
<Select
label="版本通道"
selectedKeys={[releaseChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as ReleaseChannel) || "stable";
setReleaseChannel(selected);
setSelectedVersion("");
void loadReleasesByChannel(selected);
}}
>
<SelectItem key="stable" textValue="stable">
正式版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
测试版(含 alpha / beta / rc)
</SelectItem>
</Select>
<Select
label="选择版本"
placeholder="留空则使用最新版本"
placeholder="留空则使用当前通道最新版本"
selectedKeys={selectedVersion ? [selectedVersion] : []}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string;
@@ -1909,14 +2018,14 @@ export default function NodePage() {
{r.publishedAt
? new Date(r.publishedAt).toLocaleDateString()
: ""}
{r.prerelease && (
{r.channel === "dev" && (
<Chip
className="ml-1"
color="warning"
size="sm"
variant="flat"
>
预览
测试
</Chip>
)}
</span>
@@ -1927,7 +2036,7 @@ export default function NodePage() {
<p className="text-sm text-default-500">
{selectedVersion
? `将升级到版本 ${selectedVersion}`
: "未选择版本,将自动使用最新稳定版"}
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
</p>
</div>
)}
+4 -4
View File
@@ -409,8 +409,8 @@ export default function PanelSharingPage() {
}
>
<Card>
<CardBody className="space-y-5 pt-4 md:pt-6">
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
<CardBody className="space-y-5">
<div className="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">
Provider 共享
@@ -554,8 +554,8 @@ export default function PanelSharingPage() {
}
>
<Card>
<CardBody className="space-y-5 pt-4 md:pt-6">
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
<CardBody className="space-y-5">
<div className="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">
Consumer 接入
+72 -101
View File
@@ -226,9 +226,9 @@ export default function TunnelPage() {
trafficRatio: tunnel.trafficRatio,
inIp: tunnel.inIp
? tunnel.inIp
.split(",")
.map((ip: string) => ip.trim())
.join("\n")
.split(",")
.map((ip: string) => ip.trim())
.join("\n")
: "",
ipPreference: tunnel.ipPreference || "",
status: tunnel.status,
@@ -678,7 +678,7 @@ export default function TunnelPage() {
<div className="flex items-center gap-2">
<Button
color="default"
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
@@ -1014,34 +1014,14 @@ export default function TunnelPage() {
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg
aria-hidden="true"
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无隧道配置
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何隧道配置,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无隧道配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何隧道配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
@@ -1127,11 +1107,6 @@ export default function TunnelPage() {
</Select>
<Input
endContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">x</span>
</div>
}
errorMessage={errors.trafficRatio}
isInvalid={!!errors.trafficRatio}
label="流量倍率"
@@ -1386,6 +1361,7 @@ export default function TunnelPage() {
]}
label="节点"
placeholder="选择节点(可多选)"
dropdownPlacement="top"
selectedKeys={groupNodes
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())}
@@ -1452,14 +1428,14 @@ export default function TunnelPage() {
{form.inNodeId.some(
(ct) => ct.nodeId === node.id,
) && (
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
{form.outNodeId &&
form.outNodeId.some(
(ct) => ct.nodeId === node.id,
@@ -1482,14 +1458,14 @@ export default function TunnelPage() {
ct.nodeId !== -1,
),
) && (
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为其他跳
</Chip>
)}
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为其他跳
</Chip>
)}
</div>
</div>
</SelectItem>
@@ -1603,11 +1579,12 @@ export default function TunnelPage() {
isInvalid={!!errors.outNodeId}
label="节点"
placeholder="请选择出口节点(可多选)"
dropdownPlacement="top"
selectedKeys={
form.outNodeId
? form.outNodeId
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())
: []
}
selectionMode="multiple"
@@ -1674,25 +1651,25 @@ export default function TunnelPage() {
{form.inNodeId.some(
(ct) => ct.nodeId === node.id,
) && (
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
{getSelectedChainNodeIds().includes(
node.id,
) && (
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为转发链
</Chip>
)}
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为转发链
</Chip>
)}
</div>
</div>
</SelectItem>
@@ -2055,20 +2032,18 @@ export default function TunnelPage() {
return (
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2110,11 +2085,10 @@ export default function TunnelPage() {
<td className="px-3 py-2 text-center">
{result.success ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`font-semibold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</span>
@@ -2227,19 +2201,17 @@ export default function TunnelPage() {
return (
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
className={`border rounded-lg p-3 ${result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2275,11 +2247,10 @@ export default function TunnelPage() {
</div>
<div className="text-center">
<div
className={`text-lg font-bold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</div>
+38 -47
View File
@@ -57,7 +57,6 @@ import {
SearchIcon,
EditIcon,
DeleteIcon,
UserIcon,
SettingsIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
@@ -247,7 +246,7 @@ export default function UserPage() {
if (response.code === 0) {
setTunnels(Array.isArray(response.data) ? response.data : []);
}
} catch {}
} catch { }
};
const loadSpeedLimits = async () => {
@@ -257,15 +256,15 @@ export default function UserPage() {
if (response.code === 0) {
const speedLimitList = Array.isArray(response.data)
? response.data.map((item) => ({
...item,
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
}))
...item,
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
}))
: [];
setSpeedLimits(speedLimitList);
}
} catch {}
} catch { }
};
const loadUserGroups = async () => {
@@ -275,7 +274,7 @@ export default function UserPage() {
if (response.code === 0) {
setUserGroups(Array.isArray(response.data) ? response.data : []);
}
} catch {}
} catch { }
};
const loadUserTunnels = async (userId: number) => {
@@ -326,7 +325,7 @@ export default function UserPage() {
if (groupRes.code === 0) {
currentGroupIds = groupRes.data || [];
}
} catch {}
} catch { }
setUserForm({
id: user.id,
@@ -652,21 +651,14 @@ export default function UserPage() {
{loading ? (
<PageLoadingState message="正在加载..." />
) : users.length === 0 ? (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<UserIcon className="w-8 h-8 text-default-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无用户数据
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何用户,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无用户数据
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何用户,点击上方按钮开始创建
</p>
</CardBody>
</Card>
) : (
@@ -680,9 +672,9 @@ export default function UserPage() {
const flowPercent =
user.flow > 0
? Math.min(
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
100,
)
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
100,
)
: 0;
return (
@@ -947,8 +939,8 @@ export default function UserPage() {
value={
userForm.expTime
? (parseDate(
userForm.expTime.toISOString().split("T")[0],
) as any)
userForm.expTime.toISOString().split("T")[0],
) as any)
: null
}
onChange={(date) => {
@@ -1054,12 +1046,11 @@ export default function UserPage() {
aria-disabled={isAssigned}
className={`
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
${
isAssigned
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
: isSelected
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
${isAssigned
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
: isSelected
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
}
`}
role="button"
@@ -1119,13 +1110,13 @@ export default function UserPage() {
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
batchTunnelSelections.get(tunnel.id) !==
batchTunnelSelections.get(tunnel.id) !==
undefined
? [
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
: ["null"]
}
size="sm"
@@ -1385,9 +1376,9 @@ export default function UserPage() {
setEditTunnelForm((prev) =>
prev
? {
...prev,
speedId: value === "null" ? null : Number(value),
}
...prev,
speedId: value === "null" ? null : Number(value),
}
: null,
);
}}
@@ -1442,10 +1433,10 @@ export default function UserPage() {
value={
editTunnelForm.expTime
? (parseDate(
new Date(editTunnelForm.expTime)
.toISOString()
.split("T")[0],
) as any)
new Date(editTunnelForm.expTime)
.toISOString()
.split("T")[0],
) as any)
: null
}
onChange={(date) => {
@@ -1,5 +1,4 @@
import * as React from "react";
import { CalendarIcon } from "lucide-react";
import { FieldContainer, type FieldMetaProps } from "./shared";
@@ -88,7 +87,6 @@ export function DatePicker({
value,
}: DatePickerProps) {
const id = React.useId();
const nativePickerRef = React.useRef<HTMLInputElement | null>(null);
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
const [textValue, setTextValue] = React.useState(formattedValue);
@@ -160,30 +158,6 @@ export function DatePicker({
setTextValue(formattedValue);
};
const openNativePicker = () => {
if (isDisabled) {
return;
}
const picker = nativePickerRef.current;
if (!picker) {
return;
}
const pickerWithShow = picker as HTMLInputElement & {
showPicker?: () => void;
};
if (typeof pickerWithShow.showPicker === "function") {
pickerWithShow.showPicker();
return;
}
picker.focus();
picker.click();
};
return (
<FieldContainer
description={description}
@@ -193,71 +167,40 @@ export function DatePicker({
isRequired={isRequired}
label={label}
>
{shouldUseTextInput ? (
<div className="flex items-center gap-2">
<Input
aria-invalid={isInvalid}
className={cn("flex-1", className)}
disabled={isDisabled}
id={id}
inputMode="numeric"
placeholder="YYYY-MM-DD"
required={isRequired}
type="text"
value={textValue}
onBlur={commitTextInput}
onChange={(event) => {
const nextValue = event.target.value;
<Input
aria-invalid={isInvalid}
className={cn(className)}
disabled={isDisabled}
id={id}
inputMode={shouldUseTextInput ? "numeric" : undefined}
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
required={isRequired}
type={shouldUseTextInput ? "text" : "date"}
value={shouldUseTextInput ? textValue : formattedValue}
onChange={(event) => {
const nextValue = event.target.value;
setTextValue(nextValue);
notifyTextDateChange(nextValue);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitTextInput();
if (shouldUseTextInput) {
setTextValue(nextValue);
notifyTextDateChange(nextValue);
return;
}
notifyNativeDateChange(nextValue);
}}
onBlur={shouldUseTextInput ? commitTextInput : undefined}
onKeyDown={
shouldUseTextInput
? (event) => {
if (event.key === "Enter") {
event.preventDefault();
commitTextInput();
}
}
}}
/>
<button
aria-label="打开日历选择器"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input bg-background text-default-600 shadow-sm transition-colors hover:bg-default-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isDisabled}
type="button"
onClick={openNativePicker}
>
<CalendarIcon className="h-4 w-4" />
</button>
<input
ref={nativePickerRef}
aria-hidden="true"
className="sr-only"
disabled={isDisabled}
tabIndex={-1}
type="date"
value={formattedValue}
onChange={(event) => {
const nextValue = event.target.value;
setTextValue(nextValue);
notifyNativeDateChange(nextValue);
}}
/>
</div>
) : (
<Input
aria-invalid={isInvalid}
className={cn(className)}
disabled={isDisabled}
id={id}
required={isRequired}
type="date"
value={formattedValue}
onChange={(event) => {
notifyNativeDateChange(event.target.value);
}}
/>
)}
: undefined
}
/>
</FieldContainer>
);
}
@@ -37,6 +37,7 @@ export interface SelectProps<T = unknown> extends FieldMetaProps {
selectionMode?: SelectionMode;
size?: "sm" | "md" | "lg";
variant?: string;
dropdownPlacement?: "bottom" | "top";
}
export interface SelectItemProps {
@@ -163,6 +164,7 @@ export function Select<T>({
selectedKeys,
selectionMode = "single",
size,
dropdownPlacement = "bottom",
}: SelectProps<T>) {
const generatedId = React.useId();
const options = React.useMemo(
@@ -284,9 +286,16 @@ export function Select<T>({
return null;
}
const placementClasses = dropdownPlacement === "top"
? "bottom-full mb-1"
: "top-full mt-1";
return (
<div
className="absolute left-0 top-full z-50 mt-1 max-h-56 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md"
className={cn(
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
placementClasses
)}
id={`${generatedId}-listbox`}
ref={listboxRef}
role="listbox"
@@ -4,11 +4,15 @@ import { cn } from "@/lib/utils";
interface TableClassNames {
th?: string;
td?: string;
tr?: string;
wrapper?: string;
}
interface TableStyleContextValue {
thClassName?: string;
tdClassName?: string;
trClassName?: string;
}
const TableStyleContext = React.createContext<TableStyleContextValue>({});
@@ -24,7 +28,13 @@ export function Table({
...props
}: TableProps) {
return (
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
<TableStyleContext.Provider
value={{
thClassName: classNames?.th,
tdClassName: classNames?.td,
trClassName: classNames?.tr,
}}
>
<div
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
>
@@ -62,18 +72,24 @@ interface TableBodyProps<T>
loadingContent?: React.ReactNode;
}
export function TableBody<T>({
children,
className,
emptyContent,
isLoading,
items,
loadingContent,
...props
}: TableBodyProps<T>) {
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
TableBodyProps<any>
>(function TableBody<T>(
{
children,
className,
emptyContent,
isLoading,
items,
loadingContent,
...props
}: TableBodyProps<T>,
ref: React.Ref<HTMLTableSectionElement>,
) {
if (isLoading) {
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{loadingContent ?? "加载中..."}
@@ -86,7 +102,7 @@ export function TableBody<T>({
if (items && typeof children === "function") {
if (items.length === 0) {
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{emptyContent ?? "暂无数据"}
@@ -97,7 +113,7 @@ export function TableBody<T>({
}
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
{items.map((item) => {
const key =
typeof item === "object" && item !== null && "id" in item
@@ -115,7 +131,7 @@ export function TableBody<T>({
if (staticChildren.length === 0) {
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{emptyContent ?? "暂无数据"}
@@ -126,11 +142,11 @@ export function TableBody<T>({
}
return (
<tbody className={className} {...props}>
<tbody ref={ref} className={className} {...props}>
{typeof children === "function" ? null : children}
</tbody>
);
}
});
export function TableColumn({
className,
@@ -150,12 +166,29 @@ export function TableColumn({
);
}
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
export const TableRow = React.forwardRef<
HTMLTableRowElement,
React.ComponentProps<"tr">
>(({ className, ...props }, ref) => {
const { trClassName } = React.useContext(TableStyleContext);
return (
<tr className={cn("border-b last:border-b-0", className)} {...props} />
<tr
ref={ref}
className={cn("border-b last:border-b-0", trClassName, className)}
{...props}
/>
);
}
});
TableRow.displayName = "TableRow";
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
const { tdClassName } = React.useContext(TableStyleContext);
return (
<td
className={cn("px-3 py-2 align-middle", tdClassName, className)}
{...props}
/>
);
}