Compare commits

...

26 Commits

Author SHA1 Message Date
sagit ea21a7deef fix(user): improve tunnel selector contrast in dark mode (#336) 2026-03-18 04:10:32 +00:00
sagit 9b98194a0a feat(tunnel): add delete rule resolution settings (#335)
- Add backend API for tunnel delete rule resolution (allow, deny, confirm)
- Add contract tests for delete resolution endpoint
- Add frontend API types and endpoints for delete resolution
- Add tunnel delete resolution settings UI with resolution mode selector
- Support per-tunnel and global delete resolution configuration
2026-03-18 10:05:03 +08:00
sagit 2df061a19f fix(backend): resolve SQLite deadlock in tunnel entry updates (#334)
- Fix deadlock when updating tunnel entries with offline nodes
- Add test file for tunnel entry SQLite operations
- Update contract tests for entry port conflict and limiter sync
- Add plan documents for SQLite deadlock fix and contract semantics
2026-03-18 09:00:20 +08:00
sagit 41ef814643 fix(forward): make force-delete work with offline nodes
Bypass DeleteService in force-delete and remove forward records directly, allowing deletion when nodes are offline.
2026-03-16 14:15:28 +00:00
sagit 6c7b4817f9 fix(forward): stabilize selection in non-compact grouped view
Prevent cascading checkbox toggles and scope select-all per tunnel group; update grouped styling to neutral gray.
2026-03-16 12:04:29 +00:00
sagit 7507507fd9 fix(forward): show all users by default in non-compact mode
Restore 2.1.8-beta9 admin default filtering when compact mode is off; keep compact mode focused on self.
2026-03-16 10:06:41 +00:00
sagit ff94406945 feat(node): restore info button popover style for remark/renewal info (#327)
Restore the 2.1.8-beta9 style of displaying node remark and renewal
info via an info button (ℹ️) in the CardHeader with a hover popover,
instead of inline display in the CardBody.

- Add infoPopoverPlacement state and updateInfoPopoverPlacement callback
- Add info button with hover popover showing expiry reminder and remark
- Restore drag handle with touch support and responsive visibility
- Remove inline info display from CardBody
2026-03-16 11:42:23 +08:00
sagit 9aa13c4dfb fix: remove tunnel name from card view (#326)
## Summary
- Remove tunnel name display from card view since it's already shown in
the group header
2026-03-16 10:59:18 +08:00
sagitchu 4a8c400944 fix: remove tunnel name and ratio from card view (shown in group header) 2026-03-16 10:57:56 +08:00
sagit 18e7ec94a8 fix: restore copy functionality for entry/target fields in non-compact table view and hide redundant tunnel name in grouped card view (#325) 2026-03-16 02:36:06 +00:00
sagit 02f2a1c8b3 fix: add missing renderCard prop to SortableForwardCard in non-compact card view (#324)
## Summary
- Fix TypeError "renderCard is not a function" when using non-compact
card view mode on the rules page
- The `SortableForwardCard` component was missing the required
`renderCard` prop in the non-compact grouped view
2026-03-16 09:28:11 +08:00
sagitchu 681a0bef48 fix: add missing renderCard prop to SortableForwardCard in non-compact card view 2026-03-16 09:26:42 +08:00
sagit 67bf5be0f2 Restore removed features and keep UI improvements (#322) 2026-03-15 23:35:25 +08:00
sagitchu efb613b0b5 Fix TypeScript compilation errors
- Add isIndeterminate support to Checkbox component
- Use showAddressModal in SortableTableRow for multi-address display
- Remove unused onClose parameter in search modal
- Remove unused infoPopoverPlacement and related code in node.tsx
2026-03-15 23:31:39 +08:00
sagitchu 8124e59de5 Roll back port column separation in forward table
- Merge entry address:port into single '入口' column
- Merge target address:port into single '目标' column
- Remove separate port columns from compact table
- Keep UI improvements: always show checkbox, selection highlight
2026-03-15 23:12:48 +08:00
sagitchu ac8c293ff3 Document forward.tsx refactoring review results 2026-03-15 23:01:29 +08:00
sagitchu 4e38b73cac Keep UI improvements: Modal styles, expiryReminderDismissed, BatchActionResultModal 2026-03-15 22:51:09 +08:00
sagitchu 8f336377f6 Revert user page search bar to inline implementation for consistency 2026-03-15 22:28:44 +08:00
sagitchu 5f78dd66fc Update plan: all restoration tasks completed 2026-03-15 22:12:51 +08:00
sagitchu f2ee939006 Restore BatchActionResultModal usage in tunnel.tsx 2026-03-15 22:12:33 +08:00
sagitchu 23d2060742 Restore BatchActionResultModal usage in forward.tsx
- Add BatchOperationFailure type import
- Add BatchActionResultModal component import
- Add BatchResultModalState interface and empty state constant
- Add batchResultModal state
- Add presentBatchOutcome callback for unified batch operation result handling
- Update handleBatchDelete to use presentBatchOutcome
- Add BatchActionResultModal rendering at end of component
2026-03-15 21:59:26 +08:00
sagitchu bb0da0b769 Restore version badge and FLVX branding 2026-03-15 21:33:35 +08:00
sagitchu e51af4be1f Revert backend address description to main version 2026-03-15 21:18:11 +08:00
sagitchu a82f3a75b0 Update plan document for PR #322 restoration 2026-03-15 21:08:11 +08:00
sagitchu 7d07fe08b7 Restore removed features from PR #322
- Add back BatchOperationFailure type and batch failure handling functions
- Add back dismissNodeExpiryReminder API endpoint
- Add back update channel selection UI in config page
- Keep simplified version display in version-footer.tsx
2026-03-15 21:07:08 +08:00
abai569ok 375877b223 2.1.8-beta1.7 2026-03-15 20:33:53 +08:00
35 changed files with 4145 additions and 1336 deletions
+165
View File
@@ -0,0 +1,165 @@
---
name: security-scan
description: Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions.
origin: ECC
---
# Security Scan Skill
Audit your Claude Code configuration for security issues using [AgentShield](https://github.com/affaan-m/agentshield).
## When to Activate
- Setting up a new Claude Code project
- After modifying `.claude/settings.json`, `CLAUDE.md`, or MCP configs
- Before committing configuration changes
- When onboarding to a new repository with existing Claude Code configs
- Periodic security hygiene checks
## What It Scans
| File | Checks |
|------|--------|
| `CLAUDE.md` | Hardcoded secrets, auto-run instructions, prompt injection patterns |
| `settings.json` | Overly permissive allow lists, missing deny lists, dangerous bypass flags |
| `mcp.json` | Risky MCP servers, hardcoded env secrets, npx supply chain risks |
| `hooks/` | Command injection via interpolation, data exfiltration, silent error suppression |
| `agents/*.md` | Unrestricted tool access, prompt injection surface, missing model specs |
## Prerequisites
AgentShield must be installed. Check and install if needed:
```bash
# Check if installed
npx ecc-agentshield --version
# Install globally (recommended)
npm install -g ecc-agentshield
# Or run directly via npx (no install needed)
npx ecc-agentshield scan .
```
## Usage
### Basic Scan
Run against the current project's `.claude/` directory:
```bash
# Scan current project
npx ecc-agentshield scan
# Scan a specific path
npx ecc-agentshield scan --path /path/to/.claude
# Scan with minimum severity filter
npx ecc-agentshield scan --min-severity medium
```
### Output Formats
```bash
# Terminal output (default) — colored report with grade
npx ecc-agentshield scan
# JSON — for CI/CD integration
npx ecc-agentshield scan --format json
# Markdown — for documentation
npx ecc-agentshield scan --format markdown
# HTML — self-contained dark-theme report
npx ecc-agentshield scan --format html > security-report.html
```
### Auto-Fix
Apply safe fixes automatically (only fixes marked as auto-fixable):
```bash
npx ecc-agentshield scan --fix
```
This will:
- Replace hardcoded secrets with environment variable references
- Tighten wildcard permissions to scoped alternatives
- Never modify manual-only suggestions
### Opus 4.6 Deep Analysis
Run the adversarial three-agent pipeline for deeper analysis:
```bash
# Requires ANTHROPIC_API_KEY
export ANTHROPIC_API_KEY=your-key
npx ecc-agentshield scan --opus --stream
```
This runs:
1. **Attacker (Red Team)** — finds attack vectors
2. **Defender (Blue Team)** — recommends hardening
3. **Auditor (Final Verdict)** — synthesizes both perspectives
### Initialize Secure Config
Scaffold a new secure `.claude/` configuration from scratch:
```bash
npx ecc-agentshield init
```
Creates:
- `settings.json` with scoped permissions and deny list
- `CLAUDE.md` with security best practices
- `mcp.json` placeholder
### GitHub Action
Add to your CI pipeline:
```yaml
- uses: affaan-m/agentshield@v1
with:
path: '.'
min-severity: 'medium'
fail-on-findings: true
```
## Severity Levels
| Grade | Score | Meaning |
|-------|-------|---------|
| A | 90-100 | Secure configuration |
| B | 75-89 | Minor issues |
| C | 60-74 | Needs attention |
| D | 40-59 | Significant risks |
| F | 0-39 | Critical vulnerabilities |
## Interpreting Results
### Critical Findings (fix immediately)
- Hardcoded API keys or tokens in config files
- `Bash(*)` in the allow list (unrestricted shell access)
- Command injection in hooks via `${file}` interpolation
- Shell-running MCP servers
### High Findings (fix before production)
- Auto-run instructions in CLAUDE.md (prompt injection vector)
- Missing deny lists in permissions
- Agents with unnecessary Bash access
### Medium Findings (recommended)
- Silent error suppression in hooks (`2>/dev/null`, `|| true`)
- Missing PreToolUse security hooks
- `npx -y` auto-install in MCP server configs
### Info Findings (awareness)
- Missing descriptions on MCP servers
- Prohibitive instructions correctly flagged as good practice
## Links
- **GitHub**: [github.com/affaan-m/agentshield](https://github.com/affaan-m/agentshield)
- **npm**: [npmjs.com/package/ecc-agentshield](https://www.npmjs.com/package/ecc-agentshield)
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/security-scan
@@ -0,0 +1,62 @@
# 功能请求:在规则页面显示隧道倍率
## 问题描述
当前规则(Forward)页面在列表中显示隧道名称,但**不显示隧道的流量倍率(trafficRatio)**。管理员在管理规则时无法快速查看该规则所使用的隧道倍率信息,需要跳转到隧道页面才能查看。
## 期望行为
在规则列表页面中,在隧道名称旁边或单独列显示该隧道的流量倍率(例如:`1x`, `0.5x`, `2x`)。
## 建议实现位置
### 前端修改
1. **`vite-frontend/src/pages/forward.tsx`**
- 在 `Forward` interface 中添加 `tunnelTrafficRatio?: number` 字段
- 在表格列中添加倍率显示(可以在隧道名称 Chip 旁边或单独一列)
- 从 `userTunnel` 或 `getTunnelList` API 获取隧道倍率信息
2. **显示格式建议**
```tsx
<Chip className="...">
{forward.tunnelName} ({forward.tunnelTrafficRatio}x)
</Chip>
```
或者单独一列:
```tsx
<TableCell>
{forward.tunnelTrafficRatio}x
</TableCell>
```
### 后端修改
1. **`go-backend/internal/http/handler/handler.go`**
- 在 `forwardList` 接口返回中添加隧道的 `trafficRatio` 字段
- 需要在查询 Forward 时 JOIN Tunnel 表获取倍率信息
2. **或者在前端加载规则后,批量获取隧道信息**
- 调用 `getTunnelList` 获取所有隧道信息
- 根据 `tunnelId` 匹配倍率
## 相关文件
- 前端:`vite-frontend/src/pages/forward.tsx`
- 前端类型:`vite-frontend/src/api/types.ts`
- 后端:`go-backend/internal/http/handler/handler.go`
- 隧道类型定义:`vite-frontend/src/api/types.ts` (TunnelApiItem)
## 优先级
中等 - 不影响核心功能,但能提升管理效率
## 截图参考
隧道页面已显示倍率:
- 位置:隧道卡片统计信息区域
- 显示格式:`流量倍率 {trafficRatio}x`
---
**Labels**: `enhancement`, `frontend`, `backend`, `ui/ux`
@@ -135,6 +135,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
mux.HandleFunc("/api/v1/tunnel/delete-preview", h.tunnelDeletePreview)
mux.HandleFunc("/api/v1/tunnel/delete-with-forwards", h.tunnelDeleteWithForwards)
mux.HandleFunc("/api/v1/tunnel/batch-delete-preview", h.tunnelBatchDeletePreview)
mux.HandleFunc("/api/v1/tunnel/batch-delete-with-forwards", h.tunnelBatchDeleteWithForwards)
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
+42 -8
View File
@@ -832,7 +832,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
newEntryNodeIDs = append(newEntryNodeIDs, in.NodeID)
}
}
if err := h.validateTunnelEntryPortConflictsForNewEntries(id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
if err := h.validateTunnelEntryPortConflictsForNewEntries(tx, id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
@@ -1020,8 +1020,8 @@ func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64
}
}
func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
if h == nil || h.repo == nil || tunnelID <= 0 {
func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tx *gorm.DB, tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
if h == nil || h.repo == nil || tx == nil || tunnelID <= 0 {
return nil
}
@@ -1030,7 +1030,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
return nil
}
forwards, err := h.listForwardsByTunnel(tunnelID)
forwards, err := h.repo.ListForwardsByTunnelTx(tx, tunnelID)
if err != nil || len(forwards) == 0 {
return nil
}
@@ -1040,7 +1040,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
if f == nil {
continue
}
oldPorts, portsErr := h.listForwardPorts(f.ID)
oldPorts, portsErr := h.repo.ListForwardPortsTx(tx, f.ID)
if portsErr != nil {
continue
}
@@ -1050,14 +1050,14 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
}
for _, nodeID := range addedNodeIDs {
node, nodeErr := h.getNodeRecord(nodeID)
node, nodeErr := h.repo.GetNodeRecordTx(tx, nodeID)
if nodeErr != nil {
continue
}
if err := validateLocalNodePort(node, port); err != nil {
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
}
if err := h.validateForwardPortAvailability(node, port, f.ID); err != nil {
if err := h.validateForwardPortAvailabilityTx(tx, node, port, f.ID); err != nil {
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
}
}
@@ -1852,7 +1852,27 @@ func (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {
h.forwardDelete(w, r)
id := idFromBody(r, w)
if id <= 0 {
return
}
_, _, _, err := h.resolveForwardAccess(r, id)
if err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
// Force delete: remove DB record without touching node services.
// This is used when nodes are offline or service deletion fails.
if err := h.deleteForwardByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {
@@ -4133,6 +4153,20 @@ func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, cu
return nil
}
func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecord, port int, currentForwardID int64) error {
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
return nil
}
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
if err != nil {
return err
}
if occupied {
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
}
return nil
}
func parsePortRangeMinMax(input string) (int, int) {
input = strings.TrimSpace(input)
if input == "" {
@@ -0,0 +1,655 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"go-backend/internal/http/response"
)
const tunnelDeletePreviewSampleLimit = 5
const (
tunnelDeleteActionReplace = "replace"
tunnelDeleteActionDeleteForwards = "delete_forwards"
)
var (
errInvalidTunnelDeleteTarget = errors.New("invalid tunnel delete target")
)
type tunnelDeleteForwardPreviewItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
InPort int `json:"inPort"`
}
type tunnelDeletePreviewData struct {
TunnelID int64 `json:"tunnelId"`
TunnelName string `json:"tunnelName"`
ForwardCount int `json:"forwardCount"`
SampleForwards []tunnelDeleteForwardPreviewItem `json:"sampleForwards"`
}
type tunnelBatchDeletePreviewData struct {
TunnelCount int `json:"tunnelCount"`
TotalForwardCount int `json:"totalForwardCount"`
Items []tunnelDeletePreviewData `json:"items"`
}
type tunnelDeleteWithForwardsRequest struct {
ID int64 `json:"id"`
Action string `json:"action"`
TargetTunnelID int64 `json:"targetTunnelId"`
}
type tunnelBatchDeleteWithForwardsRequest struct {
IDs []int64 `json:"ids"`
Action string `json:"action"`
TargetTunnelID int64 `json:"targetTunnelId"`
}
type tunnelDeleteWithForwardsResult struct {
ForwardCount int `json:"forwardCount"`
MigratedCount int `json:"migratedCount"`
DeletedForwardCount int `json:"deletedForwardCount"`
PortAdjustedCount int `json:"portAdjustedCount"`
Warnings []string `json:"warnings,omitempty"`
}
type tunnelBatchDeleteWithForwardsResult struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
Failures []batchFailureDetail `json:"failures,omitempty"`
DeletedForwardCount int `json:"deletedForwardCount"`
MigratedCount int `json:"migratedCount"`
PortAdjustedCount int `json:"portAdjustedCount"`
Warnings []string `json:"warnings,omitempty"`
}
type tunnelForwardMigrationPlan struct {
forward *forwardRecord
oldPorts []forwardPortRecord
targetTunnelID int64
targetPort int
keptNodeIDs []int64
removedNodeIDs []int64
portAdjusted bool
}
func (h *Handler) tunnelDeletePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
return
}
preview, err := h.buildTunnelDeletePreview(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(preview))
}
func (h *Handler) tunnelBatchDeletePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
IDs []int64 `json:"ids"`
}
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
preview, err := h.buildTunnelBatchDeletePreview(req.IDs)
if err != nil {
if strings.Contains(err.Error(), "不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(preview))
}
func (h *Handler) tunnelDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req tunnelDeleteWithForwardsRequest
if err := decodeJSON(r.Body, &req); err != nil || req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
action, err := normalizeTunnelDeleteAction(req.Action)
if err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
result, failures, err := h.processTunnelDeleteWithForwards(req.ID, action, req.TargetTunnelID)
if err != nil {
if err == errInvalidTunnelDeleteTarget {
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
return
}
if strings.Contains(err.Error(), "目标隧道不能与当前隧道相同") || strings.Contains(err.Error(), "目标隧道不存在") || strings.Contains(err.Error(), "目标隧道已禁用") || strings.Contains(err.Error(), "隧道不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if len(failures) > 0 {
response.WriteJSON(w, response.R{
Code: -2,
Msg: "部分规则迁移失败",
TS: time.Now().UnixMilli(),
Data: batchOperationResult{SuccessCount: 0, FailCount: len(failures), Failures: failures},
})
return
}
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) tunnelBatchDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req tunnelBatchDeleteWithForwardsRequest
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
action, err := normalizeTunnelDeleteAction(req.Action)
if err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
normalizedIDs := normalizeTunnelIDs(req.IDs)
if len(normalizedIDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if req.TargetTunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
return
}
for _, id := range normalizedIDs {
if id == req.TargetTunnelID {
response.WriteJSON(w, response.ErrDefault("目标隧道不能包含在删除列表中"))
return
}
}
}
result := tunnelBatchDeleteWithForwardsResult{}
for _, tunnelID := range normalizedIDs {
tunnelName, _ := h.repo.GetTunnelName(tunnelID)
singleResult, failures, processErr := h.processTunnelDeleteWithForwards(tunnelID, action, req.TargetTunnelID)
if processErr != nil {
result.FailCount++
result.Failures = appendBatchFailure(result.Failures, tunnelID, tunnelName, processErr)
continue
}
if len(failures) > 0 {
result.FailCount++
result.Failures = appendBatchFailureReason(
result.Failures,
tunnelID,
tunnelName,
summarizeTunnelDeleteRuleFailures(failures),
)
continue
}
result.SuccessCount++
result.DeletedForwardCount += singleResult.DeletedForwardCount
result.MigratedCount += singleResult.MigratedCount
result.PortAdjustedCount += singleResult.PortAdjustedCount
if len(singleResult.Warnings) > 0 {
result.Warnings = append(result.Warnings, singleResult.Warnings...)
}
}
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) buildTunnelDeletePreview(tunnelID int64) (*tunnelDeletePreviewData, error) {
if _, err := h.getTunnelRecord(tunnelID); err != nil {
return nil, err
}
tunnelName, err := h.repo.GetTunnelName(tunnelID)
if err != nil {
return nil, err
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return nil, err
}
samples := make([]tunnelDeleteForwardPreviewItem, 0, minInt(len(forwards), tunnelDeletePreviewSampleLimit))
for i, forward := range forwards {
if i >= tunnelDeletePreviewSampleLimit {
break
}
ports, portsErr := h.listForwardPorts(forward.ID)
if portsErr != nil {
return nil, portsErr
}
inPort := 0
if len(ports) > 0 {
inPort = ports[0].Port
}
samples = append(samples, tunnelDeleteForwardPreviewItem{
ID: forward.ID,
Name: forward.Name,
UserID: forward.UserID,
UserName: forward.UserName,
InPort: inPort,
})
}
return &tunnelDeletePreviewData{
TunnelID: tunnelID,
TunnelName: tunnelName,
ForwardCount: len(forwards),
SampleForwards: samples,
}, nil
}
func (h *Handler) buildTunnelBatchDeletePreview(ids []int64) (*tunnelBatchDeletePreviewData, error) {
normalizedIDs := normalizeTunnelIDs(ids)
items := make([]tunnelDeletePreviewData, 0, len(normalizedIDs))
totalForwardCount := 0
for _, id := range normalizedIDs {
preview, err := h.buildTunnelDeletePreview(id)
if err != nil {
return nil, err
}
items = append(items, *preview)
totalForwardCount += preview.ForwardCount
}
return &tunnelBatchDeletePreviewData{
TunnelCount: len(items),
TotalForwardCount: totalForwardCount,
Items: items,
}, nil
}
func normalizeTunnelDeleteAction(action string) (string, error) {
normalized := strings.TrimSpace(action)
if normalized == "" {
return tunnelDeleteActionDeleteForwards, nil
}
if normalized != tunnelDeleteActionReplace && normalized != tunnelDeleteActionDeleteForwards {
return "", errors.New("invalid tunnel delete action")
}
return normalized, nil
}
func normalizeTunnelIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
if len(failures) == 0 {
return "未知错误"
}
parts := make([]string, 0, minInt(len(failures), 3))
for i, failure := range failures {
if i >= 3 {
break
}
name := strings.TrimSpace(failure.Name)
if name == "" {
name = fmt.Sprintf("规则 #%d", failure.ID)
}
parts = append(parts, fmt.Sprintf("%s: %s", name, strings.TrimSpace(failure.Reason)))
}
if len(failures) > 3 {
parts = append(parts, fmt.Sprintf("另有 %d 条规则失败", len(failures)-3))
}
return strings.Join(parts, ";")
}
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
preview, err := h.buildTunnelDeletePreview(tunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
result := tunnelDeleteWithForwardsResult{ForwardCount: preview.ForwardCount}
if preview.ForwardCount == 0 {
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
return result, nil, nil
}
if action == tunnelDeleteActionDeleteForwards {
result.DeletedForwardCount = preview.ForwardCount
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
return result, nil, nil
}
if targetTunnelID <= 0 {
return tunnelDeleteWithForwardsResult{}, nil, errInvalidTunnelDeleteTarget
}
if targetTunnelID == tunnelID {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不能与当前隧道相同")
}
return h.processTunnelDeleteReplaceAction(tunnelID, targetTunnelID, result)
}
func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int64, result tunnelDeleteWithForwardsResult) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
targetTunnel, err := h.getTunnelRecord(targetTunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不存在")
}
if targetTunnel.Status != 1 {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道已禁用")
}
plans, failures, err := h.planTunnelDeleteForwardMigrations(tunnelID, targetTunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
if len(failures) > 0 {
return tunnelDeleteWithForwardsResult{}, failures, nil
}
portAdjustedCount := 0
warnings, execErr, execFailure := h.executeTunnelDeleteForwardMigrations(plans)
for _, plan := range plans {
if plan.portAdjusted {
portAdjustedCount++
}
}
if execErr != nil {
failures = append(failures, execFailure)
return tunnelDeleteWithForwardsResult{}, failures, nil
}
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwards(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
}
result.MigratedCount = len(plans)
result.PortAdjustedCount = portAdjustedCount
if len(warnings) > 0 {
result.Warnings = warnings
}
return result, nil, nil
}
func (h *Handler) planTunnelDeleteForwardMigrations(sourceTunnelID, targetTunnelID int64) ([]tunnelForwardMigrationPlan, []batchFailureDetail, error) {
forwards, err := h.listForwardsByTunnel(sourceTunnelID)
if err != nil {
return nil, nil, err
}
entryNodes, err := h.tunnelEntryNodeIDs(targetTunnelID)
if err != nil {
return nil, nil, err
}
if len(entryNodes) == 0 {
return nil, nil, errors.New("目标隧道缺少入口节点")
}
plans := make([]tunnelForwardMigrationPlan, 0, len(forwards))
failures := make([]batchFailureDetail, 0)
reservedPorts := make(map[int64]map[int]bool)
for _, forward := range forwards {
plan, planErr := h.planSingleTunnelDeleteForwardMigration(&forward, targetTunnelID, entryNodes, reservedPorts)
if planErr != nil {
failures = appendBatchFailure(failures, forward.ID, forward.Name, planErr)
continue
}
plans = append(plans, plan)
}
return plans, failures, nil
}
func (h *Handler) planSingleTunnelDeleteForwardMigration(forward *forwardRecord, targetTunnelID int64, targetEntryNodes []int64, reservedPorts map[int64]map[int]bool) (tunnelForwardMigrationPlan, error) {
if forward == nil {
return tunnelForwardMigrationPlan{}, errors.New("转发不存在")
}
oldPorts, err := h.listForwardPorts(forward.ID)
if err != nil {
return tunnelForwardMigrationPlan{}, err
}
if len(oldPorts) == 0 {
return tunnelForwardMigrationPlan{}, errors.New("转发入口端口不存在")
}
minPort := h.repo.GetMinForwardPort(forward.ID)
targetPort := 0
if minPort.Valid {
targetPort = int(minPort.Int64)
}
if targetPort <= 0 {
targetPort = h.pickTunnelPort(targetTunnelID)
}
if targetPort <= 0 {
targetPort = 10000
}
hasCustomInIP := false
for _, oldPort := range oldPorts {
if strings.TrimSpace(oldPort.InIP) != "" {
hasCustomInIP = true
break
}
}
if hasCustomInIP && len(targetEntryNodes) > 1 {
return tunnelForwardMigrationPlan{}, errors.New("多入口隧道的转发不支持保留自定义监听IP,请先手动调整该规则")
}
for _, nodeID := range targetEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
return tunnelForwardMigrationPlan{}, nodeErr
}
if err := validateRemoteNodePort(node, targetPort); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if err := validateLocalNodePort(node, targetPort); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if err := h.validateForwardPortAvailability(node, targetPort, forward.ID); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if reservedOnNode, ok := reservedPorts[nodeID]; ok && reservedOnNode[targetPort] {
return tunnelForwardMigrationPlan{}, fmt.Errorf("目标隧道入口节点端口 %d 已被本次迁移中的其他规则占用", targetPort)
}
}
for _, nodeID := range targetEntryNodes {
reservedOnNode := reservedPorts[nodeID]
if reservedOnNode == nil {
reservedOnNode = make(map[int]bool)
reservedPorts[nodeID] = reservedOnNode
}
reservedOnNode[targetPort] = true
}
oldNodeIDs := forwardPortNodeIDs(oldPorts)
newNodeIDs := uniqueInt64s(targetEntryNodes)
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
previousPort := 0
if len(oldPorts) > 0 {
previousPort = oldPorts[0].Port
}
return tunnelForwardMigrationPlan{
forward: forward,
oldPorts: oldPorts,
targetTunnelID: targetTunnelID,
targetPort: targetPort,
keptNodeIDs: keptNodeIDs,
removedNodeIDs: removedNodeIDs,
portAdjusted: previousPort > 0 && previousPort != targetPort,
}, nil
}
func (h *Handler) executeTunnelDeleteForwardMigrations(plans []tunnelForwardMigrationPlan) ([]string, error, batchFailureDetail) {
warnings := make([]string, 0)
completed := make([]tunnelForwardMigrationPlan, 0, len(plans))
for _, plan := range plans {
migrationWarnings, err := h.applyTunnelDeleteForwardMigration(plan)
if err != nil {
h.rollbackTunnelForwardMigrationPlans(completed)
return warnings, err, batchFailureDetail{ID: plan.forward.ID, Name: plan.forward.Name, Reason: normalizeBatchFailureReason(errString(err))}
}
warnings = append(warnings, migrationWarnings...)
completed = append(completed, plan)
}
return warnings, nil, batchFailureDetail{}
}
func (h *Handler) applyTunnelDeleteForwardMigration(plan tunnelForwardMigrationPlan) ([]string, error) {
if plan.forward == nil {
return nil, errors.New("转发不存在")
}
if err := h.repo.UpdateForwardTunnel(plan.forward.ID, plan.targetTunnelID, time.Now().UnixMilli()); err != nil {
return nil, err
}
if err := h.replaceForwardPorts(plan.forward.ID, plan.targetTunnelID, plan.targetPort, ""); err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
updatedForward, err := h.getForwardRecord(plan.forward.ID)
if err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
warnings := make([]string, 0)
if len(plan.keptNodeIDs) > 0 {
for _, nodeID := range plan.keptNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr))
}
}
time.Sleep(tunnelServiceBindRetryDelay)
}
syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
if err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
warnings = append(warnings, syncWarnings...)
if len(plan.removedNodeIDs) > 0 {
for _, nodeID := range plan.removedNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr))
}
}
}
return warnings, nil
}
func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigrationPlan) {
for i := len(plans) - 1; i >= 0; i-- {
plan := plans[i]
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
}
}
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
if err := h.deleteTunnelByID(tunnelID); err != nil {
return err
}
return nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,104 @@
package handler
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestValidateTunnelEntryPortConflictsForNewEntriesDoesNotBlockOnSQLiteTx(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, port, created_time, status, tcp_listen_addr, udp_listen_addr, is_remote)
VALUES
('entry-old', 'secret-old', '10.0.0.1', '12000-12010', ?, 1, '[::]', '[::]', 0),
('entry-new', 'secret-new', '10.0.0.2', '12000-12010', ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert nodes: %v", err)
}
var oldEntryID, newEntryID int64
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-old'`).Scan(&oldEntryID).Error; err != nil {
t.Fatalf("load old entry id: %v", err)
}
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-new'`).Scan(&newEntryID).Error; err != nil {
t.Fatalf("load new entry id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference)
VALUES('sqlite-tunnel', 1, 1, 'tls', 1, ?, ?, 1, 1, '')
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = 'sqlite-tunnel'`).Scan(&tunnelID).Error; err != nil {
t.Fatalf("load tunnel id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, inx, protocol)
VALUES(?, '1', ?, 1, 'tls')
`, tunnelID, oldEntryID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, created_time, updated_time, status, inx)
VALUES(1, 'tester', 'forward-a', ?, '127.0.0.1:8080', 'fifo', ?, ?, 1, 1)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
var forwardID int64
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = 'forward-a'`).Scan(&forwardID).Error; err != nil {
t.Fatalf("load forward id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward_port(forward_id, node_id, port)
VALUES(?, ?, 12001)
`, forwardID, oldEntryID).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
tx := r.BeginTx()
if tx == nil {
t.Fatal("begin tx: nil transaction")
}
if tx.Error != nil {
t.Fatalf("begin tx: %v", tx.Error)
}
errCh := make(chan error, 1)
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
errCh <- h.validateTunnelEntryPortConflictsForNewEntries(tx, tunnelID, []int64{oldEntryID}, []int64{oldEntryID, newEntryID})
}()
select {
case err := <-errCh:
if err != nil {
_ = tx.Rollback().Error
t.Fatalf("unexpected validation error: %v", err)
}
case <-time.After(500 * time.Millisecond):
_ = tx.Rollback().Error
<-doneCh
t.Fatal("validation blocked while transaction was open on sqlite")
}
if err := tx.Rollback().Error; err != nil {
t.Fatalf("rollback tx: %v", err)
}
}
@@ -30,8 +30,15 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.ListForwardsByTunnelTx(r.db, tunnelID)
}
func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]model.ForwardRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var forwards []model.Forward
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
@@ -95,8 +102,15 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.ListForwardPortsTx(r.db, forwardID)
}
func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.ForwardPortRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var ports []model.ForwardPort
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
err := tx.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
if err != nil {
return nil, err
}
@@ -115,12 +129,19 @@ func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentFo
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
return r.HasOtherForwardOnNodePortTx(r.db, nodeID, port, currentForwardID)
}
func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port int, currentForwardID int64) (bool, error) {
if tx == nil {
return false, errors.New("database unavailable")
}
if nodeID <= 0 || port <= 0 {
return false, nil
}
var count int64
err := r.db.Model(&model.ForwardPort{}).
err := tx.Model(&model.ForwardPort{}).
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
Count(&count).Error
if err != nil {
@@ -32,7 +32,6 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
return mustLastInsertID(t, repo, name)
}
entryA := insertNode("issue313-entry-a", "10.100.0.1", "2000-2010")
entryB1 := insertNode("issue313-entry-b1", "10.100.0.2", "2000-2010")
entryB2 := insertNode("issue313-entry-b2", "10.100.0.3", "2000-2010")
chainA := insertNode("issue313-chain-a", "10.100.0.4", "3000-3010")
@@ -51,7 +50,7 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
`, tunnelAID, entryA).Error; err != nil {
`, tunnelAID, entryB2).Error; err != nil {
t.Fatalf("insert chain_tunnel entry a: %v", err)
}
if err := repo.DB().Exec(`
@@ -109,7 +108,7 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
}
forwardAID := mustLastInsertID(t, repo, "issue313-forward-a")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryA, 2000).Error; err != nil {
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
@@ -171,7 +170,7 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
t.Fatalf("expected update failure due to cross-tunnel port conflict, got success with code 0")
}
if !bytes.Contains(res.Body.Bytes(), []byte("端口")) && !bytes.Contains(res.Body.Bytes(), []byte("占用")) {
if !bytes.Contains([]byte(out.Msg), []byte("端口")) && !bytes.Contains([]byte(out.Msg), []byte("占用")) {
t.Fatalf("expected port conflict error message, got %q", out.Msg)
}
@@ -705,7 +705,7 @@ func TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract(t *testi
}
oldEntryNodeID := insertNode("issue281-old-entry", "issue281-old-entry-secret", "10.51.0.1", "51000-51010", 0)
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "52000-52010", 1)
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "51000-51010", 1)
exitNodeID := insertNode("issue281-exit", "issue281-exit-secret", "10.51.0.3", "53000-53010", 2)
if err := r.DB().Exec(`
@@ -881,8 +881,8 @@ func TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract(t *testing.T)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "55000-55010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "56000-56010", 2)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
@@ -0,0 +1,235 @@
package contract_test
import (
"testing"
"time"
"go-backend/internal/http/response"
storeRepo "go-backend/internal/store/repo"
)
func TestTunnelDeletePreviewIncludesDependentRulesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "preview-source-tunnel", "preview-source-node", "21000-21010")
seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "preview-forward", 21001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-preview", map[string]interface{}{"id": sourceTunnelID})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
data, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected preview data object, got %T", out.Data)
}
if contractValueAsInt64(data["tunnelId"]) != sourceTunnelID {
t.Fatalf("unexpected tunnelId: %#v", data["tunnelId"])
}
if contractValueAsInt64(data["forwardCount"]) != 1 {
t.Fatalf("expected forwardCount=1, got %#v", data["forwardCount"])
}
samples, ok := data["sampleForwards"].([]interface{})
if !ok || len(samples) != 1 {
t.Fatalf("expected one sample forward, got %#v", data["sampleForwards"])
}
first, ok := samples[0].(map[string]interface{})
if !ok {
t.Fatalf("expected sample object, got %T", samples[0])
}
if first["name"] != "preview-forward" {
t.Fatalf("unexpected sample name: %#v", first["name"])
}
if contractValueAsInt64(first["inPort"]) != 21001 {
t.Fatalf("unexpected sample inPort: %#v", first["inPort"])
}
}
func TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "delete-source-tunnel", "delete-source-node", "22000-22010")
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "delete-forward", 22001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
"id": sourceTunnelID,
"action": "delete_forwards",
})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 0 {
t.Fatalf("expected tunnel deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID); count != 0 {
t.Fatalf("expected forward deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ?`, forwardID); count != 0 {
t.Fatalf("expected forward ports deleted, got count=%d", count)
}
}
func TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-source-tunnel", "replace-source-node", "23000-23010")
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "replace-forward", 23001)
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-target-tunnel", "replace-target-node", "23000-23010")
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "occupied-forward", 23001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
"id": sourceTunnelID,
"action": "replace",
"targetTunnelId": targetTunnelID,
})
if out.Code != -2 {
t.Fatalf("expected failure code -2, got code=%d msg=%q", out.Code, out.Msg)
}
result := mustTunnelDeleteFailureResult(t, out)
if contractValueAsInt64(result["failCount"]) != 1 {
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
}
assertBatchFailureNameAndReason(t, result, "replace-forward", "节点 replace-target-node 端口 23001 已被其他转发占用")
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 1 {
t.Fatalf("expected source tunnel kept, got count=%d", count)
}
if tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID); tunnelAfter != sourceTunnelID {
t.Fatalf("expected forward tunnel unchanged, got %d", tunnelAfter)
}
}
func TestTunnelBatchDeletePreviewIncludesTotalsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
tunnelA, nodeA := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-a", "batch-preview-node-a", "24000-24010")
tunnelB, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-b", "batch-preview-node-b", "24100-24110")
seedTunnelDeleteForward(t, repo, now, tunnelA, nodeA, "batch-preview-forward", 24001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-preview", map[string]interface{}{
"ids": []int64{tunnelA, tunnelB},
})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
data, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected preview object, got %T", out.Data)
}
if contractValueAsInt64(data["tunnelCount"]) != 2 {
t.Fatalf("expected tunnelCount=2, got %#v", data["tunnelCount"])
}
if contractValueAsInt64(data["totalForwardCount"]) != 1 {
t.Fatalf("expected totalForwardCount=1, got %#v", data["totalForwardCount"])
}
}
func TestTunnelBatchDeleteWithForwardsReturnsTunnelLevelFailuresContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelA, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-a", "batch-replace-source-node-a", "25000-25010")
sourceTunnelB, sourceNodeB := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-b", "batch-replace-source-node-b", "25100-25110")
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-target", "batch-replace-target-node", "25000-25010")
seedTunnelDeleteForward(t, repo, now, sourceTunnelB, sourceNodeB, "batch-replace-forward-b", 25002)
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "batch-replace-occupied", 25002)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-with-forwards", map[string]interface{}{
"ids": []int64{sourceTunnelA, sourceTunnelB},
"action": "replace",
"targetTunnelId": targetTunnelID,
})
if out.Code != 0 {
t.Fatalf("expected success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result := mustTunnelDeleteFailureResult(t, out)
if contractValueAsInt64(result["successCount"]) != 1 {
t.Fatalf("expected successCount=1, got %#v", result["successCount"])
}
if contractValueAsInt64(result["failCount"]) != 1 {
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
}
assertBatchFailureNameAndReason(t, result, "batch-replace-source-b", "batch-replace-forward-b: 节点 batch-replace-target-node 端口 25002 已被其他转发占用")
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelA); count != 0 {
t.Fatalf("expected source tunnel A deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelB); count != 1 {
t.Fatalf("expected source tunnel B kept, got count=%d", count)
}
}
func seedTunnelDeleteTunnelWithNode(t *testing.T, repo *storeRepo.Repository, now int64, tunnelName, nodeName, portRange string) (int64, int64) {
t.Helper()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, status, created_time, updated_time, in_ip, inx, ip_preference)
VALUES(?, 1.0, 1, 'tls', 1, 1, ?, ?, NULL, 0, '')
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel %s: %v", tunnelName, err)
}
tunnelID := mustLastInsertID(t, repo, tunnelName)
if err := repo.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(?, ?, '10.0.0.1', '10.0.0.1', '', ?, '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, nodeName, nodeName+"-secret", portRange, now, now).Error; err != nil {
t.Fatalf("insert node %s: %v", nodeName, err)
}
nodeID := mustLastInsertID(t, repo, nodeName)
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 0, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel for %s: %v", tunnelName, err)
}
return tunnelID, nodeID
}
func seedTunnelDeleteForward(t *testing.T, repo *storeRepo.Repository, now int64, tunnelID, nodeID int64, forwardName string, port int) int64 {
t.Helper()
if err := repo.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(2, 'contract-user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, forwardName, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward %s: %v", forwardName, err)
}
forwardID := mustLastInsertID(t, repo, forwardName)
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("insert forward_port for %s: %v", forwardName, err)
}
return forwardID
}
func mustTunnelDeleteFailureResult(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", out.Data)
}
return result
}
@@ -0,0 +1,62 @@
# 恢复 PR #322 移除的功能
**状态**: ✅ 已完成
## 背景
PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能,用户要求**加回**这些被移除的功能:
1. 批量操作失败详情弹窗(`BatchOperationFailure` 类型及相关处理)
2. 节点到期提醒关闭功能(`dismissNodeExpiryReminder` API)
3. 更新通道选择功能(稳定版/开发版切换)
用户要求**保留**的改动:
- 版本显示简化(移除 "v" 前缀和更新可用徽章)
## 任务清单
- [x] 检出 PR #322 到本地分支 `pr-322`
- [x] 恢复 `api/types.ts` 中的 `expiryReminderDismissed` 字段
- [x] 恢复 `api/types.ts` 中的 `BatchOperationFailure` 类型和 `failures` 字段
- [x] 恢复 `api/error-message.ts` 中的批量操作失败处理函数
- [x] 恢复 `api/index.ts` 中的 `dismissNodeExpiryReminder` API
- [x] 恢复 `config.tsx` 中的更新通道选择功能
- [x] 恢复 `use-dashboard-data.ts` 中的 `expiryReminderDismissed` 过滤逻辑
- [x] 恢复 `batch-actions.ts` 中的 `BatchOperationFailure` 相关处理
- [x] 恢复 `forward.tsx` 中的 `BatchActionResultModal` 使用
- [x] 恢复 `tunnel.tsx` 中的 `BatchActionResultModal` 使用
- [x] 提交并推送修改
## 修改的文件
- `vite-frontend/src/api/types.ts` - 添加 `expiryReminderDismissed` 和 `BatchOperationFailure`
- `vite-frontend/src/api/error-message.ts` - 添加批量操作失败处理函数
- `vite-frontend/src/api/index.ts` - 添加 `dismissNodeExpiryReminder` API
- `vite-frontend/src/pages/config.tsx` - 添加更新通道选择功能
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` - 恢复 `expiryReminderDismissed` 过滤逻辑
- `vite-frontend/src/pages/forward/batch-actions.ts` - 恢复批量操作失败处理
- `vite-frontend/src/pages/forward.tsx` - 恢复 `BatchActionResultModal` 组件使用
- `vite-frontend/src/pages/tunnel.tsx` - 恢复 `BatchActionResultModal` 组件使用
- `vite-frontend/src/pages/node.tsx` - 恢复 `expiryReminderDismissed` 功能和 "关闭提醒" 按钮
## 保留的 UI 改进
- Modal 样式优化(group.tsx, limit.tsx, panel-sharing.tsx)
- 按钮文本简化
- 用户页面隧道列表下拉展开
## forward.tsx 重构审查结果
PR #322 对 forward.tsx 进行了大规模重构(~2200 行 diff),经审查决定**保留**以下改动:
| 改动 | 说明 |
|------|------|
| DnD 碰撞检测 | `closestCenter` → `pointerWithin`,更适合嵌套拖拽 |
| 高级筛选模态框 | 从 SearchBar 改为五合一筛选(名称/用户/隧道/端口/目标地址) |
| 始终显示复选框 | 移除 selectMode 状态,用户无需切换模式即可选择 |
| 组件位置移动 | Sortable 组件移到组件顶部,代码组织更好 |
| UI 改进 | 表头全选、端口独立列、倍率显示优化、Modal 样式、"落地地址"文案 |
## 注意事项
- `version-footer.tsx` 保持简化版本显示(不恢复)
- `batch-action-result-modal.tsx` 组件文件未被 PR 删除,无需恢复(只需恢复 forward.tsx 中的使用)
+178
View File
@@ -0,0 +1,178 @@
# GOST UDP vs Realm UDP Analysis
**Goal:** Compare GOST UDP forwarding through tunnels with Realm's UDP implementation to understand why users report GOST UDP forwarding has problems while Realm works correctly.
## Task Checklist
- [x] Analyze GOST UDP tunnel architecture
- [x] Analyze Realm UDP relay architecture
- [x] Identify architectural differences
- [x] Identify potential issues in GOST implementation
- [ ] Document findings and recommendations
---
## GOST UDP Architecture
### Core Components
1. **UDP Relay** (`x/internal/net/udp/relay.go`)
- Simple bidirectional packet copying between two `net.PacketConn` interfaces
- Uses two goroutines: one for each direction
- Sequential packet processing (no batching)
- No idle timeout or association tracking
2. **UDP over Tunnel** (`x/handler/relay/bind.go`, `x/handler/socks/v5/udp_tun.go`)
- Wraps UDP data with SOCKS5-style framing via `UDPTunServerConn()`
- Adds address headers to each packet
- Uses smux multiplexing for tunnel connections
3. **SOCKS5 UDP Framing** (`x/internal/util/socks/conn.go`, `x/internal/util/relay/conn.go`)
- `udpTunConn.ReadFrom()`: Parses SOCKS5 UDP header to extract target address
- `udpTunConn.WriteTo()`: Wraps data with SOCKS5 UDP header including:
- RSV (data length, 2 bytes)
- Frag (0xff for tunnel relay, 1 byte)
- Address (4/16/1+n bytes for IPv4/IPv6/domain)
4. **Multiplexing** (`x/internal/util/mux/mux.go`)
- Uses smux v1.5.31 for connection multiplexing
- Adds stream framing and flow control overhead
### Data Flow (UDP over Tunnel)
```
Client UDP packet
↓
[Local GOST] SOCKS5 UDP framing (add ~10-26 bytes)
↓
smux stream (add framing, flow control)
↓
TCP tunnel to remote
↓
[Remote GOST] smux demux
↓
SOCKS5 UDP deframing
↓
Forward to target
```
---
## Realm UDP Architecture
### Core Components
1. **Association Model** (`realm_core/src/udp/middle.rs`)
- Per-client socket associations stored in `SockMap`
- Creates dedicated remote socket per client address
- Spawns `send_back` task for return path
- Association timeout for cleanup
2. **Batched I/O** (`realm_core/src/udp/batched.rs`)
- Uses `recvmmsg/sendmmsg` on Linux
- Up to 128 packets per batch
- Significantly higher throughput for high-PPS traffic
3. **No Protocol Overhead**
- Plain UDP forwarding without adding protocol headers
- No SOCKS5 framing or mux layer
### Data Flow
```
Client UDP packet
↓
[Realm] Direct relay via association socket
↓
Target server
```
---
## Key Architectural Differences
| Aspect | GOST | Realm |
|--------|------|-------|
| **UDP over Tunnel** | SOCKS5 framing + smux multiplexing | N/A (direct relay only) |
| **Protocol Overhead** | Extra ~10-26 bytes per packet | None |
| **I/O Model** | Standard Go net.PacketConn | Batched I/O (recvmmsg/sendmmsg) |
| **Connection Tracking** | Via mux session | SockMap with timeout |
| **Association Model** | None (bidirectional copy only) | Per-client socket association |
| **Idle Timeout** | None on UDP relay | Association timeout |
| **Throughput** | Single packet per syscall | Up to 128 packets per syscall |
---
## Identified Potential Issues in GOST
### 1. No Batch I/O Support
- **Impact**: High PPS (packets per second) traffic incurs syscall overhead
- **Realm advantage**: `recvmmsg/sendmmsg` batches up to 128 packets
- **Evidence**: Realm's `batched.rs` implements this; GOST uses standard `ReadFrom/WriteTo`
### 2. Protocol Overhead Per Packet
- **Impact**: Bandwidth waste, extra processing for framing/deframing
- **Overhead**: SOCKS5 UDP header adds ~10 bytes (IPv4) to ~26 bytes (IPv6) per packet
- **Evidence**: `x/internal/util/socks/conn.go:63-78` shows framing overhead
### 3. Mux Layer Overhead
- **Impact**: Latency and throughput degradation
- **smux adds**: Stream framing, flow control, potential backpressure
- **Evidence**: `x/internal/util/mux/mux.go` wraps all tunnel connections
### 4. No Association Tracking
- **Impact**: Cannot properly handle NAT translation for return traffic
- **Realm approach**: `SockMap` tracks client→remote socket mapping
- **Evidence**: GOST's `udp.Relay` just copies packets bidirectionally
### 5. No Idle Timeout on UDP Relay
- **Impact**: Stale connections may persist indefinitely
- **Evidence**: `udp.Relay.Run()` blocks until error or context cancel
- **Contrast**: Realm has association timeout for cleanup
### 6. Sequential Packet Processing
- **Impact**: Cannot pipeline multiple packets
- **Evidence**: `relay.go:44-77` shows sequential `ReadFrom` → `WriteTo` loop
- **Contrast**: Realm's batched I/O handles multiple packets concurrently
### 7. Read Deadline on Underlying TCP
- **Impact**: Could cause unexpected connection termination
- **Default**: 15-second read timeout set on connections
- **Evidence**: `x/handler/relay/metadata.go:readTimeout` defaults to 15s
- **Issue**: UDP relay may not handle deadline properly
---
## Recommendations
1. **Consider Direct UDP Mode**: For scenarios where tunnel is not required, use direct UDP relay (no SOCKS5 framing)
2. **Add Association Tracking**: Implement client→remote socket mapping with timeout
3. **Investigate Batch I/O**: Consider using `recvmmsg/sendmmsg` equivalent in Go (via `x/net` or raw syscalls)
4. **Add Idle Timeout**: Implement timeout-based cleanup for UDP associations
5. **Reduce Framing Overhead**: Consider more compact framing for tunnel UDP
---
## Files Analyzed
### GOST Files
- `go-gost/x/internal/net/udp/relay.go` - Core UDP relay logic
- `go-gost/x/internal/util/mux/mux.go` - smux multiplexing
- `go-gost/x/internal/util/socks/conn.go` - SOCKS5 UDP framing
- `go-gost/x/internal/util/relay/conn.go` - Relay UDP framing
- `go-gost/x/handler/relay/bind.go` - Relay BIND handler
- `go-gost/x/handler/socks/v5/udp_tun.go` - SOCKS5 UDP tunnel handler
- `go-gost/x/handler/tunnel/bind.go` - Tunnel BIND handler
- `go-gost/x/connector/tunnel/bind.go` - Tunnel connector BIND
- `go-gost/x/connector/tunnel/conn.go` - Tunnel connection types
- `go-gost/x/connector/tunnel/listener.go` - Tunnel bind listener
### Realm Files (fetched from GitHub)
- `realm_core/src/udp/mod.rs` - UDP relay entry point
- `realm_core/src/udp/middle.rs` - Association and relay logic with SockMap
- `realm_core/src/udp/socket.rs` - UDP socket binding and association
- `realm_core/src/udp/batched.rs` - Batched I/O implementation
+18
View File
@@ -0,0 +1,18 @@
# Node Card Info Popover
## Goal
Restore node card's remark and renewal info display to the 2.1.8-beta9 style: an info button (ℹ️) in the CardHeader that shows a hover popover with the info, instead of the current inline display in the CardBody.
## Status: Completed
PR #327 merged
## Tasks
- [x] Add `infoPopoverPlacement` state and `updateInfoPopoverPlacement` callback
- [x] Add info button with popover to CardHeader
- [x] Restore drag handle with touch support
- [x] Remove inline info display from CardBody
- [x] Fix build errors (JSX structure and unused variables)
- [x] Create PR and merge
@@ -0,0 +1,49 @@
# 042 - SQLite 隧道编辑添加入口节点卡死排查
## Issue
- 现象:SQLite 数据库下,编辑已有隧道并新增入口节点时接口卡住;PostgreSQL 下同样操作正常。
- 初步判断:`tunnelUpdate` 在事务尚未提交时触发了额外 repository 读查询,SQLite 配置 `MaxOpenConns(1)`,容易在同一请求内形成自锁等待。
## Goal
- 找出 SQLite 与 PostgreSQL 行为差异的根因。
- 修复隧道编辑新增入口节点时的阻塞问题,同时不破坏现有的入口端口冲突校验。
- 补充最小回归测试,锁定“事务内校验不可再次占用根连接”的场景。
## Checklist
- [x] 复核 `tunnelUpdate` 在新增入口节点路径上的调用链,确认事务内哪些查询绕过了 `tx`。
- [x] 为相关 repository 查询补齐 `Tx` 版本,避免 SQLite 单连接下的自锁等待。
- [x] 调整 handler 中入口端口冲突校验,保证事务内全程复用同一个 `tx`。
- [x] 增加针对 SQLite 的回归测试,验证事务内校验不会阻塞。
- [x] 运行相关 handler/backend 测试并记录结果。
## Notes
- 重点关注 `validateTunnelEntryPortConflictsForNewEntries`:当前它在 `tx.Commit()` 前执行,但内部调用 `ListForwardsByTunnel` / `ListForwardPorts` / `HasOtherForwardOnNodePort` 等非事务查询。
- SQLite 在 `go-backend/internal/store/repo/repository.go` 中显式设置了 `SetMaxOpenConns(1)`,因此这种模式在 SQLite 下会比 PostgreSQL 更容易表现为“卡死”。
## 实际改动
- `go-backend/internal/store/repo/repository_control.go`
- 新增 `ListForwardsByTunnelTx`、`ListForwardPortsTx`、`HasOtherForwardOnNodePortTx`,并让原有非事务方法复用统一实现。
- `go-backend/internal/http/handler/mutations.go`
- `tunnelUpdate` 在事务内执行入口端口冲突校验时显式传入当前 `tx`。
- `validateTunnelEntryPortConflictsForNewEntries` 改为全程使用事务查询。
- 新增 `validateForwardPortAvailabilityTx`,避免事务内回落到根连接查询。
- `go-backend/internal/http/handler/tunnel_entry_sqlite_test.go`
- 新增 SQLite 回归测试,验证开启事务后执行新增入口校验不会阻塞。
## 测试结果
### 通过
```bash
cd go-backend && go test ./internal/http/handler/...
```
- 结果:通过。
### 额外检查
```bash
cd go-backend && go test ./tests/contract/...
```
- 结果:未全绿;当前失败集中在既有的入口端口语义合同用例:
- `TestIssue313_EntryPortCrossTunnelConflictContract`
- `TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract`
- `TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract`
- 备注:这些失败反映的是“新增/切换入口时端口校验预期”与现有合同用例之间的行为差异,不是本次 SQLite 事务自锁修复本身的编译或阻塞问题。
@@ -0,0 +1,49 @@
# 043 - Entry Transition Contract Semantics Alignment
## Issue
- SQLite 阻塞修复完成后,`go test ./tests/contract/...` 暴露出 3 个入口变更相关合同用例失败。
- 失败原因分成两类:
- Issue 313 用例的数据构造没有真正制造“新增入口节点已被其他转发占用”的冲突。
- Issue 281 回归用例在后续引入“入口端口必须落在节点端口范围内”后,仍沿用旧的非重叠端口范围数据,和当前产品语义不一致。
## Goal
- 对齐这 3 个合同测试与当前后端语义。
- 保持 SQLite 自锁修复不回退。
- 让入口节点切换/新增相关合同测试重新稳定通过。
## Checklist
- [x] 复核 3 个失败用例的测试数据与当前后端校验语义差异。
- [x] 调整 Issue 313 用例,确保新增入口节点确实命中“其他转发已占用同节点同端口”。
- [x] 调整 Issue 281 两个回归用例,使入口切换场景使用与保留端口兼容的节点端口范围。
- [x] 运行相关合同测试与 handler 测试并记录结果。
## Notes
- `validateTunnelEntryPortConflictsForNewEntries` 的占用判定复用 `HasOtherForwardOnNodePort`,语义是“同节点 + 同端口 + 其他转发”,不是全局无节点维度的端口唯一性。
- 入口切换测试当前更关注 `forward_port` 重建和旧节点运行时清理,因此测试数据应避免被端口范围校验提前拦截。
## 实际调整
- `go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go`
- 将隧道 A 的入口节点改为复用即将添加到隧道 B 的 `entryB2`,确保新增入口时真正命中“同节点同端口已被其他转发占用”。
- 修正错误消息断言,直接检查 `out.Msg`,避免 JSON 解码后再读 `res.Body` 导致误判。
- `go-backend/tests/contract/limiter_sync_failure_contract_test.go`
- 将 issue 281 的新入口节点端口范围调整为包含原有保留端口,保持测试关注点在运行时清理/同步,而不是被后续引入的端口范围校验拦截。
## 测试结果
### 定向回归
```bash
cd go-backend && go test ./tests/contract/... -run 'TestIssue313_EntryPortCrossTunnelConflictContract|TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract|TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract' -v
```
- 结果:3/3 通过。
### Handler
```bash
cd go-backend && go test ./internal/http/handler/...
```
- 结果:通过。
### 全量合同测试
```bash
cd go-backend && go test ./tests/contract/...
```
- 结果:通过。
+183
View File
@@ -0,0 +1,183 @@
# 044 - 隧道删除时的规则依赖处理设计
## Goal
- 删除隧道前识别关联规则,避免默认级联误删。
- 在隧道页提供两种处理方式:迁移规则到其他隧道,或连同规则一起删除。
- 第一阶段只覆盖单条隧道删除,先把核心交互和执行链路做稳。
## Checklist
- [x] 复核当前 `tunnel.tsx` 删除交互与 `DeleteTunnelCascade` 行为。
- [x] 梳理可复用的规则换隧道能力(`forward/batch-change-tunnel`)。
- [x] 产出前端交互、接口与执行链路设计。
- [x] 明确第一阶段范围、异常处理与回滚要求。
## Implementation Checklist
- [x] 新增后端删除预检与带规则处理的删除接口。
- [x] 在后端补齐规则迁移/失败明细/回滚链路。
- [x] 接入前端隧道删除预检、替换/删除规则弹窗与结果展示。
- [x] 运行定向测试并记录结果。
## Current State
- `vite-frontend/src/pages/tunnel.tsx` 的单条删除当前直接调用 `/tunnel/delete`,没有前置依赖检查。
- `go-backend/internal/store/repo/repository_mutations.go` 里的 `DeleteTunnelCascade` 会直接删除该隧道下的 `forward`、`forward_port`、`user_tunnel`、`chain_tunnel` 等关联数据。
- `vite-frontend/src/pages/forward.tsx` 已经有“批量换隧道”交互和 `/forward/batch-change-tunnel`,但如果前端直接串联“先换规则、再删隧道”,中间任一步失败都会留下半完成状态,不适合作为删除流程的最终方案。
## Proposed UX
- 点击隧道删除按钮后,前端先请求“删除预检”接口,不直接进入最终确认。
- 若关联规则数为 `0`,继续沿用当前简单确认弹框。
- 若关联规则数大于 `0`,改为展示“处理关联规则”弹框:
- 顶部 `Alert` 提示:`隧道 "A" 正被 12 条规则使用`。
- 展示前 5 条规则摘要:规则名、所属用户、入口端口;剩余规则用“还有 N 条未展开”提示即可。
- 提供 `RadioGroup` 两个动作:
- `替换到其他隧道`(推荐,默认)
- `删除这些规则`
- 选择“替换到其他隧道”时显示 `Select`:
- 数据源直接复用当前页已经加载的 `tunnels`。
- 仅显示 `status === 1` 且 `id !== 当前隧道` 的选项。
- 若没有可选隧道,则禁用该选项并自动回退到“删除这些规则”。
- 确认按钮文案动态变化:
- `替换规则并删除隧道`
- `删除规则并删除隧道`
- 提交时展示 loading 状态;成功后关闭弹框、刷新隧道列表并 toast 成功。
- 如果后端返回规则级失败明细,前端复用现有 `BatchActionResultModal` 展示失败项,而不是只给一个通用 toast。
## API Design
### 1. 删除预检
`POST /api/v1/tunnel/delete-preview`
Request:
```json
{ "id": 12 }
```
Response:
```json
{
"code": 0,
"msg": "",
"data": {
"tunnelId": 12,
"tunnelName": "HK-Entry",
"forwardCount": 12,
"sampleForwards": [
{
"id": 101,
"name": "web-1",
"userId": 9,
"userName": "alice",
"inPort": 443
}
]
}
}
```
- `sampleForwards` 只需要返回前 5 条,前端用 `forwardCount` 决定是否显示“更多”提示。
- 预检只描述现状,不负责最终授权或一致性保证;真正提交删除时,后端必须再校验一次。
### 2. 带规则处理的删除
`POST /api/v1/tunnel/delete-with-forwards`
Request:
```json
{
"id": 12,
"action": "replace",
"targetTunnelId": 18
}
```
或:
```json
{
"id": 12,
"action": "delete_forwards"
}
```
Success response:
```json
{
"code": 0,
"msg": "",
"data": {
"forwardCount": 12,
"migratedCount": 12,
"deletedForwardCount": 0,
"portAdjustedCount": 2
}
}
```
Failure response:
```json
{
"code": -2,
"msg": "部分规则迁移失败",
"data": {
"successCount": 9,
"failCount": 3,
"failures": [
{
"id": 101,
"name": "web-1",
"reason": "目标隧道入口节点端口 443 已占用"
}
]
}
}
```
- 保持现有 `/tunnel/delete` 不动,兼容旧调用和当前批量删除逻辑。
- `vite-frontend/src/pages/tunnel.tsx` 的单条删除新流程全部走新接口。
## Backend Execution Strategy
- `delete-preview`
- 校验 tunnel 是否存在。
- 查询 `forward.tunnel_id = 当前隧道` 的数量和前 5 条摘要。
- `delete-with-forwards`
- 再次查询依赖规则,避免 preview 与提交之间状态变化导致误判。
- `action = delete_forwards`
- 直接复用当前级联删除逻辑。
- `action = replace`
- 校验 `targetTunnelId`:存在、启用、且不等于当前隧道。
- 先对全部关联规则做一次前置校验:目标隧道入口节点、端口范围、端口冲突、监听 IP 约束。
- 全部校验通过后,再逐条迁移规则并同步运行时。
- 任一规则迁移失败时,返回失败明细并中止删除;对已迁移规则做回滚,保证用户感知为“要么都成功,要么都不删”。
- 规则处理完成后,再删除隧道本身及非 `forward` 关联数据。
## Frontend Implementation Notes
- `vite-frontend/src/pages/tunnel.tsx`
- 新增删除预检 loading、依赖摘要、处理动作等 state。
- 复用现有 `Modal`,根据 preview 结果切换普通确认视图和依赖处理视图。
- 复用 `Select`、`RadioGroup`、`Alert`。
- 成功后继续沿用当前 `setTunnels`、`setTunnelOrder`、`setSelectedIds` 清理逻辑。
- `vite-frontend/src/api/index.ts`
- 新增 `previewTunnelDelete`。
- 新增 `deleteTunnelWithForwards`。
- 删除失败且返回批量明细时,复用 `BatchActionResultModal`,避免具体失败规则原因被 toast 吞掉。
## Scope Decision
- 第一阶段只改“隧道页单条删除”。
- `批量删除隧道` 先保持现有“级联删除规则”行为和提示文案不变。
- 如果第一阶段确认体验和后端回滚链路都稳定,再补第二阶段:批量删除时按每条隧道分别预检和处理。
## Edge Cases
- 没有可替换隧道:只允许“删除这些规则”。
- 目标隧道在提交前被禁用或删除:后端返回明确错误,前端保留当前弹框和用户选择。
- preview 时没有规则、提交时新建了规则:以后端最终校验为准,返回需要重新处理的提示。
- 迁移时若原入口端口在目标隧道不可用,沿用现有“换隧道”语义:优先保留原端口,不可用时自动分配可用端口,并通过 `portAdjustedCount` 给前端一个非阻塞提示。
- 第一阶段不自动补发目标隧道的 `user_tunnel` 授权,先与现有“批量换隧道”语义保持一致;如果后续需要让用户也获得目标隧道的新增规则权限,再单独评估自动补授权。
## Implementation Notes
- 后端新增 `/api/v1/tunnel/delete-preview` 和 `/api/v1/tunnel/delete-with-forwards`,单条隧道删除改为先预检再执行。
- 后端新增 `/api/v1/tunnel/batch-delete-preview` 和 `/api/v1/tunnel/batch-delete-with-forwards`,批量删除支持统一预检并选择“批量替换规则”或“批量删除规则”。
- 规则替换删除在后端先做端口/节点占用预检,再执行逐条迁移;执行阶段若任一规则失败,会回滚已迁移规则,并把失败明细返回给前端弹窗展示。
- 前端 `tunnel.tsx` 删除弹窗已支持三种状态:预检中、普通删除确认、有依赖规则时的“替换/删除规则”决策视图。
- 批量删除弹窗现在会汇总每条选中隧道的依赖规则数量,并在批量替换失败时按“隧道级”返回失败原因,避免整批操作信息丢失。
## Verification
- `cd go-backend && go test ./internal/http/handler/...`
- `cd go-backend && go test ./tests/contract/... -run 'TestTunnelDeletePreviewIncludesDependentRulesContract|TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract|TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract'`
- `cd vite-frontend && npm run build`
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"security-scan": {
"source": "affaan-m/everything-claude-code",
"sourceType": "github",
"computedHash": "92cdcaddc554e318402f066ccc073c2e3dbcfda8c2730ec62ec373f805c41a57"
}
}
}
+28
View File
@@ -6,6 +6,10 @@ import type {
NodeReleaseApiItem,
NodeApiItem,
SpeedLimitApiItem,
TunnelBatchDeletePreviewApiData,
TunnelBatchDeleteWithForwardsApiData,
TunnelDeletePreviewApiData,
TunnelDeleteWithForwardsApiData,
TunnelDiagnosisApiData,
TunnelGroupApiItem,
UserApiItem,
@@ -122,6 +126,30 @@ export const updateTunnel = (data: TunnelMutationPayload) =>
Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id });
export const previewTunnelDelete = (id: number) =>
Network.post<TunnelDeletePreviewApiData>("/tunnel/delete-preview", { id });
export const deleteTunnelWithForwards = (data: {
id: number;
action: "replace" | "delete_forwards";
targetTunnelId?: number;
}) =>
Network.post<TunnelDeleteWithForwardsApiData | BatchOperationResult>(
"/tunnel/delete-with-forwards",
data,
);
export const previewBatchTunnelDelete = (ids: number[]) =>
Network.post<TunnelBatchDeletePreviewApiData>("/tunnel/batch-delete-preview", {
ids,
});
export const batchDeleteTunnelsWithForwards = (data: {
ids: number[];
action: "replace" | "delete_forwards";
targetTunnelId?: number;
}) =>
Network.post<TunnelBatchDeleteWithForwardsApiData>(
"/tunnel/batch-delete-with-forwards",
data,
);
export const diagnoseTunnel = (tunnelId: number) =>
Network.post<TunnelDiagnosisApiData>(
"/tunnel/diagnose",
+44
View File
@@ -222,6 +222,50 @@ export interface BatchOperationFailure {
[key: string]: unknown;
}
export interface TunnelDeletePreviewForwardApiItem {
id: number;
name: string;
userId: number;
userName: string;
inPort: number;
[key: string]: unknown;
}
export interface TunnelDeletePreviewApiData {
tunnelId: number;
tunnelName: string;
forwardCount: number;
sampleForwards: TunnelDeletePreviewForwardApiItem[];
[key: string]: unknown;
}
export interface TunnelBatchDeletePreviewApiData {
tunnelCount: number;
totalForwardCount: number;
items: TunnelDeletePreviewApiData[];
[key: string]: unknown;
}
export interface TunnelDeleteWithForwardsApiData {
forwardCount: number;
migratedCount: number;
deletedForwardCount: number;
portAdjustedCount: number;
warnings?: string[];
[key: string]: unknown;
}
export interface TunnelBatchDeleteWithForwardsApiData {
successCount: number;
failCount: number;
failures?: BatchOperationFailure[];
deletedForwardCount: number;
migratedCount: number;
portAdjustedCount: number;
warnings?: string[];
[key: string]: unknown;
}
export interface UserMutationPayload {
id?: number;
user?: string;
+5 -4
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -11,17 +11,18 @@ function Checkbox({
return (
<CheckboxPrimitive.Root
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=checked]:shadow-none data-[state=indeterminate]:shadow-none",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150 data-[state=indeterminate]:animate-in data-[state=indeterminate]:zoom-in-75 data-[state=indeterminate]:duration-150"
data-slot="checkbox-indicator"
>
<CheckIcon className="h-3.5 w-3.5" />
<CheckIcon className="h-3.5 w-3.5 data-[state=indeterminate]:hidden" />
<MinusIcon className="h-3.5 w-3.5 hidden data-[state=indeterminate]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
+1 -7
View File
@@ -56,18 +56,12 @@ function DialogContent({
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
{...props}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
className,
)}
data-slot="dialog-content"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
{showCloseButton && (
+3
View File
@@ -576,6 +576,9 @@ export default function AdminLayout({
{/* 修改密码弹窗 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
+39 -26
View File
@@ -882,24 +882,13 @@ export default function ConfigPage() {
<Card className="shadow-md">
<CardHeader className="pb-6">
<div className="flex justify-between items-center w-full">
<div className="flex items-center w-full">
<div>
<h2 className="text-xl font-semibold">基本设置</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
配置网站的基本信息,这些设置会影响网站的显示效果
</p>
</div>
<div className="flex gap-2">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onPress={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
</CardHeader>
@@ -975,19 +964,29 @@ export default function ConfigPage() {
</SelectItem>
</Select>
</div>
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onPress={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</CardBody>
</Card>
{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="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 font-medium">
检测到配置变更,请记得保存您的修改
</span>
</div>
</CardBody>
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
<div className="h-10 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 flex-shrink-0" />
<span className="text-sm font-medium leading-none">
检测到配置变更,请记得保存您的修改
</span>
</div>
</Card>
)}
@@ -1045,7 +1044,7 @@ export default function ConfigPage() {
公告支持 Markdown 语法,链接会在新标签页打开
</p>
<div className="flex justify-end">
<div className="flex justify-end mt-4 pt-4 border-t border-divider/50">
<Button
color="primary"
isLoading={announcementSaving}
@@ -1086,7 +1085,7 @@ export default function ConfigPage() {
当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}
</p>
<div className="flex gap-3">
<div className="flex justify-end gap-3 pt-4">
<Button
color="primary"
isLoading={exporting}
@@ -1117,7 +1116,7 @@ export default function ConfigPage() {
onChange={handleFileChange}
/>
<div className="flex gap-3">
<div className="flex justify-end gap-3 pt-4">
<Button
color="primary"
isLoading={importing}
@@ -1136,7 +1135,14 @@ export default function ConfigPage() {
</CardBody>
</Card>
<Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={exportSelectorOpen}
onOpenChange={setExportSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
@@ -1161,7 +1167,14 @@ export default function ConfigPage() {
</ModalContent>
</Modal>
<Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importSelectorOpen}
onOpenChange={setImportSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
File diff suppressed because it is too large Load Diff
+37 -8
View File
@@ -477,10 +477,15 @@ export default function GroupPage() {
)}
<Card>
<CardHeader className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button color="primary" size="sm" onPress={openCreateTunnelGroup}>
新建隧道分组
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -544,10 +549,15 @@ export default function GroupPage() {
</Card>
<Card>
<CardHeader className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button color="primary" size="sm" onPress={openCreateUserGroup}>
新建用户分组
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -651,7 +661,7 @@ export default function GroupPage() {
size="sm"
onPress={handleAssignPermission}
>
分配权限
分配
</Button>
</div>
@@ -692,6 +702,10 @@ export default function GroupPage() {
</Card>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
@@ -735,7 +749,14 @@ export default function GroupPage() {
</ModalContent>
</Modal>
<Modal isOpen={userGroupModalOpen} onOpenChange={onUserGroupModalChange}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
@@ -777,6 +798,10 @@ export default function GroupPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
@@ -824,6 +849,10 @@ export default function GroupPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
+6
View File
@@ -341,6 +341,9 @@ export default function LimitPage() {
{/* 新增/编辑模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
@@ -416,6 +419,9 @@ export default function LimitPage() {
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
+131 -58
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import {
DndContext,
KeyboardSensor,
@@ -40,7 +39,6 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import {
createNode,
getNodeList,
@@ -252,7 +250,7 @@ const SortableItem = ({
children,
}: {
id: number;
children: (listeners: any) => any;
children: (listeners: any, attributes?: any) => any;
}) => {
const {
attributes,
@@ -277,14 +275,8 @@ const SortableItem = ({
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-visible h-full"
{...listeners}
>
{children(listeners)}
<div ref={setNodeRef} className="overflow-visible h-full" style={style}>
{children(listeners, attributes)}
</div>
);
};
@@ -318,6 +310,8 @@ export default function NodePage() {
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rollbackModalOpen, setRollbackModalOpen] = useState(false);
const [nodeToRollback, setNodeToRollback] = useState<Node | null>(null);
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
const [protocolDisabled, setProtocolDisabled] = useState(false);
const [protocolDisabledReason, setProtocolDisabledReason] = useState("");
@@ -330,7 +324,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -380,6 +374,7 @@ export default function NodePage() {
const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }>
>({});
const [infoPopoverPlacement, setInfoPopoverPlacement] = useState<
Record<number, "left" | "bottom">
>({});
@@ -865,7 +860,7 @@ export default function NodePage() {
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
port: node.port || "1000-65535",
port: node.port || "10000-65535",
tcpListenAddr: node.tcpListenAddr || "[::]",
udpListenAddr: node.udpListenAddr || "[::]",
interfaceName: (node as any).interfaceName || "",
@@ -1096,7 +1091,16 @@ export default function NodePage() {
};
// 回退节点
const handleRollbackNode = async (node: Node) => {
const handleRollbackNode = (node: Node) => {
setNodeToRollback(node);
setRollbackModalOpen(true);
};
const confirmRollback = async () => {
if (!nodeToRollback) return;
const node = nodeToRollback;
setRollbackModalOpen(false);
setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
);
@@ -1116,6 +1120,7 @@ export default function NodePage() {
n.id === node.id ? { ...n, rollbackLoading: false } : n,
),
);
setNodeToRollback(null);
}
};
@@ -1199,7 +1204,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -1506,7 +1511,11 @@ export default function NodePage() {
onPress={() => setActiveTab("local")}
>
本地节点
<Chip className="ml-1" size="sm" variant="flat">
<Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{localNodes.length}
</Chip>
</Button>
@@ -1518,7 +1527,11 @@ export default function NodePage() {
onPress={() => setActiveTab("remote")}
>
远程节点
<Chip className="ml-1" size="sm" variant="flat">
<Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{remoteNodes.length}
</Chip>
</Button>
@@ -1540,7 +1553,17 @@ export default function NodePage() {
value={currentSearchKeyword}
onChange={setCurrentSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
onOpen={() => {
setIsSearchVisible(true);
setTimeout(() => {
// 精准抓取带有“搜索”字样的输入框并强制聚焦
const searchInput = document.querySelector(
'input[placeholder*="搜索"]',
);
if (searchInput) (searchInput as HTMLElement).focus();
}, 150); // 150ms 刚好是 CSS 展开动画的时间
}}
/>
</div>
@@ -1827,9 +1850,7 @@ export default function NodePage() {
type="button"
onClick={(e) => {
e.stopPropagation();
handleDismissExpiryReminder(
node.id,
);
handleDismissExpiryReminder(node.id);
}}
>
关闭提醒
@@ -2030,7 +2051,7 @@ export default function NodePage() {
{remoteUsage.usedPorts.map((port) => (
<Chip
key={`${node.id}-port-${port}`}
className="font-mono"
className="font-mono shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
@@ -2312,6 +2333,9 @@ export default function NodePage() {
{/* 新增/编辑节点对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={dialogVisible}
placement="center"
scrollBehavior="outside"
@@ -2376,37 +2400,27 @@ export default function NodePage() {
</Select>
</div>
<DatePicker
showMonthAndYearPickers
<Input
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime}
label="续费基准时间"
max="9999-12-31"
type="date"
value={
form.expiryTime > 0
? (parseDate(
new Date(form.expiryTime).toISOString().split("T")[0],
) as any)
: null
? new Date(form.expiryTime).toISOString().slice(0, 10)
: ""
}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
onChange={(date) => {
if (date) {
const jsDate = new Date(
date.year,
date.month - 1,
date.day,
);
setForm((prev) => ({
...prev,
expiryTime: jsDate.getTime(),
}));
} else {
setForm((prev) => ({
...prev,
expiryTime: 0,
}));
}
}}
/>
<Alert
@@ -2416,11 +2430,11 @@ export default function NodePage() {
/>
<Input
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6 域名"
errorMessage={errors.serverHost}
isInvalid={!!errors.serverHost}
label="服务器域名/主机名"
placeholder="例如: node.example.com"
label="域名/主机名"
placeholder="例如: test.example.com"
value={form.serverHost}
variant="bordered"
onChange={(e) =>
@@ -2430,11 +2444,11 @@ export default function NodePage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
description="双栈节点组隧道时优先使用 IPv4"
description="填写一个 IPv4 地址"
errorMessage={errors.serverIpV4}
isInvalid={!!errors.serverIpV4}
label="服务器IPv4"
placeholder="例如: 203.0.113.10"
label="IPv4 地址"
placeholder="例如: 192.168.1.100"
value={form.serverIpV4}
variant="bordered"
onChange={(e) =>
@@ -2443,10 +2457,10 @@ export default function NodePage() {
/>
<Input
description="至少填写一个 IPv4/IPv6/域名"
description="填写一个 IPv6 地址"
errorMessage={errors.serverIpV6}
isInvalid={!!errors.serverIpV6}
label="服务器IPv6"
label="IPv6 地址"
placeholder="例如: 2001:db8::10"
value={form.serverIpV6}
variant="bordered"
@@ -2460,11 +2474,11 @@ export default function NodePage() {
classNames={{
input: "font-mono",
}}
description="支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔"
description="支持单个端口(80)、多个端口(80,443)或端口范围(10000-65535),多个可用逗号分隔"
errorMessage={errors.port}
isInvalid={!!errors.port}
label="可用端口"
placeholder="例如: 80,443,1000-65535"
placeholder="例如: 80,443,10000-65535"
value={form.port}
variant="bordered"
onChange={(e) =>
@@ -2717,7 +2731,7 @@ export default function NodePage() {
<Alert
className="mt-4"
color="primary"
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
description="节点ip地址是你要添加的入口/出口的ip地址,不是面板的ip地址。"
variant="flat"
/>
</div>
@@ -2737,9 +2751,53 @@ export default function NodePage() {
</ModalContent>
</Modal>
{/* 回退确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={rollbackModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setRollbackModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">确认回退</h2>
</ModalHeader>
<ModalBody>
<p>
确定要将节点{" "}
<strong>&quot;{nodeToRollback?.name}&quot;</strong>{" "}
回退到上一个版本吗?
</p>
<p className="text-small text-default-500">
节点将执行版本回退并自动重启,期间会导致节点短暂离线。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button color="secondary" onPress={confirmRollback}>
确认回退
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2780,6 +2838,9 @@ export default function NodePage() {
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installSelectorOpen}
placement="center"
size="md"
@@ -2830,6 +2891,9 @@ export default function NodePage() {
{/* 安装命令模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installCommandModal}
placement="center"
scrollBehavior="outside"
@@ -2884,6 +2948,9 @@ export default function NodePage() {
{/* 版本选择升级模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={upgradeModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2946,7 +3013,7 @@ export default function NodePage() {
: ""}
{r.channel === "dev" && (
<Chip
className="ml-1"
className="ml-1 shrink-0 whitespace-nowrap"
color="warning"
size="sm"
variant="flat"
@@ -2987,6 +3054,9 @@ export default function NodePage() {
{/* 批量删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -3026,6 +3096,9 @@ export default function NodePage() {
</Modal>
<Modal
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isFilterModalOpen}
placement="center"
size="md"
+16 -1
View File
@@ -678,6 +678,10 @@ export default function PanelSharingPage() {
{/* Create Share Modal */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
@@ -785,6 +789,10 @@ export default function PanelSharingPage() {
{/* Edit Share Modal */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
@@ -883,7 +891,14 @@ export default function PanelSharingPage() {
</Modal>
{/* Import Node Modal */}
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importNodeOpen}
onClose={() => setImportNodeOpen(false)}
>
<ModalContent>
<ModalHeader>导入远程节点</ModalHeader>
<ModalBody>
+3
View File
@@ -327,6 +327,9 @@ export default function ProfilePage() {
{/* 修改密码弹窗 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
+665 -50
View File
@@ -1,4 +1,8 @@
import type { BatchOperationFailure } from "@/api/types";
import type {
BatchOperationFailure,
TunnelBatchDeletePreviewApiData,
TunnelDeletePreviewApiData,
} from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
@@ -40,16 +44,19 @@ import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
import {
createTunnel,
batchDeleteTunnelsWithForwards,
getTunnelList,
updateTunnel,
deleteTunnel,
deleteTunnelWithForwards,
getNodeList,
diagnoseTunnel,
updateTunnelOrder,
batchDeleteTunnels,
batchRedeployTunnels,
previewBatchTunnelDelete,
previewTunnelDelete,
} from "@/api";
import { PageLoadingState } from "@/components/page-state";
import {
@@ -136,6 +143,8 @@ interface BatchResultModalState {
title: string;
}
type TunnelDeleteAction = "replace" | "delete_forwards";
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
@@ -143,6 +152,8 @@ const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
title: "",
};
const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
@@ -178,8 +189,16 @@ export default function TunnelPage() {
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
const [tunnelDeletePreview, setTunnelDeletePreview] =
useState<TunnelDeletePreviewApiData | null>(null);
const [deleteAction, setDeleteAction] =
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
const [deleteTargetTunnelId, setDeleteTargetTunnelId] = useState<number | null>(
null,
);
const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] =
useState<Tunnel | null>(null);
const [diagnosisResult, setDiagnosisResult] =
@@ -247,14 +266,23 @@ export default function TunnelPage() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchDeletePreviewLoading, setBatchDeletePreviewLoading] =
useState(false);
const [batchDeletePreview, setBatchDeletePreview] =
useState<TunnelBatchDeletePreviewApiData | null>(null);
const [batchDeleteAction, setBatchDeleteAction] =
useState<TunnelDeleteAction>(DEFAULT_TUNNEL_DELETE_ACTION);
const [batchDeleteTargetTunnelId, setBatchDeleteTargetTunnelId] = useState<
number | null
>(null);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
percent: 0,
});
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
useEffect(() => {
return () => {
@@ -339,6 +367,42 @@ export default function TunnelPage() {
loadData();
}, [loadData]);
const resetDeleteState = useCallback(() => {
setDeleteLoading(false);
setDeletePreviewLoading(false);
setTunnelToDelete(null);
setTunnelDeletePreview(null);
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setDeleteTargetTunnelId(null);
}, []);
const handleDeleteModalOpenChange = useCallback(
(open: boolean) => {
setDeleteModalOpen(open);
if (!open) {
resetDeleteState();
}
},
[resetDeleteState],
);
const resetBatchDeleteState = useCallback(() => {
setBatchDeletePreviewLoading(false);
setBatchDeletePreview(null);
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setBatchDeleteTargetTunnelId(null);
}, []);
const handleBatchDeleteModalOpenChange = useCallback(
(open: boolean) => {
setBatchDeleteModalOpen(open);
if (!open) {
resetBatchDeleteState();
}
},
[resetBatchDeleteState],
);
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes);
@@ -384,22 +448,78 @@ export default function TunnelPage() {
};
// 删除隧道
const handleDelete = (tunnel: Tunnel) => {
const handleDelete = async (tunnel: Tunnel) => {
setTunnelToDelete(tunnel);
setDeleteModalOpen(true);
setDeletePreviewLoading(true);
setTunnelDeletePreview(null);
setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setDeleteTargetTunnelId(null);
try {
const response = await previewTunnelDelete(tunnel.id);
if (response.code !== 0 || !response.data) {
toast.error(response.msg || "获取删除依赖失败");
setDeleteModalOpen(false);
resetDeleteState();
return;
}
setTunnelDeletePreview(response.data);
} catch (error) {
toast.error(extractApiErrorMessage(error, "获取删除依赖失败"));
setDeleteModalOpen(false);
resetDeleteState();
} finally {
setDeletePreviewLoading(false);
}
};
const confirmDelete = async () => {
if (!tunnelToDelete) return;
const forwardCount = tunnelDeletePreview?.forwardCount ?? 0;
const action: TunnelDeleteAction =
forwardCount > 0 ? deleteAction : "delete_forwards";
if (
action === "replace" &&
forwardCount > 0 &&
(!deleteTargetTunnelId ||
!deleteReplacementTunnels.some(
(tunnel) => tunnel.id === deleteTargetTunnelId,
))
) {
toast.error("请选择替换规则的目标隧道");
return;
}
setDeleteLoading(true);
try {
const response = await deleteTunnel(tunnelToDelete.id);
const response = await deleteTunnelWithForwards({
id: tunnelToDelete.id,
action,
targetTunnelId:
action === "replace" ? deleteTargetTunnelId ?? undefined : undefined,
});
if (response.code === 0) {
toast.success("删除成功");
const deleteResult = (response.data || null) as {
warnings?: string[];
} | null;
if ((deleteResult?.warnings?.length ?? 0) > 0) {
toast.success(
`删除成功,另有 ${deleteResult?.warnings?.length ?? 0} 条节点清理提示`,
);
} else {
toast.success("删除成功");
}
setDeleteModalOpen(false);
setTunnelToDelete(null);
setTunnels((prev) =>
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
);
@@ -417,11 +537,32 @@ export default function TunnelPage() {
return next;
});
resetDeleteState();
} else if (
response.data &&
typeof response.data === "object" &&
Number((response.data as { failCount?: number }).failCount ?? 0) > 0
) {
const result = response.data as {
failCount?: number;
successCount?: number;
};
const failures = extractBatchFailures(response.data);
if (failures.length > 0) {
setBatchResultModal({
failures,
open: true,
summary: `成功 ${Number(result.successCount ?? 0)} 项,失败 ${Number(result.failCount ?? failures.length)} 项`,
title: "规则处理失败",
});
}
toast.error(response.msg || "删除失败");
} else {
toast.error(response.msg || "删除失败");
}
} catch {
toast.error("删除失败");
} catch (error) {
toast.error(extractApiErrorMessage(error, "删除失败"));
} finally {
setDeleteLoading(false);
}
@@ -877,8 +1018,48 @@ export default function TunnelPage() {
[],
);
const handleOpenBatchDeleteModal = async () => {
if (selectedIds.size === 0) return;
setBatchDeleteModalOpen(true);
setBatchDeletePreviewLoading(true);
setBatchDeletePreview(null);
setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION);
setBatchDeleteTargetTunnelId(null);
try {
const response = await previewBatchTunnelDelete(selectedTunnelIdList);
if (response.code !== 0 || !response.data) {
toast.error(response.msg || "获取批量删除依赖失败");
setBatchDeleteModalOpen(false);
resetBatchDeleteState();
return;
}
setBatchDeletePreview(response.data);
} catch (error) {
toast.error(extractApiErrorMessage(error, "获取批量删除依赖失败"));
setBatchDeleteModalOpen(false);
resetBatchDeleteState();
} finally {
setBatchDeletePreviewLoading(false);
}
};
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
if (
batchDeleteHasForwardDependencies &&
batchDeleteAction === "replace" &&
(!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable)
) {
toast.error("请选择替换规则的目标隧道");
return;
}
setBatchLoading(true);
setBatchProgress({
active: true,
@@ -886,28 +1067,40 @@ export default function TunnelPage() {
percent: 30,
});
try {
const res = await batchDeleteTunnels(Array.from(selectedIds));
const res = await batchDeleteTunnelsWithForwards({
ids: selectedTunnelIdList,
action: batchDeleteHasForwardDependencies
? batchDeleteAction
: "delete_forwards",
targetTunnelId:
batchDeleteHasForwardDependencies && batchDeleteAction === "replace"
? batchDeleteTargetTunnelId ?? undefined
: undefined,
});
if (res.code === 0) {
const result = res.data;
const result = (res.data || {
successCount: 0,
failCount: 0,
warnings: [],
}) as {
successCount: number;
failCount: number;
warnings?: string[];
};
const warningCount = result?.warnings?.length ?? 0;
if (result.failCount === 0) {
toast.success(`成功删除 ${result.successCount} 项`);
toast.success(
warningCount > 0
? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示`
: `成功删除 ${result.successCount} 项`,
);
setBatchProgress({
active: true,
label: `删除完成:成功 ${result.successCount} 项`,
percent: 100,
});
setTunnels((prev) =>
prev.filter((tunnel) => !selectedIds.has(tunnel.id)),
);
setTunnelOrder((prev) => {
const next = prev.filter((id) => !selectedIds.has(id));
saveOrder(TUNNEL_ORDER_KEY, next);
return next;
});
} else {
const failures = extractBatchFailures(result);
@@ -927,11 +1120,12 @@ export default function TunnelPage() {
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 75,
});
await refreshTunnelList(false);
}
await refreshTunnelList(false);
setSelectedIds(new Set());
setSelectMode(false);
setBatchDeleteModalOpen(false);
resetBatchDeleteState();
} else {
toast.error(res.msg || "删除失败");
}
@@ -1069,6 +1263,150 @@ export default function TunnelPage() {
[sortedTunnels],
);
const deleteReplacementTunnels = useMemo(() => {
if (!tunnelToDelete) {
return [] as Tunnel[];
}
return tunnels
.filter((tunnel) => tunnel.id !== tunnelToDelete.id && tunnel.status === 1)
.sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
}, [tunnelToDelete, tunnels]);
useEffect(() => {
if (!deleteModalOpen) {
return;
}
if ((tunnelDeletePreview?.forwardCount ?? 0) <= 0) {
return;
}
if (deleteReplacementTunnels.length === 0) {
setDeleteAction("delete_forwards");
setDeleteTargetTunnelId(null);
return;
}
if (deleteAction !== "replace") {
return;
}
setDeleteTargetTunnelId((prev) => {
if (prev && deleteReplacementTunnels.some((tunnel) => tunnel.id === prev)) {
return prev;
}
return deleteReplacementTunnels[0]?.id ?? null;
});
}, [
deleteAction,
deleteModalOpen,
deleteReplacementTunnels,
tunnelDeletePreview?.forwardCount,
]);
const deletePreviewForwardCount = tunnelDeletePreview?.forwardCount ?? 0;
const deleteHasForwardDependencies = deletePreviewForwardCount > 0;
const deleteReplaceUnavailable =
deleteHasForwardDependencies && deleteReplacementTunnels.length === 0;
const deleteConfirmLabel = deleteHasForwardDependencies
? deleteAction === "replace"
? "迁移规则后删除该隧道"
: "删除规则并删除该隧道"
: "删除该隧道";
const selectedTunnelIdList = useMemo(
() => Array.from(selectedIds),
[selectedIds],
);
const batchDeleteReplacementTunnels = useMemo(() => {
if (selectedIds.size === 0) {
return [] as Tunnel[];
}
return tunnels
.filter((tunnel) => !selectedIds.has(tunnel.id) && tunnel.status === 1)
.sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
}, [selectedIds, tunnels]);
useEffect(() => {
if (!batchDeleteModalOpen) {
return;
}
if ((batchDeletePreview?.totalForwardCount ?? 0) <= 0) {
return;
}
if (batchDeleteReplacementTunnels.length === 0) {
setBatchDeleteAction("delete_forwards");
setBatchDeleteTargetTunnelId(null);
return;
}
if (batchDeleteAction !== "replace") {
return;
}
setBatchDeleteTargetTunnelId((prev) => {
if (
prev &&
batchDeleteReplacementTunnels.some((tunnel) => tunnel.id === prev)
) {
return prev;
}
return batchDeleteReplacementTunnels[0]?.id ?? null;
});
}, [
batchDeleteAction,
batchDeleteModalOpen,
batchDeletePreview?.totalForwardCount,
batchDeleteReplacementTunnels,
]);
const batchDeleteTotalForwardCount = batchDeletePreview?.totalForwardCount ?? 0;
const batchDeleteHasForwardDependencies = batchDeleteTotalForwardCount > 0;
const batchDeleteDependentTunnelCount =
batchDeletePreview?.items?.filter((item) => item.forwardCount > 0).length ?? 0;
const batchDeleteDirectDeleteTunnelCount = Math.max(
selectedTunnelIdList.length - batchDeleteDependentTunnelCount,
0,
);
const batchDeletePreviewItems = useMemo(() => {
return [...(batchDeletePreview?.items ?? [])].sort((a, b) => {
if ((a.forwardCount > 0) === (b.forwardCount > 0)) {
return a.tunnelName.localeCompare(b.tunnelName, "zh-CN");
}
return a.forwardCount > 0 ? -1 : 1;
});
}, [batchDeletePreview?.items]);
const batchDeleteDependentItems = useMemo(
() => batchDeletePreviewItems.filter((item) => item.forwardCount > 0),
[batchDeletePreviewItems],
);
const batchDeleteReplaceUnavailable =
batchDeleteHasForwardDependencies && batchDeleteReplacementTunnels.length === 0;
const batchDeleteConfirmLabel = batchDeleteHasForwardDependencies
? batchDeleteAction === "replace"
? `迁移规则后删除这 ${selectedTunnelIdList.length} 条隧道`
: `删除规则并删除 ${selectedTunnelIdList.length} 条隧道`
: `删除这 ${selectedTunnelIdList.length} 条隧道`;
const SortableItem = ({
id,
children,
@@ -1151,7 +1489,7 @@ export default function TunnelPage() {
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
onPress={handleOpenBatchDeleteModal}
>
删除
</Button>
@@ -1263,7 +1601,7 @@ export default function TunnelPage() {
</div>
</div>
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
className="cursor-grab active:cursor-grabbing p-1 -mr-1 text-default-400 hover:text-default-600 transition-colors touch-manipulation flex-shrink-0"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
@@ -1515,6 +1853,9 @@ export default function TunnelPage() {
{/* 新增/编辑模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
@@ -2449,26 +2790,150 @@ export default function TunnelPage() {
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setDeleteModalOpen}
onOpenChange={handleDeleteModalOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">确认删除</h2>
<h2 className="text-lg font-bold sm:text-xl">删除隧道</h2>
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
{tunnelDeletePreview?.tunnelName || tunnelToDelete?.name
? `即将删除“${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name}”,删除前会先检查是否有关联规则。`
: "删除前会先检查是否有关联规则。"}
</p>
</ModalHeader>
<ModalBody>
<p>
确定要删除隧道{" "}
<strong>&quot;{tunnelToDelete?.name}&quot;</strong> 吗?
</p>
<p className="text-small text-default-500">
此操作不可恢复,请谨慎操作。
</p>
<ModalBody className="space-y-3 sm:space-y-4">
{deletePreviewLoading ? (
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
<Spinner size="sm" />
正在检查是否有规则正在使用该隧道...
</div>
) : deleteHasForwardDependencies ? (
<>
<Alert
color="warning"
description={`隧道 \"${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name || ""}\" 当前被 ${deletePreviewForwardCount} 条规则使用。删除前需要先处理这些规则。`}
title="发现关联规则"
variant="flat"
/>
{(tunnelDeletePreview?.sampleForwards?.length ?? 0) > 0 ? (
<div className="space-y-3 rounded-xl border border-divider bg-content2/40 p-3 sm:p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">
关联规则预览
</h3>
<span className="text-xs text-default-500">
前 {tunnelDeletePreview?.sampleForwards?.length ?? 0} 条
</span>
</div>
<div className="space-y-2">
{tunnelDeletePreview?.sampleForwards?.map((forward) => (
<div
key={forward.id}
className="rounded-lg border border-divider/70 bg-background/80 px-2.5 py-2 sm:px-3"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground">
{forward.name}
</span>
<span className="shrink-0 font-mono text-xs text-default-500">
:{forward.inPort || 0}
</span>
</div>
<p className="mt-1 text-xs text-default-500">
用户:{forward.userName || `#${forward.userId}`}
</p>
</div>
))}
</div>
{deletePreviewForwardCount >
(tunnelDeletePreview?.sampleForwards?.length ?? 0) ? (
<p className="text-xs text-default-500">
还有 {deletePreviewForwardCount - (tunnelDeletePreview?.sampleForwards?.length ?? 0)} 条规则未展开显示。
</p>
) : null}
</div>
) : null}
<RadioGroup
label="处理方式"
value={deleteAction}
onValueChange={(value) => {
const nextAction = value as TunnelDeleteAction;
setDeleteAction(nextAction);
if (nextAction !== "replace") {
setDeleteTargetTunnelId(null);
return;
}
setDeleteTargetTunnelId(
deleteReplacementTunnels[0]?.id ?? null,
);
}}
>
<Radio value="replace">
保留规则,迁移到其他隧道{deleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"}
</Radio>
<Radio value="delete_forwards">直接删除这些关联规则</Radio>
</RadioGroup>
{deleteReplaceUnavailable ? (
<Alert
color="warning"
description="当前没有其他启用中的隧道可用于承接这些规则,只能删除关联规则后再删除该隧道。"
variant="flat"
/>
) : null}
{deleteAction === "replace" && !deleteReplaceUnavailable ? (
<div className="space-y-2">
<Select
label="目标隧道"
placeholder="请选择目标隧道"
selectedKeys={
deleteTargetTunnelId
? [String(deleteTargetTunnelId)]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0];
setDeleteTargetTunnelId(
selected ? Number(selected) : null,
);
}}
>
{deleteReplacementTunnels.map((tunnel) => (
<SelectItem key={String(tunnel.id)}>
{tunnel.name}
</SelectItem>
))}
</Select>
<p className="text-xs text-default-500">
关联规则会迁移到这里,当前要删除的隧道不会出现在可选项里。
</p>
</div>
) : null}
</>
) : (
<Alert
color="warning"
description={`当前未发现关联规则。确认后将直接删除“${tunnelToDelete?.name || "该隧道"}”,此操作不可撤销。`}
title="可以直接删除"
variant="flat"
/>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
@@ -2476,10 +2941,16 @@ export default function TunnelPage() {
</Button>
<Button
color="danger"
isDisabled={
deletePreviewLoading ||
(deleteHasForwardDependencies &&
deleteAction === "replace" &&
(!deleteTargetTunnelId || deleteReplaceUnavailable))
}
isLoading={deleteLoading}
onPress={confirmDelete}
>
{deleteLoading ? "删除中..." : "确认删除"}
{deleteLoading ? "删除中..." : deleteConfirmLabel}
</Button>
</ModalFooter>
</>
@@ -2491,10 +2962,7 @@ export default function TunnelPage() {
<Modal
backdrop="blur"
classNames={{
base: "rounded-2xl",
header: "rounded-t-2xl",
body: "rounded-none",
footer: "rounded-b-2xl",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={diagnosisModalOpen}
placement="center"
@@ -3077,18 +3545,159 @@ export default function TunnelPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen}
onOpenChange={setBatchDeleteModalOpen}
onOpenChange={handleBatchDeleteModalOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>确认删除</ModalHeader>
<ModalBody>
<p>
确定要删除选中的 {selectedIds.size}{" "}
项隧道吗?此操作不可撤销,相关规则也将被删除。
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold sm:text-xl">批量删除隧道</h2>
<p className="text-xs font-normal leading-5 text-default-500 sm:text-sm">
即将删除这 {selectedTunnelIdList.length} 条隧道,删除前会先检查是否有关联规则。
</p>
</ModalHeader>
<ModalBody className="space-y-3 sm:space-y-4">
{batchDeletePreviewLoading ? (
<div className="flex items-center gap-3 rounded-xl border border-divider bg-content2/40 px-3 py-5 text-sm text-default-600 sm:px-4 sm:py-6">
<Spinner size="sm" />
正在检查选中隧道是否有关联规则...
</div>
) : batchDeleteHasForwardDependencies ? (
<>
<Alert
color="warning"
description={`已选 ${selectedTunnelIdList.length} 条隧道,其中 ${batchDeleteDependentTunnelCount} 条仍被规则使用,共 ${batchDeleteTotalForwardCount} 条规则待处理。${batchDeleteDirectDeleteTunnelCount > 0 ? `其余 ${batchDeleteDirectDeleteTunnelCount} 条会直接删除。` : ""}`}
title="发现关联规则"
variant="flat"
/>
<div className="max-h-64 space-y-3 overflow-y-auto rounded-xl border border-divider bg-content2/40 p-3 sm:max-h-72 sm:p-4">
{batchDeleteDependentItems.map((item) => (
<div
key={item.tunnelId}
className="rounded-lg border border-divider/70 bg-background/80 p-2.5 sm:p-3"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{item.tunnelName}
</p>
<p className="mt-1 text-xs text-default-500">
{item.forwardCount} 条规则依赖
</p>
</div>
<Chip color="warning" size="sm" variant="flat">
有关联
</Chip>
</div>
{item.sampleForwards.length > 0 ? (
<div className="mt-3 space-y-2">
{item.sampleForwards.map((forward) => (
<div
key={forward.id}
className="rounded-md bg-content1/70 px-2.5 py-2 sm:px-3"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-xs font-medium text-foreground">
{forward.name}
</span>
<span className="shrink-0 font-mono text-[11px] text-default-500">
:{forward.inPort || 0}
</span>
</div>
<p className="mt-1 text-[11px] text-default-500">
用户:{forward.userName || `#${forward.userId}`}
</p>
</div>
))}
{item.forwardCount > item.sampleForwards.length ? (
<p className="text-[11px] text-default-500">
还有 {item.forwardCount - item.sampleForwards.length} 条规则未展开显示。
</p>
) : null}
</div>
) : null}
</div>
))}
</div>
<RadioGroup
label="处理方式"
value={batchDeleteAction}
onValueChange={(value) => {
const nextAction = value as TunnelDeleteAction;
setBatchDeleteAction(nextAction);
if (nextAction !== "replace") {
setBatchDeleteTargetTunnelId(null);
return;
}
setBatchDeleteTargetTunnelId(
batchDeleteReplacementTunnels[0]?.id ?? null,
);
}}
>
<Radio value="replace">
保留规则,统一迁移到其他隧道{batchDeleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"}
</Radio>
<Radio value="delete_forwards">直接删除这些关联规则</Radio>
</RadioGroup>
{batchDeleteReplaceUnavailable ? (
<Alert
color="warning"
description="当前没有可承接这些规则的启用隧道,只能删除关联规则后再删除所选隧道。"
variant="flat"
/>
) : null}
{batchDeleteAction === "replace" &&
!batchDeleteReplaceUnavailable ? (
<div className="space-y-2">
<Select
label="目标隧道"
placeholder="请选择目标隧道"
selectedKeys={
batchDeleteTargetTunnelId
? [String(batchDeleteTargetTunnelId)]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0];
setBatchDeleteTargetTunnelId(
selected ? Number(selected) : null,
);
}}
>
{batchDeleteReplacementTunnels.map((tunnel) => (
<SelectItem key={String(tunnel.id)}>
{tunnel.name}
</SelectItem>
))}
</Select>
<p className="text-xs text-default-500">
所有关联规则都会迁移到这里,删除列表中的隧道不会出现在可选项里。
</p>
</div>
) : null}
</>
) : (
<Alert
color="warning"
description={`已选 ${selectedTunnelIdList.length} 条隧道,当前未发现关联规则。确认后将直接删除这些隧道,此操作不可撤销。`}
title="可以直接删除"
variant="flat"
/>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
@@ -3096,10 +3705,17 @@ export default function TunnelPage() {
</Button>
<Button
color="danger"
isDisabled={
batchDeletePreviewLoading ||
(batchDeleteHasForwardDependencies &&
batchDeleteAction === "replace" &&
(!batchDeleteTargetTunnelId ||
batchDeleteReplaceUnavailable))
}
isLoading={batchLoading}
onPress={handleBatchDelete}
>
确认删除
{batchLoading ? "删除中..." : batchDeleteConfirmLabel}
</Button>
</ModalFooter>
</>
@@ -3118,7 +3734,6 @@ export default function TunnelPage() {
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
+195 -241
View File
@@ -60,10 +60,10 @@ import {
getUserGroups,
} from "@/api";
import {
SearchIcon,
EditIcon,
DeleteIcon,
SettingsIcon,
SearchIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
@@ -204,7 +204,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -234,6 +234,7 @@ export default function UserPage() {
// 分配新隧道权限相关状态
const [assignLoading, setAssignLoading] = useState(false);
const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false);
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
Map<number, number | null>
>(new Map());
@@ -469,7 +470,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -1049,26 +1050,6 @@ export default function UserPage() {
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
{(user.dailyQuotaGB ?? 0) > 0 ||
(user.monthlyQuotaGB ?? 0) > 0 ||
(user.disabledByQuota ?? 0) > 0 ? (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">每日配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">每月配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.monthlyUsedBytes ?? 0))}{" "}
/ {formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
</>
) : null}
<div className="flex justify-between text-sm">
<span className="text-default-600">规则数量</span>
<span className="font-medium text-xs">
@@ -1180,6 +1161,9 @@ export default function UserPage() {
{/* 用户表单模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isUserModalOpen}
placement="center"
scrollBehavior="outside"
@@ -1224,38 +1208,6 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, flow: value }));
}}
/>
<Input
label="每日配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.dailyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, dailyQuotaGB: value }));
}}
/>
<Input
label="每月配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.monthlyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value }));
}}
/>
<Input
isRequired
label="规则数量"
@@ -1452,7 +1404,7 @@ export default function UserPage() {
<Modal
backdrop="blur"
classNames={{
base: "max-w-[95vw] sm:max-w-4xl",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full sm:max-w-xl rounded-2xl",
}}
isDismissable={false}
isOpen={isTunnelModalOpen}
@@ -1469,136 +1421,147 @@ export default function UserPage() {
<div>
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
<div className="space-y-4">
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
</div>
<div className="flex flex-col gap-2 relative">
<p className="text-base text-default-700 ml-1 font-medium">
隧道列表
</p>
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
tunnel.id,
);
{/* 顶部触发框 */}
<div
className={`group flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all cursor-pointer shadow-sm w-[320px] ${isTunnelListExpanded ? "border-primary bg-white ring-2 ring-primary/10 dark:bg-default-100 dark:ring-primary/20" : "border-default-200 bg-default-50 hover:border-primary-300 dark:hover:bg-default-100 dark:hover:border-primary-400"}`}
onClick={() =>
setIsTunnelListExpanded(!isTunnelListExpanded)
}
>
<span
className={`text-sm truncate ${batchTunnelSelections.size > 0 ? "text-primary-500 font-bold" : "text-default-400"}`}
>
{batchTunnelSelections.size > 0
? `已选 ${batchTunnelSelections.size} 项:` +
Array.from(batchTunnelSelections.keys())
.map(
(id) => tunnels.find((t) => t.id === id)?.name,
)
.join("、")
: "请选择隧道(勾选后配置限速)"}
</span>
<svg
className={`w-5 h-5 text-default-400 transition-transform ${isTunnelListExpanded ? "rotate-180 text-primary" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M19 9l-7 7-7-7" strokeWidth={2.5} />
</svg>
</div>
return (
<div
key={tunnel.id}
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"
}
`}
role="button"
tabIndex={isAssigned ? -1 : 0}
onClick={() =>
!isAssigned && toggleTunnelSelection(tunnel.id)
}
onKeyDown={(event) => {
if (isAssigned) {
return;
}
{/* 列表悬浮层 */}
{isTunnelListExpanded && (
<div
className="absolute top-[calc(100%+8px)] left-0 z-[999] w-[320px] overflow-hidden rounded-2xl border border-default-200 bg-white text-foreground shadow-2xl dark:bg-default-50"
onClick={(e) => e.stopPropagation()}
>
<div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(
tunnel.id,
);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
tunnel.id,
);
const currentSpeedId = batchTunnelSelections.get(
tunnel.id,
);
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleTunnelSelection(tunnel.id);
}
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
color="primary"
isDisabled={isAssigned}
isSelected={isSelected}
size="md"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onValueChange={() =>
toggleTunnelSelection(tunnel.id)
}
/>
<span
className={`font-medium truncate ${isAssigned ? "text-default-400" : "text-default-700 dark:text-default-600"}`}
return (
<div
key={tunnel.id}
className={`mb-1 flex items-center justify-between rounded-xl border px-4 py-2.5 transition-all ${isSelected ? "border-primary-200 bg-primary-50/60 dark:border-primary-500/40 dark:bg-primary-900/40" : "border-transparent bg-transparent hover:bg-default-100"} ${isAssigned ? "cursor-not-allowed opacity-40 grayscale" : "cursor-pointer"}`}
// 核心:整行点击直接控制状态
onClick={(e) => {
if (isAssigned) return;
e.stopPropagation();
toggleTunnelSelection(tunnel.id);
}}
>
{tunnel.name}
</span>
{isAssigned && (
<Chip
className="shrink-0"
color="default"
size="sm"
variant="flat"
>
已分配
</Chip>
)}
</div>
<div className="flex items-center gap-4 min-w-0 flex-1">
<Checkbox
color="primary"
isSelected={isSelected}
isDisabled={isAssigned}
// 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架
className="pointer-events-none"
/>
<span
className={`truncate text-sm font-medium text-foreground ${isSelected ? "text-primary-700 dark:text-primary-300" : ""}`}
>
{tunnel.name}
</span>
</div>
{isSelected && !isAssigned && (
<div>
<Select
className="w-36"
classNames={{
trigger: "min-h-10 h-10",
}}
placeholder="不限速"
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
batchTunnelSelections.get(tunnel.id) !==
undefined
? [
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
: []
}
size="sm"
onClick={(e) => e.stopPropagation()}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
{/* 右侧限速选择:复刻 image_24879b */}
{isSelected && !isAssigned && (
<div
className="flex items-center ml-2"
onClick={(e) => e.stopPropagation()}
>
<Select
aria-label="限速选择"
className="w-32"
placeholder="不限速"
selectedKeys={
currentSpeedId
? [currentSpeedId.toString()]
: []
}
size="sm"
variant="flat"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0];
updateTunnelSpeedLimit(
tunnel.id,
selectedKey ? Number(selectedKey) : null,
);
}}
>
{tunnelSpeedLimits.map((sl) => (
<SelectItem
key={sl.id.toString()}
textValue={sl.name}
updateTunnelSpeedLimit(
tunnel.id,
selectedKey
? Number(selectedKey)
: null,
);
}}
>
{sl.name}
</SelectItem>
))}
</Select>
{tunnelSpeedLimits.map((sl) => (
<SelectItem key={sl.id.toString()}>
{sl.name}
</SelectItem>
))}
</Select>
</div>
)}
{isAssigned && (
<span className="text-[10px] text-default-400 italic pr-2">
已分配
</span>
)}
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end border-t border-default-200 bg-default-50/80 p-2 dark:bg-default-100/80">
<Button
className="font-bold"
color="primary"
size="md"
variant="light"
onPress={() => setIsTunnelListExpanded(false)}
>
完成配置
</Button>
</div>
);
})}
{tunnels.length === 0 && (
<div className="p-8 text-center text-default-400 bg-default-50 dark:bg-default-100/50 rounded-lg border border-dashed border-default-200 dark:border-default-100/30">
暂无可用隧道
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
className="w-full sm:w-auto"
className="w-fit px-8"
color="primary"
isDisabled={batchTunnelSelections.size === 0}
isLoading={assignLoading}
@@ -1620,20 +1583,19 @@ export default function UserPage() {
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
<Table
aria-label="用户隧道权限列表"
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
classNames={{
wrapper: "shadow-none",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto",
th: "bg-default-50 text-default-600 font-semibold",
td: "py-4 border-b border-divider",
}}
>
<TableHeader>
<TableColumn>隧道名称</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn>
<TableColumn>重置时间</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
<TableColumn className="w-[35%]">隧道名称</TableColumn>
<TableColumn className="w-[35%]">流量统计</TableColumn>
<TableColumn className="w-[30%] text-right">
操作
</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无隧道权限"
@@ -1642,61 +1604,36 @@ export default function UserPage() {
loadingContent={<Spinner />}
>
{(userTunnel) => (
<TableRow key={userTunnel.id}>
<TableCell>{userTunnel.tunnelName}</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex justify-between text-small">
<span className="text-gray-600">限制:</span>
<span className="font-medium">
{formatFlow(userTunnel.flow, "gb")}
</span>
</div>
<div className="flex justify-between text-small">
<span className="text-gray-600">已用:</span>
<span className="font-medium text-danger">
{formatFlow(
calculateTunnelUsedFlow(userTunnel),
)}
</span>
</div>
</div>
</TableCell>
<TableCell>{userTunnel.num}</TableCell>
<TableCell>
<Chip
color={
userTunnel.status === 1 ? "success" : "danger"
}
size="sm"
variant="flat"
>
{userTunnel.status === 1 ? "正常" : "禁用"}
</Chip>
</TableCell>
<TableCell>
<Chip
color={
userTunnel.speedLimitName ? "warning" : "success"
}
size="sm"
variant="flat"
>
{userTunnel.speedLimitName || "不限速"}
</Chip>
</TableCell>
<TableCell>
{userTunnel.flowResetTime === 0
? "不重置"
: `每月${userTunnel.flowResetTime}号`}
</TableCell>
<TableCell>{formatDate(userTunnel.expTime)}</TableCell>
<TableRow
key={userTunnel.id}
className="hover:bg-default-50/50 transition-colors"
>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-bold text-default-700 whitespace-nowrap">
{userTunnel.tunnelName}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 whitespace-nowrap text-sm">
<span className="text-danger font-mono font-bold">
{formatFlow(calculateTunnelUsedFlow(userTunnel))}
</span>
<span className="text-default-300">/</span>
<span className="text-default-500 font-mono">
{formatFlow(userTunnel.flow, "gb")}
</span>
</div>
</TableCell>
<TableCell>
{/* 5. justify-end 确保按钮群组整体靠右 */}
<div className="flex items-center justify-end gap-2">
<Button
isIconOnly
aria-label="编辑隧道权限"
color="primary"
className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8"
size="sm"
variant="flat"
onPress={() => handleEditTunnel(userTunnel)}
@@ -1705,15 +1642,12 @@ export default function UserPage() {
</Button>
<Button
isIconOnly
aria-label="重置隧道流量"
color="warning"
className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8"
size="sm"
title="重置流量"
variant="flat"
onPress={() => handleResetTunnelFlow(userTunnel)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
@@ -1727,8 +1661,7 @@ export default function UserPage() {
</Button>
<Button
isIconOnly
aria-label="删除隧道权限"
color="danger"
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
size="sm"
variant="flat"
onPress={() => handleRemoveTunnel(userTunnel)}
@@ -1744,8 +1677,14 @@ export default function UserPage() {
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onTunnelModalClose}>关闭</Button>
<ModalFooter className="justify-end">
<Button
color="primary"
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
onPress={onTunnelModalClose}
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
@@ -1753,6 +1692,9 @@ export default function UserPage() {
{/* 编辑隧道权限模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isDismissable={false}
isOpen={isEditTunnelModalOpen}
placement="center"
@@ -1931,6 +1873,9 @@ export default function UserPage() {
{/* 删除确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -1974,6 +1919,9 @@ export default function UserPage() {
{/* 删除隧道权限确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteTunnelModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2019,6 +1967,9 @@ export default function UserPage() {
{/* 重置流量确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetFlowModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2108,6 +2059,9 @@ export default function UserPage() {
{/* 重置隧道流量确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetTunnelFlowModalOpen}
placement="center"
scrollBehavior="outside"
@@ -11,6 +11,7 @@ export interface CheckboxProps
classNames?: Record<string, string>;
color?: string;
isDisabled?: boolean;
isIndeterminate?: boolean;
isSelected?: boolean;
onValueChange?: (value: boolean) => void;
size?: string;
@@ -20,6 +21,7 @@ export function Checkbox({
children,
className,
isDisabled,
isIndeterminate,
isSelected,
onValueChange,
...props
@@ -32,6 +34,10 @@ export function Checkbox({
onValueChange?.(value === true);
};
const checkedValue: boolean | "indeterminate" = isIndeterminate
? "indeterminate"
: Boolean(isSelected);
return (
<div
className={cn(
@@ -41,7 +47,7 @@ export function Checkbox({
)}
>
<BaseCheckbox
checked={Boolean(isSelected)}
checked={checkedValue}
disabled={isDisabled}
onCheckedChange={handleCheckedChange}
{...props}
@@ -145,7 +145,6 @@ export function ModalContent({
return (
<BaseDialogContent
{...props}
className={cn(
mapSize(resolvedSize),
context?.classNames?.base,
@@ -158,12 +157,7 @@ export function ModalContent({
className,
)}
showCloseButton={false}
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{renderedChildren}
</BaseDialogContent>
@@ -399,7 +399,7 @@ export function Select<T>({
) : (
<select
className={cn(
"w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"w-full rounded-md border border-input bg-background px-3 py-2 text-foreground shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:[color-scheme:dark]",
sizeClass(size),
classNames?.trigger,
className,
@@ -414,6 +414,7 @@ export function Select<T>({
<option value="">{placeholder ?? "请选择"}</option>
{options.map((option) => (
<option
className="bg-background text-foreground"
key={option.key}
disabled={disabled.has(option.key)}
value={option.key}