mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 15:46:38 +08:00
Compare commits
35 Commits
2.1.4-alpha7
...
2.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| b93255df3d | |||
| e1aef8700e | |||
| d5b3a39774 | |||
| 022e9e3807 | |||
| d333d463f6 | |||
| abc9f21ab9 | |||
| d216567c02 | |||
| 45bfd35a20 | |||
| 5a1b72387d | |||
| 66de566a00 | |||
| 18c2da7c7e | |||
| c4d807f1c4 | |||
| d1460ab9c7 | |||
| 9189c68800 | |||
| efbdabceca | |||
| 6e5a71f489 | |||
| e5c57f81ad | |||
| 0b1609c6cb | |||
| a10c68ef20 | |||
| 9aedeab406 | |||
| 137c34e3f5 | |||
| 25d29c305f | |||
| 9dcf9a1a43 | |||
| 2308b25bcf | |||
| b6c2159614 | |||
| 30c96a280d | |||
| 12c50df6a7 | |||
| c5124a01e6 | |||
| 6d57b49595 | |||
| d12c5bf2e1 | |||
| 42701e6c01 | |||
| d6c17aee79 | |||
| 17f8a06704 | |||
| e7b777890e | |||
| 5b03ce87ff |
@@ -259,6 +259,8 @@ gitee/
|
||||
doraemon.jks
|
||||
device.id
|
||||
commit.sh
|
||||
.opencode/
|
||||
analysis/
|
||||
sql/
|
||||
!go-backend/internal/store/sqlite/sql/
|
||||
!go-backend/internal/store/sqlite/sql/schema.sql
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Commit:** e5e22ba
|
||||
**Generated:** Thu Feb 19 2026
|
||||
**Commit:** 137c34e
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.4-rc2
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
@@ -13,7 +14,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
|
||||
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
|
||||
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
|
||||
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
|
||||
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
|
||||
├── panel_install.sh # Panel installer/upgrader (downloads compose)
|
||||
@@ -29,7 +30,9 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
|
||||
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
|
||||
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
|
||||
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
|
||||
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
|
||||
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||
|
||||
@@ -47,6 +50,8 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
|
||||
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
|
||||
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
@@ -54,6 +59,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
|
||||
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
@@ -87,3 +93,5 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure (Vitest/Jest not configured).
|
||||
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
|
||||
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged into `main`; release tag `2.1.4-rc2` points to commit `137c34e`.
|
||||
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
|
||||
|
||||
@@ -129,27 +129,28 @@ docker compose up -d
|
||||
- **License**: Apache License 2.0
|
||||
|
||||
## Modifications
|
||||
The following major changes and additions have been made in this fork (FLVX):
|
||||
This fork (FLVX) is no longer a light patch on top of the upstream project. It has been deeply reworked, with both backend and frontend rebuilt around a Go-based architecture.
|
||||
|
||||
### 1. Backend Architecture (Replaced)
|
||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
||||
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
||||
### 1. Backend (Rewritten)
|
||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) implementation.
|
||||
- **Added**: A fully rewritten `go-backend/` service (Go), including updated data and API handling for panel management.
|
||||
|
||||
### 2. Forwarding Agent (Modified)
|
||||
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
||||
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
||||
### 2. Frontend (Reworked)
|
||||
- **Reworked**: `vite-frontend/` has been substantially rebuilt to match the new backend contract and current UI layer architecture.
|
||||
- **Updated**: Dashboard pages/components and interaction flows for the current React/Vite stack.
|
||||
|
||||
### 3. Frontend (Modified)
|
||||
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
||||
### 3. Forwarding Stack (Modified)
|
||||
- **Modified**: `go-gost/` forwarding agent wrapper.
|
||||
- **Modified**: `go-gost/x/` local fork of `github.com/go-gost/x`.
|
||||
|
||||
### 4. Mobile Applications (Removed)
|
||||
- **Removed**: `android-app/` - Source code for the Android client.
|
||||
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||
### 4. Mobile Clients (Removed)
|
||||
- **Removed**: `android-app/` source code.
|
||||
- **Removed**: `ios-app/` source code.
|
||||
|
||||
### 5. Infrastructure & Scripts
|
||||
- **Modified**: `docker-compose.yml` (installer output name, auto-selects IPv4/IPv6 template, updated for Go backend).
|
||||
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||
- **Added**: `AGENTS.md` (Project documentation).
|
||||
### 5. Deployment & Project Infrastructure
|
||||
- **Updated**: Docker deployment templates and installer output flow (IPv4/IPv6 compose variants).
|
||||
- **Updated**: Release installation scripts (`install.sh`, `panel_install.sh`) and supporting automation.
|
||||
- **Added/Updated**: Project-level engineering documentation (for example `AGENTS.md`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -360,6 +360,8 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
return nil, errors.New("隧道配置不完整")
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
@@ -383,7 +385,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -391,7 +393,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,7 +408,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -415,7 +417,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,7 +481,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
case 1:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
})
|
||||
}
|
||||
@@ -532,14 +534,14 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
})
|
||||
}
|
||||
default:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
|
||||
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
|
||||
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
|
||||
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
|
||||
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
|
||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
|
||||
@@ -60,10 +60,21 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
roleID := 1
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now); err != nil {
|
||||
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
groupIDs := asInt64Slice(req["groupIds"])
|
||||
if len(groupIDs) > 0 {
|
||||
if addErr := h.repo.AddUserToGroups(userID, groupIDs, now); addErr == nil {
|
||||
for _, gid := range groupIDs {
|
||||
_ = h.syncPermissionsByUserGroup(gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -133,9 +144,36 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)
|
||||
|
||||
if groupIDsRaw, ok := req["groupIds"]; ok {
|
||||
newGroupIDs := asInt64Slice(groupIDsRaw)
|
||||
if affected, replaceErr := h.repo.ReplaceUserGroupsByUserID(id, newGroupIDs, now); replaceErr == nil {
|
||||
for _, gid := range affected {
|
||||
_ = h.syncPermissionsByUserGroup(gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) userGroups(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
|
||||
}
|
||||
ids, err := h.repo.GetUserGroupIDsByUserID(id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(ids))
|
||||
}
|
||||
|
||||
func (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -696,6 +734,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if forwards, fwdErr := h.listForwardsByTunnel(id); fwdErr == nil {
|
||||
for i := range forwards {
|
||||
_ = h.syncForwardServices(&forwards[i], "UpdateService", true)
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -3198,3 +3242,17 @@ func randomToken(n int) string {
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func asInt64Slice(v interface{}) []int64 {
|
||||
arr := asAnySlice(v)
|
||||
if len(arr) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(arr))
|
||||
for _, x := range arr {
|
||||
if id := asInt64(x, 0); id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -127,6 +127,11 @@ func OpenPostgres(dsn string) (*Repository, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := preparePostgresLegacySchema(db); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("prepare postgres legacy schema: %w", err)
|
||||
}
|
||||
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("auto migrate: %w", err)
|
||||
@@ -205,6 +210,49 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// preparePostgresLegacySchema renames unique constraints that were created by
|
||||
// the old schema.sql (which used inline UNIQUE column syntax) to the names
|
||||
// expected by GORM's NamingStrategy ("uni_<table>_<column>"). Without this,
|
||||
// GORM's AutoMigrate emits "DROP CONSTRAINT uni_..." against constraints that
|
||||
// don't exist under that name, crashing startup on upgraded PostgreSQL installs.
|
||||
func preparePostgresLegacySchema(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type rename struct{ table, oldName, newName string }
|
||||
renames := []rename{
|
||||
{"vite_config", "vite_config_name_key", "uni_vite_config_name"},
|
||||
{"peer_share", "peer_share_token_key", "uni_peer_share_token"},
|
||||
{"peer_share_runtime", "peer_share_runtime_reservation_id_key", "uni_peer_share_runtime_reservation_id"},
|
||||
{"peer_share_runtime", "peer_share_runtime_resource_key_key", "uni_peer_share_runtime_resource_key"},
|
||||
{"federation_tunnel_binding", "federation_tunnel_binding_resource_key_key", "uni_federation_tunnel_binding_resource_key"},
|
||||
}
|
||||
|
||||
for _, r := range renames {
|
||||
var count int64
|
||||
if err := db.Raw(
|
||||
`SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND constraint_name = ?
|
||||
AND constraint_type = 'UNIQUE'`,
|
||||
r.table, r.oldName,
|
||||
).Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("check constraint %s.%s: %w", r.table, r.oldName, err)
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(
|
||||
fmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("rename constraint %s.%s→%s: %w", r.table, r.oldName, r.newName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector.Name() != "sqlite" {
|
||||
return nil
|
||||
|
||||
@@ -50,8 +50,17 @@ func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ListGroupPermissionPairsByTunnelGroup returns [userGroupID, tunnelGroupID] pairs
|
||||
// for all group permissions associated with a tunnel group.
|
||||
func (r *Repository) GetUserGroupIDsByUserID(userID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserGroupUser{}).
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("user_group_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
|
||||
@@ -34,9 +34,9 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) error {
|
||||
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
user := model.User{
|
||||
User: username,
|
||||
@@ -52,7 +52,10 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
}
|
||||
return r.db.Create(&user).Error
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserRoleID(userID int64) (int, error) {
|
||||
@@ -1406,3 +1409,70 @@ func parsePortRangeSpec(input string) []int {
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Repository) AddUserToGroups(userID int64, groupIDs []int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if len(groupIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]model.UserGroupUser, 0, len(groupIDs))
|
||||
for _, gid := range groupIDs {
|
||||
if gid <= 0 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, model.UserGroupUser{UserGroupID: gid, UserID: userID, CreatedTime: now})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64, now int64) (affectedGroupIDs []int64, err error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
var oldGroupIDs []int64
|
||||
if err = r.db.Model(&model.UserGroupUser{}).
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("user_group_id", &oldGroupIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range oldGroupIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range newGroupIDs {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for id := range seen {
|
||||
affectedGroupIDs = append(affectedGroupIDs, id)
|
||||
}
|
||||
|
||||
if err = r.db.Where("user_id = ?", userID).Delete(&model.UserGroupUser{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(newGroupIDs) == 0 {
|
||||
return affectedGroupIDs, nil
|
||||
}
|
||||
rows := make([]model.UserGroupUser, 0, len(newGroupIDs))
|
||||
for _, gid := range newGroupIDs {
|
||||
if gid <= 0 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, model.UserGroupUser{UserGroupID: gid, UserID: userID, CreatedTime: now})
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err = r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return affectedGroupIDs, nil
|
||||
}
|
||||
|
||||
@@ -193,6 +193,126 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
|
||||
|
||||
insertNode := func(name, v4, v6 string) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
|
||||
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
|
||||
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, chainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
hasEntryToChain := false
|
||||
hasChainToExit := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
|
||||
|
||||
if from == 1 && to == 2 {
|
||||
hasEntryToChain = true
|
||||
if targetIP != "2001:db8:10::20" {
|
||||
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
|
||||
if from == 2 && to == 3 {
|
||||
hasChainToExit = true
|
||||
if targetIP != "2001:db8:10::30" {
|
||||
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEntryToChain || !hasChainToExit {
|
||||
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
+39
-28
@@ -1,55 +1,66 @@
|
||||
# VITE FRONTEND KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
**Generated:** Thu Feb 19 2026
|
||||
**Commit:** 137c34e
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.4-rc2
|
||||
|
||||
## OVERVIEW
|
||||
Web management console for FLVX.
|
||||
**Stack:** React 18, Vite 5 (rolldown-vite), TypeScript, TailwindCSS 4, HeroUI.
|
||||
**Stack:** React 18, rolldown-vite, TypeScript, Tailwind CSS v4, shadcn/radix primitives with HeroUI-compatible bridge.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
vite-frontend/
|
||||
├── src/
|
||||
│ ├── api/ # Axios wrapper + typed endpoint helpers
|
||||
│ ├── components/ # Shared UI components (HeroUI based)
|
||||
│ ├── config/ # Site config (title, repo, version)
|
||||
│ ├── layouts/ # Admin vs H5 page chrome
|
||||
│ ├── pages/ # Route views (many large single-file pages)
|
||||
│ ├── utils/ # Auth/JWT + WebView helpers
|
||||
│ ├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||
│ ├── main.tsx # ReactDOM + Providers (HeroUI, Theme, Toast)
|
||||
│ └── provider.tsx # Context provider wrapper
|
||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||
├── eslint.config.mjs # ESLint 9 flat config
|
||||
│ ├── api/ # Axios wrapper + typed endpoint helpers
|
||||
│ ├── components/ui/ # shadcn/radix primitive components
|
||||
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade used by pages/layouts
|
||||
│ ├── pages/ # Route views + page modules (forward/node/tunnel split helpers)
|
||||
│ ├── hooks/ # H5/WebView/mobile hooks
|
||||
│ ├── styles/
|
||||
│ │ ├── globals.css # Base styles + imports tailwind-theme.pcss
|
||||
│ │ └── tailwind-theme.pcss # Tailwind v4 @theme inline semantic token mapping
|
||||
│ ├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||
│ ├── main.tsx # ReactDOM + BrowserRouter + Provider
|
||||
│ └── provider.tsx # Toast/theme/provider composition
|
||||
├── components.json # shadcn/ui config
|
||||
├── tailwind.config.js # Compatibility config still used by migration scaffolding
|
||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Route definitions** | `src/App.tsx` | React Router v6; H5 detection logic |
|
||||
| **API Client** | `src/api/network.ts` | Sets `Authorization` header (raw token) |
|
||||
| **Endpoint Calls** | `src/api/index.ts` | Thin `Network.post` wrappers |
|
||||
| **Route definitions** | `src/App.tsx` | React Router v6 + ProtectedRoute |
|
||||
| **API Client/Auth header** | `src/api/network.ts` | Sends raw JWT in `Authorization` header |
|
||||
| **Login Flow** | `src/pages/index.tsx` | Calls `login()`, stores `localStorage.token` |
|
||||
| **Auth Logic** | `src/utils/auth.ts` | `isAdmin()` checks `role_id == 0` |
|
||||
| **Token Decoding** | `src/utils/jwt.ts` | Checks `exp` vs current time |
|
||||
| **WebView Logic** | `src/utils/panel.ts` | Handles panel address selection in app mode |
|
||||
| **Auth helpers** | `src/utils/auth.ts`, `src/utils/jwt.ts` | Role checks + token expiration parsing |
|
||||
| **UI bridge usage** | `src/shadcn-bridge/heroui/` | Import from bridge, not `@heroui/*` |
|
||||
| **Button parity mapping** | `src/shadcn-bridge/heroui/button.tsx` | Legacy `color`/`variant` mapped to shadcn classes |
|
||||
| **Semantic theme tokens** | `src/styles/tailwind-theme.pcss` | Restores classes like `bg-primary`, `border-input` |
|
||||
| **Theme wiring** | `src/styles/globals.css` | Must import `./tailwind-theme.pcss` |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Auth**: JWT stored as `localStorage.token`. Sent in `Authorization` header (no "Bearer" prefix).
|
||||
- **API**: Default base URL is `/api/v1/`. Responses follow `{code, msg, data, ts}` structure.
|
||||
- **WebView**: In WebView mode, base URL is derived from selected panel address. If unset, API returns `code: -1`.
|
||||
- **Routing**: URL query param `h5=true` forces mobile layout.
|
||||
- **Build**: `minify: false`, `treeshake: false` - unoptimized production bundles for debugging.
|
||||
- **ESLint**: `react-hooks/exhaustive-deps` disabled, unused vars starting with `_` ignored.
|
||||
- **Large Pages**: `forward.tsx` (3263 LOC), `tunnel.tsx` (2552 LOC), `node.tsx` (2194 LOC).
|
||||
- **Auth Header**: Use raw JWT token (no `Bearer` prefix).
|
||||
- **API Envelope**: Responses follow `{code, msg, data, ts}`.
|
||||
- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.
|
||||
- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.
|
||||
- **Build profile**: `minify: false`, `treeshake: false` for easier debugging.
|
||||
- **Layout mode**: H5/mobile mode still controlled by existing route/query and hook logic.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT ADD** tests - no test infrastructure (Vitest/Jest not configured).
|
||||
- **DO NOT ADD** `Bearer` to auth header in frontend requests.
|
||||
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies.
|
||||
- **DO NOT REMOVE** `src/styles/tailwind-theme.pcss` import from `src/styles/globals.css`.
|
||||
- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.
|
||||
|
||||
## NOTES
|
||||
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged in `main`.
|
||||
- Release tag `2.1.4-rc2` points to commit `137c34e`.
|
||||
- Button border/color parity depends on both bridge mapping and semantic Tailwind token export.
|
||||
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- ESLint Flat Config format with custom import ordering rules.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
# 前端彻底重构计划(进行中)
|
||||
|
||||
- 分支:`frontend/refactor-audit-20260218`
|
||||
- 范围:`vite-frontend/src`
|
||||
- 更新时间:2026-02-19
|
||||
- 当前总体进度:`99.5%`
|
||||
|
||||
## 1) 问题审计(已完成)
|
||||
|
||||
### P0(高风险,优先修复)
|
||||
|
||||
- [x] `src/api/network.ts` 存在 token 过期后 Promise 不 resolve 的路径(`then/catch` 里仅 `return`,调用方可能挂起)
|
||||
- [x] 登录态读写逻辑分散在多个页面/布局,存在重复和不一致风险(`src/pages/index.tsx`、`src/layouts/admin.tsx`、`src/layouts/h5.tsx`、`src/pages/profile.tsx`、`src/pages/group.tsx`)
|
||||
- [x] 核心页面超大文件导致可维护性差:`forward.tsx`(3263 行)、`tunnel.tsx`(2595 行)、`node.tsx`(2194 行)、`user.tsx`(1761 行)、`dashboard.tsx`(1363 行)
|
||||
- [x] `any` 使用过多,类型边界不清(`src/api/index.ts`、`src/api/network.ts`、`src/pages/*`)
|
||||
|
||||
### P1(中风险,本次并行推进)
|
||||
|
||||
- [x] 多处空 `catch {}` 吞错,定位问题困难(如 `src/App.tsx`、`src/components/navbar.tsx`、`src/pages/config.tsx` 等)
|
||||
- [x] 移动端/H5/WebView 判定逻辑重复(`src/App.tsx`、`src/pages/index.tsx`、`src/components/navbar.tsx`)
|
||||
- [x] 菜单与权限控制在多处重复定义,布局和页面耦合偏高(`src/layouts/admin.tsx`、`src/layouts/h5.tsx`、`src/pages/profile.tsx`)
|
||||
- [x] 路由保护与布局选择逻辑集中在 `src/App.tsx`,可测试性和扩展性偏弱
|
||||
|
||||
### P2(优化项,后续阶段)
|
||||
|
||||
- [x] `vite.config.ts` 生产构建配置 `minify: false`、`treeshake: false`,性能优化策略待梳理
|
||||
- [x] ESLint 中 `react-hooks/exhaustive-deps` 关闭,副作用依赖约束较弱(`eslint.config.mjs`)
|
||||
|
||||
## 2) 彻底重构任务清单(边做边更新)
|
||||
|
||||
> 状态说明:`[x] 完成` / `[ ] 未开始` / `[-] 进行中`
|
||||
|
||||
### Phase A - 会话与权限基建
|
||||
|
||||
- [x] A1. 新增统一会话工具模块(token/role/name/admin 的读写与兼容逻辑)
|
||||
- [x] A2. 登录页改为调用会话工具写入登录态,去重重复代码
|
||||
- [x] A3. 布局与页面的管理员判断改为统一工具,移除重复逻辑
|
||||
- [x] A4. `Network` 层统一 token 获取与 token 过期处理,避免悬挂 Promise
|
||||
|
||||
### Phase B - Hook 与状态复用
|
||||
|
||||
- [x] B1. 提取 `useWebViewMode`,替换 `index/navbar` 重复检测
|
||||
- [x] B2. 提取 `useH5Mode`,收敛 `App` 内设备与参数判定逻辑
|
||||
- [x] B3. 提取滚动复位逻辑(`H5/H5-simple`)到通用 hook
|
||||
|
||||
### Phase C - API 与类型边界
|
||||
|
||||
- [x] C1. 收紧 `src/api/network.ts` 的 `any` 边界(优先 `unknown` + 受控转换)
|
||||
- [-] C2. 给高频 API 接口补全请求/响应类型(先用户、节点、隧道、转发)
|
||||
- [-] C3. 统一错误消息提取策略,避免散落式字符串拼接
|
||||
|
||||
### Phase D - 大页面拆分(增量,不大爆炸重写)
|
||||
|
||||
- [-] D1. `forward.tsx` 抽离:筛选条、列表视图、批量操作、详情弹窗
|
||||
- [-] D2. `tunnel.tsx` 抽离:表单区、列表区、排序区、诊断区
|
||||
- [-] D3. `node.tsx` 抽离:状态区、安装命令区、排序区、WebSocket 区
|
||||
- [ ] D4. `dashboard.tsx` 抽离:统计卡片、图表区、公告区、刷新逻辑
|
||||
|
||||
### Phase E - 体验与可访问性
|
||||
|
||||
- [-] E1. 统一关键交互控件的 `aria-label` / 键盘可达性
|
||||
- [-] E2. 统一 loading/empty/error 三态展示组件
|
||||
- [-] E3. 统一移动端断点与布局响应策略
|
||||
|
||||
## 15) 当前实施批次(Batch-12)
|
||||
|
||||
- 目标:推进 **D2/D4/E1/E2/E3** 收尾,完成表单模块接入、仪表盘增量拆分与通用能力落地
|
||||
- 本批次进度:`6 / 6`
|
||||
|
||||
### Batch-12 明细进度
|
||||
|
||||
- [x] T51. `tunnel.tsx` 接入 `tunnel/form.ts`(默认值、表单校验、类型/流量展示)
|
||||
- [x] T52. 修复 `limit.tsx` 构建阻断问题(移除未使用 `Spinner` 导入)
|
||||
- [x] T53. `dashboard.tsx` 抽离公告区与指标卡公共组件(`dashboard/components/*`)
|
||||
- [x] T54. `dashboard.tsx` 管理员判定切换为 `session` 统一工具(`getAdminFlag`)
|
||||
- [x] T55. `admin.tsx` 接入 `useMobileBreakpoint` 收敛断点监听;`settings.tsx` 回填返回按钮 `aria-label`
|
||||
- [x] T56. 执行 `npm run build` + `npm run lint` 校验(lint 仅剩既有 `no-console` warning)
|
||||
|
||||
## 3) 当前实施批次(Batch-1)
|
||||
|
||||
- 目标:先完成 **A1/A2/A3/A4 + B1/B2 + C1**,优先解决架构一致性和稳定性风险
|
||||
- 本批次进度:`7 / 7`
|
||||
|
||||
### Batch-1 明细进度
|
||||
|
||||
- [x] T1. 新增 `session` 统一会话工具(A1)
|
||||
- [x] T2. 登录页切换到会话工具(A2)
|
||||
- [x] T3. `admin/h5/profile/group` 切换管理员判定工具(A3)
|
||||
- [x] T4. `network` 统一 token 与过期返回(A4 + C1)
|
||||
- [x] T5. 新增 `useWebViewMode` 并接入 `index/navbar`(B1)
|
||||
- [x] T6. 新增 `useH5Mode` 并接入 `App`(B2)
|
||||
- [x] T7. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 4) 当前实施批次(Batch-2)
|
||||
|
||||
- 目标:推进 **D2/D3 的排序逻辑抽离** 与 **会话 token 复用**,降低大型页面重复代码
|
||||
- 本批次进度:`7 / 7`
|
||||
|
||||
### Batch-2 明细进度
|
||||
|
||||
- [x] T8. 新增通用排序存储工具(node/tunnel 共用)
|
||||
- [x] T9. `tunnel.tsx` 接入排序存储工具
|
||||
- [x] T10. `node.tsx` 接入排序存储工具
|
||||
- [x] T11. `node.tsx` WebSocket token 改用统一会话工具
|
||||
- [x] T12. 执行 `npm run build` 与必要诊断校验
|
||||
- [x] T13. 新增 `useScrollTopOnPathChange` 通用 hook
|
||||
- [x] T14. `h5/h5-simple` 接入滚动复位 hook 并完成构建校验
|
||||
|
||||
## 5) 当前实施批次(Batch-3)
|
||||
|
||||
- 目标:推进 **D3(node WebSocket 区域)结构化抽离**,先拆出系统信息解析逻辑
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-3 明细进度
|
||||
|
||||
- [x] T15. 新增 `node` 系统信息解析工具模块
|
||||
- [x] T16. `node.tsx` WebSocket `info` 消息处理接入解析工具
|
||||
- [x] T17. 保持在线/离线状态切换与速度计算逻辑一致
|
||||
- [x] T18. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 6) 外部最佳实践参考(已纳入本计划)
|
||||
|
||||
- React 官方:重复逻辑应抽为自定义 Hook(`reusing-logic-with-custom-hooks`)
|
||||
- React 官方:共享逻辑不共享状态,跨组件共享状态应提升或集中管理
|
||||
- Vite 官方:大型项目优先审计插件成本、动态导入与分块策略
|
||||
- WAI-ARIA APG:导航与交互组件优先语义化与键盘可达性
|
||||
|
||||
## 7) 当前实施批次(Batch-4)
|
||||
|
||||
- 目标:继续推进 **D3(node WebSocket 区域)**,抽离连接生命周期与重连策略
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-4 明细进度
|
||||
|
||||
- [x] T19. 新增 `useNodeRealtime` Hook(连接/重连/断开)
|
||||
- [x] T20. `node.tsx` 接入 `useNodeRealtime`,移除内联连接管理代码
|
||||
- [x] T21. 保留离线延迟逻辑并在页面卸载时清理离线定时器
|
||||
- [x] T22. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 8) 当前实施批次(Batch-5)
|
||||
|
||||
- 目标:继续推进 **D3(node WebSocket 区域)**,抽离离线延迟定时器生命周期
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-5 明细进度
|
||||
|
||||
- [x] T23. 新增 `useNodeOfflineTimers` Hook(离线延迟/清理)
|
||||
- [x] T24. `node.tsx` 接入 `useNodeOfflineTimers`,移除内联定时器管理
|
||||
- [x] T25. 校验状态/信息消息路径行为一致(在线切换与离线延迟)
|
||||
- [x] T26. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 9) 当前实施批次(Batch-6)
|
||||
|
||||
- 目标:推进 **D2(tunnel 诊断区)**,抽离诊断兜底与质量评估逻辑
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-6 明细进度
|
||||
|
||||
- [x] T27. 新增 `tunnel/diagnosis` 诊断工具模块
|
||||
- [x] T28. `tunnel.tsx` 接入诊断兜底与质量评估工具
|
||||
- [x] T29. 校验诊断弹窗展示逻辑与质量标签行为一致
|
||||
- [x] T30. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 10) 当前实施批次(Batch-7)
|
||||
|
||||
- 目标:推进 **C2/C3(API 类型边界与错误消息统一)**,先覆盖高频 node/tunnel/forward 路径
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-7 明细进度
|
||||
|
||||
- [x] T31. 新增 `api` 高频领域类型定义(node/tunnel/forward)
|
||||
- [x] T32. `api/index.ts` 的高频列表接口接入类型定义
|
||||
- [x] T33. 新增网络错误消息提取工具并接入 `network.ts`
|
||||
- [x] T34. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 11) 当前实施批次(Batch-8)
|
||||
|
||||
- 目标:推进 **D1(forward 排序区)**,抽离直接模式排序初始化逻辑
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-8 明细进度
|
||||
|
||||
- [x] T35. 新增 `forward/order` 排序工具模块
|
||||
- [x] T36. `forward.tsx` 接入排序工具并复用通用存储
|
||||
- [x] T37. 校验直接模式排序初始化与拖拽持久化行为一致
|
||||
- [x] T38. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 12) 当前实施批次(Batch-9)
|
||||
|
||||
- 目标:推进 **D1(forward 诊断区)**,抽离诊断兜底与质量评估逻辑
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-9 明细进度
|
||||
|
||||
- [x] T39. 新增 `forward/diagnosis` 诊断工具模块
|
||||
- [x] T40. `forward.tsx` 接入诊断兜底与质量评估工具
|
||||
- [x] T41. 校验诊断弹窗展示与质量标签行为一致
|
||||
- [x] T42. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 13) 当前实施批次(Batch-10)
|
||||
|
||||
- 目标:推进 **D1(forward 批量操作区)**,抽离批量动作执行与反馈逻辑
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-10 明细进度
|
||||
|
||||
- [x] T43. 新增 `forward/batch-actions` 批量操作工具模块
|
||||
- [x] T44. `forward.tsx` 接入批量操作工具并移除重复处理分支
|
||||
- [x] T45. 校验批量删除/启停/重下发/换隧道行为一致
|
||||
- [x] T46. 执行 `npm run build` 与必要诊断校验
|
||||
|
||||
## 14) 当前实施批次(Batch-11)
|
||||
|
||||
- 目标:推进 **D1(forward 地址展示/复制区)**,抽离地址格式化与弹窗分流逻辑
|
||||
- 本批次进度:`4 / 4`
|
||||
|
||||
### Batch-11 明细进度
|
||||
|
||||
- [x] T47. 新增 `forward/address` 地址工具模块
|
||||
- [x] T48. `forward.tsx` 接入地址工具并移除内联格式化/分流逻辑
|
||||
- [x] T49. 校验地址单项复制与多项弹窗行为一致
|
||||
- [x] T50. 执行 `npm run build` 与必要诊断校验
|
||||
@@ -0,0 +1,52 @@
|
||||
# shadcn/ui 全量迁移计划(已完成)
|
||||
|
||||
- 分支:`feat/shadcn-ui-full-migration-20260219`
|
||||
- 日期:`2026-02-19`
|
||||
- 目标:将 `vite-frontend` 从 HeroUI 彻底迁移到 shadcn/ui(含依赖、Provider、组件实现与构建验证)
|
||||
- 说明:用户提到的 `shadcu-ui` 按 `shadcn/ui` 执行
|
||||
|
||||
## 0. 现状基线(已完成)
|
||||
|
||||
- HeroUI 直接使用文件:`22` 个(`src/` 下)
|
||||
- HeroUI 组件族:`button/card/input/select/modal/table/chip/spinner/switch/alert/accordion/checkbox/dropdown/tabs/radio/date-picker/progress/navbar/link/system/use-theme` 等
|
||||
- 关键复杂页:`forward.tsx`、`tunnel.tsx`、`node.tsx`、`user.tsx`
|
||||
- 语义色类大量依赖:`text-default-*`、`bg-primary-*`、`border-divider`、`text-foreground` 等
|
||||
|
||||
## 1. 执行步骤
|
||||
|
||||
状态标记:`[ ] 未开始` / `[-] 进行中` / `[x] 已完成`
|
||||
|
||||
- [x] S1. 创建迁移分支并冻结迁移范围(仅 `vite-frontend`)
|
||||
- [x] S2. 完成全量使用点扫描(Grep/rg/AST + 官方文档检索)
|
||||
- [x] S3. 建立 shadcn/ui 基础设施(`components.json`、`src/lib/utils.ts`、`src/components/ui/*` 基础原子组件)
|
||||
- [x] S4. 建立 HeroUI -> shadcn 兼容桥接层(`src/shadcn-bridge/heroui/*`)并替换全部页面导入
|
||||
- [x] S5. 迁移全局 Provider/主题能力(替换 `HeroUIProvider`、`useTheme`、`useDisclosure`)
|
||||
- [x] S6. 替换 Tailwind 主题来源(移除 HeroUI 主题插件,补齐语义色 token 与兼容工具类)
|
||||
- [x] S7. 移除 HeroUI 依赖并修复构建(`npm install` + `npm run build`)
|
||||
- [x] S8. 回写完成记录与验收(确认无 `@heroui/*` 运行时依赖)
|
||||
|
||||
## 2. 组件映射策略(本次执行)
|
||||
|
||||
- Button -> shadcn `button` + 兼容 `isLoading/isIconOnly/startContent/endContent/onPress`
|
||||
- Input/Textarea -> shadcn `input/textarea` + label/description/error 容器
|
||||
- Modal -> shadcn `dialog`(兼容 `isOpen/onOpenChange` 与 Header/Body/Footer 插槽)
|
||||
- Select -> shadcn `select`(单选)+ 命令式多选兼容实现(多选场景)
|
||||
- Table -> shadcn `table`(兼容 `items + render function + empty/loading`)
|
||||
- Dropdown/Tabs/Radio/Switch/Checkbox/Accordion/Alert/Progress/Card/Separator -> 对应 shadcn 组件封装
|
||||
- DatePicker -> 基于原生日期输入 + 兼容 value/onChange 的桥接实现(保留现有业务数据结构)
|
||||
|
||||
## 3. 执行记录(每步完成即更新)
|
||||
|
||||
- [2026-02-19] 完成 S1:创建分支 `feat/shadcn-ui-full-migration-20260219`
|
||||
- [2026-02-19] 完成 S2:完成 HeroUI 使用点与迁移风险扫描;确认迁移顺序
|
||||
- [2026-02-19] 完成 S3:新增 `components.json`、`src/lib/utils.ts` 与 `src/components/ui/*`(button/dialog/dropdown/select/table/checkbox/switch/tabs/accordion/progress 等)
|
||||
- [2026-02-19] 完成 S4:新增 `src/shadcn-bridge/heroui/*` 兼容桥接层,并将现网全部导入替换为 `@/shadcn-bridge/heroui/*`
|
||||
- [2026-02-19] 完成 S5:通过桥接层接管 `HeroUIProvider`、`useTheme`、`useDisclosure`,保持页面业务逻辑不改动
|
||||
- [2026-02-19] 完成 S6:移除 `@heroui/theme` Tailwind 插件,改为本地 token 体系(`tailwind.config.js` + `src/styles/globals.css`)
|
||||
- [2026-02-19] 完成 S7:删除全部 HeroUI/NextUI 依赖,补齐 `@internationalized/date` 与 `@react-aria/i18n` 显式依赖
|
||||
- [2026-02-19] 完成 S8:构建验收通过(`npm run build`),`package.json` 已无 `heroui/nextui` 依赖
|
||||
- [2026-02-19] 验证结果:业务代码中 `@heroui/*` 导入为 `0`,已统一替换为 `@/shadcn-bridge/heroui/*`(22 文件,106 处)
|
||||
- [2026-02-19] 后续修复:`src/components/ui/button.tsx` 与 `src/shadcn-bridge/heroui/button.tsx` 改为 `forwardRef`,消除 `DropdownMenuTrigger asChild` 场景 ref 警告;复构建通过
|
||||
- [2026-02-19] 回归修复:定位“按钮边框/颜色丢失”根因是 Tailwind v4 下语义色未生成;新增 `src/styles/tailwind-theme.pcss`(由 `src/styles/globals.css` 引入)承载 `@theme inline` token 映射,恢复 `bg-primary`/`text-foreground`/`border-input` 等语义类输出
|
||||
- [2026-02-19] 回归修复:增强 `src/shadcn-bridge/heroui/button.tsx` 的 `solid/light/flat/bordered/shadow` 颜色映射,并修正 `src/components/ui/button.tsx` 的 `outline/ghost` hover 语义类,避免依赖未定义的 `accent` 色
|
||||
- [2026-02-19] 复验结果:`npm run build` 通过;编译产物已包含关键语义类;页面级视觉回归(forward/tunnel/node/user)通过(后端未启动时仅保留 `ERR_CONNECTION_REFUSED` 噪音)
|
||||
+19
-31
@@ -13,45 +13,31 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@heroui/accordion": "^2.2.21",
|
||||
"@heroui/alert": "^2.2.24",
|
||||
"@heroui/autocomplete": "^2.3.25",
|
||||
"@heroui/avatar": "^2.2.19",
|
||||
"@heroui/badge": "^2.2.15",
|
||||
"@heroui/button": "2.2.23",
|
||||
"@heroui/card": "^2.2.22",
|
||||
"@heroui/chip": "^2.2.19",
|
||||
"@heroui/code": "2.2.17",
|
||||
"@heroui/date-picker": "^2.3.22",
|
||||
"@heroui/dropdown": "2.3.23",
|
||||
"@heroui/image": "^2.2.15",
|
||||
"@heroui/input": "2.4.24",
|
||||
"@heroui/kbd": "2.2.18",
|
||||
"@heroui/link": "2.2.20",
|
||||
"@heroui/modal": "^2.2.21",
|
||||
"@heroui/navbar": "2.2.21",
|
||||
"@heroui/pagination": "^2.2.21",
|
||||
"@heroui/progress": "^2.2.20",
|
||||
"@heroui/radio": "^2.3.24",
|
||||
"@heroui/select": "^2.4.22",
|
||||
"@heroui/snippet": "2.2.24",
|
||||
"@heroui/spinner": "^2.2.20",
|
||||
"@heroui/switch": "^2.2.21",
|
||||
"@heroui/system": "2.4.19",
|
||||
"@heroui/table": "^2.2.24",
|
||||
"@heroui/tabs": "^2.2.27",
|
||||
"@heroui/theme": "2.4.24",
|
||||
"@heroui/use-theme": "2.1.10",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@nextui-org/system": "^2.4.6",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@react-aria/i18n": "^3.12.12",
|
||||
"@react-aria/visually-hidden": "3.8.25",
|
||||
"@react-types/shared": "3.30.0",
|
||||
"@tailwindcss/postcss": "4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"framer-motion": "11.18.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.3.1",
|
||||
@@ -60,8 +46,10 @@
|
||||
"react-router-dom": "6.30.3",
|
||||
"recharts": "^3.1.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.11"
|
||||
"tailwindcss": "4.1.11",
|
||||
"tw-animate-css": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "1.2.8",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import IndexPage from "@/pages/index";
|
||||
import ChangePasswordPage from "@/pages/change-password";
|
||||
@@ -19,50 +19,7 @@ import H5Layout from "@/layouts/h5";
|
||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { siteConfig } from "@/config/site";
|
||||
|
||||
// 检测是否为H5模式
|
||||
const useH5Mode = () => {
|
||||
// 立即检测H5模式,避免初始渲染时的闪屏
|
||||
const getInitialH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// 检测是否为移动端浏览器
|
||||
const isMobileBrowser =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
// 检测URL参数是否包含h5模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get("h5") === "true";
|
||||
|
||||
return isMobile || isMobileBrowser || isH5Param;
|
||||
};
|
||||
|
||||
const [isH5, setIsH5] = useState(getInitialH5Mode);
|
||||
|
||||
useEffect(() => {
|
||||
const checkH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// 检测是否为移动端浏览器
|
||||
const isMobileBrowser =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
// 检测URL参数是否包含h5模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get("h5") === "true";
|
||||
|
||||
setIsH5(isMobile || isMobileBrowser || isH5Param);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", checkH5Mode);
|
||||
|
||||
return () => window.removeEventListener("resize", checkH5Mode);
|
||||
}, []);
|
||||
|
||||
return isH5;
|
||||
};
|
||||
import { useH5Mode } from "@/hooks/useH5Mode";
|
||||
|
||||
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
||||
const ProtectedRoute = ({
|
||||
@@ -99,15 +56,8 @@ const ProtectedRoute = ({
|
||||
}
|
||||
|
||||
// 根据模式和页面类型选择布局
|
||||
let Layout;
|
||||
|
||||
if (isH5 && useSimpleLayout) {
|
||||
Layout = H5SimpleLayout;
|
||||
} else if (isH5) {
|
||||
Layout = H5Layout;
|
||||
} else {
|
||||
Layout = AdminLayout;
|
||||
}
|
||||
const Layout =
|
||||
isH5 && useSimpleLayout ? H5SimpleLayout : isH5 ? H5Layout : AdminLayout;
|
||||
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios from "axios";
|
||||
|
||||
interface ErrorPayload {
|
||||
msg?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const isUnauthorizedError = (error: unknown): boolean => {
|
||||
return axios.isAxiosError(error) && error.response?.status === 401;
|
||||
};
|
||||
|
||||
export const extractApiErrorMessage = (
|
||||
error: unknown,
|
||||
fallback = "网络请求失败",
|
||||
): string => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const payload = error.response?.data as ErrorPayload | undefined;
|
||||
|
||||
return payload?.msg || payload?.message || error.message || fallback;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
+103
-49
@@ -1,3 +1,32 @@
|
||||
import type {
|
||||
BatchOperationResult,
|
||||
ForwardDiagnosisApiData,
|
||||
ForwardApiItem,
|
||||
GroupPermissionApiItem,
|
||||
NodeReleaseApiItem,
|
||||
NodeApiItem,
|
||||
SpeedLimitApiItem,
|
||||
TunnelDiagnosisApiData,
|
||||
TunnelGroupApiItem,
|
||||
UserApiItem,
|
||||
UserGroupApiItem,
|
||||
UserListQuery,
|
||||
UserPackageInfoApiData,
|
||||
UserTunnelPermissionApiItem,
|
||||
TunnelApiItem,
|
||||
UserTunnelApiItem,
|
||||
UserMutationPayload,
|
||||
NodeMutationPayload,
|
||||
TunnelMutationPayload,
|
||||
UserTunnelAssignPayload,
|
||||
UserTunnelListQuery,
|
||||
UserTunnelRemovePayload,
|
||||
ForwardMutationPayload,
|
||||
SpeedLimitMutationPayload,
|
||||
UpdatePasswordPayload,
|
||||
BackupImportPayload,
|
||||
} from "./types";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import Network from "./network";
|
||||
@@ -20,20 +49,25 @@ export const login = (data: LoginData) =>
|
||||
Network.post<LoginResponse>("/user/login", data);
|
||||
|
||||
// 用户CRUD操作 - 全部使用POST请求
|
||||
export const createUser = (data: any) => Network.post("/user/create", data);
|
||||
export const getAllUsers = (pageData: any = {}) =>
|
||||
Network.post("/user/list", pageData);
|
||||
export const updateUser = (data: any) => Network.post("/user/update", data);
|
||||
export const createUser = (data: UserMutationPayload) =>
|
||||
Network.post("/user/create", data);
|
||||
export const getAllUsers = (pageData: UserListQuery = {}) =>
|
||||
Network.post<UserApiItem[]>("/user/list", pageData);
|
||||
export const updateUser = (data: UserMutationPayload) =>
|
||||
Network.post("/user/update", data);
|
||||
export const deleteUser = (id: number) => Network.post("/user/delete", { id });
|
||||
export const getUserPackageInfo = () => Network.post("/user/package");
|
||||
export const getUserPackageInfo = () =>
|
||||
Network.post<UserPackageInfoApiData>("/user/package");
|
||||
|
||||
// 节点CRUD操作 - 全部使用POST请求
|
||||
export const createNode = (data: any) => Network.post("/node/create", data);
|
||||
export const getNodeList = () => Network.post("/node/list");
|
||||
export const updateNode = (data: any) => Network.post("/node/update", data);
|
||||
export const createNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/create", data);
|
||||
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||
export const updateNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
export const getNodeInstallCommand = (id: number) =>
|
||||
Network.post("/node/install", { id });
|
||||
Network.post<string>("/node/install", { id });
|
||||
export const updateNodeOrder = (data: {
|
||||
nodes: Array<{ id: number; inx: number }>;
|
||||
}) => Network.post("/node/update-order", data);
|
||||
@@ -55,44 +89,50 @@ export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
||||
{ ids, version: version || "" },
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
);
|
||||
export const getNodeReleases = () => Network.post("/node/releases");
|
||||
export const getNodeReleases = () =>
|
||||
Network.post<NodeReleaseApiItem[]>("/node/releases");
|
||||
export const rollbackNode = (id: number) =>
|
||||
Network.post("/node/rollback", { id });
|
||||
|
||||
// 隧道CRUD操作 - 全部使用POST请求
|
||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
||||
export const getTunnelList = () => Network.post("/tunnel/list");
|
||||
export const createTunnel = (data: TunnelMutationPayload) =>
|
||||
Network.post("/tunnel/create", data);
|
||||
export const getTunnelList = () =>
|
||||
Network.post<TunnelApiItem[]>("/tunnel/list");
|
||||
export const getTunnelById = (id: number) =>
|
||||
Network.post("/tunnel/get", { id });
|
||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||
Network.post<TunnelApiItem>("/tunnel/get", { id });
|
||||
export const updateTunnel = (data: TunnelMutationPayload) =>
|
||||
Network.post("/tunnel/update", data);
|
||||
export const deleteTunnel = (id: number) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) =>
|
||||
Network.post("/tunnel/diagnose", { tunnelId });
|
||||
Network.post<TunnelDiagnosisApiData>("/tunnel/diagnose", { tunnelId });
|
||||
export const updateTunnelOrder = (data: {
|
||||
tunnels: Array<{ id: number; inx: number }>;
|
||||
}) => Network.post("/tunnel/update-order", data);
|
||||
|
||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||
export const assignUserTunnel = (data: any) =>
|
||||
export const assignUserTunnel = (data: UserTunnelAssignPayload) =>
|
||||
Network.post("/tunnel/user/assign", data);
|
||||
export const batchAssignUserTunnel = (data: {
|
||||
userId: number;
|
||||
tunnels: Array<{ tunnelId: number; speedId?: number | null }>;
|
||||
}) => Network.post("/tunnel/user/batch-assign", data);
|
||||
export const getUserTunnelList = (queryData: any = {}) =>
|
||||
Network.post("/tunnel/user/list", queryData);
|
||||
export const removeUserTunnel = (params: any) =>
|
||||
export const getUserTunnelList = (queryData: UserTunnelListQuery = {}) =>
|
||||
Network.post<UserTunnelPermissionApiItem[]>("/tunnel/user/list", queryData);
|
||||
export const removeUserTunnel = (params: UserTunnelRemovePayload) =>
|
||||
Network.post("/tunnel/user/remove", params);
|
||||
export const updateUserTunnel = (data: any) =>
|
||||
export const updateUserTunnel = (data: UserTunnelAssignPayload) =>
|
||||
Network.post("/tunnel/user/update", data);
|
||||
export const userTunnel = () => Network.post("/tunnel/user/tunnel");
|
||||
export const userTunnel = () =>
|
||||
Network.post<UserTunnelApiItem[]>("/tunnel/user/tunnel");
|
||||
|
||||
// 转发CRUD操作 - 全部使用POST请求
|
||||
export const createForward = (data: any) =>
|
||||
export const createForward = (data: ForwardMutationPayload) =>
|
||||
Network.post("/forward/create", data);
|
||||
export const getForwardList = () => Network.post("/forward/list");
|
||||
export const updateForward = (data: any) =>
|
||||
export const getForwardList = () =>
|
||||
Network.post<ForwardApiItem[]>("/forward/list");
|
||||
export const updateForward = (data: ForwardMutationPayload) =>
|
||||
Network.post("/forward/update", data);
|
||||
export const deleteForward = (id: number) =>
|
||||
Network.post("/forward/delete", { id });
|
||||
@@ -107,7 +147,7 @@ export const resumeForwardService = (forwardId: number) =>
|
||||
|
||||
// 转发诊断操作
|
||||
export const diagnoseForward = (forwardId: number) =>
|
||||
Network.post("/forward/diagnose", { forwardId });
|
||||
Network.post<ForwardDiagnosisApiData>("/forward/diagnose", { forwardId });
|
||||
|
||||
// 转发排序操作
|
||||
export const updateForwardOrder = (data: {
|
||||
@@ -115,34 +155,41 @@ export const updateForwardOrder = (data: {
|
||||
}) => Network.post("/forward/update-order", data);
|
||||
|
||||
// 限速规则CRUD操作 - 全部使用POST请求
|
||||
export const createSpeedLimit = (data: any) =>
|
||||
export const createSpeedLimit = (data: SpeedLimitMutationPayload) =>
|
||||
Network.post("/speed-limit/create", data);
|
||||
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
|
||||
export const updateSpeedLimit = (data: any) =>
|
||||
export const getSpeedLimitList = () =>
|
||||
Network.post<SpeedLimitApiItem[]>("/speed-limit/list");
|
||||
export const updateSpeedLimit = (data: SpeedLimitMutationPayload) =>
|
||||
Network.post("/speed-limit/update", data);
|
||||
export const deleteSpeedLimit = (id: number) =>
|
||||
Network.post("/speed-limit/delete", { id });
|
||||
|
||||
// 修改密码接口
|
||||
export const updatePassword = (data: any) =>
|
||||
export const updatePassword = (data: UpdatePasswordPayload) =>
|
||||
Network.post("/user/updatePassword", data);
|
||||
|
||||
// 重置流量接口
|
||||
export const resetUserFlow = (data: { id: number; type: number }) =>
|
||||
Network.post("/user/reset", data);
|
||||
|
||||
export const getUserGroups = (id: number) =>
|
||||
Network.post<number[]>("/user/groups", { id });
|
||||
|
||||
// 网站配置相关接口
|
||||
export const getConfigs = () => Network.post("/config/list");
|
||||
export const getConfigs = () =>
|
||||
Network.post<Record<string, string>>("/config/list");
|
||||
export const getConfigByName = (name: string) =>
|
||||
Network.post("/config/get", { name });
|
||||
Network.post<{ name: string; value: string }>("/config/get", { name });
|
||||
export const updateConfigs = (configMap: Record<string, string>) =>
|
||||
Network.post("/config/update", configMap);
|
||||
export const updateConfig = (name: string, value: string) =>
|
||||
Network.post("/config/update-single", { name, value });
|
||||
|
||||
export const exportBackupData = () => Network.post("/backup/export");
|
||||
export const importBackupData = (data: any) => Network.post("/backup/import", data);
|
||||
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data);
|
||||
export const importBackupData = (data: BackupImportPayload) =>
|
||||
Network.post("/backup/import", data);
|
||||
export const restoreBackupData = (data: BackupImportPayload) =>
|
||||
Network.post("/backup/restore", data);
|
||||
|
||||
// 验证码相关接口
|
||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||
@@ -152,26 +199,27 @@ export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
|
||||
|
||||
// 批量操作接口
|
||||
export const batchDeleteForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-delete", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-delete", { ids });
|
||||
export const batchPauseForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-pause", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-pause", { ids });
|
||||
export const batchResumeForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-resume", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-resume", { ids });
|
||||
export const batchDeleteTunnels = (ids: number[]) =>
|
||||
Network.post("/tunnel/batch-delete", { ids });
|
||||
Network.post<BatchOperationResult>("/tunnel/batch-delete", { ids });
|
||||
export const batchDeleteNodes = (ids: number[]) =>
|
||||
Network.post("/node/batch-delete", { ids });
|
||||
Network.post<BatchOperationResult>("/node/batch-delete", { ids });
|
||||
export const batchRedeployForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-redeploy", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-redeploy", { ids });
|
||||
export const batchRedeployTunnels = (ids: number[]) =>
|
||||
Network.post("/tunnel/batch-redeploy", { ids });
|
||||
Network.post<BatchOperationResult>("/tunnel/batch-redeploy", { ids });
|
||||
export const batchChangeTunnel = (data: {
|
||||
forwardIds: number[];
|
||||
targetTunnelId: number;
|
||||
}) => Network.post("/forward/batch-change-tunnel", data);
|
||||
}) => Network.post<BatchOperationResult>("/forward/batch-change-tunnel", data);
|
||||
|
||||
// 分组与权限分配接口
|
||||
export const getTunnelGroupList = () => Network.post("/group/tunnel/list");
|
||||
export const getTunnelGroupList = () =>
|
||||
Network.post<TunnelGroupApiItem[]>("/group/tunnel/list");
|
||||
export const createTunnelGroup = (data: { name: string; status?: number }) =>
|
||||
Network.post("/group/tunnel/create", data);
|
||||
export const updateTunnelGroup = (data: {
|
||||
@@ -186,7 +234,8 @@ export const assignTunnelsToGroup = (data: {
|
||||
tunnelIds: number[];
|
||||
}) => Network.post("/group/tunnel/assign", data);
|
||||
|
||||
export const getUserGroupList = () => Network.post("/group/user/list");
|
||||
export const getUserGroupList = () =>
|
||||
Network.post<UserGroupApiItem[]>("/group/user/list");
|
||||
export const createUserGroup = (data: { name: string; status?: number }) =>
|
||||
Network.post("/group/user/create", data);
|
||||
export const updateUserGroup = (data: {
|
||||
@@ -202,7 +251,7 @@ export const assignUsersToGroup = (data: {
|
||||
}) => Network.post("/group/user/assign", data);
|
||||
|
||||
export const getGroupPermissionList = () =>
|
||||
Network.post("/group/permission/list");
|
||||
Network.post<GroupPermissionApiItem[]>("/group/permission/list");
|
||||
export const assignGroupPermission = (data: {
|
||||
userGroupId: number;
|
||||
tunnelGroupId: number;
|
||||
@@ -211,7 +260,8 @@ export const removeGroupPermission = (id: number) =>
|
||||
Network.post("/group/permission/remove", { id });
|
||||
|
||||
// 面板共享 (Federation) 接口
|
||||
export const getPeerShareList = () => Network.post("/federation/share/list");
|
||||
export const getPeerShareList = () =>
|
||||
Network.post<Array<Record<string, unknown>>>("/federation/share/list");
|
||||
export const createPeerShare = (data: {
|
||||
name: string;
|
||||
nodeId: number;
|
||||
@@ -237,7 +287,9 @@ export const deletePeerShare = (id: number) =>
|
||||
export const resetPeerShareFlow = (id: number) =>
|
||||
Network.post("/federation/share/reset-flow", { id });
|
||||
export const getPeerRemoteUsageList = () =>
|
||||
Network.post("/federation/share/remote-usage/list");
|
||||
Network.post<Array<Record<string, unknown>>>(
|
||||
"/federation/share/remote-usage/list",
|
||||
);
|
||||
export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
|
||||
Network.post("/federation/node/import", data);
|
||||
|
||||
@@ -283,7 +335,7 @@ export const exportBackup = async (types: string[] = []) => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
|
||||
export const importBackup = (data: BackupImportPayload) =>
|
||||
Network.post("/backup/import", data);
|
||||
|
||||
export interface AnnouncementData {
|
||||
@@ -291,5 +343,7 @@ export interface AnnouncementData {
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export const getAnnouncement = () => Network.get<AnnouncementData>("/announcement/get");
|
||||
export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data);
|
||||
export const getAnnouncement = () =>
|
||||
Network.get<AnnouncementData>("/announcement/get");
|
||||
export const updateAnnouncement = (data: AnnouncementData) =>
|
||||
Network.post("/announcement/update", data);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
|
||||
import {
|
||||
extractApiErrorMessage,
|
||||
isUnauthorizedError,
|
||||
} from "@/api/error-message";
|
||||
import { getPanelAddresses, isWebViewFunc } from "@/utils/panel";
|
||||
import { clearSession, getToken } from "@/utils/session";
|
||||
|
||||
interface PanelAddress {
|
||||
name: string;
|
||||
@@ -37,7 +42,7 @@ export const reinitializeBaseURL = () => {
|
||||
|
||||
reinitializeBaseURL();
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
@@ -49,10 +54,7 @@ interface RequestOptions {
|
||||
|
||||
// 处理token失效的逻辑
|
||||
function handleTokenExpired() {
|
||||
// 清除localStorage中的token
|
||||
window.localStorage.removeItem("token");
|
||||
window.localStorage.removeItem("role_id");
|
||||
window.localStorage.removeItem("name");
|
||||
clearSession();
|
||||
|
||||
// 跳转到登录页面
|
||||
if (window.location.pathname !== "/") {
|
||||
@@ -61,7 +63,7 @@ function handleTokenExpired() {
|
||||
}
|
||||
|
||||
// 检查响应是否为token失效
|
||||
function isTokenExpired(response: ApiResponse) {
|
||||
function isTokenExpired(response: ApiResponse<unknown>) {
|
||||
return (
|
||||
response &&
|
||||
response.code === 401 &&
|
||||
@@ -72,9 +74,9 @@ function isTokenExpired(response: ApiResponse) {
|
||||
}
|
||||
|
||||
const Network = {
|
||||
get: function <T = any>(
|
||||
get: function <T = unknown>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
data: unknown = {},
|
||||
options: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(function (resolve) {
|
||||
@@ -90,38 +92,45 @@ const Network = {
|
||||
params: data,
|
||||
timeout: options.timeout ?? 30000,
|
||||
headers: {
|
||||
Authorization: window.localStorage.getItem("token"),
|
||||
Authorization: getToken(),
|
||||
},
|
||||
})
|
||||
.then(function (response: AxiosResponse<ApiResponse<T>>) {
|
||||
// 检查是否token失效
|
||||
if (isTokenExpired(response.data)) {
|
||||
handleTokenExpired();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function (error: any) {
|
||||
.catch(function (error: unknown) {
|
||||
const errorMessage = extractApiErrorMessage(error);
|
||||
|
||||
// 检查是否是401错误(token失效)
|
||||
if (error.response && error.response.status === 401) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
handleTokenExpired();
|
||||
|
||||
resolve({
|
||||
code: 401,
|
||||
msg: "未登录或token已过期",
|
||||
data: null as T,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: error.message || "网络请求失败",
|
||||
msg: errorMessage,
|
||||
data: null as T,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
post: function <T = any>(
|
||||
post: function <T = unknown>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
data: unknown = {},
|
||||
options: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(function (resolve) {
|
||||
@@ -136,7 +145,7 @@ const Network = {
|
||||
.post(path, data, {
|
||||
timeout: options.timeout ?? 30000,
|
||||
headers: {
|
||||
Authorization: window.localStorage.getItem("token"),
|
||||
Authorization: getToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
@@ -144,22 +153,29 @@ const Network = {
|
||||
// 检查是否token失效
|
||||
if (isTokenExpired(response.data)) {
|
||||
handleTokenExpired();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function (error: any) {
|
||||
.catch(function (error: unknown) {
|
||||
const errorMessage = extractApiErrorMessage(error);
|
||||
|
||||
// 检查是否是401错误(token失效)
|
||||
if (error.response && error.response.status === 401) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
handleTokenExpired();
|
||||
|
||||
resolve({
|
||||
code: 401,
|
||||
msg: "未登录或token已过期",
|
||||
data: null as T,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: error.message || "网络请求失败",
|
||||
msg: errorMessage,
|
||||
data: null as T,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
export interface NodeApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
inx?: number;
|
||||
syncError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserApiItem {
|
||||
id: number;
|
||||
user: string;
|
||||
name?: string;
|
||||
status: number;
|
||||
flow: number;
|
||||
num: number;
|
||||
expTime?: number;
|
||||
flowResetTime?: number;
|
||||
inFlow?: number;
|
||||
outFlow?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserListQuery {
|
||||
current?: number;
|
||||
size?: number;
|
||||
keyword?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
entryNodeId: number;
|
||||
exitNodeId: number;
|
||||
inx?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ForwardApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
tunnelName?: string;
|
||||
inIp?: string;
|
||||
inPort?: number;
|
||||
remoteAddr?: string;
|
||||
inFlow?: number;
|
||||
outFlow?: number;
|
||||
userId?: number;
|
||||
tunnelId?: number;
|
||||
inx?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserTunnelApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId?: number;
|
||||
tunnelName?: string;
|
||||
inNodePortSta?: number;
|
||||
inNodePortEnd?: number;
|
||||
speedId?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserTunnelPermissionApiItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
status: number;
|
||||
flow: number;
|
||||
num: number;
|
||||
expTime: number;
|
||||
flowResetTime: number;
|
||||
speedId?: number | null;
|
||||
speedLimitName?: string;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
tunnelFlow?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StatisticsFlowApiItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
flow: number;
|
||||
totalFlow: number;
|
||||
time: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SpeedLimitApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
speed: number;
|
||||
status: number;
|
||||
tunnelName: string;
|
||||
createdTime: string;
|
||||
updatedTime: string;
|
||||
uploadSpeed?: number;
|
||||
downloadSpeed?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelGroupApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
tunnelIds: number[];
|
||||
tunnelNames: string[];
|
||||
createdTime: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserGroupApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
userIds: number[];
|
||||
userNames: string[];
|
||||
createdTime: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GroupPermissionApiItem {
|
||||
id: number;
|
||||
userGroupId: number;
|
||||
userGroupName: string;
|
||||
tunnelGroupId: number;
|
||||
tunnelGroupName: string;
|
||||
createdTime: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelDiagnosisApiItem {
|
||||
success: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
targetIp: string;
|
||||
targetPort?: number;
|
||||
message?: string;
|
||||
averageTime?: number;
|
||||
packetLoss?: number;
|
||||
fromChainType?: number;
|
||||
fromInx?: number;
|
||||
toChainType?: number;
|
||||
toInx?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelDiagnosisApiData {
|
||||
tunnelName: string;
|
||||
tunnelType: string;
|
||||
timestamp: number;
|
||||
results: TunnelDiagnosisApiItem[];
|
||||
}
|
||||
|
||||
export interface ForwardDiagnosisApiData {
|
||||
forwardName: string;
|
||||
timestamp: number;
|
||||
results: TunnelDiagnosisApiItem[];
|
||||
}
|
||||
|
||||
export interface NodeReleaseApiItem {
|
||||
version: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
export interface UserPackageInfoApiData {
|
||||
userInfo: {
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
tunnelPermissions: UserTunnelPermissionApiItem[];
|
||||
forwards: ForwardApiItem[];
|
||||
statisticsFlows: StatisticsFlowApiItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BatchOperationResult {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserMutationPayload {
|
||||
id?: number;
|
||||
user?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
status?: number;
|
||||
flow?: number;
|
||||
num?: number;
|
||||
expTime?: number | string;
|
||||
flowResetTime?: number;
|
||||
tunnelFlow?: number;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
id?: number | null;
|
||||
name?: string;
|
||||
status?: number;
|
||||
inx?: number;
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
port?: string;
|
||||
tcpListenAddr?: string;
|
||||
udpListenAddr?: string;
|
||||
interfaceName?: string;
|
||||
http?: number;
|
||||
tls?: number;
|
||||
socks?: number;
|
||||
}
|
||||
|
||||
export interface TunnelChainNodePayload {
|
||||
nodeId: number;
|
||||
protocol?: string;
|
||||
strategy?: string;
|
||||
chainType?: number;
|
||||
inx?: number;
|
||||
}
|
||||
|
||||
export interface TunnelMutationPayload {
|
||||
id?: number;
|
||||
name?: string;
|
||||
type?: number;
|
||||
status?: number;
|
||||
flow?: number;
|
||||
trafficRatio?: number;
|
||||
inIp?: string;
|
||||
ipPreference?: string;
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
outNodeId?: TunnelChainNodePayload[];
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
}
|
||||
|
||||
export interface UserTunnelAssignPayload {
|
||||
userId?: number;
|
||||
id?: number;
|
||||
tunnelId?: number;
|
||||
flow?: number;
|
||||
num?: number;
|
||||
expTime?: number;
|
||||
flowResetTime?: number;
|
||||
status?: number;
|
||||
speedId?: number | null;
|
||||
tunnels?: Array<{ tunnelId: number; speedId?: number | null }>;
|
||||
}
|
||||
|
||||
export interface UserTunnelListQuery {
|
||||
userId?: number;
|
||||
tunnelId?: number;
|
||||
current?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface UserTunnelRemovePayload {
|
||||
id?: number;
|
||||
userId?: number;
|
||||
tunnelId?: number;
|
||||
}
|
||||
|
||||
export interface ForwardMutationPayload {
|
||||
id?: number;
|
||||
name?: string;
|
||||
status?: number;
|
||||
tunnelId?: number | null;
|
||||
inIp?: string;
|
||||
inPort?: number | null;
|
||||
remoteAddr?: string;
|
||||
strategy?: string;
|
||||
}
|
||||
|
||||
export interface SpeedLimitMutationPayload {
|
||||
id?: number;
|
||||
name?: string;
|
||||
speed?: number;
|
||||
status?: number;
|
||||
tunnelId?: number | null;
|
||||
tunnelName?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePasswordPayload {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
newUsername?: string;
|
||||
}
|
||||
|
||||
export interface BackupImportPayload {
|
||||
types: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "@heroui/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Link } from "@/shadcn-bridge/heroui/link";
|
||||
import {
|
||||
Navbar as HeroUINavbar,
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
} from "@heroui/navbar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
} from "@/shadcn-bridge/heroui/navbar";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
export const Navbar = () => {
|
||||
const navigate = useNavigate();
|
||||
// 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁
|
||||
const [appName, setAppName] = useState(siteConfig.name);
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
// 检测是否在WebView中运行
|
||||
useEffect(() => {
|
||||
setIsWebView(isWebViewFunc());
|
||||
}, []);
|
||||
const isWebView = useWebViewMode();
|
||||
|
||||
useEffect(() => {
|
||||
// 异步检查是否有更新的配置
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
|
||||
interface BaseStateProps {
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PageLoadingState = ({
|
||||
message,
|
||||
className = "h-64",
|
||||
}: BaseStateProps) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageEmptyState = ({
|
||||
message,
|
||||
className = "h-48",
|
||||
}: BaseStateProps) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<span className="text-default-500">{message}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageErrorState = ({
|
||||
message,
|
||||
className = "h-48",
|
||||
}: BaseStateProps) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<span className="text-danger">{message}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTheme } from "@heroui/use-theme";
|
||||
|
||||
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
className={cn("border-b", className)}
|
||||
data-slot="accordion-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex" data-slot="accordion-header">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between gap-3 px-3 py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
data-slot="accordion-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-default-500 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
data-slot="accordion-content"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-default-200 bg-default-50/70 text-foreground",
|
||||
destructive:
|
||||
"border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
|
||||
success:
|
||||
"border-success-200 bg-success-50 text-success-700 dark:text-success-300",
|
||||
warning:
|
||||
"border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(alertVariants({ className, variant }))}
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
|
||||
return (
|
||||
<h5
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
data-slot="alert-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
data-slot="alert-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-danger text-white",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-success text-white",
|
||||
warning: "border-transparent bg-warning text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(badgeVariants({ className, variant }))}
|
||||
data-slot="badge"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-danger text-white hover:bg-danger/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
light:
|
||||
"bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
|
||||
flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ asChild = false, className, size, variant, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ className, size, variant }))}
|
||||
data-slot="button"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-default-200 bg-white text-card-foreground shadow-sm dark:bg-default-50/20",
|
||||
className,
|
||||
)}
|
||||
data-slot="card"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
data-slot="card-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
data-slot="card-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
className={cn("text-sm text-default-500", className)}
|
||||
data-slot="card-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("p-6 pt-0", className)}
|
||||
data-slot="card-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
data-slot="card-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow 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",
|
||||
className,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="flex items-center justify-center text-current"
|
||||
data-slot="checkbox-indicator"
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
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 sm:rounded-lg dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn("text-sm text-default-500", className)}
|
||||
data-slot="dialog-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-default-100 data-[state=open]:bg-default-100",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
className={cn("-mx-1 my-1 h-px bg-divider", className)}
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-default-500",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-default-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="input"
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
data-slot="label"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
indicatorClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root> & {
|
||||
indicatorClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-default-200",
|
||||
className,
|
||||
)}
|
||||
data-slot="progress"
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
indicatorClassName,
|
||||
)}
|
||||
data-slot="progress-indicator"
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
data-slot="radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="radio-group-item"
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
className="flex items-center justify-center"
|
||||
data-slot="radio-group-indicator"
|
||||
>
|
||||
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1,177 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-default-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-scroll-up-button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-scroll-down-button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-content"
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
data-slot="select-label"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
className={cn("-mx-1 my-1 h-px bg-divider", className)}
|
||||
data-slot="select-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
decorative = true,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
className={cn(
|
||||
"shrink-0 bg-divider",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors 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=unchecked]:bg-default-200",
|
||||
className,
|
||||
)}
|
||||
data-slot="switch"
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
data-slot="switch-thumb"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto" data-slot="table-wrapper">
|
||||
<table
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
data-slot="table"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
data-slot="table-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
data-slot="table-body"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
className={cn(
|
||||
"border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-default-50/50",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-row"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-default-600 [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-head"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-cell"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
className={cn("mt-4 text-sm text-default-500", className)}
|
||||
data-slot="table-caption"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-default-100 p-1 text-default-500",
|
||||
className,
|
||||
)}
|
||||
data-slot="tabs-list"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="tabs-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
className={cn("outline-none", className)}
|
||||
data-slot="tabs-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[84px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-default-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="textarea"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { DEFAULT_MOBILE_BREAKPOINT } from "@/hooks/useMobileBreakpoint";
|
||||
|
||||
const detectH5Mode = (): boolean => {
|
||||
const isMobile = window.innerWidth <= DEFAULT_MOBILE_BREAKPOINT;
|
||||
const isMobileBrowser =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get("h5") === "true";
|
||||
|
||||
return isMobile || isMobileBrowser || isH5Param;
|
||||
};
|
||||
|
||||
export const useH5Mode = (): boolean => {
|
||||
const [isH5, setIsH5] = useState(detectH5Mode);
|
||||
|
||||
useEffect(() => {
|
||||
const checkH5Mode = () => {
|
||||
setIsH5(detectH5Mode());
|
||||
};
|
||||
|
||||
window.addEventListener("resize", checkH5Mode);
|
||||
|
||||
return () => window.removeEventListener("resize", checkH5Mode);
|
||||
}, []);
|
||||
|
||||
return isH5;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export const useMobileBreakpoint = (
|
||||
breakpoint = DEFAULT_MOBILE_BREAKPOINT,
|
||||
): boolean => {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.innerWidth < breakpoint;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
setIsMobile(window.innerWidth < breakpoint);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export const useScrollTopOnPathChange = (): void => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
} catch {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
document.body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
}, [pathname]);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
|
||||
export const useWebViewMode = (): boolean => {
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsWebView(isWebViewFunc());
|
||||
}, []);
|
||||
|
||||
return isWebView;
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@heroui/dropdown";
|
||||
} from "@/shadcn-bridge/heroui/dropdown";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -14,14 +16,14 @@ import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Input } from "@heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
|
||||
import { getAdminFlag, getSessionName } from "@/utils/session";
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
@@ -46,7 +48,6 @@ export default function AdminLayout({
|
||||
const location = useLocation();
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
@@ -57,6 +58,7 @@ export default function AdminLayout({
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const isMobile = useMobileBreakpoint();
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuItem[] = [
|
||||
@@ -170,41 +172,21 @@ export default function AdminLayout({
|
||||
},
|
||||
];
|
||||
|
||||
// 检查移动端
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
if (window.innerWidth > 768) {
|
||||
setMobileMenuVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 获取用户信息
|
||||
const name = localStorage.getItem("name") || "Admin";
|
||||
|
||||
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
|
||||
let adminFlag = localStorage.getItem("admin") === "true";
|
||||
|
||||
if (localStorage.getItem("admin") === null) {
|
||||
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
|
||||
|
||||
adminFlag = roleId === 0;
|
||||
// 补充设置admin字段,避免下次再次判断
|
||||
localStorage.setItem("admin", adminFlag.toString());
|
||||
}
|
||||
const name = getSessionName() || "Admin";
|
||||
const adminFlag = getAdminFlag();
|
||||
|
||||
setUsername(name);
|
||||
setIsAdmin(adminFlag);
|
||||
|
||||
// 响应式检查
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileMenuVisible(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
safeLogout();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
|
||||
export default function H5SimpleLayout({
|
||||
children,
|
||||
@@ -11,18 +12,8 @@ export default function H5SimpleLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 路由切换时回到顶部,避免上一页滚动位置保留
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
} catch {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
document.body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
}, [location.pathname]);
|
||||
useScrollTopOnPathChange();
|
||||
|
||||
const handleBack = () => {
|
||||
navigate("/profile");
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
|
||||
interface TabItem {
|
||||
path: string;
|
||||
@@ -16,6 +18,8 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useScrollTopOnPathChange();
|
||||
|
||||
// Tabbar配置
|
||||
const tabItems: TabItem[] = [
|
||||
{
|
||||
@@ -80,18 +84,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
|
||||
let adminFlag = localStorage.getItem("admin") === "true";
|
||||
|
||||
if (localStorage.getItem("admin") === null) {
|
||||
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
|
||||
|
||||
adminFlag = roleId === 0;
|
||||
// 补充设置admin字段,避免下次再次判断
|
||||
localStorage.setItem("admin", adminFlag.toString());
|
||||
}
|
||||
|
||||
setIsAdmin(adminFlag);
|
||||
setIsAdmin(getAdminFlag());
|
||||
}, []);
|
||||
|
||||
// Tab点击处理
|
||||
@@ -104,17 +97,6 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
(item) => !item.adminOnly || isAdmin,
|
||||
);
|
||||
|
||||
// 路由切换时回到页面顶部,避免上一页的滚动位置遗留
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
} catch {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
document.body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
|
||||
{/* 顶部导航栏 */}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { title } from "@/components/primitives";
|
||||
import { updatePassword } from "@/api";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
@@ -115,6 +115,7 @@ export default function ChangePasswordPage() {
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-warning-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -200,7 +201,7 @@ export default function ChangePasswordPage() {
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
{loading ? "修改中..." : "立即修改账号密码"}
|
||||
</Button>
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Textarea } from "@heroui/input";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api";
|
||||
import {
|
||||
updateConfigs,
|
||||
exportBackup,
|
||||
importBackup,
|
||||
getAnnouncement,
|
||||
updateAnnouncement,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
@@ -98,6 +112,21 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const BACKUP_TYPE_OPTIONS = [
|
||||
{ value: "users", label: "用户" },
|
||||
{ value: "nodes", label: "节点" },
|
||||
{ value: "tunnels", label: "隧道" },
|
||||
{ value: "forwards", label: "转发" },
|
||||
{ value: "userTunnels", label: "用户隧道权限" },
|
||||
{ value: "speedLimits", label: "限速规则" },
|
||||
{ value: "tunnelGroups", label: "隧道分组" },
|
||||
{ value: "userGroups", label: "用户分组" },
|
||||
{ value: "permissions", label: "分组权限" },
|
||||
{ value: "configs", label: "系统配置" },
|
||||
] as const;
|
||||
|
||||
const BACKUP_TYPE_VALUES = BACKUP_TYPE_OPTIONS.map((option) => option.value);
|
||||
|
||||
// 初始化时从缓存读取配置,避免闪烁
|
||||
const getInitialConfigs = (): Record<string, string> => {
|
||||
if (typeof window === "undefined") return {};
|
||||
@@ -120,7 +149,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
initialConfigs[key] = cachedValue;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return initialConfigs;
|
||||
};
|
||||
@@ -142,6 +171,8 @@ export default function ConfigPage() {
|
||||
const [importTypes, setImportTypes] = useState<string[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
||||
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -387,6 +418,7 @@ export default function ConfigPage() {
|
||||
try {
|
||||
await exportBackup(exportTypes);
|
||||
toast.success("导出成功");
|
||||
setExportSelectorOpen(false);
|
||||
} catch {
|
||||
toast.error("导出失败,请重试");
|
||||
} finally {
|
||||
@@ -394,6 +426,17 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const triggerImportFilePicker = () => {
|
||||
if (importTypes.length === 0) {
|
||||
toast.error("请先选择要导入的数据类型");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setImportSelectorOpen(false);
|
||||
requestAnimationFrame(() => fileInputRef.current?.click());
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -434,6 +477,90 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTypeSelection = (
|
||||
type: string,
|
||||
setTypes: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
) => {
|
||||
setTypes((prev) =>
|
||||
prev.includes(type)
|
||||
? prev.filter((item) => item !== type)
|
||||
: [...prev, type],
|
||||
);
|
||||
};
|
||||
|
||||
const isAllTypesSelected = (types: string[]) =>
|
||||
BACKUP_TYPE_VALUES.every((type) => types.includes(type));
|
||||
|
||||
const renderTypeSelection = (
|
||||
label: string,
|
||||
selectedTypes: string[],
|
||||
setTypes: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
) => {
|
||||
const allSelected = isAllTypesSelected(selectedTypes);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-default-700 dark:text-default-300">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])
|
||||
}
|
||||
>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
<Button size="sm" variant="light" onPress={() => setTypes([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{BACKUP_TYPE_OPTIONS.map((option) => {
|
||||
const isSelected = selectedTypes.includes(option.value);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
aria-pressed={isSelected}
|
||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
classNames={{
|
||||
base: "pointer-events-none",
|
||||
}}
|
||||
color="primary"
|
||||
isSelected={isSelected}
|
||||
size="md"
|
||||
/>
|
||||
<span
|
||||
className={`font-medium ${isSelected
|
||||
? "text-default-900 dark:text-default-100"
|
||||
: "text-default-700 dark:text-default-500"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -456,7 +583,7 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">基本设置</h2>
|
||||
@@ -470,7 +597,7 @@ export default function ConfigPage() {
|
||||
disabled={!hasChanges}
|
||||
isLoading={saving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
@@ -480,7 +607,7 @@ export default function ConfigPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
<CardBody className="space-y-6 pt-8 md:pt-8">
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
@@ -520,9 +647,9 @@ export default function ConfigPage() {
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
<span className="text-sm font-medium">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
@@ -531,7 +658,7 @@ export default function ConfigPage() {
|
||||
)}
|
||||
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">公告管理</h2>
|
||||
@@ -544,7 +671,7 @@ export default function ConfigPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-4 pt-6">
|
||||
<CardBody className="space-y-4 pt-8 md:pt-8">
|
||||
{announcementLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
@@ -555,7 +682,10 @@ export default function ConfigPage() {
|
||||
<Switch
|
||||
isSelected={announcement.enabled === 1}
|
||||
onValueChange={(checked) =>
|
||||
setAnnouncement({ ...announcement, enabled: checked ? 1 : 0 })
|
||||
setAnnouncement({
|
||||
...announcement,
|
||||
enabled: checked ? 1 : 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
@@ -569,10 +699,10 @@ export default function ConfigPage() {
|
||||
|
||||
<Textarea
|
||||
label="公告内容"
|
||||
minRows={4}
|
||||
placeholder="请输入公告内容"
|
||||
value={announcement.content}
|
||||
variant="bordered"
|
||||
minRows={4}
|
||||
onChange={(e) =>
|
||||
setAnnouncement({ ...announcement, content: e.target.value })
|
||||
}
|
||||
@@ -583,7 +713,7 @@ export default function ConfigPage() {
|
||||
color="primary"
|
||||
isLoading={announcementSaving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={saveAnnouncement}
|
||||
onPress={saveAnnouncement}
|
||||
>
|
||||
保存公告
|
||||
</Button>
|
||||
@@ -595,7 +725,7 @@ export default function ConfigPage() {
|
||||
|
||||
{/* 备份与恢复 */}
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
|
||||
@@ -608,64 +738,24 @@ export default function ConfigPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
<CardBody className="space-y-6 pt-8 md:pt-8">
|
||||
{/* 导出部分 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">导出数据</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择要导出的数据类型,导出为 JSON 格式文件
|
||||
</p>
|
||||
|
||||
<CheckboxGroup
|
||||
classNames={{
|
||||
wrapper: "gap-4",
|
||||
}}
|
||||
label="选择导出内容"
|
||||
orientation="horizontal"
|
||||
value={exportTypes}
|
||||
onValueChange={(values) => setExportTypes(values as string[])}
|
||||
>
|
||||
<Checkbox value="users">用户</Checkbox>
|
||||
<Checkbox value="nodes">节点</Checkbox>
|
||||
<Checkbox value="tunnels">隧道</Checkbox>
|
||||
<Checkbox value="forwards">转发</Checkbox>
|
||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||
<Checkbox value="permissions">分组权限</Checkbox>
|
||||
<Checkbox value="configs">系统配置</Checkbox>
|
||||
</CheckboxGroup>
|
||||
<p className="text-xs text-default-500">
|
||||
当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={exporting}
|
||||
onPress={handleExport}
|
||||
onPress={() => setExportSelectorOpen(true)}
|
||||
>
|
||||
{exporting ? "导出中..." : "导出数据"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onPress={() => {
|
||||
setExportTypes([
|
||||
"users",
|
||||
"nodes",
|
||||
"tunnels",
|
||||
"forwards",
|
||||
"userTunnels",
|
||||
"speedLimits",
|
||||
"tunnelGroups",
|
||||
"userGroups",
|
||||
"permissions",
|
||||
"configs",
|
||||
]);
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button variant="flat" onPress={() => setExportTypes([])}>
|
||||
清空
|
||||
{exporting ? "导出中..." : "选择并导出"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,27 +768,9 @@ export default function ConfigPage() {
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择要导入的数据类型,支持从备份文件恢复数据
|
||||
</p>
|
||||
|
||||
<CheckboxGroup
|
||||
classNames={{
|
||||
wrapper: "gap-4",
|
||||
}}
|
||||
label="选择导入内容"
|
||||
orientation="horizontal"
|
||||
value={importTypes}
|
||||
onValueChange={(values) => setImportTypes(values as string[])}
|
||||
>
|
||||
<Checkbox value="users">用户</Checkbox>
|
||||
<Checkbox value="nodes">节点</Checkbox>
|
||||
<Checkbox value="tunnels">隧道</Checkbox>
|
||||
<Checkbox value="forwards">转发</Checkbox>
|
||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||
<Checkbox value="permissions">分组权限</Checkbox>
|
||||
<Checkbox value="configs">系统配置</Checkbox>
|
||||
</CheckboxGroup>
|
||||
<p className="text-xs text-default-500">
|
||||
当前已选 {importTypes.length} / {BACKUP_TYPE_VALUES.length}
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -713,9 +785,9 @@ export default function ConfigPage() {
|
||||
color="primary"
|
||||
isLoading={importing}
|
||||
variant="flat"
|
||||
onPress={() => fileInputRef.current?.click()}
|
||||
onPress={() => setImportSelectorOpen(true)}
|
||||
>
|
||||
{importing ? "导入中..." : "选择文件导入"}
|
||||
{importing ? "导入中..." : "选择并导入"}
|
||||
</Button>
|
||||
{importFileName && (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -726,6 +798,56 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>选择导出内容</ModalHeader>
|
||||
<ModalBody>
|
||||
{renderTypeSelection("导出内容", exportTypes, setExportTypes)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={exporting}
|
||||
onPress={handleExport}
|
||||
>
|
||||
{exporting ? "导出中..." : "确认导出"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>选择导入内容</ModalHeader>
|
||||
<ModalBody>
|
||||
{renderTypeSelection("导入内容", importTypes, setImportTypes)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={importTypes.length === 0}
|
||||
onPress={triggerImportFilePicker}
|
||||
>
|
||||
下一步选择文件
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,23 @@
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { getUserPackageInfo, getAnnouncement, type AnnouncementData } from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
}
|
||||
|
||||
interface UserTunnel {
|
||||
id: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
tunnelFlow: number;
|
||||
}
|
||||
|
||||
interface Forward {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
inIp: string;
|
||||
inPort: number;
|
||||
remoteAddr: string;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
}
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
||||
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
||||
import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
||||
import {
|
||||
useDashboardData,
|
||||
type DashboardForward as Forward,
|
||||
type DashboardUserTunnel as UserTunnel,
|
||||
} from "@/pages/dashboard/use-dashboard-data";
|
||||
|
||||
interface AddressItem {
|
||||
id: number;
|
||||
@@ -56,176 +26,21 @@ interface AddressItem {
|
||||
copying: boolean;
|
||||
}
|
||||
|
||||
interface StatisticsFlow {
|
||||
id: number;
|
||||
userId: number;
|
||||
flow: number;
|
||||
totalFlow: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
|
||||
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||
const {
|
||||
loading,
|
||||
userInfo,
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
isAdmin,
|
||||
announcement,
|
||||
} = useDashboardData();
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState("");
|
||||
const [addressList, setAddressList] = useState<AddressItem[]>([]);
|
||||
|
||||
// 检查有效期通知
|
||||
const checkExpirationNotifications = (
|
||||
userInfo: UserInfo,
|
||||
tunnels: UserTunnel[],
|
||||
) => {
|
||||
// 避免重复通知,检查是否已经显示过
|
||||
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
|
||||
const lastNotified = localStorage.getItem("lastNotified");
|
||||
|
||||
if (lastNotified === notificationKey) {
|
||||
return; // 已经通知过,不重复显示
|
||||
}
|
||||
|
||||
let hasNotification = false;
|
||||
|
||||
// 检查主账户有效期
|
||||
if (userInfo.expTime) {
|
||||
const expDate = new Date(userInfo.expTime);
|
||||
const now = new Date();
|
||||
|
||||
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7 && diffDays > 0) {
|
||||
hasNotification = true;
|
||||
if (diffDays === 1) {
|
||||
toast("账户将于明天过期,请及时续费", {
|
||||
icon: "⚠️",
|
||||
duration: 6000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
} else {
|
||||
toast(`账户将于${diffDays}天后过期,请及时续费`, {
|
||||
icon: "⚠️",
|
||||
duration: 6000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
}
|
||||
} else if (diffDays <= 0) {
|
||||
hasNotification = true;
|
||||
toast("账户已过期,请立即续费", {
|
||||
icon: "⚠️",
|
||||
duration: 8000,
|
||||
style: { background: "#ef4444", color: "#fff" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查隧道有效期
|
||||
tunnels.forEach((tunnel) => {
|
||||
if (tunnel.expTime) {
|
||||
const expDate = new Date(tunnel.expTime);
|
||||
const now = new Date();
|
||||
|
||||
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7 && diffDays > 0) {
|
||||
hasNotification = true;
|
||||
if (diffDays === 1) {
|
||||
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
} else {
|
||||
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
}
|
||||
} else if (diffDays <= 0) {
|
||||
hasNotification = true;
|
||||
toast(`隧道"${tunnel.tunnelName}"已过期`, {
|
||||
icon: "⚠️",
|
||||
duration: 6000,
|
||||
style: { background: "#ef4444", color: "#fff" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果显示了通知,记录防止重复
|
||||
if (hasNotification) {
|
||||
localStorage.setItem("lastNotified", notificationKey);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setUserInfo({} as UserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
|
||||
const adminStatus = localStorage.getItem("admin");
|
||||
|
||||
setIsAdmin(adminStatus === "true");
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcement:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
|
||||
setUserInfo(data.userInfo || {});
|
||||
setUserTunnels(data.tunnelPermissions || []);
|
||||
setForwardList(data.forwards || []);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
// 检查有效期并显示通知
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取套餐信息失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
// 99999 表示无限制
|
||||
if (value === 99999) {
|
||||
@@ -283,7 +98,7 @@ export default function DashboardPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const getExpStatus = (expTime?: string) => {
|
||||
const getExpStatus = (expTime?: string | number) => {
|
||||
if (!expTime)
|
||||
return {
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
@@ -444,7 +259,7 @@ export default function DashboardPage() {
|
||||
const now = new Date();
|
||||
const currentDay = now.getDate();
|
||||
|
||||
let daysUntilReset;
|
||||
let daysUntilReset: number;
|
||||
|
||||
if (resetDay > currentDay) {
|
||||
daysUntilReset = resetDay - currentDay;
|
||||
@@ -527,7 +342,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
const firstIp = items[0];
|
||||
let formattedFirstIp;
|
||||
let formattedFirstIp: string;
|
||||
|
||||
if (firstIp.includes(":") && !firstIp.startsWith("[")) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
@@ -592,7 +407,7 @@ export default function DashboardPage() {
|
||||
// 检查是否已经包含端口
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
|
||||
let formattedList;
|
||||
let formattedList: AddressItem[];
|
||||
|
||||
if (hasPort) {
|
||||
// 已经包含完整的 IP:Port 组合,直接使用
|
||||
@@ -605,7 +420,7 @@ export default function DashboardPage() {
|
||||
} else {
|
||||
// 只包含IP,需要添加端口
|
||||
formattedList = items.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
let formattedAddress: string;
|
||||
|
||||
if (ip.includes(":") && !ip.startsWith("[")) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
@@ -703,324 +518,147 @@ export default function DashboardPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full" />
|
||||
<span className="text-default-600">正在加载数据...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageLoadingState message="正在加载数据..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
{announcement && announcement.content && (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
{announcement.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{announcement && <AnnouncementBanner announcement={announcement} />}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
总流量
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{formatFlow(userInfo.flow, "gb")}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
已用流量
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-green-100 dark:bg-green-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{formatFlow(calculateUserTotalUsedFlow())}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("flow"),
|
||||
"sm",
|
||||
userInfo.flow === 99999,
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
{userInfo.flow === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("flow").toFixed(1)}%`}
|
||||
</p>
|
||||
{userInfo.flowResetTime !== undefined &&
|
||||
userInfo.flowResetTime !== null && (
|
||||
<div className="text-xs text-default-500 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">
|
||||
{formatResetTime(userInfo.flowResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
转发配额
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-purple-100 dark:bg-purple-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{formatNumber(userInfo.num || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
已用转发
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-orange-100 dark:bg-orange-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{forwardList.length}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("forwards"),
|
||||
"sm",
|
||||
userInfo.num === 99999,
|
||||
)}
|
||||
<p className="text-xs text-default-500 mt-1 truncate">
|
||||
{userInfo.num === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("forwards").toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 24小时流量统计图表 */}
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MetricCard
|
||||
icon={
|
||||
<svg
|
||||
className="w-5 h-5 text-primary"
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
24小时流量统计
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{statisticsFlows.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
className="w-12 h-12 text-default-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-default-500">暂无流量统计数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 流量趋势图 */}
|
||||
<div className="h-64 lg:h-80 w-full">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={processFlowChartData()}>
|
||||
<CartesianGrid
|
||||
className="opacity-30"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<XAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value: number | string) => {
|
||||
const v =
|
||||
typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
iconClassName="bg-blue-100 dark:bg-blue-500/20"
|
||||
title="总流量"
|
||||
value={formatFlow(userInfo.flow, "gb")}
|
||||
/>
|
||||
|
||||
if (!Number.isFinite(v)) return String(value);
|
||||
if (v === 0) return "0";
|
||||
if (v < 1024) return `${v}B`;
|
||||
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
|
||||
if (v < 1024 * 1024 * 1024)
|
||||
return `${(v / (1024 * 1024)).toFixed(1)}M`;
|
||||
|
||||
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
}}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value?: number | string }>;
|
||||
label?: string | number;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const firstValue = payload[0]?.value;
|
||||
const numericValue =
|
||||
typeof firstValue === "number"
|
||||
? firstValue
|
||||
: Number(firstValue);
|
||||
const flowValue = Number.isFinite(numericValue)
|
||||
? numericValue
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
|
||||
<p className="text-primary">
|
||||
{`流量: ${formatFlow(flowValue)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: "#8b5cf6",
|
||||
strokeWidth: 2,
|
||||
fill: "#fff",
|
||||
}}
|
||||
dataKey="flow"
|
||||
dot={false}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<MetricCard
|
||||
bottomContent={
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("flow"),
|
||||
"sm",
|
||||
userInfo.flow === 99999,
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
{userInfo.flow === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("flow").toFixed(1)}%`}
|
||||
</p>
|
||||
{userInfo.flowResetTime !== undefined &&
|
||||
userInfo.flowResetTime !== null && (
|
||||
<div className="text-xs text-default-500 flex items-center gap-1">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">
|
||||
{formatResetTime(userInfo.flowResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
}
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-green-100 dark:bg-green-500/20"
|
||||
title="已用流量"
|
||||
value={formatFlow(calculateUserTotalUsedFlow())}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-purple-100 dark:bg-purple-500/20"
|
||||
title="转发配额"
|
||||
value={formatNumber(userInfo.num || 0)}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
bottomContent={
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("forwards"),
|
||||
"sm",
|
||||
userInfo.num === 99999,
|
||||
)}
|
||||
<p className="text-xs text-default-500 mt-1 truncate">
|
||||
{userInfo.num === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("forwards").toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-orange-100 dark:bg-orange-500/20"
|
||||
title="已用转发"
|
||||
value={forwardList.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FlowChartCard
|
||||
chartData={processFlowChartData()}
|
||||
formatFlow={formatFlow}
|
||||
statisticsFlowsCount={statisticsFlows.length}
|
||||
/>
|
||||
|
||||
{/* 隧道权限 - 管理员不显示 */}
|
||||
{!isAdmin && (
|
||||
@@ -1028,6 +666,7 @@ export default function DashboardPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1048,22 +687,7 @@ export default function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{userTunnels.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
className="w-12 h-12 text-default-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-default-500">暂无隧道权限</p>
|
||||
</div>
|
||||
<PageEmptyState className="h-48" message="暂无隧道权限" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{userTunnels.map((tunnel) => {
|
||||
@@ -1164,6 +788,7 @@ export default function DashboardPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1186,6 +811,7 @@ export default function DashboardPage() {
|
||||
{groupedForwards().length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-12 h-12 text-default-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1324,7 +950,7 @@ export default function DashboardPage() {
|
||||
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
||||
<ModalBody className="pb-6">
|
||||
<div className="mb-4 text-right">
|
||||
<Button size="sm" onClick={copyAllAddresses}>
|
||||
<Button size="sm" onPress={copyAllAddresses}>
|
||||
复制全部
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1342,7 +968,7 @@ export default function DashboardPage() {
|
||||
isLoading={item.copying}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => copyAddress(item)}
|
||||
onPress={() => copyAddress(item)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AnnouncementData } from "@/api";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
|
||||
interface AnnouncementBannerProps {
|
||||
announcement: AnnouncementData;
|
||||
}
|
||||
|
||||
export const AnnouncementBanner = ({
|
||||
announcement,
|
||||
}: AnnouncementBannerProps) => {
|
||||
if (!announcement.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start justify-start gap-3.5">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0 flex items-center justify-center mt-0.5">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3 className="text-base font-semibold leading-none text-blue-900 dark:text-blue-100 mb-1.5">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{announcement.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { PageEmptyState } from "@/components/page-state";
|
||||
|
||||
interface FlowChartPoint {
|
||||
time: string;
|
||||
flow: number;
|
||||
}
|
||||
|
||||
interface FlowChartCardProps {
|
||||
statisticsFlowsCount: number;
|
||||
chartData: FlowChartPoint[];
|
||||
formatFlow: (value: number, unit?: string) => string;
|
||||
}
|
||||
|
||||
export const FlowChartCard = ({
|
||||
statisticsFlowsCount,
|
||||
chartData,
|
||||
formatFlow,
|
||||
}: FlowChartCardProps) => {
|
||||
return (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
24小时流量统计
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{statisticsFlowsCount === 0 ? (
|
||||
<PageEmptyState className="h-48" message="暂无流量统计数据" />
|
||||
) : (
|
||||
<div className="h-64 lg:h-80 w-full">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid className="opacity-30" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value: number | string) => {
|
||||
const v = typeof value === "number" ? value : Number(value);
|
||||
|
||||
if (!Number.isFinite(v)) return String(value);
|
||||
if (v === 0) return "0";
|
||||
if (v < 1024) return `${v}B`;
|
||||
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
|
||||
if (v < 1024 * 1024 * 1024)
|
||||
return `${(v / (1024 * 1024)).toFixed(1)}M`;
|
||||
|
||||
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
}}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value?: number | string }>;
|
||||
label?: string | number;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const firstValue = payload[0]?.value;
|
||||
const numericValue =
|
||||
typeof firstValue === "number"
|
||||
? firstValue
|
||||
: Number(firstValue);
|
||||
const flowValue = Number.isFinite(numericValue)
|
||||
? numericValue
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
|
||||
<p className="text-primary">{`流量: ${formatFlow(flowValue)}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: "#8b5cf6",
|
||||
strokeWidth: 2,
|
||||
fill: "#fff",
|
||||
}}
|
||||
dataKey="flow"
|
||||
dot={false}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
iconClassName: string;
|
||||
icon: ReactNode;
|
||||
bottomContent?: ReactNode;
|
||||
}
|
||||
|
||||
export const MetricCard = ({
|
||||
title,
|
||||
value,
|
||||
iconClassName,
|
||||
icon,
|
||||
bottomContent,
|
||||
}: MetricCardProps) => {
|
||||
return (
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
{title}
|
||||
</p>
|
||||
<div
|
||||
className={`p-1.5 lg:p-2 rounded-lg flex-shrink-0 ${iconClassName}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{value}
|
||||
</p>
|
||||
{bottomContent}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getAnnouncement,
|
||||
getUserPackageInfo,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
export interface DashboardUserInfo {
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string | number;
|
||||
flowResetTime?: number;
|
||||
}
|
||||
|
||||
export interface DashboardUserTunnel {
|
||||
id: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: number;
|
||||
flowResetTime?: number;
|
||||
tunnelFlow?: number;
|
||||
}
|
||||
|
||||
export interface DashboardForward {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
inIp: string;
|
||||
inPort: number;
|
||||
remoteAddr: string;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
}
|
||||
|
||||
export interface DashboardStatisticsFlow {
|
||||
id: number;
|
||||
userId: number;
|
||||
flow: number;
|
||||
totalFlow: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface DashboardDataState {
|
||||
loading: boolean;
|
||||
userInfo: DashboardUserInfo;
|
||||
userTunnels: DashboardUserTunnel[];
|
||||
forwardList: DashboardForward[];
|
||||
statisticsFlows: DashboardStatisticsFlow[];
|
||||
isAdmin: boolean;
|
||||
announcement: AnnouncementData | null;
|
||||
}
|
||||
|
||||
const checkExpirationNotifications = (
|
||||
userInfo: DashboardUserInfo,
|
||||
tunnels: DashboardUserTunnel[],
|
||||
) => {
|
||||
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
|
||||
const lastNotified = localStorage.getItem("lastNotified");
|
||||
|
||||
if (lastNotified === notificationKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasNotification = false;
|
||||
|
||||
if (userInfo.expTime) {
|
||||
const expDate = new Date(userInfo.expTime);
|
||||
const now = new Date();
|
||||
|
||||
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7 && diffDays > 0) {
|
||||
hasNotification = true;
|
||||
if (diffDays === 1) {
|
||||
toast("账户将于明天过期,请及时续费", {
|
||||
icon: "⚠️",
|
||||
duration: 6000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
} else {
|
||||
toast(`账户将于${diffDays}天后过期,请及时续费`, {
|
||||
icon: "⚠️",
|
||||
duration: 6000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
}
|
||||
} else if (diffDays <= 0) {
|
||||
hasNotification = true;
|
||||
toast("账户已过期,请立即续费", {
|
||||
icon: "⚠️",
|
||||
duration: 8000,
|
||||
style: { background: "#ef4444", color: "#fff" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tunnels.forEach((tunnel) => {
|
||||
if (!tunnel.expTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expDate = new Date(tunnel.expTime);
|
||||
const now = new Date();
|
||||
|
||||
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7 && diffDays > 0) {
|
||||
hasNotification = true;
|
||||
if (diffDays === 1) {
|
||||
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
} else {
|
||||
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
style: { background: "#f59e0b", color: "#fff" },
|
||||
});
|
||||
}
|
||||
} else if (diffDays <= 0) {
|
||||
hasNotification = true;
|
||||
toast(`隧道"${tunnel.tunnelName}"已过期`, {
|
||||
icon: "⚠️",
|
||||
duration: 6000,
|
||||
style: { background: "#ef4444", color: "#fff" },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNotification) {
|
||||
localStorage.setItem("lastNotified", notificationKey);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeForwards = (items: ForwardApiItem[]) => {
|
||||
return (items || []).map((item) => ({
|
||||
...item,
|
||||
name: item.name || "",
|
||||
tunnelId: item.tunnelId ?? 0,
|
||||
tunnelName: item.tunnelName || "",
|
||||
inIp: item.inIp || "",
|
||||
inPort: item.inPort || 0,
|
||||
remoteAddr: item.remoteAddr || "",
|
||||
inFlow: item.inFlow || 0,
|
||||
outFlow: item.outFlow || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
|
||||
return (items || []).map((item) => ({
|
||||
...item,
|
||||
inFlow: item.inFlow ?? 0,
|
||||
outFlow: item.outFlow ?? 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export const useDashboardData = (): DashboardDataState => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
||||
{} as DashboardUserInfo,
|
||||
);
|
||||
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||
DashboardStatisticsFlow[]
|
||||
>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const res = await getAnnouncement();
|
||||
|
||||
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||
setAnnouncement(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcement:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取套餐信息失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setUserInfo({} as DashboardUserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
setIsAdmin(getAdminFlag());
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
userInfo,
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
isAdmin,
|
||||
announcement,
|
||||
};
|
||||
};
|
||||
+626
-659
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
export interface ForwardAddressItem {
|
||||
id: number;
|
||||
address: string;
|
||||
copying: boolean;
|
||||
}
|
||||
|
||||
export type ForwardAddressAction =
|
||||
| { type: "none" }
|
||||
| { type: "copy"; text: string; label: string }
|
||||
| { type: "modal"; title: string; items: ForwardAddressItem[] };
|
||||
|
||||
const splitAddressEntries = (value: string): string[] => {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item);
|
||||
};
|
||||
|
||||
const formatAddressWithPort = (ip: string, port: number): string => {
|
||||
if (ip.includes(":") && !ip.startsWith("[")) {
|
||||
return `[${ip}]:${port}`;
|
||||
}
|
||||
|
||||
return `${ip}:${port}`;
|
||||
};
|
||||
|
||||
export const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const items = splitAddressEntries(ipString);
|
||||
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
|
||||
if (hasPort) {
|
||||
if (items.length === 1) {
|
||||
return items[0];
|
||||
}
|
||||
|
||||
return `${items[0]} (+${items.length - 1}个)`;
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (items.length === 1) {
|
||||
return formatAddressWithPort(items[0], port);
|
||||
}
|
||||
|
||||
return `${formatAddressWithPort(items[0], port)} (+${items.length - 1}个)`;
|
||||
};
|
||||
|
||||
export const formatRemoteAddress = (addressString: string): string => {
|
||||
if (!addressString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const addresses = splitAddressEntries(addressString);
|
||||
|
||||
if (addresses.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (addresses.length === 1) {
|
||||
return addresses[0];
|
||||
}
|
||||
|
||||
return `${addresses[0]} (+${addresses.length - 1})`;
|
||||
};
|
||||
|
||||
export const hasMultipleAddresses = (addressString: string): boolean => {
|
||||
if (!addressString) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return splitAddressEntries(addressString).length > 1;
|
||||
};
|
||||
|
||||
export const resolveForwardAddressAction = (
|
||||
addressString: string,
|
||||
port: number | null,
|
||||
title: string,
|
||||
): ForwardAddressAction => {
|
||||
if (!addressString) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
let addresses: string[];
|
||||
|
||||
if (port !== null) {
|
||||
const items = splitAddressEntries(addressString);
|
||||
|
||||
if (items.length <= 1) {
|
||||
return {
|
||||
type: "copy",
|
||||
text: formatInAddress(addressString, port),
|
||||
label: title,
|
||||
};
|
||||
}
|
||||
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
|
||||
addresses = hasPort
|
||||
? items
|
||||
: items.map((ip) => formatAddressWithPort(ip, port));
|
||||
} else {
|
||||
addresses = splitAddressEntries(addressString);
|
||||
|
||||
if (addresses.length <= 1) {
|
||||
return {
|
||||
type: "copy",
|
||||
text: addressString,
|
||||
label: title,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "modal",
|
||||
title: `${title} (${addresses.length}个)`,
|
||||
items: addresses.map((address, index) => ({
|
||||
id: index,
|
||||
address,
|
||||
copying: false,
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { BatchOperationResult } from "@/api/types";
|
||||
|
||||
import {
|
||||
batchChangeTunnel,
|
||||
batchDeleteForwards,
|
||||
batchPauseForwards,
|
||||
batchRedeployForwards,
|
||||
batchResumeForwards,
|
||||
} from "@/api";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
|
||||
export interface ForwardBatchActionOutcome {
|
||||
toastVariant: "success" | "error";
|
||||
toastMessage: string;
|
||||
shouldRefresh: boolean;
|
||||
closeDeleteModal?: boolean;
|
||||
closeChangeTunnelModal?: boolean;
|
||||
resetTargetTunnel?: boolean;
|
||||
}
|
||||
|
||||
const normalizeBatchResult = (value: unknown): BatchOperationResult => {
|
||||
const raw = (value ?? {}) as Partial<BatchOperationResult>;
|
||||
|
||||
return {
|
||||
successCount: Number(raw.successCount ?? 0),
|
||||
failCount: Number(raw.failCount ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const buildBatchToast = (
|
||||
result: BatchOperationResult,
|
||||
successText: string,
|
||||
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
|
||||
if (result.failCount === 0) {
|
||||
return {
|
||||
toastVariant: "success",
|
||||
toastMessage: successText,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
};
|
||||
};
|
||||
|
||||
export const executeForwardBatchDelete = async (
|
||||
ids: number[],
|
||||
): Promise<ForwardBatchActionOutcome> => {
|
||||
try {
|
||||
const response = await batchDeleteForwards(ids);
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: response.msg || "删除失败",
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
closeDeleteModal: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: extractApiErrorMessage(error, "删除失败"),
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const executeForwardBatchToggleService = async (
|
||||
ids: number[],
|
||||
enable: boolean,
|
||||
): Promise<ForwardBatchActionOutcome> => {
|
||||
const fallback = enable ? "启用失败" : "停用失败";
|
||||
|
||||
try {
|
||||
const response = enable
|
||||
? await batchResumeForwards(ids)
|
||||
: await batchPauseForwards(ids);
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: response.msg || fallback,
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(
|
||||
summary,
|
||||
enable
|
||||
? `成功启用 ${summary.successCount} 项`
|
||||
: `成功停用 ${summary.successCount} 项`,
|
||||
),
|
||||
shouldRefresh: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: extractApiErrorMessage(error, fallback),
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const executeForwardBatchRedeploy = async (
|
||||
ids: number[],
|
||||
): Promise<ForwardBatchActionOutcome> => {
|
||||
try {
|
||||
const response = await batchRedeployForwards(ids);
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: response.msg || "下发失败",
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: extractApiErrorMessage(error, "下发失败"),
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const executeForwardBatchChangeTunnel = async (
|
||||
ids: number[],
|
||||
targetTunnelId: number,
|
||||
): Promise<ForwardBatchActionOutcome> => {
|
||||
try {
|
||||
const response = await batchChangeTunnel({
|
||||
forwardIds: ids,
|
||||
targetTunnelId,
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: response.msg || "隧道失败",
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
||||
shouldRefresh: true,
|
||||
closeChangeTunnelModal: true,
|
||||
resetTargetTunnel: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: extractApiErrorMessage(error, "隧道失败"),
|
||||
shouldRefresh: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
export interface ForwardDiagnosisEntry {
|
||||
success: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
targetIp: string;
|
||||
targetPort?: number;
|
||||
message?: string;
|
||||
averageTime?: number;
|
||||
packetLoss?: number;
|
||||
fromChainType?: number;
|
||||
fromInx?: number;
|
||||
toChainType?: number;
|
||||
toInx?: number;
|
||||
}
|
||||
|
||||
export interface ForwardDiagnosisResult {
|
||||
forwardName: string;
|
||||
timestamp: number;
|
||||
results: ForwardDiagnosisEntry[];
|
||||
}
|
||||
|
||||
export interface ForwardDiagnosisFallbackInput {
|
||||
forwardName: string;
|
||||
remoteAddr: string;
|
||||
description: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const pickPrimaryTargetIp = (remoteAddr: string): string => {
|
||||
return remoteAddr.split(",")[0] || "-";
|
||||
};
|
||||
|
||||
export const buildForwardDiagnosisFallbackResult = ({
|
||||
forwardName,
|
||||
remoteAddr,
|
||||
description,
|
||||
message,
|
||||
}: ForwardDiagnosisFallbackInput): ForwardDiagnosisResult => {
|
||||
return {
|
||||
forwardName,
|
||||
timestamp: Date.now(),
|
||||
results: [
|
||||
{
|
||||
success: false,
|
||||
description,
|
||||
nodeName: "-",
|
||||
nodeId: "-",
|
||||
targetIp: pickPrimaryTargetIp(remoteAddr),
|
||||
message,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const getForwardDiagnosisQualityDisplay = (
|
||||
averageTime?: number,
|
||||
packetLoss?: number,
|
||||
): {
|
||||
text: string;
|
||||
color: "success" | "primary" | "warning" | "danger";
|
||||
} | null => {
|
||||
if (averageTime === undefined || packetLoss === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (averageTime < 30 && packetLoss === 0) {
|
||||
return { text: "🚀 优秀", color: "success" };
|
||||
}
|
||||
|
||||
if (averageTime < 50 && packetLoss === 0) {
|
||||
return { text: "✨ 很好", color: "success" };
|
||||
}
|
||||
|
||||
if (averageTime < 100 && packetLoss < 1) {
|
||||
return { text: "👍 良好", color: "primary" };
|
||||
}
|
||||
|
||||
if (averageTime < 150 && packetLoss < 2) {
|
||||
return { text: "😐 一般", color: "warning" };
|
||||
}
|
||||
|
||||
if (averageTime < 200 && packetLoss < 5) {
|
||||
return { text: "😟 较差", color: "warning" };
|
||||
}
|
||||
|
||||
return { text: "😵 很差", color: "danger" };
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { loadStoredOrder } from "@/utils/order-storage";
|
||||
|
||||
export interface ForwardOrderItem {
|
||||
id: number;
|
||||
userId?: number;
|
||||
inx?: number;
|
||||
}
|
||||
|
||||
export const FORWARD_ORDER_KEY = "forward-order";
|
||||
|
||||
export const getUserScopedForwards = <T extends ForwardOrderItem>(
|
||||
forwards: T[],
|
||||
currentUserId: number | null,
|
||||
): T[] => {
|
||||
if (currentUserId === null) {
|
||||
return forwards;
|
||||
}
|
||||
|
||||
return forwards.filter((item) => item.userId === currentUserId);
|
||||
};
|
||||
|
||||
export const buildForwardOrder = <T extends ForwardOrderItem>(
|
||||
forwards: T[],
|
||||
currentUserId: number | null,
|
||||
): { order: number[]; fromDatabase: boolean } => {
|
||||
const userForwards = getUserScopedForwards(forwards, currentUserId);
|
||||
|
||||
const hasDbOrdering = userForwards.some(
|
||||
(item) => item.inx !== undefined && item.inx !== 0,
|
||||
);
|
||||
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...userForwards]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((item) => item.id);
|
||||
|
||||
return { order: dbOrder, fromDatabase: true };
|
||||
}
|
||||
|
||||
return {
|
||||
order: loadStoredOrder(
|
||||
FORWARD_ORDER_KEY,
|
||||
userForwards.map((item) => item.id),
|
||||
),
|
||||
fromDatabase: false,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -9,8 +11,8 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,11 +20,9 @@ import {
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@heroui/table";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import {
|
||||
assignGroupPermission,
|
||||
assignTunnelsToGroup,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
updateTunnelGroup,
|
||||
updateUserGroup,
|
||||
} from "@/api";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
interface TunnelItem {
|
||||
id: number;
|
||||
@@ -86,22 +87,9 @@ const formatDate = (timestamp?: number): string => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const isAdminUser = () => {
|
||||
let adminFlag = localStorage.getItem("admin") === "true";
|
||||
|
||||
if (localStorage.getItem("admin") === null) {
|
||||
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
|
||||
|
||||
adminFlag = roleId === 0;
|
||||
localStorage.setItem("admin", adminFlag.toString());
|
||||
}
|
||||
|
||||
return adminFlag;
|
||||
};
|
||||
|
||||
export default function GroupPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin] = useState(isAdminUser());
|
||||
const [isAdmin] = useState(getAdminFlag());
|
||||
|
||||
const [tunnelGroups, setTunnelGroups] = useState<TunnelGroup[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
@@ -185,6 +173,22 @@ export default function GroupPage() {
|
||||
return map;
|
||||
}, [users]);
|
||||
|
||||
const selectedTunnelSummary = useMemo(() => {
|
||||
const value = Array.from(selectedTunnelKeys)
|
||||
.map((id) => tunnelNameMap.get(Number(id)) || id)
|
||||
.join("、");
|
||||
|
||||
return value || "无";
|
||||
}, [selectedTunnelKeys, tunnelNameMap]);
|
||||
|
||||
const selectedUserSummary = useMemo(() => {
|
||||
const value = Array.from(selectedUserKeys)
|
||||
.map((id) => userNameMap.get(Number(id)) || id)
|
||||
.join("、");
|
||||
|
||||
return value || "无";
|
||||
}, [selectedUserKeys, userNameMap]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -198,19 +202,25 @@ export default function GroupPage() {
|
||||
]);
|
||||
|
||||
if (tunnelGroupRes.code === 0) {
|
||||
setTunnelGroups(tunnelGroupRes.data || []);
|
||||
setTunnelGroups(
|
||||
Array.isArray(tunnelGroupRes.data) ? tunnelGroupRes.data : [],
|
||||
);
|
||||
}
|
||||
if (userGroupRes.code === 0) {
|
||||
setUserGroups(userGroupRes.data || []);
|
||||
setUserGroups(
|
||||
Array.isArray(userGroupRes.data) ? userGroupRes.data : [],
|
||||
);
|
||||
}
|
||||
if (permissionRes.code === 0) {
|
||||
setPermissions(permissionRes.data || []);
|
||||
setPermissions(
|
||||
Array.isArray(permissionRes.data) ? permissionRes.data : [],
|
||||
);
|
||||
}
|
||||
if (tunnelRes.code === 0) {
|
||||
setTunnels(tunnelRes.data || []);
|
||||
setTunnels(Array.isArray(tunnelRes.data) ? tunnelRes.data : []);
|
||||
}
|
||||
if (userRes.code === 0) {
|
||||
setUsers(userRes.data || []);
|
||||
setUsers(Array.isArray(userRes.data) ? userRes.data : []);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -605,7 +615,7 @@ export default function GroupPage() {
|
||||
<h3 className="text-lg font-semibold">权限分配</h3>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
|
||||
<Select
|
||||
items={userGroups}
|
||||
label="用户分组"
|
||||
@@ -635,8 +645,10 @@ export default function GroupPage() {
|
||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||
</Select>
|
||||
<Button
|
||||
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
|
||||
color="primary"
|
||||
isLoading={savingPermission}
|
||||
size="sm"
|
||||
onPress={handleAssignPermission}
|
||||
>
|
||||
分配权限
|
||||
@@ -770,8 +782,10 @@ export default function GroupPage() {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalBody className="min-w-0">
|
||||
<Select
|
||||
className="min-w-0"
|
||||
classNames={{ trigger: "max-w-full" }}
|
||||
items={tunnels}
|
||||
label="选择隧道"
|
||||
selectedKeys={selectedTunnelKeys}
|
||||
@@ -784,11 +798,11 @@ export default function GroupPage() {
|
||||
>
|
||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||
</Select>
|
||||
<p className="text-xs text-default-500">
|
||||
当前已选:
|
||||
{Array.from(selectedTunnelKeys)
|
||||
.map((id) => tunnelNameMap.get(Number(id)) || id)
|
||||
.join("、") || "无"}
|
||||
<p
|
||||
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
|
||||
title={`当前已选:${selectedTunnelSummary}`}
|
||||
>
|
||||
当前已选:{selectedTunnelSummary}
|
||||
</p>
|
||||
<p className="text-xs text-default-500">
|
||||
不选择任何隧道并保存将清空该分组成员。
|
||||
@@ -815,8 +829,10 @@ export default function GroupPage() {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalBody className="min-w-0">
|
||||
<Select
|
||||
className="min-w-0"
|
||||
classNames={{ trigger: "max-w-full" }}
|
||||
items={users}
|
||||
label="选择用户"
|
||||
selectedKeys={selectedUserKeys}
|
||||
@@ -829,11 +845,11 @@ export default function GroupPage() {
|
||||
>
|
||||
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
|
||||
</Select>
|
||||
<p className="text-xs text-default-500">
|
||||
当前已选:
|
||||
{Array.from(selectedUserKeys)
|
||||
.map((id) => userNameMap.get(Number(id)) || id)
|
||||
.join("、") || "无"}
|
||||
<p
|
||||
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
|
||||
title={`当前已选:${selectedUserSummary}`}
|
||||
>
|
||||
当前已选:{selectedUserSummary}
|
||||
</p>
|
||||
<p className="text-xs text-default-500">
|
||||
不选择任何用户并保存将清空该分组成员。
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
|
||||
import { writeLoginSession } from "@/utils/session";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
interface LoginForm {
|
||||
username: string;
|
||||
@@ -29,12 +30,7 @@ export default function IndexPage() {
|
||||
const [showCaptcha, setShowCaptcha] = useState(false);
|
||||
const [siteKey, setSiteKey] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
// 检测是否在WebView中运行
|
||||
useEffect(() => {
|
||||
setIsWebView(isWebViewFunc());
|
||||
}, []);
|
||||
const isWebView = useWebViewMode();
|
||||
|
||||
// 验证表单
|
||||
const validateForm = (): boolean => {
|
||||
@@ -91,10 +87,7 @@ export default function IndexPage() {
|
||||
|
||||
// 检查是否需要强制修改密码
|
||||
if (response.data.requirePasswordChange) {
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
writeLoginSession(response.data);
|
||||
toast.success("检测到默认密码,即将跳转到修改密码页面");
|
||||
navigate("/change-password");
|
||||
|
||||
@@ -102,10 +95,7 @@ export default function IndexPage() {
|
||||
}
|
||||
|
||||
// 保存登录信息
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
writeLoginSession(response.data);
|
||||
|
||||
// 登录成功
|
||||
toast.success("登录成功");
|
||||
@@ -206,7 +196,7 @@ export default function IndexPage() {
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
onPress={handleLogin}
|
||||
>
|
||||
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
|
||||
</Button>
|
||||
@@ -238,10 +228,9 @@ export default function IndexPage() {
|
||||
{showCaptcha && siteKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
|
||||
<div
|
||||
<button
|
||||
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCaptcha(false);
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
deleteSpeedLimit,
|
||||
getTunnelList,
|
||||
} from "@/api";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
|
||||
interface SpeedLimitRule {
|
||||
id: number;
|
||||
@@ -188,7 +188,7 @@ export default function LimitPage() {
|
||||
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
let res;
|
||||
let res: { code: number; msg: string };
|
||||
|
||||
if (isEdit) {
|
||||
res = await updateSpeedLimit(form);
|
||||
@@ -215,14 +215,7 @@ export default function LimitPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -242,9 +235,9 @@ export default function LimitPage() {
|
||||
{rules.map((rule) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
@@ -260,7 +253,7 @@ export default function LimitPage() {
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
@@ -293,6 +286,7 @@ export default function LimitPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -311,6 +305,7 @@ export default function LimitPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -344,6 +339,7 @@ export default function LimitPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
+120
-320
@@ -1,25 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Textarea } from "@heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import axios from "axios";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -38,6 +18,25 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
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 {
|
||||
createNode,
|
||||
getNodeList,
|
||||
@@ -51,6 +50,16 @@ import {
|
||||
getNodeReleases,
|
||||
rollbackNode,
|
||||
} from "@/api";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import {
|
||||
getConnectionStatusMeta,
|
||||
getRemoteSyncErrorMessage,
|
||||
} from "@/pages/node/display";
|
||||
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
@@ -140,8 +149,6 @@ export default function NodePage() {
|
||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnecting, setWsConnecting] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
@@ -200,53 +207,26 @@ export default function NodePage() {
|
||||
Record<number, { stage: string; percent: number; message: string }>
|
||||
>({});
|
||||
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const offlineTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const offlineDelayMs = 3000;
|
||||
const handleNodeOffline = useCallback((nodeId: number) => {
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (node.connectionStatus === "offline" && node.systemInfo === null) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const clearOfflineTimer = (nodeId: number) => {
|
||||
const timer = offlineTimersRef.current.get(nodeId);
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
offlineTimersRef.current.delete(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNodeOffline = (nodeId: number) => {
|
||||
if (offlineTimersRef.current.has(nodeId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
offlineTimersRef.current.delete(nodeId);
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (node.connectionStatus === "offline" && node.systemInfo === null)
|
||||
return node;
|
||||
|
||||
return { ...node, connectionStatus: "offline", systemInfo: null };
|
||||
}),
|
||||
);
|
||||
}, offlineDelayMs);
|
||||
|
||||
offlineTimersRef.current.set(nodeId, timer);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
initWebSocket();
|
||||
|
||||
return () => {
|
||||
closeWebSocket();
|
||||
};
|
||||
return { ...node, connectionStatus: "offline", systemInfo: null };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({
|
||||
delayMs: 3000,
|
||||
onNodeOffline: handleNodeOffline,
|
||||
});
|
||||
|
||||
// 加载节点列表
|
||||
const loadNodes = async () => {
|
||||
const loadNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
@@ -279,32 +259,12 @@ export default function NodePage() {
|
||||
|
||||
setNodeOrder(dbOrder);
|
||||
} else {
|
||||
try {
|
||||
const stored = localStorage.getItem("node-order");
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const existingIds = new Set(nodesData.map((n) => n.id));
|
||||
const validOrder = parsed
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => existingIds.has(id));
|
||||
|
||||
if (validOrder.length > 0) {
|
||||
setNodeOrder(validOrder);
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} catch {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
setNodeOrder(
|
||||
loadStoredOrder(
|
||||
"node-order",
|
||||
nodesData.map((n) => n.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
@@ -314,68 +274,7 @@ export default function NodePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化WebSocket连接
|
||||
const initWebSocket = () => {
|
||||
if (
|
||||
websocketRef.current &&
|
||||
(websocketRef.current.readyState === WebSocket.OPEN ||
|
||||
websocketRef.current.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (websocketRef.current) {
|
||||
closeWebSocket();
|
||||
}
|
||||
|
||||
// 构建WebSocket URL,使用axios的baseURL
|
||||
const baseUrl =
|
||||
axios.defaults.baseURL ||
|
||||
(import.meta.env.VITE_API_BASE
|
||||
? `${import.meta.env.VITE_API_BASE}/api/v1/`
|
||||
: "/api/v1/");
|
||||
const wsUrl =
|
||||
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
|
||||
`/system-info?type=0&secret=${localStorage.getItem("token")}`;
|
||||
|
||||
try {
|
||||
setWsConnecting(true);
|
||||
websocketRef.current = new WebSocket(wsUrl);
|
||||
|
||||
websocketRef.current.onopen = () => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(true);
|
||||
setWsConnecting(false);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
handleWebSocketMessage(data);
|
||||
} catch {
|
||||
// 解析失败时不输出错误信息
|
||||
}
|
||||
};
|
||||
|
||||
websocketRef.current.onerror = () => {
|
||||
// WebSocket错误时不输出错误信息
|
||||
};
|
||||
|
||||
websocketRef.current.onclose = () => {
|
||||
websocketRef.current = null;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
attemptReconnect();
|
||||
};
|
||||
} catch {
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理WebSocket消息
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
@@ -404,61 +303,20 @@ export default function NodePage() {
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
try {
|
||||
let systemInfo;
|
||||
const systemInfo = buildNodeSystemInfo(
|
||||
messageData,
|
||||
node.systemInfo,
|
||||
);
|
||||
|
||||
if (typeof messageData === "string") {
|
||||
systemInfo = JSON.parse(messageData);
|
||||
} else {
|
||||
systemInfo = messageData;
|
||||
}
|
||||
|
||||
const currentUpload = parseInt(systemInfo.bytes_transmitted) || 0;
|
||||
const currentDownload = parseInt(systemInfo.bytes_received) || 0;
|
||||
const currentUptime = parseInt(systemInfo.uptime) || 0;
|
||||
|
||||
let uploadSpeed = 0;
|
||||
let downloadSpeed = 0;
|
||||
|
||||
if (node.systemInfo && node.systemInfo.uptime) {
|
||||
const timeDiff = currentUptime - node.systemInfo.uptime;
|
||||
|
||||
if (timeDiff > 0 && timeDiff <= 10) {
|
||||
const lastUpload = node.systemInfo.uploadTraffic || 0;
|
||||
const lastDownload = node.systemInfo.downloadTraffic || 0;
|
||||
|
||||
const uploadDiff = currentUpload - lastUpload;
|
||||
const downloadDiff = currentDownload - lastDownload;
|
||||
|
||||
const uploadReset = currentUpload < lastUpload;
|
||||
const downloadReset = currentDownload < lastDownload;
|
||||
|
||||
if (!uploadReset && uploadDiff >= 0) {
|
||||
uploadSpeed = uploadDiff / timeDiff;
|
||||
}
|
||||
|
||||
if (!downloadReset && downloadDiff >= 0) {
|
||||
downloadSpeed = downloadDiff / timeDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
systemInfo: {
|
||||
cpuUsage: parseFloat(systemInfo.cpu_usage) || 0,
|
||||
memoryUsage: parseFloat(systemInfo.memory_usage) || 0,
|
||||
uploadTraffic: currentUpload,
|
||||
downloadTraffic: currentDownload,
|
||||
uploadSpeed: uploadSpeed,
|
||||
downloadSpeed: downloadSpeed,
|
||||
uptime: currentUptime,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
if (!systemInfo) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
systemInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
@@ -487,49 +345,13 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试重新连接
|
||||
const attemptReconnect = () => {
|
||||
if (reconnectTimerRef.current) return;
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const { wsConnected, wsConnecting } = useNodeRealtime({
|
||||
onMessage: handleWebSocketMessage,
|
||||
});
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
initWebSocket();
|
||||
}, 3000 * reconnectAttemptsRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭WebSocket连接
|
||||
const closeWebSocket = () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
offlineTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
offlineTimersRef.current.clear();
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (websocketRef.current) {
|
||||
websocketRef.current.onopen = null;
|
||||
websocketRef.current.onmessage = null;
|
||||
websocketRef.current.onerror = null;
|
||||
websocketRef.current.onclose = null;
|
||||
|
||||
if (
|
||||
websocketRef.current.readyState === WebSocket.OPEN ||
|
||||
websocketRef.current.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
websocketRef.current.close();
|
||||
}
|
||||
|
||||
websocketRef.current = null;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
}, [loadNodes]);
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
@@ -797,11 +619,11 @@ export default function NodePage() {
|
||||
const res = await getNodeInstallCommand(node.id);
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(res.data);
|
||||
const copied = await tryCopyInstallCommand(res.data);
|
||||
|
||||
if (copied) {
|
||||
toast.success("安装命令已复制到剪贴板");
|
||||
} catch {
|
||||
// 复制失败,显示安装命令模态框
|
||||
} else {
|
||||
setInstallCommand(res.data);
|
||||
setCurrentNodeName(node.name);
|
||||
setInstallCommandModal(true);
|
||||
@@ -1031,10 +853,7 @@ export default function NodePage() {
|
||||
|
||||
setNodeOrder(newOrder);
|
||||
|
||||
// 保存到 localStorage
|
||||
try {
|
||||
localStorage.setItem("node-order", JSON.stringify(newOrder));
|
||||
} catch {}
|
||||
saveOrder("node-order", newOrder);
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
@@ -1181,12 +1000,12 @@ export default function NodePage() {
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
color={selectMode ? "warning" : "default"}
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "取消多选" : "多选"}
|
||||
{selectMode ? "退出" : "批量"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
@@ -1201,10 +1020,20 @@ export default function NodePage() {
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button size="sm" variant="flat" onPress={selectAll}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1241,42 +1070,12 @@ export default function NodePage() {
|
||||
|
||||
{/* 节点列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : nodeList.length === 0 ? (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M5 12l4-4m-4 4l4 4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无节点配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何节点配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<PageEmptyState
|
||||
className="h-64"
|
||||
message="暂无节点配置,点击上方按钮开始创建"
|
||||
/>
|
||||
) : (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
@@ -1294,7 +1093,7 @@ export default function NodePage() {
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{selectMode && (
|
||||
@@ -1315,6 +1114,7 @@ export default function NodePage() {
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1332,34 +1132,31 @@ export default function NodePage() {
|
||||
远程
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={
|
||||
node.connectionStatus === "online"
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{node.connectionStatus === "online"
|
||||
? "在线"
|
||||
: "离线"}
|
||||
</Chip>
|
||||
{(() => {
|
||||
const connectionStatusMeta =
|
||||
getConnectionStatusMeta(
|
||||
node.connectionStatus,
|
||||
);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={connectionStatusMeta.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{connectionStatusMeta.text}
|
||||
</Chip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
{isRemoteNode && node.syncError && (
|
||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
? "提供方已删除该分享"
|
||||
: node.syncError === "provider_share_disabled"
|
||||
? "提供方已禁用该分享"
|
||||
: node.syncError === "provider_share_expired"
|
||||
? "提供方分享已过期"
|
||||
: `远程同步失败: ${node.syncError}`}
|
||||
{getRemoteSyncErrorMessage(node.syncError)}
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
@@ -1368,7 +1165,7 @@ export default function NodePage() {
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
</span>
|
||||
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
|
||||
{node.serverIpV4?.trim() ||
|
||||
node.serverIpV6?.trim() ? (
|
||||
<div className="space-y-0.5">
|
||||
@@ -1816,6 +1613,7 @@ export default function NodePage() {
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1856,6 +1654,7 @@ export default function NodePage() {
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1899,6 +1698,7 @@ export default function NodePage() {
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const getConnectionStatusMeta = (
|
||||
status: "online" | "offline",
|
||||
): { color: "success" | "danger"; text: string } => {
|
||||
if (status === "online") {
|
||||
return { color: "success", text: "在线" };
|
||||
}
|
||||
|
||||
return { color: "danger", text: "离线" };
|
||||
};
|
||||
|
||||
export const getRemoteSyncErrorMessage = (syncError: string): string => {
|
||||
if (syncError === "provider_share_deleted") {
|
||||
return "提供方已删除该分享";
|
||||
}
|
||||
|
||||
if (syncError === "provider_share_disabled") {
|
||||
return "提供方已禁用该分享";
|
||||
}
|
||||
|
||||
if (syncError === "provider_share_expired") {
|
||||
return "提供方分享已过期";
|
||||
}
|
||||
|
||||
return `远程同步失败: ${syncError}`;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export const tryCopyInstallCommand = async (
|
||||
command: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface NodeSystemInfo {
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
uploadTraffic: number;
|
||||
downloadTraffic: number;
|
||||
uploadSpeed: number;
|
||||
downloadSpeed: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
type RawSystemInfo = Record<string, string | number | undefined>;
|
||||
|
||||
const toInteger = (value: string | number | undefined): number => {
|
||||
return Number.parseInt(String(value ?? 0), 10) || 0;
|
||||
};
|
||||
|
||||
const toFloat = (value: string | number | undefined): number => {
|
||||
return Number.parseFloat(String(value ?? 0)) || 0;
|
||||
};
|
||||
|
||||
const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
|
||||
if (typeof messageData === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(messageData);
|
||||
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as RawSystemInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (messageData && typeof messageData === "object") {
|
||||
return messageData as RawSystemInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildNodeSystemInfo = (
|
||||
messageData: unknown,
|
||||
previous: NodeSystemInfo | null | undefined,
|
||||
): NodeSystemInfo | null => {
|
||||
const raw = parseRawSystemInfo(messageData);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uploadTraffic = toInteger(raw.bytes_transmitted);
|
||||
const downloadTraffic = toInteger(raw.bytes_received);
|
||||
const uptime = toInteger(raw.uptime);
|
||||
|
||||
let uploadSpeed = 0;
|
||||
let downloadSpeed = 0;
|
||||
|
||||
if (previous && previous.uptime) {
|
||||
const timeDiff = uptime - previous.uptime;
|
||||
|
||||
if (timeDiff > 0 && timeDiff <= 10) {
|
||||
const uploadDiff = uploadTraffic - previous.uploadTraffic;
|
||||
const downloadDiff = downloadTraffic - previous.downloadTraffic;
|
||||
|
||||
if (uploadTraffic >= previous.uploadTraffic && uploadDiff >= 0) {
|
||||
uploadSpeed = uploadDiff / timeDiff;
|
||||
}
|
||||
|
||||
if (downloadTraffic >= previous.downloadTraffic && downloadDiff >= 0) {
|
||||
downloadSpeed = downloadDiff / timeDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cpuUsage: toFloat(raw.cpu_usage),
|
||||
memoryUsage: toFloat(raw.memory_usage),
|
||||
uploadTraffic,
|
||||
downloadTraffic,
|
||||
uploadSpeed,
|
||||
downloadSpeed,
|
||||
uptime,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface UseNodeOfflineTimersOptions {
|
||||
delayMs?: number;
|
||||
onNodeOffline: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export const useNodeOfflineTimers = ({
|
||||
delayMs = 3000,
|
||||
onNodeOffline,
|
||||
}: UseNodeOfflineTimersOptions) => {
|
||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const clearOfflineTimer = useCallback((nodeId: number) => {
|
||||
const timer = timersRef.current.get(nodeId);
|
||||
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(nodeId);
|
||||
}, []);
|
||||
|
||||
const clearAllOfflineTimers = useCallback(() => {
|
||||
timersRef.current.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
timersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const scheduleNodeOffline = useCallback(
|
||||
(nodeId: number) => {
|
||||
if (timersRef.current.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timersRef.current.delete(nodeId);
|
||||
onNodeOffline(nodeId);
|
||||
}, delayMs);
|
||||
|
||||
timersRef.current.set(nodeId, timer);
|
||||
},
|
||||
[delayMs, onNodeOffline],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearAllOfflineTimers();
|
||||
};
|
||||
}, [clearAllOfflineTimers]);
|
||||
|
||||
return {
|
||||
clearOfflineTimer,
|
||||
scheduleNodeOffline,
|
||||
clearAllOfflineTimers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
import { getToken } from "@/utils/session";
|
||||
|
||||
interface NodeRealtimeMessage {
|
||||
id?: string | number;
|
||||
type?: string;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface UseNodeRealtimeOptions {
|
||||
onMessage: (message: NodeRealtimeMessage) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const getRealtimeWsUrl = (): string => {
|
||||
const baseUrl =
|
||||
axios.defaults.baseURL ||
|
||||
(import.meta.env.VITE_API_BASE
|
||||
? `${import.meta.env.VITE_API_BASE}/api/v1/`
|
||||
: "/api/v1/");
|
||||
|
||||
return (
|
||||
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
|
||||
`/system-info?type=0&secret=${getToken() || ""}`
|
||||
);
|
||||
};
|
||||
|
||||
export const useNodeRealtime = ({
|
||||
onMessage,
|
||||
enabled = true,
|
||||
}: UseNodeRealtimeOptions) => {
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnecting, setWsConnecting] = useState(false);
|
||||
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
|
||||
const clearReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
clearReconnectTimer();
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (!websocketRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
websocketRef.current.onopen = null;
|
||||
websocketRef.current.onmessage = null;
|
||||
websocketRef.current.onerror = null;
|
||||
websocketRef.current.onclose = null;
|
||||
|
||||
if (
|
||||
websocketRef.current.readyState === WebSocket.OPEN ||
|
||||
websocketRef.current.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
websocketRef.current.close();
|
||||
}
|
||||
|
||||
websocketRef.current = null;
|
||||
}, [clearReconnectTimer]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
websocketRef.current &&
|
||||
(websocketRef.current.readyState === WebSocket.OPEN ||
|
||||
websocketRef.current.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (websocketRef.current) {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
try {
|
||||
setWsConnecting(true);
|
||||
websocketRef.current = new WebSocket(getRealtimeWsUrl());
|
||||
|
||||
websocketRef.current.onopen = () => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(true);
|
||||
setWsConnecting(false);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
|
||||
if (parsed && typeof parsed === "object") {
|
||||
onMessageRef.current(parsed as NodeRealtimeMessage);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
websocketRef.current.onerror = () => {};
|
||||
|
||||
websocketRef.current.onclose = () => {
|
||||
websocketRef.current = null;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current += 1;
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
connect();
|
||||
}, 3000 * reconnectAttemptsRef.current);
|
||||
};
|
||||
} catch {
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
}
|
||||
}, [disconnect, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [connect, disconnect, enabled]);
|
||||
|
||||
return {
|
||||
wsConnected,
|
||||
wsConnecting,
|
||||
reconnectRealtime: connect,
|
||||
disconnectRealtime: disconnect,
|
||||
};
|
||||
};
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Tabs, Tab } from "@heroui/tabs";
|
||||
import { Input } from "@heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
getNodeList,
|
||||
createPeerShare,
|
||||
@@ -131,7 +131,9 @@ export default function PanelSharingPage() {
|
||||
const res = await getPeerShareList();
|
||||
|
||||
if (res.code === 0) {
|
||||
setShares(res.data || []);
|
||||
setShares(
|
||||
Array.isArray(res.data) ? (res.data as unknown as PeerShare[]) : [],
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "加载分享列表失败");
|
||||
}
|
||||
@@ -145,9 +147,9 @@ export default function PanelSharingPage() {
|
||||
const res = await getNodeList();
|
||||
|
||||
if (res.code === 0) {
|
||||
const localNodes: Node[] = (res.data || []).filter(
|
||||
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
||||
);
|
||||
const localNodes: Node[] = (
|
||||
Array.isArray(res.data) ? (res.data as Node[]) : []
|
||||
).filter((node: Node) => (node?.isRemote ?? 0) !== 1);
|
||||
|
||||
setNodes(localNodes);
|
||||
setShareForm((prev) => {
|
||||
@@ -172,7 +174,11 @@ export default function PanelSharingPage() {
|
||||
const res = await getPeerRemoteUsageList();
|
||||
|
||||
if (res.code === 0) {
|
||||
setRemoteUsageNodes(res.data || []);
|
||||
setRemoteUsageNodes(
|
||||
Array.isArray(res.data)
|
||||
? (res.data as unknown as RemoteUsageNode[])
|
||||
: [],
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "加载远程占用端口失败");
|
||||
}
|
||||
@@ -391,11 +397,30 @@ export default function PanelSharingPage() {
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(k) => setSelectedTab(k as string)}
|
||||
>
|
||||
<Tab key="my-shares" title="我分享的 (Provider)">
|
||||
<Tab
|
||||
key="my-shares"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Provider
|
||||
</span>
|
||||
<span className="text-xs text-default-500">我分享的</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<CardBody className="space-y-5">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Provider 共享
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start md:self-auto"
|
||||
color="primary"
|
||||
onPress={() => setCreateShareOpen(true)}
|
||||
>
|
||||
@@ -404,9 +429,25 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">暂无分享</div>
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
暂无分享
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-default-500">
|
||||
先创建一个分享,把本地节点开放给其他面板使用。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() => setCreateShareOpen(true)}
|
||||
>
|
||||
创建第一个分享
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{shares.map((share) => (
|
||||
@@ -414,7 +455,7 @@ export default function PanelSharingPage() {
|
||||
key={share.id}
|
||||
className="border border-divider shadow-sm"
|
||||
>
|
||||
<CardHeader className="flex justify-between">
|
||||
<CardHeader className="flex justify-between pb-2 md:pb-2">
|
||||
<h3 className="font-bold">{share.name}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -441,7 +482,7 @@ export default function PanelSharingPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
<CardBody className="text-sm space-y-2 pt-0 md:pt-0">
|
||||
<p>
|
||||
端口范围: {share.portRangeStart} -{" "}
|
||||
{share.portRangeEnd}
|
||||
@@ -501,11 +542,30 @@ export default function PanelSharingPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tab>
|
||||
<Tab key="remote-nodes" title="远程节点 (Consumer)">
|
||||
<Tab
|
||||
key="remote-nodes"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Consumer
|
||||
</span>
|
||||
<span className="text-xs text-default-500">远程节点</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<CardBody className="space-y-5">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Consumer 接入
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
导入远程节点后,可在这里查看端口占用和同步状态。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start md:self-auto"
|
||||
color="secondary"
|
||||
onPress={() => setImportNodeOpen(true)}
|
||||
>
|
||||
@@ -514,13 +574,24 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{remoteUsageLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>暂无远程节点占用记录。</p>
|
||||
<p className="mt-2">
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
暂无远程节点占用记录
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-default-500">
|
||||
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="flat"
|
||||
onPress={() => setImportNodeOpen(true)}
|
||||
>
|
||||
去导入远程节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -529,13 +600,13 @@ export default function PanelSharingPage() {
|
||||
key={node.nodeId}
|
||||
className="border border-divider shadow-sm"
|
||||
>
|
||||
<CardHeader className="flex justify-between">
|
||||
<CardHeader className="flex justify-between pb-2 md:pb-2">
|
||||
<h3 className="font-bold">{node.nodeName}</h3>
|
||||
<span className="text-xs text-default-500">
|
||||
绑定 {node.activeBindingNum || 0}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
<CardBody className="text-sm space-y-2 pt-0 md:pt-0">
|
||||
{node.syncError && (
|
||||
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -8,15 +11,13 @@ import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Input } from "@heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { getAdminFlag, getSessionName } from "@/utils/session";
|
||||
interface PasswordForm {
|
||||
newUsername: string;
|
||||
currentPassword: string;
|
||||
@@ -47,21 +48,8 @@ export default function ProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
// 获取用户信息
|
||||
const name = localStorage.getItem("name") || "Admin";
|
||||
|
||||
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
|
||||
let adminFlag = localStorage.getItem("admin") === "true";
|
||||
|
||||
if (localStorage.getItem("admin") === null) {
|
||||
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
|
||||
|
||||
adminFlag = roleId === 0;
|
||||
// 补充设置admin字段,避免下次再次判断
|
||||
localStorage.setItem("admin", adminFlag.toString());
|
||||
}
|
||||
|
||||
setUsername(name);
|
||||
setIsAdmin(adminFlag);
|
||||
setUsername(getSessionName() || "Admin");
|
||||
setIsAdmin(getAdminFlag());
|
||||
}, []);
|
||||
|
||||
// 管理员菜单项
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import {
|
||||
getPanelAddresses,
|
||||
@@ -87,11 +87,13 @@ export const SettingsPage = () => {
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="返回上一页"
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
variant="light"
|
||||
onClick={() => navigate(-1)}
|
||||
onPress={() => navigate(-1)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -136,7 +138,7 @@ export const SettingsPage = () => {
|
||||
onChange={(e) => setNewAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button color="primary" onClick={addPanelAddress}>
|
||||
<Button color="primary" onPress={addPanelAddress}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
@@ -182,7 +184,7 @@ export const SettingsPage = () => {
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => setCurrentPanel(panel.name)}
|
||||
onPress={() => setCurrentPanel(panel.name)}
|
||||
>
|
||||
设为当前
|
||||
</Button>
|
||||
@@ -191,7 +193,7 @@ export const SettingsPage = () => {
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => handleDeletePanelAddress(panel.name)}
|
||||
onPress={() => handleDeletePanelAddress(panel.name)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
|
||||
+187
-335
@@ -1,20 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input, Textarea } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -34,6 +18,22 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
@@ -45,6 +45,20 @@ import {
|
||||
batchDeleteTunnels,
|
||||
batchRedeployTunnels,
|
||||
} from "@/api";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import {
|
||||
buildDiagnosisFallbackResult,
|
||||
getDiagnosisQualityDisplay,
|
||||
type DiagnosisResult,
|
||||
} from "@/pages/tunnel/diagnosis";
|
||||
import {
|
||||
createTunnelFormDefaults,
|
||||
getTunnelFlowDisplay,
|
||||
getTunnelTypeDisplay,
|
||||
validateTunnelForm,
|
||||
} from "@/pages/tunnel/form";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
|
||||
interface ChainTunnel {
|
||||
nodeId: number;
|
||||
@@ -92,27 +106,6 @@ interface TunnelForm {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface DiagnosisResult {
|
||||
tunnelName: string;
|
||||
tunnelType: string;
|
||||
timestamp: number;
|
||||
results: Array<{
|
||||
success: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
targetIp: string;
|
||||
targetPort?: number;
|
||||
message?: string;
|
||||
averageTime?: number;
|
||||
packetLoss?: number;
|
||||
fromChainType?: number; // 1: 入口, 2: 链, 3: 出口
|
||||
fromInx?: number;
|
||||
toChainType?: number;
|
||||
toInx?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function TunnelPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
@@ -134,18 +127,7 @@ export default function TunnelPage() {
|
||||
useState<DiagnosisResult | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState<TunnelForm>({
|
||||
name: "",
|
||||
type: 1,
|
||||
inNodeId: [],
|
||||
outNodeId: [],
|
||||
chainNodes: [],
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
});
|
||||
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
@@ -189,32 +171,12 @@ export default function TunnelPage() {
|
||||
|
||||
setTunnelOrder(dbOrder);
|
||||
} else {
|
||||
try {
|
||||
const stored = localStorage.getItem("tunnel-order");
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const existingIds = new Set(tunnelsData.map((t) => t.id));
|
||||
const validOrder = parsed
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => existingIds.has(id));
|
||||
|
||||
if (validOrder.length > 0) {
|
||||
setTunnelOrder(validOrder);
|
||||
} else {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
} else {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
} else {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
} catch {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
setTunnelOrder(
|
||||
loadStoredOrder(
|
||||
"tunnel-order",
|
||||
tunnelsData.map((t) => t.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(tunnelsRes.msg || "获取隧道列表失败");
|
||||
@@ -233,59 +195,7 @@ export default function TunnelPage() {
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = "请输入隧道名称";
|
||||
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||
newErrors.name = "隧道名称长度应在2-50个字符之间";
|
||||
}
|
||||
|
||||
if (!form.inNodeId || form.inNodeId.length === 0) {
|
||||
newErrors.inNodeId = "请至少选择一个入口节点";
|
||||
} else {
|
||||
// 验证所有选择的节点都在线
|
||||
const offlineNodes = form.inNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
|
||||
return node && node.status !== 1;
|
||||
});
|
||||
|
||||
if (offlineNodes.length > 0) {
|
||||
newErrors.inNodeId = "所有入口节点必须在线";
|
||||
}
|
||||
}
|
||||
|
||||
if (form.trafficRatio < 0.0 || form.trafficRatio > 100.0) {
|
||||
newErrors.trafficRatio = "流量倍率必须在0.0-100.0之间";
|
||||
}
|
||||
|
||||
// 隧道转发时的验证
|
||||
if (form.type === 2) {
|
||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||
newErrors.outNodeId = "请至少选择一个出口节点";
|
||||
} else {
|
||||
// 验证所有选择的节点都在线
|
||||
const offlineNodes = form.outNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
|
||||
return node && node.status !== 1;
|
||||
});
|
||||
|
||||
if (offlineNodes.length > 0) {
|
||||
newErrors.outNodeId = "所有出口节点必须在线";
|
||||
}
|
||||
|
||||
// 检查是否有重复节点
|
||||
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
||||
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
|
||||
const overlap = inNodeIds.filter((id) => outNodeIds.includes(id));
|
||||
|
||||
if (overlap.length > 0) {
|
||||
newErrors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点";
|
||||
}
|
||||
}
|
||||
}
|
||||
const newErrors = validateTunnelForm(form, nodes);
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
@@ -295,18 +205,7 @@ export default function TunnelPage() {
|
||||
// 新增隧道
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
setForm({
|
||||
name: "",
|
||||
type: 1,
|
||||
inNodeId: [],
|
||||
outNodeId: [],
|
||||
chainNodes: [],
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
});
|
||||
setForm(createTunnelFormDefaults());
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -317,22 +216,22 @@ export default function TunnelPage() {
|
||||
|
||||
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
|
||||
setForm({
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
type: tunnel.type,
|
||||
inNodeId: tunnel.inNodeId || [],
|
||||
outNodeId: tunnel.outNodeId || [],
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
id: tunnel.id,
|
||||
name: tunnel.name,
|
||||
type: tunnel.type,
|
||||
inNodeId: tunnel.inNodeId || [],
|
||||
outNodeId: tunnel.outNodeId || [],
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
@@ -518,91 +417,33 @@ export default function TunnelPage() {
|
||||
const response = await diagnoseTunnel(tunnel.id);
|
||||
|
||||
if (response.code === 0) {
|
||||
setDiagnosisResult(response.data);
|
||||
setDiagnosisResult(response.data as DiagnosisResult);
|
||||
} else {
|
||||
toast.error(response.msg || "诊断失败");
|
||||
setDiagnosisResult({
|
||||
tunnelName: tunnel.name,
|
||||
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
|
||||
timestamp: Date.now(),
|
||||
results: [
|
||||
{
|
||||
success: false,
|
||||
description: "诊断失败",
|
||||
nodeName: "-",
|
||||
nodeId: "-",
|
||||
targetIp: "-",
|
||||
targetPort: 443,
|
||||
message: response.msg || "诊断过程中发生错误",
|
||||
},
|
||||
],
|
||||
});
|
||||
setDiagnosisResult(
|
||||
buildDiagnosisFallbackResult({
|
||||
tunnelName: tunnel.name,
|
||||
tunnelType: tunnel.type,
|
||||
description: "诊断失败",
|
||||
message: response.msg || "诊断过程中发生错误",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
setDiagnosisResult({
|
||||
tunnelName: tunnel.name,
|
||||
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
|
||||
timestamp: Date.now(),
|
||||
results: [
|
||||
{
|
||||
success: false,
|
||||
description: "网络错误",
|
||||
nodeName: "-",
|
||||
nodeId: "-",
|
||||
targetIp: "-",
|
||||
targetPort: 443,
|
||||
message: "无法连接到服务器",
|
||||
},
|
||||
],
|
||||
});
|
||||
setDiagnosisResult(
|
||||
buildDiagnosisFallbackResult({
|
||||
tunnelName: tunnel.name,
|
||||
tunnelType: tunnel.type,
|
||||
description: "网络错误",
|
||||
message: "无法连接到服务器",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setDiagnosisLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取类型显示
|
||||
const getTypeDisplay = (type: number) => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return { text: "端口转发", color: "primary" };
|
||||
case 2:
|
||||
return { text: "隧道转发", color: "secondary" };
|
||||
default:
|
||||
return { text: "未知", color: "default" };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取流量计算显示
|
||||
const getFlowDisplay = (flow: number) => {
|
||||
switch (flow) {
|
||||
case 1:
|
||||
return "单向计算";
|
||||
case 2:
|
||||
return "双向计算";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取连接质量
|
||||
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
|
||||
if (averageTime === undefined || packetLoss === undefined) return null;
|
||||
|
||||
if (averageTime < 30 && packetLoss === 0)
|
||||
return { text: "🚀 优秀", color: "success" };
|
||||
if (averageTime < 50 && packetLoss === 0)
|
||||
return { text: "✨ 很好", color: "success" };
|
||||
if (averageTime < 100 && packetLoss < 1)
|
||||
return { text: "👍 良好", color: "primary" };
|
||||
if (averageTime < 150 && packetLoss < 2)
|
||||
return { text: "😐 一般", color: "warning" };
|
||||
if (averageTime < 200 && packetLoss < 5)
|
||||
return { text: "😟 较差", color: "warning" };
|
||||
|
||||
return { text: "😵 很差", color: "danger" };
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -624,10 +465,7 @@ export default function TunnelPage() {
|
||||
|
||||
setTunnelOrder(newOrder);
|
||||
|
||||
// 保存到 localStorage
|
||||
try {
|
||||
localStorage.setItem("tunnel-order", JSON.stringify(newOrder));
|
||||
} catch {}
|
||||
saveOrder("tunnel-order", newOrder);
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
@@ -701,8 +539,8 @@ export default function TunnelPage() {
|
||||
} else {
|
||||
toast.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "删除失败");
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "删除失败"));
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
@@ -730,8 +568,8 @@ export default function TunnelPage() {
|
||||
} else {
|
||||
toast.error(res.msg || "下发失败");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "下发失败");
|
||||
} catch (error) {
|
||||
toast.error(extractApiErrorMessage(error, "下发失败"));
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
@@ -829,14 +667,7 @@ export default function TunnelPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -847,7 +678,7 @@ export default function TunnelPage() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color={selectMode ? "warning" : "default"}
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
@@ -866,10 +697,20 @@ export default function TunnelPage() {
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button size="sm" variant="flat" onPress={selectAll}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
@@ -902,16 +743,16 @@ export default function TunnelPage() {
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedTunnels.map((tunnel) => {
|
||||
const typeDisplay = getTypeDisplay(tunnel.type);
|
||||
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
||||
|
||||
return (
|
||||
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={tunnel.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
@@ -942,6 +783,7 @@ export default function TunnelPage() {
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -952,7 +794,7 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
{/* 拓扑结构 */}
|
||||
<div className="pt-2 border-t border-divider">
|
||||
@@ -960,6 +802,7 @@ export default function TunnelPage() {
|
||||
{/* 入口节点 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-primary-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -977,6 +820,7 @@ export default function TunnelPage() {
|
||||
|
||||
{/* 箭头 */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -993,6 +837,7 @@ export default function TunnelPage() {
|
||||
{/* 转发链 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50 dark:bg-secondary-100/20 rounded border border-secondary-200 dark:border-secondary-300/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-secondary-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1013,6 +858,7 @@ export default function TunnelPage() {
|
||||
|
||||
{/* 箭头 */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1029,6 +875,7 @@ export default function TunnelPage() {
|
||||
{/* 出口节点 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-success-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1050,13 +897,15 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
{/* 流量配置 */}
|
||||
<div className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}>
|
||||
<div
|
||||
className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}
|
||||
>
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
||||
<div className="text-xs text-default-500">
|
||||
流量计算
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground mt-0.5">
|
||||
{getFlowDisplay(tunnel.flow)}
|
||||
{getTunnelFlowDisplay(tunnel.flow)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
||||
@@ -1073,7 +922,9 @@ export default function TunnelPage() {
|
||||
连接偏好
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground mt-0.5">
|
||||
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}
|
||||
{tunnel.ipPreference === "v4"
|
||||
? "IPv4"
|
||||
: "IPv6"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1087,6 +938,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1105,6 +957,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1127,6 +980,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1165,6 +1019,7 @@ export default function TunnelPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1272,15 +1127,13 @@ export default function TunnelPage() {
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
endContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">x</span>
|
||||
</div>
|
||||
}
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
placeholder="请输入流量倍率"
|
||||
max={100}
|
||||
min={0.01}
|
||||
placeholder="例如:0.5 或 1 或 2"
|
||||
step="any"
|
||||
type="number"
|
||||
value={form.trafficRatio.toString()}
|
||||
variant="bordered"
|
||||
@@ -1415,6 +1268,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1474,12 +1328,14 @@ export default function TunnelPage() {
|
||||
</span>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label={`删除第${groupIndex + 1}跳`}
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => removeChainNode(groupIndex)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1525,6 +1381,7 @@ export default function TunnelPage() {
|
||||
]}
|
||||
label="节点"
|
||||
placeholder="选择节点(可多选)"
|
||||
dropdownPlacement="top"
|
||||
selectedKeys={groupNodes
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())}
|
||||
@@ -1552,17 +1409,17 @@ export default function TunnelPage() {
|
||||
);
|
||||
|
||||
// 添加新节点
|
||||
addedIds.forEach((nodeId) =>
|
||||
addNodeToChain(groupIndex, nodeId),
|
||||
);
|
||||
addedIds.forEach((nodeId) => {
|
||||
addNodeToChain(groupIndex, nodeId);
|
||||
});
|
||||
|
||||
// 删除取消选择的节点
|
||||
removedIds.forEach((nodeId) =>
|
||||
removedIds.forEach((nodeId) => {
|
||||
removeNodeFromChain(
|
||||
groupIndex,
|
||||
nodeId,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
@@ -1591,14 +1448,14 @@ export default function TunnelPage() {
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{form.outNodeId &&
|
||||
form.outNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
@@ -1621,14 +1478,14 @@ export default function TunnelPage() {
|
||||
ct.nodeId !== -1,
|
||||
),
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为其他跳
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为其他跳
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -1742,11 +1599,12 @@ export default function TunnelPage() {
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
dropdownPlacement="top"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
@@ -1813,25 +1671,25 @@ export default function TunnelPage() {
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -2186,7 +2044,7 @@ export default function TunnelPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(
|
||||
const quality = getDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
@@ -2194,20 +2052,18 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2224,7 +2080,6 @@ export default function TunnelPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Chip
|
||||
className="min-w-[50px]"
|
||||
color={
|
||||
result.success
|
||||
? "success"
|
||||
@@ -2250,11 +2105,10 @@ export default function TunnelPage() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`font-semibold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</span>
|
||||
@@ -2267,7 +2121,7 @@ export default function TunnelPage() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success && quality ? (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
className="text-xs whitespace-nowrap"
|
||||
color={quality.color as any}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -2359,7 +2213,7 @@ export default function TunnelPage() {
|
||||
</h3>
|
||||
</div>
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(
|
||||
const quality = getDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
@@ -2367,19 +2221,17 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`border rounded-lg p-3 ${result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2415,11 +2267,10 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</div>
|
||||
@@ -2431,7 +2282,7 @@ export default function TunnelPage() {
|
||||
{quality && (
|
||||
<>
|
||||
<Chip
|
||||
className="text-xs"
|
||||
className="text-xs whitespace-nowrap"
|
||||
color={quality.color as any}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -2515,6 +2366,7 @@ export default function TunnelPage() {
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
export interface DiagnosisEntry {
|
||||
success: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
targetIp: string;
|
||||
targetPort?: number;
|
||||
message?: string;
|
||||
averageTime?: number;
|
||||
packetLoss?: number;
|
||||
fromChainType?: number;
|
||||
fromInx?: number;
|
||||
toChainType?: number;
|
||||
toInx?: number;
|
||||
}
|
||||
|
||||
export interface DiagnosisResult {
|
||||
tunnelName: string;
|
||||
tunnelType: string;
|
||||
timestamp: number;
|
||||
results: DiagnosisEntry[];
|
||||
}
|
||||
|
||||
export interface DiagnosisFallbackInput {
|
||||
tunnelName: string;
|
||||
tunnelType: number;
|
||||
description: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const buildDiagnosisFallbackResult = ({
|
||||
tunnelName,
|
||||
tunnelType,
|
||||
description,
|
||||
message,
|
||||
}: DiagnosisFallbackInput): DiagnosisResult => {
|
||||
return {
|
||||
tunnelName,
|
||||
tunnelType: tunnelType === 1 ? "端口转发" : "隧道转发",
|
||||
timestamp: Date.now(),
|
||||
results: [
|
||||
{
|
||||
success: false,
|
||||
description,
|
||||
nodeName: "-",
|
||||
nodeId: "-",
|
||||
targetIp: "-",
|
||||
targetPort: 443,
|
||||
message,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const getDiagnosisQualityDisplay = (
|
||||
averageTime?: number,
|
||||
packetLoss?: number,
|
||||
): {
|
||||
text: string;
|
||||
color: "success" | "primary" | "warning" | "danger";
|
||||
} | null => {
|
||||
if (averageTime === undefined || packetLoss === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (averageTime < 30 && packetLoss === 0) {
|
||||
return { text: "🚀 优秀", color: "success" };
|
||||
}
|
||||
|
||||
if (averageTime < 50 && packetLoss === 0) {
|
||||
return { text: "✨ 很好", color: "success" };
|
||||
}
|
||||
|
||||
if (averageTime < 100 && packetLoss < 1) {
|
||||
return { text: "👍 良好", color: "primary" };
|
||||
}
|
||||
|
||||
if (averageTime < 150 && packetLoss < 2) {
|
||||
return { text: "😐 一般", color: "warning" };
|
||||
}
|
||||
|
||||
if (averageTime < 200 && packetLoss < 5) {
|
||||
return { text: "😟 较差", color: "warning" };
|
||||
}
|
||||
|
||||
return { text: "😵 很差", color: "danger" };
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
interface TunnelChainNode {
|
||||
nodeId: number;
|
||||
}
|
||||
|
||||
interface TunnelFormInput {
|
||||
name: string;
|
||||
type: number;
|
||||
inNodeId: TunnelChainNode[];
|
||||
outNodeId?: TunnelChainNode[];
|
||||
trafficRatio: number;
|
||||
}
|
||||
|
||||
interface TunnelNodeInput {
|
||||
id: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export const createTunnelFormDefaults = () => {
|
||||
return {
|
||||
name: "",
|
||||
type: 1,
|
||||
inNodeId: [],
|
||||
outNodeId: [],
|
||||
chainNodes: [],
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateTunnelForm = (
|
||||
form: TunnelFormInput,
|
||||
nodes: TunnelNodeInput[],
|
||||
): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
errors.name = "请输入隧道名称";
|
||||
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||
errors.name = "隧道名称长度应在2-50个字符之间";
|
||||
}
|
||||
|
||||
if (!form.inNodeId || form.inNodeId.length === 0) {
|
||||
errors.inNodeId = "请至少选择一个入口节点";
|
||||
} else {
|
||||
const offlineInNodes = form.inNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
|
||||
return node && node.status !== 1;
|
||||
});
|
||||
|
||||
if (offlineInNodes.length > 0) {
|
||||
errors.inNodeId = "所有入口节点必须在线";
|
||||
}
|
||||
}
|
||||
|
||||
if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) {
|
||||
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||
}
|
||||
|
||||
if (form.type === 2) {
|
||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||
errors.outNodeId = "请至少选择一个出口节点";
|
||||
} else {
|
||||
const offlineOutNodes = form.outNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
|
||||
return node && node.status !== 1;
|
||||
});
|
||||
|
||||
if (offlineOutNodes.length > 0) {
|
||||
errors.outNodeId = "所有出口节点必须在线";
|
||||
}
|
||||
|
||||
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
||||
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
|
||||
const overlap = inNodeIds.filter((id) => outNodeIds.includes(id));
|
||||
|
||||
if (overlap.length > 0) {
|
||||
errors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const getTunnelTypeDisplay = (type: number) => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return { text: "端口转发", color: "primary" };
|
||||
case 2:
|
||||
return { text: "隧道转发", color: "secondary" };
|
||||
default:
|
||||
return { text: "未知", color: "default" };
|
||||
}
|
||||
};
|
||||
|
||||
export const getTunnelFlowDisplay = (flow: number) => {
|
||||
switch (flow) {
|
||||
case 1:
|
||||
return "单向计算";
|
||||
case 2:
|
||||
return "双向计算";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
@@ -9,7 +12,7 @@ import {
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@heroui/table";
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -17,20 +20,18 @@ import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { RadioGroup, Radio } from "@heroui/radio";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import { DatePicker } from "@heroui/date-picker";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { RadioGroup, Radio } from "@/shadcn-bridge/heroui/radio";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import {
|
||||
User,
|
||||
UserForm,
|
||||
UserGroup,
|
||||
UserTunnel,
|
||||
TunnelAssignItem,
|
||||
Tunnel,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
updateUserTunnel,
|
||||
getSpeedLimitList,
|
||||
resetUserFlow,
|
||||
getUserGroupList,
|
||||
getUserGroups,
|
||||
} from "@/api";
|
||||
import {
|
||||
SearchIcon,
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
UserIcon,
|
||||
SettingsIcon,
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -204,12 +208,14 @@ export default function UserPage() {
|
||||
// 其他数据
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadTunnels();
|
||||
loadSpeedLimits();
|
||||
loadUserGroups();
|
||||
}, [pagination.current, pagination.size, searchKeyword]);
|
||||
|
||||
// 数据加载函数
|
||||
@@ -223,9 +229,7 @@ export default function UserPage() {
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
const data = response.data || {};
|
||||
|
||||
setUsers(data || []);
|
||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
||||
} else {
|
||||
toast.error(response.msg || "获取用户列表失败");
|
||||
}
|
||||
@@ -241,7 +245,7 @@ export default function UserPage() {
|
||||
const response = await getTunnelList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setTunnels(response.data || []);
|
||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -251,7 +255,25 @@ export default function UserPage() {
|
||||
const response = await getSpeedLimitList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setSpeedLimits(response.data || []);
|
||||
const speedLimitList = Array.isArray(response.data)
|
||||
? response.data.map((item) => ({
|
||||
...item,
|
||||
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
|
||||
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
setSpeedLimits(speedLimitList);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadUserGroups = async () => {
|
||||
try {
|
||||
const response = await getUserGroupList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -262,7 +284,7 @@ export default function UserPage() {
|
||||
const response = await getUserTunnelList({ userId });
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserTunnels(response.data || []);
|
||||
setUserTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
} else {
|
||||
toast.error(response.msg || "获取隧道权限列表失败");
|
||||
}
|
||||
@@ -289,12 +311,23 @@ export default function UserPage() {
|
||||
num: 10,
|
||||
expTime: null,
|
||||
flowResetTime: 0,
|
||||
groupIds: [],
|
||||
});
|
||||
onUserModalOpen();
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
const handleEdit = async (user: User) => {
|
||||
setIsEdit(true);
|
||||
let currentGroupIds: number[] = [];
|
||||
|
||||
try {
|
||||
const groupRes = await getUserGroups(user.id);
|
||||
|
||||
if (groupRes.code === 0) {
|
||||
currentGroupIds = groupRes.data || [];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setUserForm({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -305,6 +338,7 @@ export default function UserPage() {
|
||||
num: user.num,
|
||||
expTime: user.expTime ? new Date(user.expTime) : null,
|
||||
flowResetTime: user.flowResetTime ?? 0,
|
||||
groupIds: currentGroupIds,
|
||||
});
|
||||
onUserModalOpen();
|
||||
};
|
||||
@@ -345,6 +379,7 @@ export default function UserPage() {
|
||||
const submitData: any = {
|
||||
...userForm,
|
||||
expTime: userForm.expTime.getTime(),
|
||||
groupIds: userForm.groupIds ?? [],
|
||||
};
|
||||
|
||||
if (isEdit && !submitData.pwd) {
|
||||
@@ -588,20 +623,20 @@ export default function UserPage() {
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 focus-within:border-primary data-[hover=true]:border-default-300",
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索用户名"
|
||||
startContent={<SearchIcon className="w-4 h-4 text-default-400" />}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索用户"
|
||||
className="min-h-10 w-10"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onClick={handleSearch}
|
||||
onPress={handleSearch}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -615,12 +650,7 @@ export default function UserPage() {
|
||||
|
||||
{/* 用户列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : users.length === 0 ? (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
@@ -658,9 +688,9 @@ export default function UserPage() {
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
@@ -683,7 +713,7 @@ export default function UserPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -771,6 +801,7 @@ export default function UserPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -912,7 +943,6 @@ export default function UserPage() {
|
||||
<DatePicker
|
||||
isRequired
|
||||
showMonthAndYearPickers
|
||||
className="cursor-pointer"
|
||||
label="过期时间"
|
||||
value={
|
||||
userForm.expTime
|
||||
@@ -951,6 +981,26 @@ export default function UserPage() {
|
||||
<Radio value="1">正常</Radio>
|
||||
<Radio value="0">禁用</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
{userGroups.length > 0 && (
|
||||
<Select
|
||||
label="用户分组(可选)"
|
||||
placeholder="选择要加入的分组"
|
||||
selectedKeys={new Set((userForm.groupIds ?? []).map(String))}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys as Set<string>).map(Number);
|
||||
|
||||
setUserForm((prev) => ({ ...prev, groupIds: selected }));
|
||||
}}
|
||||
>
|
||||
{userGroups.map((g) => (
|
||||
<SelectItem key={g.id.toString()} textValue={g.name}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={onUserModalClose}>取消</Button>
|
||||
@@ -1031,13 +1081,12 @@ export default function UserPage() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Checkbox
|
||||
classNames={{
|
||||
base: "pointer-events-none",
|
||||
}}
|
||||
color="primary"
|
||||
isDisabled={isAssigned}
|
||||
isSelected={isSelected}
|
||||
size="md"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onValueChange={() =>
|
||||
toggleTunnelSelection(tunnel.id)
|
||||
}
|
||||
@@ -1066,7 +1115,7 @@ export default function UserPage() {
|
||||
classNames={{
|
||||
trigger: "min-h-10 h-10",
|
||||
}}
|
||||
label="限速规则"
|
||||
placeholder="限速规则"
|
||||
selectedKeys={
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
null &&
|
||||
@@ -1117,17 +1166,22 @@ export default function UserPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
color="primary"
|
||||
isDisabled={batchTunnelSelections.size === 0}
|
||||
isLoading={assignLoading}
|
||||
onPress={handleBatchAssignTunnel}
|
||||
>
|
||||
分配权限{" "}
|
||||
{batchTunnelSelections.size > 0 &&
|
||||
`(${batchTunnelSelections.size}个隧道)`}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
color="primary"
|
||||
isDisabled={batchTunnelSelections.size === 0}
|
||||
isLoading={assignLoading}
|
||||
onPress={handleBatchAssignTunnel}
|
||||
>
|
||||
分配权限
|
||||
</Button>
|
||||
{batchTunnelSelections.size > 0 && (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
已选 {batchTunnelSelections.size} 个隧道
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1211,22 +1265,25 @@ export default function UserPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="编辑隧道权限"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => handleEditTunnel(userTunnel)}
|
||||
onPress={() => handleEditTunnel(userTunnel)}
|
||||
>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="重置隧道流量"
|
||||
color="warning"
|
||||
size="sm"
|
||||
title="重置流量"
|
||||
variant="flat"
|
||||
onClick={() => handleResetTunnelFlow(userTunnel)}
|
||||
onPress={() => handleResetTunnelFlow(userTunnel)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1240,10 +1297,11 @@ export default function UserPage() {
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="删除隧道权限"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => handleRemoveTunnel(userTunnel)}
|
||||
onPress={() => handleRemoveTunnel(userTunnel)}
|
||||
>
|
||||
<DeleteIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1380,7 +1438,6 @@ export default function UserPage() {
|
||||
<DatePicker
|
||||
isRequired
|
||||
showMonthAndYearPickers
|
||||
className="cursor-pointer"
|
||||
label="到期时间"
|
||||
value={
|
||||
editTunnelForm.expTime
|
||||
@@ -1548,6 +1605,7 @@ export default function UserPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-warning"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1636,6 +1694,7 @@ export default function UserPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-warning"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { NavigateOptions } from "react-router-dom";
|
||||
|
||||
import * as React from "react";
|
||||
import { HeroUIProvider } from "@heroui/system";
|
||||
import { useHref, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { I18nProvider } from "@react-aria/i18n";
|
||||
|
||||
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
declare module "@react-types/shared" {
|
||||
interface RouterConfig {
|
||||
routerOptions: NavigateOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Accordion as BaseAccordion,
|
||||
AccordionContent,
|
||||
AccordionItem as BaseAccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccordionProps
|
||||
extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
children: React.ReactNode;
|
||||
variant?: "bordered" | "light" | "splitted";
|
||||
}
|
||||
|
||||
export interface AccordionItemProps {
|
||||
"aria-label"?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title: React.ReactNode;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function variantClass(variant: AccordionProps["variant"]) {
|
||||
if (variant === "splitted") {
|
||||
return "[&>[data-slot=accordion-item]]:mb-3 [&>[data-slot=accordion-item]]:rounded-xl [&>[data-slot=accordion-item]]:border [&>[data-slot=accordion-item]]:border-divider [&>[data-slot=accordion-item]]:border-b-0 [&>[data-slot=accordion-item]]:bg-content1 [&>[data-slot=accordion-item]]:shadow-sm [&>[data-slot=accordion-item]]:overflow-hidden [&>[data-slot=accordion-item]:last-child]:mb-0";
|
||||
}
|
||||
if (variant === "bordered") {
|
||||
return "rounded-xl border border-divider bg-content1 overflow-hidden [&>[data-slot=accordion-item]]:border-b [&>[data-slot=accordion-item]:last-child]:border-b-0";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
children,
|
||||
className,
|
||||
variant = "light",
|
||||
}: AccordionProps) {
|
||||
return (
|
||||
<BaseAccordion
|
||||
className={cn("w-full", variantClass(variant), className)}
|
||||
type="multiple"
|
||||
>
|
||||
{children}
|
||||
</BaseAccordion>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccordionItem({
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
value,
|
||||
}: AccordionItemProps) {
|
||||
const generatedValue = React.useId();
|
||||
|
||||
return (
|
||||
<BaseAccordionItem className={className} value={value ?? generatedValue}>
|
||||
<AccordionTrigger aria-label={ariaLabel}>{title}</AccordionTrigger>
|
||||
<AccordionContent>{children}</AccordionContent>
|
||||
</BaseAccordionItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Alert as BaseAlert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
|
||||
type AlertVariant = "solid" | "flat" | "faded" | "bordered";
|
||||
|
||||
interface AlertProps
|
||||
extends Omit<React.ComponentProps<"div">, "color" | "title"> {
|
||||
color?: AlertColor;
|
||||
description?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
variant?: AlertVariant;
|
||||
}
|
||||
|
||||
function mapVariant(color: AlertColor) {
|
||||
if (color === "danger") {
|
||||
return "destructive" as const;
|
||||
}
|
||||
if (color === "success") {
|
||||
return "success" as const;
|
||||
}
|
||||
if (color === "warning") {
|
||||
return "warning" as const;
|
||||
}
|
||||
|
||||
return "default" as const;
|
||||
}
|
||||
|
||||
export function Alert({
|
||||
children,
|
||||
color = "default",
|
||||
description,
|
||||
title,
|
||||
variant,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
return (
|
||||
<BaseAlert
|
||||
className={variant === "flat" ? "bg-opacity-15" : undefined}
|
||||
variant={mapVariant(color)}
|
||||
{...props}
|
||||
>
|
||||
{title ? <AlertTitle>{title}</AlertTitle> : null}
|
||||
{description ? <AlertDescription>{description}</AlertDescription> : null}
|
||||
{!description && !title ? children : null}
|
||||
</BaseAlert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import * as React from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { Button as BaseButton } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HeroButtonColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
type HeroButtonVariant =
|
||||
| "solid"
|
||||
| "light"
|
||||
| "flat"
|
||||
| "ghost"
|
||||
| "bordered"
|
||||
| "shadow";
|
||||
type HeroButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
|
||||
color?: HeroButtonColor;
|
||||
endContent?: React.ReactNode;
|
||||
isIconOnly?: boolean;
|
||||
isLoading?: boolean;
|
||||
onPress?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
size?: HeroButtonSize;
|
||||
startContent?: React.ReactNode;
|
||||
variant?: HeroButtonVariant;
|
||||
}
|
||||
|
||||
function mapVariant(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
):
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "ghost"
|
||||
| "light"
|
||||
| "flat" {
|
||||
if (variant === "bordered") {
|
||||
return "outline";
|
||||
}
|
||||
if (variant === "ghost") {
|
||||
return "ghost";
|
||||
}
|
||||
if (variant === "light") {
|
||||
return "light";
|
||||
}
|
||||
if (variant === "flat") {
|
||||
return "flat";
|
||||
}
|
||||
if (color === "danger") {
|
||||
return "destructive";
|
||||
}
|
||||
if (color === "secondary") {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
function mapSize(
|
||||
size: HeroButtonSize,
|
||||
isIconOnly: boolean,
|
||||
): "default" | "sm" | "lg" | "icon" {
|
||||
if (isIconOnly) {
|
||||
return "icon";
|
||||
}
|
||||
if (size === "sm") {
|
||||
return "sm";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "lg";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
|
||||
default:
|
||||
"bg-default-200 text-default-800 hover:bg-default-300 dark:bg-default-100 dark:text-default-800 dark:hover:bg-default-200",
|
||||
success: "bg-success text-white hover:bg-success-600",
|
||||
warning: "bg-warning text-warning-900 hover:bg-warning-400",
|
||||
};
|
||||
|
||||
const borderedColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default:
|
||||
"border-default-300 text-default-700 hover:bg-default-100 dark:border-default-300 dark:text-default-600 dark:hover:bg-default-200/40",
|
||||
primary:
|
||||
"border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
|
||||
secondary:
|
||||
"border-secondary text-secondary hover:bg-secondary-50 dark:border-secondary-500/60 dark:text-secondary-300 dark:hover:bg-secondary-900/20",
|
||||
success:
|
||||
"border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
|
||||
warning:
|
||||
"border-warning text-warning-700 hover:bg-warning-50 dark:border-warning-500/60 dark:text-warning-300 dark:hover:bg-warning-900/20",
|
||||
danger:
|
||||
"border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
|
||||
};
|
||||
|
||||
const lightColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default:
|
||||
"text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
|
||||
primary:
|
||||
"text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
|
||||
secondary:
|
||||
"text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
|
||||
success:
|
||||
"text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
|
||||
warning:
|
||||
"text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
|
||||
danger:
|
||||
"text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
|
||||
};
|
||||
|
||||
const flatColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default:
|
||||
"bg-default-100 text-default-700 hover:bg-default-200 dark:bg-default-100/45 dark:text-default-700 dark:hover:bg-default-200/60",
|
||||
primary:
|
||||
"bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
|
||||
secondary:
|
||||
"bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-900/30 dark:text-secondary-300 dark:hover:bg-secondary-900/45",
|
||||
success:
|
||||
"bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
|
||||
warning:
|
||||
"bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
|
||||
danger:
|
||||
"bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
|
||||
};
|
||||
|
||||
const shadowColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default: "shadow-md shadow-default-400/40",
|
||||
primary: "shadow-md shadow-primary-500/35",
|
||||
secondary: "shadow-md shadow-secondary-500/35",
|
||||
success: "shadow-md shadow-success-500/35",
|
||||
warning: "shadow-md shadow-warning-500/40",
|
||||
danger: "shadow-md shadow-danger-500/35",
|
||||
};
|
||||
|
||||
function mapColorClass(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
): string {
|
||||
if (variant === "bordered") {
|
||||
return borderedColorClassMap[color];
|
||||
}
|
||||
if (variant === "light") {
|
||||
return lightColorClassMap[color];
|
||||
}
|
||||
if (variant === "flat") {
|
||||
return flatColorClassMap[color];
|
||||
}
|
||||
if (variant === "solid" || variant === "shadow") {
|
||||
return solidColorClassMap[color] ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapShadowClass(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
): string {
|
||||
if (variant !== "shadow") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return shadowColorClassMap[color];
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ButtonProps & {
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
color = "default",
|
||||
disabled,
|
||||
endContent,
|
||||
isIconOnly = false,
|
||||
isLoading = false,
|
||||
isDisabled,
|
||||
onClick,
|
||||
onPress,
|
||||
size = "md",
|
||||
startContent,
|
||||
type = "button",
|
||||
variant = "solid",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const resolvedVariant = mapVariant(color, variant);
|
||||
const resolvedSize = mapSize(size, isIconOnly);
|
||||
const resolvedDisabled = Boolean(disabled || isDisabled || isLoading);
|
||||
const resolvedColorClass = mapColorClass(color, variant);
|
||||
const resolvedShadowClass = mapShadowClass(color, variant);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
onPress?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
isIconOnly ? "p-0" : "",
|
||||
resolvedColorClass,
|
||||
resolvedShadowClass,
|
||||
className,
|
||||
)}
|
||||
disabled={resolvedDisabled}
|
||||
size={resolvedSize}
|
||||
type={type}
|
||||
variant={resolvedVariant}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{startContent}
|
||||
{isIconOnly ? null : children}
|
||||
{isIconOnly ? children : null}
|
||||
{endContent}
|
||||
</BaseButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "HeroBridgeButton";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Card as BaseCard,
|
||||
CardContent,
|
||||
CardHeader as BaseCardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <BaseCard className={className} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<CardContent
|
||||
className={cn("p-4 pt-0 md:p-6 md:pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof BaseCheckbox>,
|
||||
"checked" | "onCheckedChange"
|
||||
> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
children,
|
||||
className,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
onValueChange,
|
||||
...props
|
||||
}: CheckboxProps & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const handleCheckedChange: React.ComponentProps<
|
||||
typeof BaseCheckbox
|
||||
>["onCheckedChange"] = (value: boolean | "indeterminate") => {
|
||||
onValueChange?.(value === true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2",
|
||||
isDisabled ? "opacity-50" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BaseCheckbox
|
||||
checked={Boolean(isSelected)}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
{...props}
|
||||
/>
|
||||
{children ? <span className="text-sm">{children}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChipColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
type ChipVariant = "solid" | "flat" | "light" | "bordered";
|
||||
type ChipSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ChipProps extends React.ComponentProps<"span"> {
|
||||
color?: ChipColor;
|
||||
size?: ChipSize;
|
||||
variant?: ChipVariant;
|
||||
}
|
||||
|
||||
function colorVariant(color: ChipColor) {
|
||||
if (color === "danger") {
|
||||
return "destructive" as const;
|
||||
}
|
||||
if (color === "success") {
|
||||
return "success" as const;
|
||||
}
|
||||
if (color === "warning") {
|
||||
return "warning" as const;
|
||||
}
|
||||
if (color === "secondary") {
|
||||
return "secondary" as const;
|
||||
}
|
||||
|
||||
return "default" as const;
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
children,
|
||||
className,
|
||||
color = "default",
|
||||
size = "md",
|
||||
variant = "solid",
|
||||
...props
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
variant === "flat" || variant === "light" ? "bg-opacity-15" : "",
|
||||
variant === "bordered" ? "border-current bg-transparent" : "",
|
||||
size === "sm" ? "px-2 py-0 text-[10px]" : "",
|
||||
size === "lg" ? "px-3 py-1 text-sm" : "",
|
||||
className,
|
||||
)}
|
||||
variant={colorVariant(color)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CalendarDateLike {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
function isValidCalendarDate(year: number, month: number, day: number) {
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return false;
|
||||
}
|
||||
const candidate = new Date(year, month - 1, day);
|
||||
|
||||
return (
|
||||
candidate.getFullYear() === year &&
|
||||
candidate.getMonth() === month - 1 &&
|
||||
candidate.getDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function parseDateText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
|
||||
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
const day = Number(matched[3]);
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day, month, year };
|
||||
}
|
||||
|
||||
export interface DatePickerProps extends FieldMetaProps {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
isRequired?: boolean;
|
||||
onChange?: (value: CalendarDateLike | null) => void;
|
||||
showMonthAndYearPickers?: boolean;
|
||||
value?: CalendarDateLike | null;
|
||||
}
|
||||
|
||||
function formatDateValue(value: CalendarDateLike | null | undefined) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const month = String(value.month).padStart(2, "0");
|
||||
const day = String(value.day).padStart(2, "0");
|
||||
|
||||
return `${value.year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
className,
|
||||
description,
|
||||
errorMessage,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
onChange,
|
||||
showMonthAndYearPickers,
|
||||
value,
|
||||
}: DatePickerProps) {
|
||||
const id = React.useId();
|
||||
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
|
||||
const [textValue, setTextValue] = React.useState(formattedValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTextValue(formattedValue);
|
||||
}, [formattedValue]);
|
||||
|
||||
const shouldUseTextInput = Boolean(showMonthAndYearPickers);
|
||||
|
||||
const notifyNativeDateChange = (rawValue: string) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawValue) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = rawValue.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ day, month, year });
|
||||
};
|
||||
|
||||
const notifyTextDateChange = (rawValue: string) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawValue.trim()) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDateText(rawValue);
|
||||
|
||||
if (parsed) {
|
||||
onChange(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const commitTextInput = () => {
|
||||
const parsed = parseDateText(textValue);
|
||||
|
||||
if (parsed) {
|
||||
setTextValue(formatDateValue(parsed));
|
||||
onChange?.(parsed);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textValue.trim()) {
|
||||
onChange?.(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTextValue(formattedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<Input
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
inputMode={shouldUseTextInput ? "numeric" : undefined}
|
||||
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
|
||||
required={isRequired}
|
||||
type={shouldUseTextInput ? "text" : "date"}
|
||||
value={shouldUseTextInput ? textValue : formattedValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
|
||||
if (shouldUseTextInput) {
|
||||
setTextValue(nextValue);
|
||||
notifyTextDateChange(nextValue);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
notifyNativeDateChange(nextValue);
|
||||
}}
|
||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||
onKeyDown={
|
||||
shouldUseTextInput
|
||||
? (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitTextInput();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function Divider(props: React.ComponentProps<typeof Separator>) {
|
||||
return <Separator {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu as BaseDropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem as BaseDropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger as BaseDropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
|
||||
interface DropdownContextValue {
|
||||
align: "start" | "end";
|
||||
}
|
||||
|
||||
const DropdownContext = React.createContext<DropdownContextValue>({
|
||||
align: "start",
|
||||
});
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
children,
|
||||
placement = "bottom-start",
|
||||
}: DropdownProps) {
|
||||
const align = placement.endsWith("end") ? "end" : "start";
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={{ align }}>
|
||||
<BaseDropdownMenu>{children}</BaseDropdownMenu>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownTrigger({ children }: { children: React.ReactNode }) {
|
||||
return <BaseDropdownMenuTrigger asChild>{children}</BaseDropdownMenuTrigger>;
|
||||
}
|
||||
|
||||
export interface DropdownMenuProps {
|
||||
"aria-label"?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DropdownMenu({ children }: DropdownMenuProps) {
|
||||
const { align } = React.useContext(DropdownContext);
|
||||
|
||||
return <DropdownMenuContent align={align}>{children}</DropdownMenuContent>;
|
||||
}
|
||||
|
||||
export interface DropdownItemProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
color?: "default" | "danger";
|
||||
onPress?: () => void;
|
||||
startContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DropdownItem({
|
||||
children,
|
||||
className,
|
||||
color = "default",
|
||||
onPress,
|
||||
startContent,
|
||||
}: DropdownItemProps) {
|
||||
return (
|
||||
<BaseDropdownMenuItem
|
||||
className={cn(
|
||||
color === "danger" ? "text-danger focus:text-danger" : "",
|
||||
className,
|
||||
)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onPress?.();
|
||||
}}
|
||||
>
|
||||
{startContent}
|
||||
{children}
|
||||
</BaseDropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export { DropdownMenuLabel, DropdownMenuSeparator };
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
import { Input as BaseInput } from "@/components/ui/input";
|
||||
import { Textarea as BaseTextarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ClassNameMap = {
|
||||
base?: string;
|
||||
description?: string;
|
||||
errorMessage?: string;
|
||||
input?: string;
|
||||
inputWrapper?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
FieldMetaProps {
|
||||
classNames?: ClassNameMap;
|
||||
endContent?: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
startContent?: React.ReactNode;
|
||||
variant?: "flat" | "bordered" | "faded" | "underlined";
|
||||
}
|
||||
|
||||
export interface TextareaProps
|
||||
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size">,
|
||||
FieldMetaProps {
|
||||
classNames?: ClassNameMap;
|
||||
isDisabled?: boolean;
|
||||
maxRows?: number;
|
||||
minRows?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "flat" | "bordered" | "faded" | "underlined";
|
||||
}
|
||||
|
||||
function inputSizeClass(size: "sm" | "md" | "lg" | undefined) {
|
||||
if (size === "sm") {
|
||||
return "h-8 text-xs";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "h-10 text-base";
|
||||
}
|
||||
|
||||
return "h-9 text-sm";
|
||||
}
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
classNames,
|
||||
description,
|
||||
endContent,
|
||||
errorMessage,
|
||||
id,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
size,
|
||||
startContent,
|
||||
variant,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const generatedId = React.useId();
|
||||
const resolvedId = id ?? generatedId;
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={resolvedId}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center rounded-md",
|
||||
variant === "bordered" ? "border border-input" : "",
|
||||
classNames?.inputWrapper,
|
||||
)}
|
||||
>
|
||||
{startContent ? (
|
||||
<div className="pl-3 text-default-500">{startContent}</div>
|
||||
) : null}
|
||||
<BaseInput
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
inputSizeClass(size),
|
||||
variant === "bordered" ? "border-0 shadow-none" : "",
|
||||
startContent ? "pl-2" : "",
|
||||
endContent ? "pr-2" : "",
|
||||
classNames?.input,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={resolvedId}
|
||||
required={isRequired}
|
||||
{...props}
|
||||
/>
|
||||
{endContent ? (
|
||||
<div className="pr-3 text-default-500">{endContent}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
classNames,
|
||||
description,
|
||||
errorMessage,
|
||||
id,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
maxRows,
|
||||
minRows,
|
||||
size,
|
||||
variant,
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const generatedId = React.useId();
|
||||
const resolvedId = id ?? generatedId;
|
||||
const rows = props.rows ?? minRows ?? 3;
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={resolvedId}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<BaseTextarea
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
variant === "bordered" ? "border border-input" : "",
|
||||
size === "sm" ? "text-xs" : "",
|
||||
size === "lg" ? "text-base" : "",
|
||||
maxRows ? "max-h-[40vh]" : "",
|
||||
classNames?.input,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={resolvedId}
|
||||
required={isRequired}
|
||||
rows={rows}
|
||||
{...props}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LinkColor = "default" | "foreground" | "primary";
|
||||
|
||||
export interface LinkProps extends React.ComponentProps<"a"> {
|
||||
color?: LinkColor;
|
||||
}
|
||||
|
||||
function mapColor(color: LinkColor) {
|
||||
if (color === "primary") {
|
||||
return "text-primary hover:text-primary/80";
|
||||
}
|
||||
if (color === "foreground") {
|
||||
return "text-foreground hover:text-foreground/80";
|
||||
}
|
||||
|
||||
return "text-default-600 hover:text-default-700";
|
||||
}
|
||||
|
||||
export function Link({ className, color = "default", ...props }: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
className={cn("transition-colors", mapColor(color), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent as BaseDialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DisclosureOptions {
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
type OpenChangeHandler = (open?: boolean) => void;
|
||||
|
||||
export function useDisclosure(options: DisclosureOptions = {}) {
|
||||
const [isOpen, setIsOpen] = React.useState(Boolean(options.isOpen));
|
||||
|
||||
const onOpen = React.useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const onOpenChange = React.useCallback<OpenChangeHandler>((open) => {
|
||||
setIsOpen((prev) => (typeof open === "boolean" ? open : !prev));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpen,
|
||||
onOpenChange,
|
||||
};
|
||||
}
|
||||
|
||||
interface ModalContextValue {
|
||||
classNames?: Record<string, string>;
|
||||
onClose: () => void;
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<ModalContextValue | null>(null);
|
||||
|
||||
function useModalContext() {
|
||||
return React.useContext(ModalContext);
|
||||
}
|
||||
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
|
||||
|
||||
function mapSize(size: ModalSize | undefined) {
|
||||
if (size === "sm") {
|
||||
return "max-w-md";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "max-w-2xl";
|
||||
}
|
||||
if (size === "xl") {
|
||||
return "max-w-3xl";
|
||||
}
|
||||
if (size === "2xl") {
|
||||
return "max-w-5xl";
|
||||
}
|
||||
if (size === "4xl") {
|
||||
return "max-w-6xl";
|
||||
}
|
||||
if (size === "full") {
|
||||
return "w-[95vw] max-w-[95vw] h-[95vh] max-h-[95vh]";
|
||||
}
|
||||
|
||||
return "max-w-lg";
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
backdrop?: "blur" | "opaque" | "transparent";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
classNames?: Record<string, string>;
|
||||
isDismissable?: boolean;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
placement?: "center" | "top" | "bottom";
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
classNames,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
scrollBehavior,
|
||||
size,
|
||||
}: ModalProps) {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
onOpenChange?.(open);
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
classNames,
|
||||
onClose: () => {
|
||||
handleOpenChange(false);
|
||||
},
|
||||
scrollBehavior,
|
||||
size,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalContentProps
|
||||
extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
children: React.ReactNode | ((onClose: () => void) => React.ReactNode);
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
export function ModalContent({
|
||||
children,
|
||||
className,
|
||||
scrollBehavior,
|
||||
size,
|
||||
...props
|
||||
}: ModalContentProps) {
|
||||
const context = useModalContext();
|
||||
const resolvedScrollBehavior = scrollBehavior ?? context?.scrollBehavior;
|
||||
const resolvedSize = size ?? context?.size;
|
||||
const renderedChildren =
|
||||
typeof children === "function"
|
||||
? children(() => context?.onClose())
|
||||
: children;
|
||||
|
||||
return (
|
||||
<BaseDialogContent
|
||||
className={cn(
|
||||
mapSize(resolvedSize),
|
||||
context?.classNames?.base,
|
||||
resolvedScrollBehavior === "outside"
|
||||
? "max-h-[90vh] overflow-y-auto"
|
||||
: "",
|
||||
resolvedScrollBehavior === "inside"
|
||||
? "max-h-[90vh] flex flex-col overflow-hidden [&>[data-slot=modal-body]]:min-h-0 [&>[data-slot=modal-body]]:flex-1 [&>[data-slot=modal-body]]:overflow-y-auto"
|
||||
: "",
|
||||
className,
|
||||
)}
|
||||
showCloseButton={false}
|
||||
{...props}
|
||||
>
|
||||
{renderedChildren}
|
||||
</BaseDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const context = useModalContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg font-semibold",
|
||||
context?.classNames?.header,
|
||||
className,
|
||||
)}
|
||||
data-slot="modal-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalBody({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const context = useModalContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-4", context?.classNames?.body, className)}
|
||||
data-slot="modal-body"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const context = useModalContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
context?.classNames?.footer,
|
||||
className,
|
||||
)}
|
||||
data-slot="modal-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavbarPosition = "static" | "sticky";
|
||||
type MaxWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
|
||||
export interface NavbarProps extends React.ComponentProps<"nav"> {
|
||||
height?: string;
|
||||
maxWidth?: MaxWidth;
|
||||
position?: NavbarPosition;
|
||||
}
|
||||
|
||||
function maxWidthClass(maxWidth: MaxWidth | undefined) {
|
||||
if (maxWidth === "sm") {
|
||||
return "max-w-screen-sm";
|
||||
}
|
||||
if (maxWidth === "md") {
|
||||
return "max-w-screen-md";
|
||||
}
|
||||
if (maxWidth === "lg") {
|
||||
return "max-w-screen-lg";
|
||||
}
|
||||
if (maxWidth === "xl") {
|
||||
return "max-w-screen-xl";
|
||||
}
|
||||
if (maxWidth === "2xl") {
|
||||
return "max-w-screen-2xl";
|
||||
}
|
||||
if (maxWidth === "full") {
|
||||
return "max-w-none";
|
||||
}
|
||||
|
||||
return "max-w-screen-xl";
|
||||
}
|
||||
|
||||
export function Navbar({
|
||||
children,
|
||||
className,
|
||||
height,
|
||||
maxWidth = "xl",
|
||||
position = "static",
|
||||
...props
|
||||
}: NavbarProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"w-full border-b border-default-200 bg-white/90 backdrop-blur dark:bg-default-50/60",
|
||||
position === "sticky" ? "sticky top-0 z-40" : "",
|
||||
className,
|
||||
)}
|
||||
style={height ? { minHeight: height } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-full w-full items-center justify-between px-4",
|
||||
maxWidthClass(maxWidth),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NavbarContentProps extends React.ComponentProps<"div"> {
|
||||
justify?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export function NavbarContent({
|
||||
children,
|
||||
className,
|
||||
justify = "start",
|
||||
...props
|
||||
}: NavbarContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2",
|
||||
justify === "center" ? "justify-center" : "",
|
||||
justify === "end" ? "justify-end" : "",
|
||||
justify === "start" ? "justify-start" : "",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavbarBrand({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return <div className={cn("flex items-center", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Progress as BaseProgress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProgressColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
export interface ProgressProps {
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
color?: ProgressColor;
|
||||
label?: React.ReactNode;
|
||||
showValueLabel?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
value?: number;
|
||||
}
|
||||
|
||||
function indicatorClassName(color: ProgressColor) {
|
||||
if (color === "danger") {
|
||||
return "bg-danger";
|
||||
}
|
||||
if (color === "success") {
|
||||
return "bg-success";
|
||||
}
|
||||
if (color === "warning") {
|
||||
return "bg-warning";
|
||||
}
|
||||
|
||||
return "bg-primary";
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
color = "primary",
|
||||
label,
|
||||
showValueLabel,
|
||||
size = "md",
|
||||
value = 0,
|
||||
}: ProgressProps) {
|
||||
return (
|
||||
<div className={cn("w-full space-y-1", className)}>
|
||||
{label || showValueLabel ? (
|
||||
<div className="flex items-center justify-between text-xs text-default-500">
|
||||
<span>{label}</span>
|
||||
{showValueLabel ? <span>{Math.round(value)}%</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<BaseProgress
|
||||
aria-label={ariaLabel}
|
||||
className={cn(size === "sm" ? "h-1.5" : "", size === "lg" ? "h-3" : "")}
|
||||
indicatorClassName={indicatorClassName(color)}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
RadioGroup as BaseRadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RadioContextValue {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RadioContext = React.createContext<RadioContextValue>({
|
||||
name: "radio-group",
|
||||
});
|
||||
|
||||
export interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
onValueChange?: (value: string) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function RadioGroup({
|
||||
children,
|
||||
label,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
value,
|
||||
}: RadioGroupProps) {
|
||||
const generatedName = React.useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label ? <p className="text-sm font-medium">{label}</p> : null}
|
||||
<RadioContext.Provider value={{ name: generatedName }}>
|
||||
<BaseRadioGroup
|
||||
className={cn(
|
||||
orientation === "horizontal"
|
||||
? "flex flex-wrap items-center gap-4"
|
||||
: "grid gap-3",
|
||||
)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
{children}
|
||||
</BaseRadioGroup>
|
||||
</RadioContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RadioProps {
|
||||
children?: React.ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function Radio({ children, value }: RadioProps) {
|
||||
const context = React.useContext(RadioContext);
|
||||
const id = `${context.name}-${value}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id={id} value={value} />
|
||||
<Label htmlFor={id}>{children}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { FieldContainer, extractText, type FieldMetaProps } from "./shared";
|
||||
|
||||
import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SelectionMode = "single" | "multiple";
|
||||
|
||||
type SelectionValue = Iterable<React.Key> | Set<React.Key> | Array<React.Key>;
|
||||
|
||||
interface OptionItem {
|
||||
disabled?: boolean;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ClassNameMap {
|
||||
base?: string;
|
||||
trigger?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
className?: string;
|
||||
classNames?: ClassNameMap;
|
||||
disabledKeys?: SelectionValue;
|
||||
isDisabled?: boolean;
|
||||
items?: Iterable<T>;
|
||||
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
onClick?: (event: React.MouseEvent<HTMLSelectElement>) => void;
|
||||
onSelectionChange?: (keys: Set<React.Key>) => void;
|
||||
placeholder?: string;
|
||||
selectedKeys?: SelectionValue;
|
||||
selectionMode?: SelectionMode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: string;
|
||||
dropdownPlacement?: "bottom" | "top";
|
||||
}
|
||||
|
||||
export interface SelectItemProps {
|
||||
children?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
textValue?: string;
|
||||
}
|
||||
|
||||
export function SelectItem(_props: SelectItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SelectItem.displayName = "HeroSelectItem";
|
||||
|
||||
function toSet(value?: SelectionValue) {
|
||||
if (!value) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(value).map((item) => String(item)));
|
||||
}
|
||||
|
||||
function flattenOptionsFromNode(node: React.ReactNode, options: OptionItem[]) {
|
||||
React.Children.forEach(node, (child, index) => {
|
||||
if (child === null || child === undefined || typeof child === "boolean") {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
flattenOptionsFromNode(child, options);
|
||||
|
||||
return;
|
||||
}
|
||||
if (React.isValidElement(child)) {
|
||||
if (child.type === React.Fragment) {
|
||||
flattenOptionsFromNode(child.props.children, options);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.type === SelectItem) {
|
||||
const key = child.key ? String(child.key) : String(index);
|
||||
const props = child.props as SelectItemProps;
|
||||
|
||||
options.push({
|
||||
key,
|
||||
label: props.textValue ?? extractText(props.children) ?? key,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getOptions<T>(
|
||||
children: React.ReactNode | ((item: T) => React.ReactNode) | undefined,
|
||||
items: Iterable<T> | undefined,
|
||||
) {
|
||||
const options: OptionItem[] = [];
|
||||
|
||||
if (typeof children === "function" && items) {
|
||||
Array.from(items).forEach((item, index) => {
|
||||
const rendered = children(item);
|
||||
|
||||
if (React.isValidElement(rendered) && rendered.type === SelectItem) {
|
||||
const key = rendered.key ? String(rendered.key) : String(index);
|
||||
const props = rendered.props as SelectItemProps;
|
||||
|
||||
options.push({
|
||||
key,
|
||||
label: props.textValue ?? extractText(props.children) ?? key,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
if (typeof children !== "function") {
|
||||
flattenOptionsFromNode(children, options);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sizeClass(size: SelectProps["size"]) {
|
||||
if (size === "sm") {
|
||||
return "h-8 text-xs";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "h-10 text-base";
|
||||
}
|
||||
|
||||
return "h-9 text-sm";
|
||||
}
|
||||
|
||||
function textSizeClass(size: SelectProps["size"]) {
|
||||
if (size === "sm") {
|
||||
return "text-xs";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "text-base";
|
||||
}
|
||||
|
||||
return "text-sm";
|
||||
}
|
||||
|
||||
export function Select<T>({
|
||||
children,
|
||||
className,
|
||||
classNames,
|
||||
description,
|
||||
disabledKeys,
|
||||
errorMessage,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
items,
|
||||
label,
|
||||
onChange,
|
||||
onClick,
|
||||
onSelectionChange,
|
||||
placeholder,
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
size,
|
||||
dropdownPlacement = "bottom",
|
||||
}: SelectProps<T>) {
|
||||
const generatedId = React.useId();
|
||||
const options = React.useMemo(
|
||||
() => getOptions(children, items),
|
||||
[children, items],
|
||||
);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const listboxRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]);
|
||||
const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const container = containerRef.current;
|
||||
const listbox = listboxRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (container.contains(target) || listbox?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("touchstart", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("touchstart", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isDisabled) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [isDisabled]);
|
||||
|
||||
const selectedArray = Array.from(selected);
|
||||
const singleValue = selectedArray[0] ?? "";
|
||||
const selectedLabels = options
|
||||
.filter((option) => selected.has(option.key))
|
||||
.map((option) => option.label);
|
||||
const resolvedSelectedValues =
|
||||
selectedLabels.length > 0 ? selectedLabels : selectedArray;
|
||||
const selectedFullText = resolvedSelectedValues.join("、");
|
||||
const selectedText =
|
||||
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
||||
|
||||
const updateMultipleSelection = (key: string, checked?: boolean) => {
|
||||
if (isDisabled || disabled.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(selected);
|
||||
const shouldSelect =
|
||||
typeof checked === "boolean" ? checked : !next.has(key);
|
||||
|
||||
if (shouldSelect) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
|
||||
onSelectionChange?.(next);
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange?.(event);
|
||||
|
||||
if (!onSelectionChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionMode === "multiple") {
|
||||
const values = Array.from(event.target.selectedOptions).map(
|
||||
(option) => option.value,
|
||||
);
|
||||
|
||||
onSelectionChange(new Set(values));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.value) {
|
||||
onSelectionChange(new Set());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(new Set([event.target.value]));
|
||||
};
|
||||
|
||||
const renderMultipleListbox = () => {
|
||||
if (!isExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const placementClasses = dropdownPlacement === "top"
|
||||
? "bottom-full mb-1"
|
||||
: "top-full mt-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
||||
placementClasses
|
||||
)}
|
||||
id={`${generatedId}-listbox`}
|
||||
ref={listboxRef}
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-1 text-default-500",
|
||||
textSizeClass(size),
|
||||
)}
|
||||
>
|
||||
暂无可选项
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const optionDisabled = isDisabled || disabled.has(option.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5",
|
||||
optionDisabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "hover:bg-default-100",
|
||||
)}
|
||||
>
|
||||
<BaseCheckbox
|
||||
checked={selected.has(option.key)}
|
||||
disabled={optionDisabled}
|
||||
onCheckedChange={(value) =>
|
||||
updateMultipleSelection(option.key, value === true)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-foreground",
|
||||
textSizeClass(size),
|
||||
optionDisabled
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
disabled={optionDisabled}
|
||||
type="button"
|
||||
onClick={() => updateMultipleSelection(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={generatedId}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
{selectionMode === "multiple" ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-controls={`${generatedId}-listbox`}
|
||||
aria-expanded={isExpanded}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md border border-input bg-background px-3 py-2 text-left shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isDisabled ? "cursor-not-allowed opacity-60" : "",
|
||||
classNames?.trigger,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={generatedId}
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block min-w-0 flex-1 truncate",
|
||||
textSizeClass(size),
|
||||
selectedArray.length > 0
|
||||
? "text-foreground"
|
||||
: "text-default-500",
|
||||
)}
|
||||
title={selectedArray.length > 0 ? selectedFullText : undefined}
|
||||
>
|
||||
{selectedText}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 flex-shrink-0 text-default-500 transition-transform",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{renderMultipleListbox()}
|
||||
</div>
|
||||
) : (
|
||||
<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",
|
||||
sizeClass(size),
|
||||
classNames?.trigger,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={generatedId}
|
||||
required={isRequired}
|
||||
value={singleValue}
|
||||
onChange={handleChange}
|
||||
onClick={onClick}
|
||||
>
|
||||
<option value="">{placeholder ?? "请选择"}</option>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.key}
|
||||
disabled={disabled.has(option.key)}
|
||||
value={option.key}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FieldMetaProps {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
errorMessage?: React.ReactNode;
|
||||
isInvalid?: boolean;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
interface FieldContainerProps extends FieldMetaProps {
|
||||
className?: string;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FieldContainer({
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
errorMessage,
|
||||
id,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
}: FieldContainerProps) {
|
||||
return (
|
||||
<div className={cn("w-full space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<Label htmlFor={id}>
|
||||
{label}
|
||||
{isRequired ? <span className="ml-1 text-danger">*</span> : null}
|
||||
</Label>
|
||||
) : null}
|
||||
{children}
|
||||
{description ? (
|
||||
<p className="text-xs text-default-500">{description}</p>
|
||||
) : null}
|
||||
{isInvalid && errorMessage ? (
|
||||
<p className="text-xs text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function extractText(content: React.ReactNode): string {
|
||||
if (
|
||||
content === null ||
|
||||
content === undefined ||
|
||||
typeof content === "boolean"
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string" || typeof content === "number") {
|
||||
return String(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => extractText(item))
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
if (React.isValidElement(content)) {
|
||||
return extractText(content.props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SpinnerSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface SpinnerProps extends React.ComponentProps<"div"> {
|
||||
label?: string;
|
||||
size?: SpinnerSize;
|
||||
}
|
||||
|
||||
function iconSize(size: SpinnerSize | undefined) {
|
||||
if (size === "sm") {
|
||||
return "h-4 w-4";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "h-7 w-7";
|
||||
}
|
||||
|
||||
return "h-5 w-5";
|
||||
}
|
||||
|
||||
export function Spinner({
|
||||
className,
|
||||
label,
|
||||
size = "md",
|
||||
...props
|
||||
}: SpinnerProps) {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", className)} {...props}>
|
||||
<Loader2Icon
|
||||
className={cn("animate-spin text-default-500", iconSize(size))}
|
||||
/>
|
||||
{label ? <span className="text-sm text-default-500">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Switch as BaseSwitch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SwitchProps
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof BaseSwitch>,
|
||||
"checked" | "onCheckedChange"
|
||||
> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
className,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
onValueChange,
|
||||
...props
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch
|
||||
checked={Boolean(isSelected)}
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={onValueChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface HeroUIProviderProps {
|
||||
children: React.ReactNode;
|
||||
navigate?: unknown;
|
||||
useHref?: unknown;
|
||||
}
|
||||
|
||||
export function HeroUIProvider({ children }: HeroUIProviderProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TableClassNames {
|
||||
th?: string;
|
||||
td?: string;
|
||||
tr?: string;
|
||||
wrapper?: string;
|
||||
}
|
||||
|
||||
interface TableStyleContextValue {
|
||||
thClassName?: string;
|
||||
tdClassName?: string;
|
||||
trClassName?: string;
|
||||
}
|
||||
|
||||
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
||||
|
||||
export interface TableProps extends React.ComponentProps<"table"> {
|
||||
classNames?: TableClassNames;
|
||||
}
|
||||
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
classNames,
|
||||
...props
|
||||
}: TableProps) {
|
||||
return (
|
||||
<TableStyleContext.Provider
|
||||
value={{
|
||||
thClassName: classNames?.th,
|
||||
tdClassName: classNames?.td,
|
||||
trClassName: classNames?.tr,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
|
||||
>
|
||||
<table className={cn("w-full text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
</TableStyleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"thead">) {
|
||||
const childArray = React.Children.toArray(children);
|
||||
const hasRow = childArray.some(
|
||||
(child) => React.isValidElement(child) && child.type === TableRow,
|
||||
);
|
||||
|
||||
return (
|
||||
<thead className={cn("border-b bg-default-50/60", className)} {...props}>
|
||||
{hasRow ? children : <TableRow>{children}</TableRow>}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps<T>
|
||||
extends Omit<React.ComponentProps<"tbody">, "children"> {
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
emptyContent?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
items?: T[];
|
||||
loadingContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
TableBodyProps<any>
|
||||
>(function TableBody<T>(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>,
|
||||
ref: React.Ref<HTMLTableSectionElement>,
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{loadingContent ?? "加载中..."}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
if (items && typeof children === "function") {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{items.map((item) => {
|
||||
const key =
|
||||
typeof item === "object" && item !== null && "id" in item
|
||||
? String((item as { id: React.Key }).id)
|
||||
: JSON.stringify(item);
|
||||
|
||||
return <React.Fragment key={key}>{children(item)}</React.Fragment>;
|
||||
})}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
const staticChildren =
|
||||
typeof children === "function" ? [] : React.Children.toArray(children);
|
||||
|
||||
if (staticChildren.length === 0) {
|
||||
return (
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{typeof children === "function" ? null : children}
|
||||
</tbody>
|
||||
);
|
||||
});
|
||||
|
||||
export function TableColumn({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"th">) {
|
||||
const { thClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-3 py-2 text-left font-medium text-default-600",
|
||||
thClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.ComponentProps<"tr">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { trClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b last:border-b-0", trClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
const { tdClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cn("px-3 py-2 align-middle", tdClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user