mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42ae3457b5 | |||
| 088027da7b | |||
| d37adee5df | |||
| c147e52d72 | |||
| d483258eef | |||
| 79c28103d5 | |||
| f36bf1437c | |||
| 4ad3aa2c06 | |||
| 357a4b165e | |||
| a15be253f5 | |||
| 572d1c16a6 | |||
| 39e22c07de | |||
| ca24573803 | |||
| 0cb3263a2e | |||
| fb2189c924 | |||
| 1383174b31 | |||
| c95bde7055 | |||
| 57f5e3a1a3 | |||
| 66ad52c199 | |||
| 2081dc9658 | |||
| b93255df3d | |||
| e1aef8700e | |||
| d5b3a39774 | |||
| 022e9e3807 | |||
| d333d463f6 | |||
| abc9f21ab9 | |||
| d216567c02 | |||
| 45bfd35a20 | |||
| 5a1b72387d | |||
| 66de566a00 | |||
| 18c2da7c7e | |||
| c4d807f1c4 | |||
| d1460ab9c7 | |||
| 9189c68800 | |||
| efbdabceca | |||
| 6e5a71f489 | |||
| e5c57f81ad | |||
| 0b1609c6cb | |||
| a10c68ef20 | |||
| 9aedeab406 |
@@ -268,3 +268,6 @@ sql/
|
||||
!go-backend/internal/store/postgres/sql/
|
||||
!go-backend/internal/store/postgres/sql/schema.sql
|
||||
!go-backend/internal/store/postgres/sql/data.sql
|
||||
go-backend/gost.db-shm
|
||||
.gitignore
|
||||
go-backend/gost.db-wal
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ type Handler struct {
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -70,12 +73,15 @@ type flowItem struct {
|
||||
}
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) WebSocketHandler() http.Handler {
|
||||
|
||||
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
id := idFromBody(r, w)
|
||||
if id <= 0 {
|
||||
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
secret, err := h.repo.GetNodeSecret(id)
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version, err := resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := h.repo.GetNodeSecret(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||
return
|
||||
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
|
||||
response.WriteJSON(w, response.OK(cmd))
|
||||
}
|
||||
|
||||
@@ -901,6 +918,58 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tunnel.Type == 2 {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
state, err := h.reconstructTunnelState(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
||||
if fedErr != nil {
|
||||
return fedErr
|
||||
}
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
return tx.Error
|
||||
}
|
||||
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||
tx.Rollback()
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
return replaceErr
|
||||
}
|
||||
if commitErr := tx.Commit().Error; commitErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
return commitErr
|
||||
}
|
||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||
if applyErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
return applyErr
|
||||
}
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range forwards {
|
||||
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
ids := idsFromBody(r, w)
|
||||
if ids == nil {
|
||||
@@ -909,72 +978,11 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||
success := 0
|
||||
fail := 0
|
||||
for _, tunnelID := range ids {
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
|
||||
if tunnel.Type == 2 {
|
||||
h.cleanupTunnelRuntime(tunnelID)
|
||||
h.cleanupFederationRuntime(tunnelID)
|
||||
state, err := h.reconstructTunnelState(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
||||
if fedErr != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
tx := h.repo.BeginTx()
|
||||
if tx.Error != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||
tx.Rollback()
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if commitErr := tx.Commit().Error; commitErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||
if applyErr != nil {
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
success++
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for i := range forwards {
|
||||
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
success++
|
||||
} else {
|
||||
fail++
|
||||
}
|
||||
success++
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,8 +20,104 @@ const (
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
batchWorkers = 5
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
)
|
||||
|
||||
var (
|
||||
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
|
||||
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
||||
)
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
|
||||
func normalizeReleaseChannel(channel string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(channel)) {
|
||||
case releaseChannelDev:
|
||||
return releaseChannelDev
|
||||
default:
|
||||
return releaseChannelStable
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChannelFromTag(tag string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(tag))
|
||||
if normalized == "" {
|
||||
return releaseChannelDev
|
||||
}
|
||||
if testKeywordPattern.MatchString(normalized) {
|
||||
return releaseChannelDev
|
||||
}
|
||||
if stableVersionPattern.MatchString(normalized) {
|
||||
return releaseChannelStable
|
||||
}
|
||||
|
||||
return releaseChannelDev
|
||||
}
|
||||
|
||||
func releaseChannelLabel(channel string) string {
|
||||
if normalizeReleaseChannel(channel) == releaseChannelDev {
|
||||
return "测试版"
|
||||
}
|
||||
|
||||
return "正式版"
|
||||
}
|
||||
|
||||
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func resolveLatestReleaseByChannel(channel string) (string, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
releases, err := fetchGitHubReleases(50)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if releaseChannelFromTag(tag) == normalizedChannel {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||
}
|
||||
|
||||
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestRelease()
|
||||
version, err = resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -67,6 +166,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
|
||||
return
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(req.ID)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"version": version,
|
||||
@@ -75,61 +175,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func resolveLatestRelease() (string, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求GitHub失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
parts := strings.Split(location, "/")
|
||||
tag := parts[len(parts)-1]
|
||||
if tag == "" || tag == "latest" {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||
}
|
||||
|
||||
func resolveLatestReleaseAPI() (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return "", fmt.Errorf("无法从GitHub获取最新版本号")
|
||||
}
|
||||
|
||||
return release.TagName, nil
|
||||
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,6 +191,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
@@ -151,12 +202,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestRelease()
|
||||
version, err = resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -195,6 +247,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
|
||||
return
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(nodeID)
|
||||
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
|
||||
}(i, id)
|
||||
}
|
||||
@@ -212,37 +265,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
|
||||
releases, err := fetchGitHubReleases(50)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
|
||||
return
|
||||
}
|
||||
|
||||
var releases []struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
type releaseItem struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
items := make([]releaseItem, 0, len(releases))
|
||||
@@ -250,11 +294,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
itemChannel := releaseChannelFromTag(tag)
|
||||
if itemChannel != channel {
|
||||
continue
|
||||
}
|
||||
items = append(items, releaseItem{
|
||||
Version: r.TagName,
|
||||
Version: tag,
|
||||
Name: r.Name,
|
||||
PublishedAt: r.PublishedAt,
|
||||
Prerelease: r.Prerelease,
|
||||
Prerelease: itemChannel == releaseChannelDev,
|
||||
Channel: itemChannel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,3 +342,66 @@ func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
|
||||
"message": result.Message,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) markNodePendingUpgradeRedeploy(nodeID int64) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
h.pendingUpgradeRedeploy[nodeID] = struct{}{}
|
||||
h.upgradeMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return false
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
_, ok := h.pendingUpgradeRedeploy[nodeID]
|
||||
if ok {
|
||||
delete(h.pendingUpgradeRedeploy, nodeID)
|
||||
}
|
||||
h.upgradeMu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
|
||||
return
|
||||
}
|
||||
h.redeployNodeRuntimeAfterUpgrade(nodeID)
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
}
|
||||
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelFailed := make(map[int64]struct{})
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
tunnelFailed[tunnelID] = struct{}{}
|
||||
fmt.Printf("post-upgrade redeploy: tunnel %d failed on node %d: %v\n", tunnelID, nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forwardID := range forwardIDs {
|
||||
forward, getErr := h.getForwardRecord(forwardID)
|
||||
if getErr != nil || forward == nil {
|
||||
continue
|
||||
}
|
||||
if _, skipped := tunnelFailed[forward.TunnelID]; skipped {
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseChannelFromTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tag string
|
||||
expects string
|
||||
}{
|
||||
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
|
||||
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
|
||||
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
|
||||
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
|
||||
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
|
||||
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
|
||||
{name: "empty tag", tag: "", expects: releaseChannelDev},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
|
||||
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReleaseChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expects string
|
||||
}{
|
||||
{input: "", expects: releaseChannelStable},
|
||||
{input: "stable", expects: releaseChannelStable},
|
||||
{input: "dev", expects: releaseChannelDev},
|
||||
{input: "DEV", expects: releaseChannelDev},
|
||||
{input: "preview", expects: releaseChannelStable},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
|
||||
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,40 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Joins("JOIN tunnel ON tunnel.id = chain_tunnel.tunnel_id").
|
||||
Where("chain_tunnel.node_id = ? AND tunnel.status = 1", nodeID).
|
||||
Select("DISTINCT chain_tunnel.tunnel_id").
|
||||
Order("chain_tunnel.tunnel_id ASC").
|
||||
Pluck("chain_tunnel.tunnel_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Joins("JOIN forward ON forward.id = forward_port.forward_id").
|
||||
Where("forward_port.node_id = ? AND forward.status = 1", nodeID).
|
||||
Select("DISTINCT forward_port.forward_id").
|
||||
Order("forward_port.forward_id ASC").
|
||||
Pluck("forward_port.forward_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
|
||||
@@ -68,9 +68,10 @@ type CommandResult struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
onNodeOnline func(nodeID int64)
|
||||
|
||||
mu sync.RWMutex
|
||||
admins map[*connWrap]struct{}
|
||||
@@ -79,6 +80,15 @@ type Server struct {
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.onNodeOnline = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
@@ -183,6 +193,13 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
|
||||
s.broadcastStatus(nodeID, 1)
|
||||
|
||||
s.mu.RLock()
|
||||
onlineHook := s.onNodeOnline
|
||||
s.mu.RUnlock()
|
||||
if onlineHook != nil {
|
||||
go onlineHook(nodeID)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
needOfflineBroadcast := false
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
|
||||
VITE_APP_VERSION=beta
|
||||
|
||||
+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
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
@@ -25,8 +27,6 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@react-aria/i18n": "^3.12.12",
|
||||
"@react-aria/visually-hidden": "3.8.25",
|
||||
"@react-types/shared": "3.30.0",
|
||||
@@ -43,8 +43,11 @@
|
||||
"react-dom": "18.3.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-is": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "6.30.3",
|
||||
"recharts": "^3.1.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwind-variants": "1.0.0",
|
||||
|
||||
@@ -31,6 +31,8 @@ import axios from "axios";
|
||||
|
||||
import Network from "./network";
|
||||
|
||||
export type ReleaseChannel = "stable" | "dev";
|
||||
|
||||
// 登陆相关接口
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||
export const updateNode = (data: NodeMutationPayload) =>
|
||||
Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
export const getNodeInstallCommand = (id: number) =>
|
||||
Network.post<string>("/node/install", { id });
|
||||
export const getNodeInstallCommand = (
|
||||
id: number,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) => Network.post<string>("/node/install", { id, channel });
|
||||
export const updateNodeOrder = (data: {
|
||||
nodes: Array<{ id: number; inx: number }>;
|
||||
}) => Network.post("/node/update-order", data);
|
||||
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
|
||||
return Network.post("/node/check-status", params);
|
||||
};
|
||||
|
||||
export const upgradeNode = (id: number, version?: string) =>
|
||||
export const upgradeNode = (
|
||||
id: number,
|
||||
version?: string,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) =>
|
||||
Network.post(
|
||||
"/node/upgrade",
|
||||
{ id, version: version || "" },
|
||||
{ id, version: version || "", channel },
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
);
|
||||
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
||||
export const batchUpgradeNodes = (
|
||||
ids: number[],
|
||||
version?: string,
|
||||
channel: ReleaseChannel = "stable",
|
||||
) =>
|
||||
Network.post(
|
||||
"/node/batch-upgrade",
|
||||
{ ids, version: version || "" },
|
||||
{ ids, version: version || "", channel },
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
);
|
||||
export const getNodeReleases = () =>
|
||||
Network.post<NodeReleaseApiItem[]>("/node/releases");
|
||||
export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
|
||||
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
|
||||
export const rollbackNode = (id: number) =>
|
||||
Network.post("/node/rollback", { id });
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export interface NodeReleaseApiItem {
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
channel: "stable" | "dev";
|
||||
}
|
||||
|
||||
export interface UserPackageInfoApiData {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Wraps page content with a smooth fade-in + slide-up entrance animation.
|
||||
* Use this as the outermost wrapper inside each page component.
|
||||
*/
|
||||
export const AnimatedPage = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Stagger container — apply to the parent of a list/grid of animated items.
|
||||
* Children should use `staggerItem` as their `variants` prop.
|
||||
*/
|
||||
export const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual stagger item variant — fade-in + slide-up.
|
||||
*/
|
||||
export const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience wrapper for a stagger list.
|
||||
* Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour.
|
||||
*/
|
||||
export const StaggerList = ({
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "ul" | "tbody";
|
||||
}) => {
|
||||
const Component = motion[as] as React.ElementType;
|
||||
|
||||
return (
|
||||
<Component
|
||||
animate="show"
|
||||
className={className}
|
||||
initial="hidden"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual animated item for use inside StaggerList.
|
||||
*/
|
||||
export const StaggerItem = ({
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "li" | "tr";
|
||||
}) => {
|
||||
const Component = motion[as] as React.ElementType;
|
||||
|
||||
return (
|
||||
<Component className={className} variants={staggerItem}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple fade-in animation for standalone elements (chips, badges, counters, etc.)
|
||||
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
|
||||
*/
|
||||
export const FadeIn = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.2, delay, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -1,12 +1,12 @@
|
||||
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 "@/shadcn-bridge/heroui/navbar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { SearchIcon } from "@/components/icons";
|
||||
|
||||
interface SearchBarProps {
|
||||
isVisible: boolean;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
isVisible,
|
||||
value,
|
||||
placeholder = "搜索",
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
|
||||
<div className="flex items-center gap-2 h-8 overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{!isVisible ? (
|
||||
<motion.div
|
||||
key="search-btn"
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索"
|
||||
className="text-default-600"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={onOpen}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex w-full items-center gap-2"
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input:
|
||||
"bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
||||
inputWrapper: "bg-default-100 border-0 shadow-none h-8 min-h-8",
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onChange("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
|
||||
@@ -4,11 +4,16 @@ import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
className={cn("border-b", className)}
|
||||
@@ -27,7 +32,7 @@ function AccordionTrigger({
|
||||
<AccordionPrimitive.Header className="flex" data-slot="accordion-header">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
"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"
|
||||
|
||||
@@ -9,9 +9,12 @@ const alertVariants = cva(
|
||||
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",
|
||||
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: {
|
||||
@@ -45,9 +48,16 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("text-sm opacity-90", className)} data-slot="alert-description" {...props} />
|
||||
<div
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
data-slot="alert-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,21 @@ 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",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors transition-transform duration-100 active:scale-95 disabled:active:scale-100 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",
|
||||
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",
|
||||
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: {
|
||||
@@ -43,9 +47,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ className, size, variant }))}
|
||||
data-slot="button"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,10 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
data-slot="card-title"
|
||||
{...props}
|
||||
/>
|
||||
@@ -47,7 +50,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)} data-slot="card-content" {...props} />
|
||||
<div
|
||||
className={cn("p-6 pt-0", className)}
|
||||
data-slot="card-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,4 +68,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
||||
@@ -4,18 +4,21 @@ import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
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",
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="flex items-center justify-center text-current"
|
||||
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
|
||||
data-slot="checkbox-indicator"
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -4,23 +4,34 @@ import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
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>) {
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
@@ -46,7 +57,7 @@ function DialogContent({
|
||||
<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",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
@@ -54,9 +65,7 @@ function DialogContent({
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
@@ -69,7 +78,10 @@ function DialogContent({
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-header"
|
||||
{...props}
|
||||
/>
|
||||
@@ -79,17 +91,26 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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)}
|
||||
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>) {
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -4,32 +4,54 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
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} />;
|
||||
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 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 DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
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} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -63,7 +85,7 @@ function DropdownMenuSubContent({
|
||||
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",
|
||||
"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 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
@@ -81,7 +103,7 @@ function DropdownMenuContent({
|
||||
<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",
|
||||
"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 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
@@ -171,7 +193,11 @@ function DropdownMenuLabel({
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
/>
|
||||
@@ -191,10 +217,16 @@ function DropdownMenuSeparator({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-default-500", className)}
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-default-500",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(
|
||||
|
||||
@@ -13,13 +13,19 @@ function Progress({
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-default-200", className)}
|
||||
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)}
|
||||
className={cn(
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
indicatorClassName,
|
||||
)}
|
||||
data-slot="progress-indicator"
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
@@ -27,7 +30,10 @@ function RadioGroupItem({
|
||||
data-slot="radio-group-item"
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center" data-slot="radio-group-indicator">
|
||||
<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>
|
||||
|
||||
@@ -4,15 +4,21 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
@@ -44,7 +50,10 @@ function SelectScrollUpButton({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-scroll-up-button"
|
||||
{...props}
|
||||
>
|
||||
@@ -59,7 +68,10 @@ function SelectScrollDownButton({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-scroll-down-button"
|
||||
{...props}
|
||||
>
|
||||
@@ -78,7 +90,7 @@ function SelectContent({
|
||||
<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",
|
||||
"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 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 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,
|
||||
@@ -103,7 +115,10 @@ function SelectContent({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
|
||||
@@ -5,7 +5,11 @@ 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} />
|
||||
<table
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
data-slot="table"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +37,10 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
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)}
|
||||
className={cn(
|
||||
"border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-footer"
|
||||
{...props}
|
||||
/>
|
||||
@@ -43,7 +50,10 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
className={cn("border-b transition-colors hover:bg-default-50/50", className)}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-default-50/50",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-row"
|
||||
{...props}
|
||||
/>
|
||||
@@ -66,16 +76,26 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-cell"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption className={cn("mt-4 text-sm text-default-500", className)} data-slot="table-caption" {...props} />
|
||||
<caption
|
||||
className={cn("mt-4 text-sm text-default-500", className)}
|
||||
data-slot="table-caption"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,23 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} data-slot="tabs" {...props} />
|
||||
<TabsPrimitive.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
@@ -22,7 +32,10 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
@@ -35,7 +48,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
className={cn("outline-none", className)}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useState, type SetStateAction } from "react";
|
||||
|
||||
const readStoredValue = <T>(key: string, fallbackValue: T): T => {
|
||||
try {
|
||||
const rawValue = localStorage.getItem(key);
|
||||
|
||||
if (rawValue === null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return JSON.parse(rawValue) as T;
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalStorageState = <T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
): readonly [T, (value: SetStateAction<T>) => void, () => void] => {
|
||||
const [value, setValue] = useState<T>(() =>
|
||||
readStoredValue(key, initialValue),
|
||||
);
|
||||
|
||||
const setPersistedValue = useCallback(
|
||||
(nextValue: SetStateAction<T>) => {
|
||||
setValue((prevValue) => {
|
||||
const resolvedValue =
|
||||
typeof nextValue === "function"
|
||||
? (nextValue as (value: T) => T)(prevValue)
|
||||
: nextValue;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(resolvedValue));
|
||||
} catch {}
|
||||
|
||||
return resolvedValue;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const resetPersistedValue = useCallback(() => {
|
||||
setValue(initialValue);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {}
|
||||
}, [initialValue, key]);
|
||||
|
||||
return [value, setPersistedValue, resetPersistedValue] as const;
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
@@ -16,8 +19,6 @@ import {
|
||||
useDisclosure,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
@@ -49,6 +50,7 @@ export default function AdminLayout({
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
@@ -203,6 +205,13 @@ export default function AdminLayout({
|
||||
setMobileMenuVisible(false);
|
||||
};
|
||||
|
||||
// 切换折叠状态
|
||||
const toggleCollapse = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newCollapsed);
|
||||
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
|
||||
};
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = (path: string) => {
|
||||
navigate(path);
|
||||
@@ -303,62 +312,87 @@ export default function AdminLayout({
|
||||
className={`
|
||||
${isMobile ? "fixed" : "relative"}
|
||||
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
|
||||
${isMobile ? "w-64" : "w-72"}
|
||||
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
|
||||
bg-white dark:bg-black
|
||||
shadow-lg
|
||||
border-r border-gray-200 dark:border-gray-600
|
||||
z-50
|
||||
transition-transform duration-300 ease-in-out
|
||||
transition-all duration-300 ease-in-out
|
||||
flex flex-col
|
||||
${isMobile ? "h-screen" : "h-full"}
|
||||
${isMobile ? "top-0 left-0" : ""}
|
||||
`}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="px-3 py-3 h-14 flex items-center">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Logo size={24} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
<p className="text-xs text-default-500">v{siteConfig.version}</p>
|
||||
</div>
|
||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||
<Logo size={28} />
|
||||
</div>
|
||||
<div className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}>
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单导航 */}
|
||||
<nav className="flex-1 px-4 py-6 overflow-y-auto">
|
||||
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
|
||||
<ul className="space-y-1">
|
||||
{filteredMenuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<button
|
||||
<motion.button
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${
|
||||
isActive
|
||||
? "bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
}
|
||||
w-full flex items-center p-2 rounded-lg text-left
|
||||
relative min-h-[44px] overflow-hidden transition-colors
|
||||
${isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
`}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={() => handleMenuClick(item.path)}
|
||||
>
|
||||
<div className="flex-shrink-0">{item.icon}</div>
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</button>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
|
||||
layoutId="sidebar-active"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-gray-100 dark:bg-gray-900 opacity-0"
|
||||
transition={{ duration: 0.15 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}>
|
||||
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部版权信息 */}
|
||||
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
|
||||
<div className="text-center">
|
||||
{/* 底部版权信息和折叠按钮 */}
|
||||
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
|
||||
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
@@ -371,6 +405,29 @@ export default function AdminLayout({
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 桌面端折叠按钮 */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
|
||||
onPress={toggleCollapse}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
// 向右扩展的提示
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
// 向左收起的提示
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -476,9 +533,20 @@ export default function AdminLayout({
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? "" : "overflow-y-auto"}`}
|
||||
className={`flex-1 bg-gray-100 dark:bg-black overflow-hidden ${isMobile ? "" : "overflow-y-scroll"}`}
|
||||
>
|
||||
{children}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="h-full"
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.22, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/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";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -16,8 +18,6 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
updateConfigs,
|
||||
exportBackup,
|
||||
@@ -585,7 +585,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>
|
||||
@@ -609,7 +609,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)) {
|
||||
@@ -649,9 +649,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>
|
||||
@@ -660,7 +660,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>
|
||||
@@ -673,7 +673,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" />
|
||||
@@ -702,13 +702,16 @@ export default function ConfigPage() {
|
||||
<Textarea
|
||||
label="公告内容"
|
||||
minRows={4}
|
||||
placeholder="请输入公告内容"
|
||||
placeholder="支持 Markdown,例如:**加粗**、[链接](https://example.com)、- 列表"
|
||||
value={announcement.content}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setAnnouncement({ ...announcement, content: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
公告支持 Markdown 语法,链接会在新标签页打开
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
@@ -727,7 +730,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>
|
||||
@@ -740,7 +743,7 @@ 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>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@/shadcn-bridge/heroui/modal";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
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";
|
||||
@@ -519,7 +525,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
{announcement && <AnnouncementBanner announcement={announcement} />}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<MetricCard
|
||||
@@ -973,6 +979,6 @@ export default function DashboardPage() {
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AnnouncementData } from "@/api";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
|
||||
interface AnnouncementBannerProps {
|
||||
@@ -14,13 +18,13 @@ export const AnnouncementBanner = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-100 dark:border-blue-500/20 bg-gradient-to-br from-blue-50/80 to-indigo-50/80 dark:from-blue-500/5 dark:to-indigo-500/5 shadow-sm">
|
||||
<CardBody className="p-4 sm:p-5">
|
||||
<div className="flex items-center gap-3.5 sm:gap-4">
|
||||
<div className="w-10 h-10 sm:w-11 sm:h-11 bg-white dark:bg-blue-500/10 rounded-full flex-shrink-0 flex items-center justify-center shadow-sm border border-blue-100 dark:border-blue-500/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
className="w-5 h-5 sm:w-5 sm:h-5 text-blue-500 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -32,12 +36,57 @@ export const AnnouncementBanner = ({
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
<h3 className="text-[15px] font-semibold text-blue-900 dark:text-blue-100 tracking-wide mb-1 sm:mb-1.5 mt-1.5">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
{announcement.content}
|
||||
</p>
|
||||
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-2 border-l-2 border-blue-300/80 dark:border-blue-500/60 pl-3 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{announcement.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { PageEmptyState } from "@/components/page-state";
|
||||
|
||||
interface FlowChartPoint {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
getUserPackageInfo,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
export interface DashboardUserInfo {
|
||||
@@ -180,9 +181,13 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
);
|
||||
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||
DashboardStatisticsFlow[]
|
||||
>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
@@ -214,7 +219,10 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(data.userInfo, normalizedTunnelPermissions);
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
|
||||
+619
-401
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, 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 { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -21,8 +23,6 @@ import {
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
assignGroupPermission,
|
||||
assignTunnelsToGroup,
|
||||
@@ -173,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 {
|
||||
@@ -599,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="用户分组"
|
||||
@@ -629,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}
|
||||
>
|
||||
分配权限
|
||||
@@ -764,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}
|
||||
@@ -778,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">
|
||||
不选择任何隧道并保存将清空该分组成员。
|
||||
@@ -809,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}
|
||||
@@ -823,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,11 +1,12 @@
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
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";
|
||||
@@ -152,7 +153,12 @@ export default function IndexPage() {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100dvh-120px)] sm:min-h-[calc(100dvh-200px)]">
|
||||
<div className="w-full max-w-md px-4 sm:px-0">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md px-4 sm:px-0"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<h1 className={title({ size: "sm" })}>登陆</h1>
|
||||
@@ -203,7 +209,7 @@ export default function IndexPage() {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||
|
||||
|
||||
+142
-132
@@ -1,4 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -11,8 +19,6 @@ import {
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
getTunnelList,
|
||||
} from "@/api";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
|
||||
interface SpeedLimitRule {
|
||||
id: number;
|
||||
@@ -51,6 +58,22 @@ export default function LimitPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"limit-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return rules;
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
return rules.filter(
|
||||
(r) =>
|
||||
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}, [rules, searchKeyword]);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -219,10 +242,18 @@ export default function LimitPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索规则名称或绑定隧道"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
@@ -230,138 +261,117 @@ export default function LimitPage() {
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
{filteredRules.length > 0 ? (
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{filteredRules.map((rule) => (
|
||||
<StaggerItem key={rule.id}>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</CardHeader>
|
||||
<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">
|
||||
速度限制
|
||||
</span>
|
||||
)}
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无限速规则
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何限速规则,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -524,6 +534,6 @@ export default function LimitPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
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 toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -37,6 +18,27 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
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,
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
batchUpgradeNodes,
|
||||
getNodeReleases,
|
||||
rollbackNode,
|
||||
type ReleaseChannel,
|
||||
} from "@/api";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import {
|
||||
@@ -59,6 +62,7 @@ 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 { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
interface Node {
|
||||
@@ -126,11 +130,17 @@ const SortableItem = ({
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
const style: React.CSSProperties = {
|
||||
transform: transform
|
||||
? CSS.Transform.toString({
|
||||
...transform,
|
||||
x: Math.round(transform.x),
|
||||
y: Math.round(transform.y),
|
||||
})
|
||||
: undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: "transform",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -149,6 +159,11 @@ export default function NodePage() {
|
||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
@@ -183,6 +198,10 @@ export default function NodePage() {
|
||||
const [installCommandModal, setInstallCommandModal] = useState(false);
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
const [currentNodeName, setCurrentNodeName] = useState("");
|
||||
const [installSelectorOpen, setInstallSelectorOpen] = useState(false);
|
||||
const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);
|
||||
const [installChannel, setInstallChannel] =
|
||||
useState<ReleaseChannel>("stable");
|
||||
|
||||
// 升级相关状态
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
@@ -198,9 +217,12 @@ export default function NodePage() {
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
channel: ReleaseChannel;
|
||||
}>
|
||||
>([]);
|
||||
const [releasesLoading, setReleasesLoading] = useState(false);
|
||||
const [releaseChannel, setReleaseChannel] =
|
||||
useState<ReleaseChannel>("stable");
|
||||
const [selectedVersion, setSelectedVersion] = useState("");
|
||||
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
||||
const [upgradeProgress, setUpgradeProgress] = useState<
|
||||
@@ -609,20 +631,31 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openInstallSelector = (node: Node) => {
|
||||
setInstallTargetNode(node);
|
||||
setInstallChannel("stable");
|
||||
setInstallSelectorOpen(true);
|
||||
};
|
||||
|
||||
// 复制安装命令
|
||||
const handleCopyInstallCommand = async (node: Node) => {
|
||||
const handleCopyInstallCommand = async (
|
||||
node: Node,
|
||||
channel: ReleaseChannel,
|
||||
) => {
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await getNodeInstallCommand(node.id);
|
||||
const res = await getNodeInstallCommand(node.id, channel);
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
const copied = await tryCopyInstallCommand(res.data);
|
||||
|
||||
if (copied) {
|
||||
toast.success("安装命令已复制到剪贴板");
|
||||
toast.success(
|
||||
`${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`,
|
||||
);
|
||||
} else {
|
||||
setInstallCommand(res.data);
|
||||
setCurrentNodeName(node.name);
|
||||
@@ -640,6 +673,13 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmInstallCommand = async () => {
|
||||
if (!installTargetNode) return;
|
||||
|
||||
setInstallSelectorOpen(false);
|
||||
await handleCopyInstallCommand(installTargetNode, installChannel);
|
||||
};
|
||||
|
||||
// 手动复制安装命令
|
||||
const handleManualCopy = async () => {
|
||||
try {
|
||||
@@ -651,18 +691,10 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开版本选择弹窗
|
||||
const openUpgradeModal = async (
|
||||
target: "single" | "batch",
|
||||
nodeId?: number,
|
||||
) => {
|
||||
setUpgradeTarget(target);
|
||||
setUpgradeTargetNodeId(nodeId || null);
|
||||
setSelectedVersion("");
|
||||
setUpgradeModalOpen(true);
|
||||
const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {
|
||||
setReleasesLoading(true);
|
||||
try {
|
||||
const res = await getNodeReleases();
|
||||
const res = await getNodeReleases(channel);
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
setReleases(res.data);
|
||||
@@ -674,6 +706,21 @@ export default function NodePage() {
|
||||
} finally {
|
||||
setReleasesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开版本选择弹窗
|
||||
const openUpgradeModal = async (
|
||||
target: "single" | "batch",
|
||||
nodeId?: number,
|
||||
) => {
|
||||
const defaultChannel: ReleaseChannel = "stable";
|
||||
|
||||
setUpgradeTarget(target);
|
||||
setUpgradeTargetNodeId(nodeId || null);
|
||||
setReleaseChannel(defaultChannel);
|
||||
setSelectedVersion("");
|
||||
setUpgradeModalOpen(true);
|
||||
await loadReleasesByChannel(defaultChannel);
|
||||
};
|
||||
|
||||
// 确认升级(从版本弹窗)
|
||||
@@ -692,7 +739,11 @@ export default function NodePage() {
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await upgradeNode(upgradeTargetNodeId, version);
|
||||
const res = await upgradeNode(
|
||||
upgradeTargetNodeId,
|
||||
version,
|
||||
releaseChannel,
|
||||
);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`节点升级命令已发送,节点将自动重启`);
|
||||
@@ -712,7 +763,11 @@ export default function NodePage() {
|
||||
setBatchUpgradeLoading(true);
|
||||
setUpgradeModalOpen(false);
|
||||
try {
|
||||
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
||||
const res = await batchUpgradeNodes(
|
||||
Array.from(selectedIds),
|
||||
version,
|
||||
releaseChannel,
|
||||
);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
||||
@@ -953,7 +1008,21 @@ export default function NodePage() {
|
||||
const sortedNodes = useMemo((): Node[] => {
|
||||
if (!nodeList || nodeList.length === 0) return [];
|
||||
|
||||
const sortedByDb = [...nodeList].sort((a, b) => {
|
||||
let filteredNodes = nodeList;
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
filteredNodes = filteredNodes.filter(
|
||||
(n) =>
|
||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -966,7 +1035,7 @@ export default function NodePage() {
|
||||
nodeOrder.length > 0 &&
|
||||
sortedByDb.every((n) => n.inx === undefined || n.inx === 0)
|
||||
) {
|
||||
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
|
||||
const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));
|
||||
const localSorted: Node[] = [];
|
||||
|
||||
nodeOrder.forEach((id) => {
|
||||
@@ -975,7 +1044,7 @@ export default function NodePage() {
|
||||
if (node) localSorted.push(node);
|
||||
});
|
||||
|
||||
nodeList.forEach((node) => {
|
||||
filteredNodes.forEach((node) => {
|
||||
if (!nodeOrder.includes(node.id)) {
|
||||
localSorted.push(node);
|
||||
}
|
||||
@@ -985,7 +1054,7 @@ export default function NodePage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [nodeList, nodeOrder]);
|
||||
}, [nodeList, nodeOrder, searchKeyword]);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => sortedNodes.map((n) => n.id),
|
||||
@@ -993,19 +1062,27 @@ export default function NodePage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索节点名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
新增
|
||||
@@ -1020,10 +1097,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
|
||||
@@ -1083,7 +1170,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 && (
|
||||
@@ -1143,7 +1230,7 @@ export default function NodePage() {
|
||||
</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">
|
||||
{getRemoteSyncErrorMessage(node.syncError)}
|
||||
@@ -1155,7 +1242,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">
|
||||
@@ -1357,7 +1444,7 @@ export default function NodePage() {
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleCopyInstallCommand(node)}
|
||||
onPress={() => openInstallSelector(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
@@ -1801,6 +1888,55 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={installSelectorOpen}
|
||||
placement="center"
|
||||
size="md"
|
||||
onOpenChange={setInstallSelectorOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
选择安装通道
|
||||
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
|
||||
</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="版本通道"
|
||||
selectedKeys={[installChannel]}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as ReleaseChannel;
|
||||
|
||||
setInstallChannel(selected || "stable");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" textValue="stable">
|
||||
正式版(纯数字版本,如 2.1.4)
|
||||
</SelectItem>
|
||||
<SelectItem key="dev" textValue="dev">
|
||||
测试版(含 alpha / beta / rc)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleConfirmInstallCommand}>
|
||||
生成命令
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 安装命令模态框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
@@ -1881,9 +2017,28 @@ export default function NodePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="版本通道"
|
||||
selectedKeys={[releaseChannel]}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected =
|
||||
(Array.from(keys)[0] as ReleaseChannel) || "stable";
|
||||
|
||||
setReleaseChannel(selected);
|
||||
setSelectedVersion("");
|
||||
void loadReleasesByChannel(selected);
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" textValue="stable">
|
||||
正式版(纯数字版本,如 2.1.4)
|
||||
</SelectItem>
|
||||
<SelectItem key="dev" textValue="dev">
|
||||
测试版(含 alpha / beta / rc)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
<Select
|
||||
label="选择版本"
|
||||
placeholder="留空则使用最新版本"
|
||||
placeholder="留空则使用当前通道最新版本"
|
||||
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as string;
|
||||
@@ -1899,14 +2054,14 @@ export default function NodePage() {
|
||||
{r.publishedAt
|
||||
? new Date(r.publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
{r.prerelease && (
|
||||
{r.channel === "dev" && (
|
||||
<Chip
|
||||
className="ml-1"
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
预览
|
||||
测试
|
||||
</Chip>
|
||||
)}
|
||||
</span>
|
||||
@@ -1917,7 +2072,7 @@ export default function NodePage() {
|
||||
<p className="text-sm text-default-500">
|
||||
{selectedVersion
|
||||
? `将升级到版本 ${selectedVersion}`
|
||||
: "未选择版本,将自动使用最新稳定版"}
|
||||
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1979,6 +2134,6 @@ export default function NodePage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
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";
|
||||
@@ -11,8 +13,6 @@ import {
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getNodeList,
|
||||
createPeerShare,
|
||||
@@ -397,11 +397,31 @@ 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="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Provider 共享
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
将本地节点分享给其他面板,统一管理
|
||||
Token、端口范围和到期策略。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start md:self-auto"
|
||||
color="primary"
|
||||
onPress={() => setCreateShareOpen(true)}
|
||||
>
|
||||
@@ -410,9 +430,27 @@ 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) => (
|
||||
@@ -420,7 +458,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
|
||||
@@ -447,7 +485,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}
|
||||
@@ -507,11 +545,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="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Consumer 接入
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
导入远程节点后,可在这里查看端口占用和同步状态。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start md:self-auto"
|
||||
color="secondary"
|
||||
onPress={() => setImportNodeOpen(true)}
|
||||
>
|
||||
@@ -520,13 +577,26 @@ 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">
|
||||
@@ -535,13 +605,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,4 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
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 {
|
||||
@@ -10,9 +13,6 @@ import {
|
||||
useDisclosure,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { updatePassword } from "@/api";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/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,
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
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 toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -34,6 +18,24 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
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,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
getTunnelTypeDisplay,
|
||||
validateTunnelForm,
|
||||
} from "@/pages/tunnel/form";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
|
||||
@@ -111,6 +114,11 @@ export default function TunnelPage() {
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"tunnel-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -597,7 +605,19 @@ export default function TunnelPage() {
|
||||
const sortedTunnels = useMemo((): Tunnel[] => {
|
||||
if (!tunnels || tunnels.length === 0) return [];
|
||||
|
||||
const sortedByDb = [...tunnels].sort((a, b) => {
|
||||
let filteredTunnels = tunnels;
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
filteredTunnels = filteredTunnels.filter(
|
||||
(t) =>
|
||||
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
const sortedByDb = [...filteredTunnels].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
|
||||
@@ -610,7 +630,7 @@ export default function TunnelPage() {
|
||||
tunnelOrder.length > 0 &&
|
||||
sortedByDb.every((t) => t.inx === undefined || t.inx === 0)
|
||||
) {
|
||||
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
|
||||
const tunnelMap = new Map(filteredTunnels.map((t) => [t.id, t] as const));
|
||||
const localSorted: Tunnel[] = [];
|
||||
|
||||
tunnelOrder.forEach((id) => {
|
||||
@@ -619,7 +639,7 @@ export default function TunnelPage() {
|
||||
if (tunnel) localSorted.push(tunnel);
|
||||
});
|
||||
|
||||
tunnels.forEach((tunnel) => {
|
||||
filteredTunnels.forEach((tunnel) => {
|
||||
if (!tunnelOrder.includes(tunnel.id)) {
|
||||
localSorted.push(tunnel);
|
||||
}
|
||||
@@ -629,7 +649,7 @@ export default function TunnelPage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [tunnels, tunnelOrder]);
|
||||
}, [tunnels, tunnelOrder, searchKeyword]);
|
||||
|
||||
const sortableTunnelIds = useMemo(
|
||||
() => sortedTunnels.map((t) => t.id),
|
||||
@@ -652,11 +672,17 @@ export default function TunnelPage() {
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
const style: React.CSSProperties = {
|
||||
transform: transform
|
||||
? CSS.Transform.toString({
|
||||
...transform,
|
||||
x: Math.round(transform.x),
|
||||
y: Math.round(transform.y),
|
||||
})
|
||||
: undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: "transform",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -671,14 +697,22 @@ export default function TunnelPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1" />
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索隧道名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color={selectMode ? "warning" : "default"}
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
@@ -697,10 +731,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
|
||||
@@ -740,9 +784,9 @@ export default function TunnelPage() {
|
||||
{(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
|
||||
@@ -784,7 +828,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">
|
||||
@@ -1004,34 +1048,14 @@ export default function TunnelPage() {
|
||||
</DndContext>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无隧道配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何隧道配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无隧道配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何隧道配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -1117,11 +1141,6 @@ export default function TunnelPage() {
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
endContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">x</span>
|
||||
</div>
|
||||
}
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
@@ -1374,6 +1393,7 @@ export default function TunnelPage() {
|
||||
.filter((id) => id !== -1)
|
||||
.map((id) => id.toString()),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
label="节点"
|
||||
placeholder="选择节点(可多选)"
|
||||
selectedKeys={groupNodes
|
||||
@@ -1589,6 +1609,7 @@ export default function TunnelPage() {
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
@@ -2075,7 +2096,6 @@ export default function TunnelPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Chip
|
||||
className="min-w-[50px]"
|
||||
color={
|
||||
result.success
|
||||
? "success"
|
||||
@@ -2118,7 +2138,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"
|
||||
@@ -2282,7 +2302,7 @@ export default function TunnelPage() {
|
||||
{quality && (
|
||||
<>
|
||||
<Chip
|
||||
className="text-xs"
|
||||
className="text-xs whitespace-nowrap"
|
||||
color={quality.color as any}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -2435,6 +2455,6 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
+255
-222
@@ -1,4 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -25,9 +33,6 @@ 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 toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import {
|
||||
User,
|
||||
UserForm,
|
||||
@@ -57,10 +62,10 @@ import {
|
||||
SearchIcon,
|
||||
EditIcon,
|
||||
DeleteIcon,
|
||||
UserIcon,
|
||||
SettingsIcon,
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -121,7 +126,11 @@ export default function UserPage() {
|
||||
// 状态管理
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"user-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
size: 10,
|
||||
@@ -610,68 +619,89 @@ export default function UserPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center gap-3" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 max-w-md">
|
||||
<Input
|
||||
className="flex-1"
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"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()}
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
||||
{!isSearchVisible ? (
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索用户"
|
||||
className="min-h-10 w-10"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={handleSearch}
|
||||
aria-label="搜索"
|
||||
className="text-default-600"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setIsSearchVisible(true)}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button color="primary" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索用户名"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchKeyword("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
{loading ? (
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : users.length === 0 ? (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<UserIcon className="w-8 h-8 text-default-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无用户数据
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何用户,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||
暂无用户数据
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
还没有创建任何用户,点击上方按钮开始创建
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{users.map((user) => {
|
||||
const userStatus = getUserStatus(user);
|
||||
const expStatus = user.expTime
|
||||
@@ -687,169 +717,170 @@ export default function UserPage() {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<CardHeader className="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">
|
||||
{user.name || user.user}
|
||||
</h3>
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
@{user.user}
|
||||
</p>
|
||||
<StaggerItem key={user.id}>
|
||||
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
|
||||
<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">
|
||||
{user.name || user.user}
|
||||
</h3>
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
@{user.user}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={userStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={userStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">流量限制</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">已使用</span>
|
||||
<span className="font-medium text-xs text-danger">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
|
||||
className="mt-1"
|
||||
color={
|
||||
flowPercent > 90
|
||||
? "danger"
|
||||
: flowPercent > 70
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
value={flowPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">{user.num}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
<span className="text-xs">
|
||||
{user.flowResetTime === 0
|
||||
? "不重置"
|
||||
: `每月${user.flowResetTime}号`}
|
||||
</span>
|
||||
</div>
|
||||
{user.expTime && (
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">过期时间</span>
|
||||
<div className="text-right">
|
||||
{expStatus && expStatus.color === "success" ? (
|
||||
<div className="text-xs">
|
||||
{formatDate(user.expTime)}
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={expStatus?.color || "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expStatus?.text || "未知状态"}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-default-600">流量限制</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">已使用</span>
|
||||
<span className="font-medium text-xs text-danger">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
|
||||
className="mt-1"
|
||||
color={
|
||||
flowPercent > 90
|
||||
? "danger"
|
||||
: flowPercent > 70
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
value={flowPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{/* 第一行:编辑和重置 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<EditIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="warning"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">
|
||||
{user.num}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
<span className="text-xs">
|
||||
{user.flowResetTime === 0
|
||||
? "不重置"
|
||||
: `每月${user.flowResetTime}号`}
|
||||
</span>
|
||||
</div>
|
||||
{user.expTime && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">过期时间</span>
|
||||
<div className="text-right">
|
||||
{expStatus && expStatus.color === "success" ? (
|
||||
<div className="text-xs">
|
||||
{formatDate(user.expTime)}
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={expStatus?.color || "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expStatus?.text || "未知状态"}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:权限和删除 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
size="sm"
|
||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{/* 第一行:编辑和重置 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<EditIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="warning"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:权限和删除 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
size="sm"
|
||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)}
|
||||
|
||||
{/* 用户表单模态框 */}
|
||||
@@ -944,7 +975,6 @@ export default function UserPage() {
|
||||
<DatePicker
|
||||
isRequired
|
||||
showMonthAndYearPickers
|
||||
className="cursor-pointer"
|
||||
label="过期时间"
|
||||
value={
|
||||
userForm.expTime
|
||||
@@ -1083,13 +1113,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)
|
||||
}
|
||||
@@ -1118,7 +1147,7 @@ export default function UserPage() {
|
||||
classNames={{
|
||||
trigger: "min-h-10 h-10",
|
||||
}}
|
||||
label="限速规则"
|
||||
placeholder="限速规则"
|
||||
selectedKeys={
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
null &&
|
||||
@@ -1169,17 +1198,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>
|
||||
|
||||
@@ -1436,7 +1470,6 @@ export default function UserPage() {
|
||||
<DatePicker
|
||||
isRequired
|
||||
showMonthAndYearPickers
|
||||
className="cursor-pointer"
|
||||
label="到期时间"
|
||||
value={
|
||||
editTunnelForm.expTime
|
||||
@@ -1766,6 +1799,6 @@ export default function UserPage() {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { HeroUIProvider } from "@/shadcn-bridge/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";
|
||||
|
||||
export interface ProvidersProps {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccordionProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
export interface AccordionProps
|
||||
extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
children: React.ReactNode;
|
||||
variant?: "bordered" | "light" | "splitted";
|
||||
}
|
||||
@@ -21,9 +22,27 @@ export interface AccordionItemProps {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function Accordion({ children, className }: AccordionProps) {
|
||||
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", className)} type="multiple">
|
||||
<BaseAccordion
|
||||
className={cn("w-full", variantClass(variant), className)}
|
||||
type="multiple"
|
||||
>
|
||||
{children}
|
||||
</BaseAccordion>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
|
||||
type AlertVariant = "solid" | "flat" | "faded" | "bordered";
|
||||
|
||||
interface AlertProps extends Omit<React.ComponentProps<"div">, "color" | "title"> {
|
||||
interface AlertProps
|
||||
extends Omit<React.ComponentProps<"div">, "color" | "title"> {
|
||||
color?: AlertColor;
|
||||
description?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
|
||||
@@ -4,8 +4,20 @@ 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 HeroButtonColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
type HeroButtonVariant =
|
||||
| "solid"
|
||||
| "light"
|
||||
| "flat"
|
||||
| "ghost"
|
||||
| "bordered"
|
||||
| "shadow";
|
||||
type HeroButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -20,7 +32,17 @@ export interface ButtonProps
|
||||
variant?: HeroButtonVariant;
|
||||
}
|
||||
|
||||
function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "default" | "destructive" | "secondary" | "outline" | "ghost" | "light" | "flat" {
|
||||
function mapVariant(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
):
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "ghost"
|
||||
| "light"
|
||||
| "flat" {
|
||||
if (variant === "bordered") {
|
||||
return "outline";
|
||||
}
|
||||
@@ -43,7 +65,10 @@ function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "defaul
|
||||
return "default";
|
||||
}
|
||||
|
||||
function mapSize(size: HeroButtonSize, isIconOnly: boolean): "default" | "sm" | "lg" | "icon" {
|
||||
function mapSize(
|
||||
size: HeroButtonSize,
|
||||
isIconOnly: boolean,
|
||||
): "default" | "sm" | "lg" | "icon" {
|
||||
if (isIconOnly) {
|
||||
return "icon";
|
||||
}
|
||||
@@ -67,33 +92,46 @@ const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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> = {
|
||||
@@ -105,7 +143,10 @@ const shadowColorClassMap: Record<HeroButtonColor, string> = {
|
||||
danger: "shadow-md shadow-danger-500/35",
|
||||
};
|
||||
|
||||
function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
|
||||
function mapColorClass(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
): string {
|
||||
if (variant === "bordered") {
|
||||
return borderedColorClassMap[color];
|
||||
}
|
||||
@@ -122,7 +163,10 @@ function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): stri
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapShadowClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
|
||||
function mapShadowClass(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
): string {
|
||||
if (variant !== "shadow") {
|
||||
return "";
|
||||
}
|
||||
@@ -169,16 +213,23 @@ export const Button = React.forwardRef<
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
className={cn(isIconOnly ? "p-0" : "", resolvedColorClass, resolvedShadowClass, className)}
|
||||
disabled={resolvedDisabled}
|
||||
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}
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{startContent}
|
||||
{isIconOnly ? null : children}
|
||||
{isIconOnly ? children : null}
|
||||
|
||||
@@ -11,10 +11,18 @@ export function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <BaseCard className={className} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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 md:p-6", className)} {...props} />;
|
||||
return (
|
||||
<CardContent
|
||||
className={cn("p-4 pt-0 md:p-6 md:pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.ComponentProps<typeof BaseCheckbox>, "checked" | "onCheckedChange"> {
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof BaseCheckbox>,
|
||||
"checked" | "onCheckedChange"
|
||||
> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
@@ -30,7 +33,13 @@ export function Checkbox({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", isDisabled ? "opacity-50" : "", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2",
|
||||
isDisabled ? "opacity-50" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BaseCheckbox
|
||||
checked={Boolean(isSelected)}
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -3,7 +3,13 @@ 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 ChipColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
type ChipVariant = "solid" | "flat" | "light" | "bordered";
|
||||
type ChipSize = "sm" | "md" | "lg";
|
||||
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
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;
|
||||
@@ -39,9 +83,80 @@ export function DatePicker({
|
||||
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
|
||||
@@ -57,33 +172,34 @@ export function DatePicker({
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
inputMode={shouldUseTextInput ? "numeric" : undefined}
|
||||
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
|
||||
required={isRequired}
|
||||
type="date"
|
||||
value={formatDateValue(value)}
|
||||
type={shouldUseTextInput ? "text" : "date"}
|
||||
value={shouldUseTextInput ? textValue : formattedValue}
|
||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||
onChange={(event) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
const nextValue = event.target.value;
|
||||
|
||||
if (!event.target.value) {
|
||||
onChange(null);
|
||||
if (shouldUseTextInput) {
|
||||
setTextValue(nextValue);
|
||||
notifyTextDateChange(nextValue);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = event.target.value.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ day, month, year });
|
||||
notifyNativeDateChange(nextValue);
|
||||
}}
|
||||
onKeyDown={
|
||||
shouldUseTextInput
|
||||
? (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitTextInput();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger as BaseDropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
@@ -17,14 +16,19 @@ interface DropdownContextValue {
|
||||
align: "start" | "end";
|
||||
}
|
||||
|
||||
const DropdownContext = React.createContext<DropdownContextValue>({ align: "start" });
|
||||
const DropdownContext = React.createContext<DropdownContextValue>({
|
||||
align: "start",
|
||||
});
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export function Dropdown({ children, placement = "bottom-start" }: DropdownProps) {
|
||||
export function Dropdown({
|
||||
children,
|
||||
placement = "bottom-start",
|
||||
}: DropdownProps) {
|
||||
const align = placement.endsWith("end") ? "end" : "start";
|
||||
|
||||
return (
|
||||
@@ -66,7 +70,10 @@ export function DropdownItem({
|
||||
}: DropdownItemProps) {
|
||||
return (
|
||||
<BaseDropdownMenuItem
|
||||
className={cn(color === "danger" ? "text-danger focus:text-danger" : "", className)}
|
||||
className={cn(
|
||||
color === "danger" ? "text-danger focus:text-danger" : "",
|
||||
className,
|
||||
)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onPress?.();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
type ClassNameMap = {
|
||||
base?: string;
|
||||
description?: string;
|
||||
@@ -84,7 +84,9 @@ export function Input({
|
||||
classNames?.inputWrapper,
|
||||
)}
|
||||
>
|
||||
{startContent ? <div className="pl-3 text-default-500">{startContent}</div> : null}
|
||||
{startContent ? (
|
||||
<div className="pl-3 text-default-500">{startContent}</div>
|
||||
) : null}
|
||||
<BaseInput
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
@@ -100,7 +102,9 @@ export function Input({
|
||||
required={isRequired}
|
||||
{...props}
|
||||
/>
|
||||
{endContent ? <div className="pr-3 text-default-500">{endContent}</div> : null}
|
||||
{endContent ? (
|
||||
<div className="pr-3 text-default-500">{endContent}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FieldContainer>
|
||||
);
|
||||
|
||||
@@ -20,5 +20,10 @@ function mapColor(color: LinkColor) {
|
||||
}
|
||||
|
||||
export function Link({ className, color = "default", ...props }: LinkProps) {
|
||||
return <a className={cn("transition-colors", mapColor(color), className)} {...props} />;
|
||||
return (
|
||||
<a
|
||||
className={cn("transition-colors", mapColor(color), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Dialog, DialogContent as BaseDialogContent } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent as BaseDialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DisclosureOptions {
|
||||
@@ -35,6 +38,8 @@ export function useDisclosure(options: DisclosureOptions = {}) {
|
||||
interface ModalContextValue {
|
||||
classNames?: Record<string, string>;
|
||||
onClose: () => void;
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<ModalContextValue | null>(null);
|
||||
@@ -88,6 +93,8 @@ export function Modal({
|
||||
isOpen = false,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
scrollBehavior,
|
||||
size,
|
||||
}: ModalProps) {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
onOpenChange?.(open);
|
||||
@@ -101,16 +108,21 @@ export function Modal({
|
||||
onClose: () => {
|
||||
handleOpenChange(false);
|
||||
},
|
||||
scrollBehavior,
|
||||
size,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalContext.Provider value={contextValue}>{children}</ModalContext.Provider>
|
||||
<ModalContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalContentProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
interface ModalContentProps
|
||||
extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
children: React.ReactNode | ((onClose: () => void) => React.ReactNode);
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
@@ -124,15 +136,24 @@ export function ModalContent({
|
||||
...props
|
||||
}: ModalContentProps) {
|
||||
const context = useModalContext();
|
||||
const resolvedScrollBehavior = scrollBehavior ?? context?.scrollBehavior;
|
||||
const resolvedSize = size ?? context?.size;
|
||||
const renderedChildren =
|
||||
typeof children === "function" ? children(() => context?.onClose()) : children;
|
||||
typeof children === "function"
|
||||
? children(() => context?.onClose())
|
||||
: children;
|
||||
|
||||
return (
|
||||
<BaseDialogContent
|
||||
className={cn(
|
||||
mapSize(size),
|
||||
mapSize(resolvedSize),
|
||||
context?.classNames?.base,
|
||||
scrollBehavior === "outside" ? "max-h-[90vh] overflow-y-auto" : "",
|
||||
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}
|
||||
@@ -143,18 +164,54 @@ export function ModalContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("text-lg font-semibold", className)} {...props} />;
|
||||
}
|
||||
export function ModalHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const context = useModalContext();
|
||||
|
||||
export function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("space-y-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,12 @@ export function Navbar({
|
||||
style={height ? { minHeight: height } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("mx-auto flex h-full w-full items-center justify-between px-4", maxWidthClass(maxWidth))}>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-full w-full items-center justify-between px-4",
|
||||
maxWidthClass(maxWidth),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -85,6 +90,9 @@ export function NavbarContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function NavbarBrand({ className, ...props }: React.ComponentProps<"div">) {
|
||||
export function NavbarBrand({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return <div className={cn("flex items-center", className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ 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";
|
||||
type ProgressColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
export interface ProgressProps {
|
||||
"aria-label"?: string;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { RadioGroup as BaseRadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
RadioGroup as BaseRadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -8,7 +11,9 @@ interface RadioContextValue {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RadioContext = React.createContext<RadioContextValue>({ name: "radio-group" });
|
||||
const RadioContext = React.createContext<RadioContextValue>({
|
||||
name: "radio-group",
|
||||
});
|
||||
|
||||
export interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
@@ -33,7 +38,9 @@ export function RadioGroup({
|
||||
<RadioContext.Provider value={{ name: generatedName }}>
|
||||
<BaseRadioGroup
|
||||
className={cn(
|
||||
orientation === "horizontal" ? "flex flex-wrap items-center gap-4" : "grid gap-3",
|
||||
orientation === "horizontal"
|
||||
? "flex flex-wrap items-center gap-4"
|
||||
: "grid gap-3",
|
||||
)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
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>;
|
||||
@@ -35,6 +37,7 @@ export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||
selectionMode?: SelectionMode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: string;
|
||||
dropdownPlacement?: "bottom" | "top";
|
||||
}
|
||||
|
||||
export interface SelectItemProps {
|
||||
@@ -131,6 +134,17 @@ function sizeClass(size: SelectProps["size"]) {
|
||||
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,
|
||||
@@ -150,14 +164,96 @@ export function Select<T>({
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
size,
|
||||
dropdownPlacement = "bottom",
|
||||
}: SelectProps<T>) {
|
||||
const generatedId = React.useId();
|
||||
const options = React.useMemo(() => getOptions(children, items), [children, items]);
|
||||
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);
|
||||
@@ -167,7 +263,9 @@ export function Select<T>({
|
||||
}
|
||||
|
||||
if (selectionMode === "multiple") {
|
||||
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
|
||||
const values = Array.from(event.target.selectedOptions).map(
|
||||
(option) => option.value,
|
||||
);
|
||||
|
||||
onSelectionChange(new Set(values));
|
||||
|
||||
@@ -183,6 +281,71 @@ export function Select<T>({
|
||||
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
|
||||
ref={listboxRef}
|
||||
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`}
|
||||
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}
|
||||
@@ -193,31 +356,70 @@ export function Select<T>({
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<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),
|
||||
selectionMode === "multiple" ? "min-h-32" : "",
|
||||
classNames?.trigger,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={generatedId}
|
||||
multiple={selectionMode === "multiple"}
|
||||
required={isRequired}
|
||||
value={selectionMode === "multiple" ? selectedArray : singleValue}
|
||||
onClick={onClick}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{selectionMode === "single" ? (
|
||||
{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>
|
||||
) : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.key} disabled={disabled.has(option.key)} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.key}
|
||||
disabled={disabled.has(option.key)}
|
||||
value={option.key}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export function FieldContainer({
|
||||
</Label>
|
||||
) : null}
|
||||
{children}
|
||||
{description ? <p className="text-xs text-default-500">{description}</p> : null}
|
||||
{description ? (
|
||||
<p className="text-xs text-default-500">{description}</p>
|
||||
) : null}
|
||||
{isInvalid && errorMessage ? (
|
||||
<p className="text-xs text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
@@ -45,14 +47,21 @@ export function FieldContainer({
|
||||
}
|
||||
|
||||
export function extractText(content: React.ReactNode): string {
|
||||
if (content === null || content === undefined || typeof content === "boolean") {
|
||||
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();
|
||||
return content
|
||||
.map((item) => extractText(item))
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
if (React.isValidElement(content)) {
|
||||
return extractText(content.props.children);
|
||||
|
||||
@@ -21,10 +21,17 @@ function iconSize(size: SpinnerSize | undefined) {
|
||||
return "h-5 w-5";
|
||||
}
|
||||
|
||||
export function Spinner({ className, label, size = "md", ...props }: SpinnerProps) {
|
||||
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))} />
|
||||
<Loader2Icon
|
||||
className={cn("animate-spin text-default-500", iconSize(size))}
|
||||
/>
|
||||
{label ? <span className="text-sm text-default-500">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ 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"> {
|
||||
export interface SwitchProps
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof BaseSwitch>,
|
||||
"checked" | "onCheckedChange"
|
||||
> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
|
||||
@@ -4,11 +4,15 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface TableClassNames {
|
||||
th?: string;
|
||||
td?: string;
|
||||
tr?: string;
|
||||
wrapper?: string;
|
||||
}
|
||||
|
||||
interface TableStyleContextValue {
|
||||
thClassName?: string;
|
||||
tdClassName?: string;
|
||||
trClassName?: string;
|
||||
}
|
||||
|
||||
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
||||
@@ -17,10 +21,23 @@ export interface TableProps extends React.ComponentProps<"table"> {
|
||||
classNames?: TableClassNames;
|
||||
}
|
||||
|
||||
export function Table({ children, className, classNames, ...props }: TableProps) {
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
classNames,
|
||||
...props
|
||||
}: TableProps) {
|
||||
return (
|
||||
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
|
||||
<div className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}>
|
||||
<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>
|
||||
@@ -29,7 +46,11 @@ export function Table({ children, className, classNames, ...props }: TableProps)
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({ children, className, ...props }: React.ComponentProps<"thead">) {
|
||||
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,
|
||||
@@ -42,7 +63,8 @@ export function TableHeader({ children, className, ...props }: React.ComponentPr
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "children"> {
|
||||
interface TableBodyProps<T>
|
||||
extends Omit<React.ComponentProps<"tbody">, "children"> {
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
emptyContent?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
@@ -50,18 +72,24 @@ interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "childre
|
||||
loadingContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TableBody<T>({
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>) {
|
||||
export const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
TableBodyProps<any>
|
||||
>(function TableBody<T>(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>,
|
||||
ref: React.Ref<HTMLTableSectionElement>,
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{loadingContent ?? "加载中..."}
|
||||
@@ -74,7 +102,7 @@ export function TableBody<T>({
|
||||
if (items && typeof children === "function") {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
@@ -85,7 +113,7 @@ export function TableBody<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{items.map((item) => {
|
||||
const key =
|
||||
typeof item === "object" && item !== null && "id" in item
|
||||
@@ -103,7 +131,7 @@ export function TableBody<T>({
|
||||
|
||||
if (staticChildren.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
@@ -114,27 +142,53 @@ export function TableBody<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{typeof children === "function" ? null : children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function TableColumn({ className, ...props }: React.ComponentProps<"th">) {
|
||||
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)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-left font-medium text-default-600",
|
||||
thClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return <tr className={cn("border-b last:border-b-0", 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">) {
|
||||
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
|
||||
const { tdClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cn("px-3 py-2 align-middle", tdClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ function resolveInitialTheme(): ThemeMode {
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (fromStorage === "dark" || fromStorage === "light") {
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
let currentTheme: ThemeMode = resolveInitialTheme();
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
@import "tw-animate-css";
|
||||
@import "./tailwind-theme.pcss";
|
||||
|
||||
/* GPU acceleration helper - prevents font blurriness during transforms */
|
||||
.gpu-accelerated {
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
@@ -181,12 +189,12 @@ body {
|
||||
@keyframes captchaModalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user