Compare commits

...

5 Commits

Author SHA1 Message Date
sagitchu 5ceced57e8 feat: add forward flow reset action 2026-07-10 16:57:05 +08:00
sagitchu d153c2832c feat: add forward flow reset endpoint 2026-07-10 16:51:47 +08:00
sagitchu ff3b91c4ae feat: add forward flow reset repository method 2026-07-10 16:47:02 +08:00
sagitchu 1428a3fdde docs: plan forward flow reset implementation 2026-07-10 16:38:48 +08:00
sagitchu 9a415d86e0 docs: design forward flow reset 2026-07-10 16:29:28 +08:00
9 changed files with 1267 additions and 4 deletions
@@ -0,0 +1,650 @@
# Forward Flow Reset Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a permission-checked action that resets only one forward rule's displayed upload and download counters.
**Architecture:** A dedicated repository method updates only the selected `forward` row. A dedicated authenticated handler reuses `resolveForwardAccess`, and the React page calls the endpoint from all three rule views through one confirmation modal.
**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL-compatible models, React, TypeScript, shadcn bridge components, Tailwind CSS v4.
## Global Constraints
- Only `forward.in_flow`, `forward.out_flow`, and `forward.updated_time` may change during reset.
- Do not modify `user`, `user_tunnel`, quota, historical statistics, nftables counter state, or running services.
- Administrators may reset any rule; non-admin users may reset only their own rules through existing `resolveForwardAccess` behavior.
- All API responses must keep the `{code, msg, data, ts}` envelope.
- Frontend imports must use `src/shadcn-bridge/heroui/*`; do not add `@heroui/*` or `@nextui-org/*` dependencies.
- Do not add frontend test infrastructure.
- Do not edit generated protobuf files, `install.sh`, or `panel_install.sh`.
---
### Task 1: Add the repository flow-reset primitive
**Files:**
- Create: `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`
- Modify: `go-backend/internal/store/repo/repository_mutations.go`
**Interfaces:**
- Consumes: `model.Forward`, the repository's GORM database handle, and an explicit Unix-millisecond timestamp.
- Produces: `func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error`.
- [ ] **Step 1: Write the failing repository tests**
Create `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`:
```go
package repo
import (
"path/filepath"
"testing"
)
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
const originalUpdated int64 = 1000
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
`).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
`).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)
`).Error; err != nil {
t.Fatalf("insert user tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES
(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
`, originalUpdated, originalUpdated).Error; err != nil {
t.Fatalf("insert forwards: %v", err)
}
const resetAt int64 = 2000
if err := r.ResetForwardFlow(20, resetAt); err != nil {
t.Fatalf("ResetForwardFlow: %v", err)
}
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
}
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
var r *Repository
if err := r.ResetForwardFlow(20, 2000); err == nil {
t.Fatal("expected uninitialized repository error")
}
}
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
```
- [ ] **Step 2: Run the repository tests and verify the missing method failure**
Run:
```bash
cd go-backend && go test ./internal/store/repo -run TestResetForwardFlow -count=1
```
Expected: compilation fails because `ResetForwardFlow` is undefined.
- [ ] **Step 3: Implement the minimal repository method**
Add to the flow-reset section of `go-backend/internal/store/repo/repository_mutations.go`:
```go
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Forward{}).
Where("id = ?", forwardID).
Updates(map[string]interface{}{
"in_flow": 0,
"out_flow": 0,
"updated_time": now,
}).Error
}
```
The file already imports `errors` and `model`; do not add a new dependency.
- [ ] **Step 4: Format and run the focused repository tests**
Run:
```bash
cd go-backend && gofmt -w internal/store/repo/repository_forward_flow_reset_test.go internal/store/repo/repository_mutations.go
go test ./internal/store/repo -run TestResetForwardFlow -count=1
```
Expected: both reset tests pass.
- [ ] **Step 5: Commit the repository change**
```bash
git add go-backend/internal/store/repo/repository_mutations.go go-backend/internal/store/repo/repository_forward_flow_reset_test.go
git commit -m "feat: add forward flow reset repository method"
```
---
### Task 2: Add the authenticated reset endpoint
**Files:**
- Create: `go-backend/internal/http/handler/forward_reset_flow_test.go`
- Modify: `go-backend/internal/http/handler/handler.go`
- Modify: `go-backend/internal/http/handler/mutations.go`
**Interfaces:**
- Consumes: `POST` JSON `{ "id": number }`, `resolveForwardAccess`, and `Repository.ResetForwardFlow` from Task 1.
- Produces: `POST /api/v1/forward/reset-flow` and `func (h *Handler) forwardResetFlow(http.ResponseWriter, *http.Request)`.
- [ ] **Step 1: Write the failing handler tests**
Create `go-backend/internal/http/handler/forward_reset_flow_test.go`:
```go
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/store/repo"
)
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
tests := []struct {
name string
actorID int64
actorRole int
forwardID int64
wantCode int
wantInFlow int64
wantOutFlow int64
}{
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, r := setupForwardResetFlowHandler(t)
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
}
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
})
}
}
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
h, _ := setupForwardResetFlowHandler(t)
t.Run("non post", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
t.Run("invalid id", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
}
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
statements := []string{
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(1, 'admin', 'pwd', 0, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
}
for _, statement := range statements {
if err := r.DB().Exec(statement).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
}
return New(r, "test-secret"), r
}
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]int64{"id": forwardID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
}
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
t.Helper()
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
}
return payload.Code
}
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
```
If the project's default error code differs from `-1`, replace the test expectation with the actual `response.ErrDefault` code after inspecting one existing handler response; do not weaken the success and database assertions.
- [ ] **Step 2: Run the handler tests and verify the missing handler failure**
Run:
```bash
cd go-backend && go test ./internal/http/handler -run TestForwardResetFlow -count=1
```
Expected: compilation fails because `forwardResetFlow` is undefined.
- [ ] **Step 3: Register and implement the endpoint**
Add this route beside the other forward routes in `go-backend/internal/http/handler/handler.go`:
```go
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
```
Add this handler beside `forwardPause` and `forwardResume` in `go-backend/internal/http/handler/mutations.go`:
```go
func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) {
id := idFromBody(r, w)
if id <= 0 {
return
}
if _, _, _, err := h.resolveForwardAccess(r, id); err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
This deliberately does not call runtime service controls or nftables reconciliation.
- [ ] **Step 4: Format and run the focused handler tests**
Run:
```bash
cd go-backend && gofmt -w internal/http/handler/forward_reset_flow_test.go internal/http/handler/handler.go internal/http/handler/mutations.go
go test ./internal/http/handler -run TestForwardResetFlow -count=1
```
Expected: all reset endpoint tests pass.
- [ ] **Step 5: Run all backend tests**
Run:
```bash
cd go-backend && go test ./...
```
Expected: all backend packages and contract tests pass, excluding environment-gated PostgreSQL tests when `FLVX_POSTGRES_TEST_DSN` is unset.
- [ ] **Step 6: Commit the endpoint change**
```bash
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/forward_reset_flow_test.go
git commit -m "feat: add forward flow reset endpoint"
```
---
### Task 3: Add the rule-page reset action and confirmation modal
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/pages/forward.tsx`
**Interfaces:**
- Consumes: `POST /forward/reset-flow`, the page's `Forward` shape, `refreshForwardList`, toast notifications, and existing modal/button bridge components.
- Produces: `resetForwardFlow(id: number)`, a shared reset handler, disabled zero-usage actions in all rule views, and one confirmation modal.
- [ ] **Step 1: Add the frontend API wrapper**
Add beside the forward control operations in `vite-frontend/src/api/index.ts`:
```ts
export const resetForwardFlow = (forwardId: number) =>
Network.post("/forward/reset-flow", { id: forwardId });
```
Import `resetForwardFlow` from `@/api` in `vite-frontend/src/pages/forward.tsx`.
- [ ] **Step 2: Add page state and shared reset handlers**
Add state beside the existing delete modal state:
```ts
const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false);
const [resetFlowLoading, setResetFlowLoading] = useState(false);
const [forwardToResetFlow, setForwardToResetFlow] = useState<Forward | null>(null);
```
Add these handlers beside `handleDelete` and `confirmDelete`:
```ts
const handleResetFlow = (forward: Forward) => {
if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return;
setForwardToResetFlow(forward);
setResetFlowModalOpen(true);
};
const confirmResetFlow = async () => {
if (!forwardToResetFlow) return;
setResetFlowLoading(true);
try {
const res = await resetForwardFlow(forwardToResetFlow.id);
if (res.code !== 0) {
toast.error(res.msg || "流量清零失败");
return;
}
toast.success("规则流量已清零");
setResetFlowModalOpen(false);
setForwardToResetFlow(null);
await refreshForwardList(false);
} catch {
toast.error("流量清零失败");
} finally {
setResetFlowLoading(false);
}
};
```
- [ ] **Step 3: Add one reusable reset icon button to both table row components**
Pass `handleResetFlow` into `SortableTableRow` and `SortableCompactTableRow` at every render site. Add it to each component's destructured props.
Insert this button between diagnosis and delete in each table action cell:
```tsx
<Button
isIconOnly
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
title="流量清零"
onPress={() => handleResetFlow(forward)}
>
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
```
- [ ] **Step 4: Add the reset action to the card view**
Insert a fourth action button between diagnosis and delete in `renderForwardCard`:
```tsx
<Button
className="flex-1 min-h-8"
color="secondary"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
}
variant="flat"
onPress={() => handleResetFlow(forward)}
>
清零
</Button>
```
Change the card action container from `flex gap-1.5 mt-3` to `grid grid-cols-2 gap-1.5 mt-3` so all four actions remain readable at the smallest supported card width.
- [ ] **Step 5: Add the confirmation modal**
Add beside the delete confirmation modal:
```tsx
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={resetFlowModalOpen}
placement="center"
scrollBehavior="inside"
size="lg"
onOpenChange={setResetFlowModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-secondary">确认流量清零</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要清零规则{" "}
<span className="font-semibold text-foreground">
&quot;{forwardToResetFlow?.name}&quot;
</span>{" "}
当前显示的上传和下载流量吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。
</p>
</ModalBody>
<ModalFooter>
<Button isDisabled={resetFlowLoading} variant="light" onPress={onClose}>
取消
</Button>
<Button
color="secondary"
isLoading={resetFlowLoading}
onPress={confirmResetFlow}
>
确认清零
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
```
Add this wrapper beside the other reset handlers and pass it to the modal as `onOpenChange={handleResetFlowModalOpenChange}`:
```ts
const handleResetFlowModalOpenChange = (isOpen: boolean) => {
if (resetFlowLoading) return;
setResetFlowModalOpen(isOpen);
if (!isOpen) {
setForwardToResetFlow(null);
}
};
```
- [ ] **Step 6: Format and verify the frontend**
Run:
```bash
cd vite-frontend && pnpm exec prettier --write src/api/index.ts src/pages/forward.tsx
pnpm run build
pnpm run lint
```
Expected: TypeScript/Vite build succeeds and ESLint finishes without errors.
- [ ] **Step 7: Commit the frontend change**
```bash
git add vite-frontend/src/api/index.ts vite-frontend/src/pages/forward.tsx
git commit -m "feat: add forward flow reset action"
```
---
### Task 4: Perform integrated verification
**Files:**
- Verify only; no planned source changes.
**Interfaces:**
- Consumes: the repository method, API endpoint, and rule-page action from Tasks 1-3.
- Produces: evidence that the complete feature builds and all affected tests pass.
- [ ] **Step 1: Run the complete backend suite**
```bash
cd go-backend && go test ./...
```
Expected: all available backend tests pass.
- [ ] **Step 2: Run the complete frontend checks**
```bash
cd vite-frontend && pnpm run build && pnpm run lint
```
Expected: both commands exit successfully.
- [ ] **Step 3: Check formatting and working-tree scope**
```bash
git diff --check
git status --short
git log -4 --oneline
```
Expected: no whitespace errors; the working tree is clean; the three feature commits are visible after the design and implementation-plan commits.
- [ ] **Step 4: Manually verify the feature when a local panel is available**
1. Open the Rules page as an administrator and reset a rule with non-zero upload/download traffic.
2. Confirm the modal states that user totals, tunnel quota, and history are unaffected.
3. Confirm the rule immediately shows zero after success.
4. Confirm the user page's total traffic and user-tunnel traffic values did not change.
5. Generate new traffic and confirm the rule starts accumulating from zero.
6. Log in as a normal user and confirm the user can reset an owned rule but cannot access another user's rule through a direct API request.
Expected: all six checks match the design specification.
@@ -0,0 +1,197 @@
# 规则流量清零设计
## 背景
Issue #523 希望“规则”页面中每条隧道规则显示的流量使用量支持手动清零。
当前规则流量保存在 `forward.in_flow` 和 `forward.out_flow`。流量上报时,同一份增量还会累计到用户总流量、用户隧道流量和相关配额统计中。因此,本功能必须将“规则展示计数器清零”与“用户或隧道配额重置”严格区分。
## 目标
为单条规则提供手动流量清零能力:
- 将所选规则的上传流量和下载流量清零。
- 管理员可以清零任意规则。
- 普通用户只能清零自己的规则。
- 清零后,新产生的流量继续从零正常累计。
## 非目标
本功能不会:
- 修改用户总流量 `user.in_flow` 或 `user.out_flow`。
- 修改用户隧道流量 `user_tunnel.in_flow` 或 `user_tunnel.out_flow`。
- 修改每日或每月配额用量。
- 修改历史流量统计。
- 重置 nftables 节点计数器或其增量计算基线。
- 重启、暂停、恢复或重新部署规则服务。
- 增加批量流量清零功能。
## 后端设计
### API
新增接口:
```text
POST /api/v1/forward/reset-flow
```
请求体:
```json
{
"id": 123
}
```
成功响应沿用统一 envelope:
```json
{
"code": 0,
"msg": "success",
"data": null,
"ts": 0
}
```
具体 `msg`、`data` 和 `ts` 值继续由现有 response helper 生成。
### 参数与权限校验
Handler 执行以下步骤:
1. 只接受 `POST` 请求。
2. 从 JSON 请求体读取正整数规则 ID。
3. 调用现有 `resolveForwardAccess`:
- 管理员角色可以访问任意存在的规则。
- 普通用户仅能访问 `forward.user_id` 等于当前用户 ID 的规则。
- 对普通用户访问他人规则的情况,沿用现有逻辑返回“转发不存在”,避免暴露规则存在性。
4. 调用 Repository 完成清零。
5. 返回统一成功响应。
### Repository
新增方法:
```go
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error
```
该方法只更新指定 `forward` 记录:
```text
in_flow = 0
out_flow = 0
updated_time = now
```
Repository 不直接操作 Handler 的身份信息,也不更新任何其他表。
### 并发与后续流量
清零使用单条 SQL `UPDATE`。agent 流量上报和 nftables 流量采集仍使用原有增量累加逻辑。清零不会重置采集基线,因此下一次采集只会把清零之后新计算出的增量加回规则计数,不会把清零前的累计值整体恢复。
若清零 SQL 与流量增量 SQL 同时执行,数据库按实际语句执行顺序决定最终值;每条更新本身保持原子性。本功能不引入暂停采集或跨节点同步流程。
## 前端设计
### API 封装
在 `vite-frontend/src/api/index.ts` 新增:
```ts
export const resetForwardFlow = (id: number) =>
Network.post("/forward/reset-flow", { id });
```
### 入口
在规则页面所有单条规则操作入口中增加“流量清零”操作:
- 分组表格视图。
- 精简表格视图。
- 卡片视图。
按钮使用独立的清零/刷新语义图标和提示文本,不复用删除按钮样式。
当规则的 `inFlow + outFlow` 等于零时,按钮禁用,避免重复请求。
### 确认交互
点击按钮后打开确认弹窗,显示规则名称,并明确说明:
- 仅清零当前规则显示的上传和下载流量。
- 不影响用户总流量、用户隧道配额和历史统计。
- 操作不可撤销。
确认期间显示 loading 状态并阻止重复提交。
### 成功与失败
- 成功:关闭弹窗,显示成功 toast,并刷新规则列表。
- 失败:保留弹窗,显示后端错误信息或通用失败 toast。
- 刷新后,该规则上传和下载均显示为零;后续流量继续正常累计。
## 错误处理
- 非 POST 请求:返回现有通用请求失败响应。
- 请求体无法解析、ID 缺失或 ID 非正数:返回“请求参数错误”。
- 规则不存在或普通用户访问他人规则:返回“转发不存在”。
- Repository 更新失败:返回包含 Repository 错误信息的统一错误响应。
- 前端网络错误:显示“流量清零失败”。
## 测试策略
### Repository 测试
验证:
- 指定规则的 `in_flow`、`out_flow` 被清零。
- 指定规则的 `updated_time` 被更新。
- 其他规则的流量不变。
- 用户总流量不变。
- 用户隧道流量不变。
- Repository 未初始化时返回错误。
### Handler 测试
验证:
- 管理员能够清零任意存在的规则。
- 普通用户能够清零自己的规则。
- 普通用户不能清零他人的规则。
- 不存在的规则返回错误。
- 无效 ID 返回参数错误。
- 非 POST 请求返回请求失败。
- 成功请求不修改用户和用户隧道流量。
### 前端验证
项目没有配置前端测试框架,因此不新增前端单元测试。使用以下命令验证:
```bash
(cd vite-frontend && pnpm run build)
(cd vite-frontend && pnpm run lint)
```
后端使用:
```bash
(cd go-backend && go test ./...)
```
## 文件范围
预计修改:
- `go-backend/internal/http/handler/handler.go`
- `go-backend/internal/http/handler/mutations.go`
- `go-backend/internal/http/handler/*_test.go`
- `go-backend/internal/store/repo/repository_mutations.go`
- `go-backend/internal/store/repo/*_test.go`
- `vite-frontend/src/api/index.ts`
- `vite-frontend/src/pages/forward.tsx`
不需要数据库迁移或新增依赖。
@@ -0,0 +1,129 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/store/repo"
)
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
tests := []struct {
name string
actorID int64
actorRole int
forwardID int64
wantCode int
wantInFlow int64
wantOutFlow int64
}{
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, r := setupForwardResetFlowHandler(t)
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
}
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
})
}
}
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
h, _ := setupForwardResetFlowHandler(t)
t.Run("non post", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
t.Run("invalid id", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
}
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
statements := []string{
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
}
for _, statement := range statements {
if err := r.DB().Exec(statement).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
}
return New(r, "test-secret"), r
}
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]int64{"id": forwardID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
}
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
t.Helper()
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
}
return payload.Code
}
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
@@ -221,6 +221,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
@@ -2502,6 +2502,26 @@ func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) {
id := idFromBody(r, w)
if id <= 0 {
return
}
if _, _, _, err := h.resolveForwardAccess(r, id); err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {
id := idFromBody(r, w)
if id <= 0 {
@@ -0,0 +1,75 @@
package repo
import (
"path/filepath"
"testing"
)
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
const originalUpdated int64 = 1000
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
`).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
`).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)
`).Error; err != nil {
t.Fatalf("insert user tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES
(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
`, originalUpdated, originalUpdated).Error; err != nil {
t.Fatalf("insert forwards: %v", err)
}
const resetAt int64 = 2000
if err := r.ResetForwardFlow(20, resetAt); err != nil {
t.Fatalf("ResetForwardFlow: %v", err)
}
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
}
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
var r *Repository
if err := r.ResetForwardFlow(20, 2000); err == nil {
t.Fatal("expected uninitialized repository error")
}
}
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
@@ -197,6 +197,19 @@ func (r *Repository) ResetUserFlowByUserTunnel(userTunnelID int64) {
Updates(map[string]interface{}{"in_flow": 0, "out_flow": 0}).Error
}
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Forward{}).
Where("id = ?", forwardID).
Updates(map[string]interface{}{
"in_flow": 0,
"out_flow": 0,
"updated_time": now,
}).Error
}
func (r *Repository) GetUsernameByID(userID int64) string {
if r == nil || r.db == nil {
return ""
+2
View File
@@ -217,6 +217,8 @@ export const pauseForwardService = (forwardId: number) =>
Network.post("/forward/pause", { id: forwardId });
export const resumeForwardService = (forwardId: number) =>
Network.post("/forward/resume", { id: forwardId });
export const resetForwardFlow = (forwardId: number) =>
Network.post("/forward/reset-flow", { id: forwardId });
// 转发诊断操作
export const diagnoseForward = (forwardId: number) =>
+180 -4
View File
@@ -69,6 +69,7 @@ import {
getNodeList,
pauseForwardService,
resumeForwardService,
resetForwardFlow,
diagnoseForward,
updateForwardOrder,
getConfigByName,
@@ -230,7 +231,7 @@ const FORWARD_GROUPED_TABLE_COLUMN_CLASS = {
strategy: "w-[100px]",
totalFlow: "w-[120px]",
status: "w-[100px]",
actions: "w-[144px] text-right",
actions: "w-[176px] text-right",
} as const;
const normalizeForwardUserName = (userName?: string): string => {
@@ -764,6 +765,7 @@ const SortableTableRow = ({
handleEdit,
handleDelete,
handleDiagnose,
handleResetFlow,
showAddressModal,
formatFlow,
}: any) => {
@@ -919,6 +921,29 @@ const SortableTableRow = ({
/>
</svg>
</Button>
<Button
isIconOnly
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
title="流量清零"
onPress={() => handleResetFlow(forward)}
>
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
<Button
isIconOnly
className="bg-danger/10 text-danger hover:bg-danger/20"
@@ -958,6 +983,7 @@ const SortableCompactTableRow = ({
handleEdit,
handleDelete,
handleDiagnose,
handleResetFlow,
showAddressModal,
hasMultipleAddresses,
formatFlow,
@@ -1144,6 +1170,29 @@ const SortableCompactTableRow = ({
/>
</svg>
</Button>
<Button
isIconOnly
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
title="流量清零"
onPress={() => handleResetFlow(forward)}
>
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
<Button
isIconOnly
className="bg-danger/10 text-danger hover:bg-danger/20"
@@ -1289,13 +1338,18 @@ export default function ForwardPage() {
const [modalOpen, setModalOpen] = useState(false);
// isFilterModalOpen removed
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [resetFlowLoading, setResetFlowLoading] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
const [forwardToResetFlow, setForwardToResetFlow] = useState<Forward | null>(
null,
);
const [currentDiagnosisForward, setCurrentDiagnosisForward] =
useState<Forward | null>(null);
const [diagnosisResult, setDiagnosisResult] =
@@ -2171,7 +2225,8 @@ export default function ForwardPage() {
maxConn: forward.maxConn ?? 0,
proxyProtocol: forward.proxyProtocol ?? 0,
proxyProtocolReceive: forward.proxyProtocolReceive ?? 0,
proxyProtocolSend: forward.proxyProtocolSend ?? forward.proxyProtocol ?? 0,
proxyProtocolSend:
forward.proxyProtocolSend ?? forward.proxyProtocol ?? 0,
});
setErrors({});
setModalOpen(true);
@@ -2183,6 +2238,46 @@ export default function ForwardPage() {
setDeleteModalOpen(true);
};
const handleResetFlow = (forward: Forward) => {
if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return;
setForwardToResetFlow(forward);
setResetFlowModalOpen(true);
};
const handleResetFlowModalOpenChange = (isOpen: boolean) => {
if (resetFlowLoading) return;
setResetFlowModalOpen(isOpen);
if (!isOpen) {
setForwardToResetFlow(null);
}
};
const confirmResetFlow = async () => {
if (!forwardToResetFlow) return;
setResetFlowLoading(true);
try {
const res = await resetForwardFlow(forwardToResetFlow.id);
if (res.code !== 0) {
toast.error(res.msg || "流量清零失败");
return;
}
toast.success("规则流量已清零");
setResetFlowModalOpen(false);
setForwardToResetFlow(null);
await refreshForwardList(false);
} catch {
toast.error("流量清零失败");
} finally {
setResetFlowLoading(false);
}
};
// 确认删除规则
const confirmDelete = async () => {
if (!forwardToDelete) return;
@@ -3957,7 +4052,7 @@ export default function ForwardPage() {
</div>
</div>
<div className="flex gap-1.5 mt-3">
<div className="grid grid-cols-2 gap-1.5 mt-3">
<Button
className="flex-1 min-h-8"
color="primary"
@@ -4000,6 +4095,32 @@ export default function ForwardPage() {
>
诊断
</Button>
<Button
className="flex-1 min-h-8"
color="secondary"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
}
variant="flat"
onPress={() => handleResetFlow(forward)}
>
清零
</Button>
<Button
className="flex-1 min-h-8"
color="danger"
@@ -4302,7 +4423,7 @@ export default function ForwardPage() {
<TableColumn className="w-[80px]">策略</TableColumn>
<TableColumn className="w-[100px]">用量</TableColumn>
<TableColumn className="w-[80px]">状态</TableColumn>
<TableColumn align="left" className="w-[120px] pl-4">
<TableColumn align="left" className="w-[160px] pl-4">
操作
</TableColumn>
</TableHeader>
@@ -4321,6 +4442,7 @@ export default function ForwardPage() {
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleResetFlow={handleResetFlow}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
@@ -4616,6 +4738,7 @@ export default function ForwardPage() {
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleResetFlow={handleResetFlow}
handleServiceToggle={
handleServiceToggle
}
@@ -5172,6 +5295,59 @@ export default function ForwardPage() {
</ModalContent>
</Modal>
{/* 规则流量清零确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={resetFlowModalOpen}
placement="center"
scrollBehavior="inside"
size="lg"
onOpenChange={handleResetFlowModalOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-secondary">
确认流量清零
</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要清零规则{" "}
<span className="font-semibold text-foreground">
&quot;{forwardToResetFlow?.name}&quot;
</span>{" "}
当前显示的上传和下载流量吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。
</p>
</ModalBody>
<ModalFooter>
<Button
isDisabled={resetFlowLoading}
variant="light"
onPress={onClose}
>
取消
</Button>
<Button
color="secondary"
isLoading={resetFlowLoading}
onPress={confirmResetFlow}
>
确认清零
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 地址列表弹窗 */}
<Modal
classNames={{