Compare commits

...

61 Commits

Author SHA1 Message Date
sagit 9c41410f17 fix(backend): handle federation service arrays in runtime command (#206)
## Summary
- accept both top-level service arrays and wrapped `services` payloads
in federation runtime command parsing
- fix forward runtime service binding and port-range validation for
remote shared-node AddService/UpdateService calls
- add regression tests for top-level array payload handling to prevent
listener cleanup/flow mapping regressions

## Verification
- go test ./internal/http/handler -run
"TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray|TestValidateFederationCommandPortsAcceptsTopLevelServiceArray|TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole|TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName"
- go test ./...
- make build
2026-02-25 00:08:32 +08:00
sagitchu bc71c524e0 fix(backend): accept top-level federation service payloads 2026-02-25 00:06:31 +08:00
sagit f46b2b4d86 fix(backend): keep shared federation port-forward listeners alive (#205)
## Summary
- prevent shared federation forward services from being misclassified as
orphaned during node config cleanup
- harden peer-share runtime mapping for service-name based flow
accounting and node/port conflict checks
- add regression tests for listener persistence, cleanup guards, runtime
binding, and federation port-forward lifecycle

## Verification
- go test ./internal/http/handler -run
\"TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestCleanOrphanedServicesSkipsFederationServicePrefix|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName|TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous|TestFederationTunnelCreateRejectsOccupiedPort\"
- go test ./...
- make build
2026-02-24 23:08:02 +08:00
sagitchu 9f17d63cdc fix(backend): keep shared federation port-forward services stable 2026-02-24 23:06:09 +08:00
sagit 92f8ec47db fix(backend): track traffic stats for federation port-forward tunnels (#204)
## Summary

- Fixed traffic statistics not being tracked for federation port-forward
tunnels (tunnelType=1) in Panel Peering mode
- Added `parsePeerShareIDFromFederationTunnelName()` to extract shareID
from tunnel names matching `Share-{shareId}-Port-{port}` pattern
- Added `processPeerShareFlowFromForward()` to update
`peer_share.current_flow` when forward traffic belongs to a federation
port-forward tunnel
- Added regression test
`TestProcessFlowItemTracksPeerShareFlowForFederationPortForward`

## Root Cause

Federation + tunnel type=2 (隧道转发) traffic uses service names
`fed_svc_{runtimeID}` → correctly routed to `processPeerShareFlow()` →
`peer_share.current_flow` updated.

Federation + tunnel type=1 (端口转发) traffic uses regular forward service
names `{forwardId}_{userId}_{userTunnelId}` → only `AddFlow()` called →
`peer_share.current_flow` NOT updated → shared flow stats remain 0.

## Test Plan

- [x] `go test ./internal/http/handler -run
'TestProcessFlowItemTracksPeerShareFlow'` passes
- [x] `go test ./internal/http/handler` passes
- [x] `go test ./...` passes
- [x] `make build` succeeds
2026-02-24 20:41:18 +08:00
sagitchu a97484cd9b fix(backend): track traffic stats for federation port-forward tunnels
Federation mode panel peering with port-forward tunnel type (tunnelType=1)
was not updating peer_share.current_flow because regular forward traffic
uses different service name pattern than federation tunnel traffic.

Added parsePeerShareIDFromFederationTunnelName() to extract shareID from
tunnel names matching 'Share-{shareId}-Port-{port}' pattern, and
processPeerShareFlowFromForward() to update peer_share flow stats when
the forward belongs to a federation port-forward tunnel.
2026-02-24 20:25:04 +08:00
sagit ee6bc8c50e fix(frontend): keep mobile batch toolbar right-aligned (#203)
## Summary
- keep mobile batch action bars visually right-aligned while preserving
Android horizontal swipe behavior
- move overflow handling to an outer scroller and keep action layout in
an inner `min-w-full` flex row
- apply the same structure across forward, node, and tunnel pages for
consistent behavior

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint -- src/pages/forward.tsx src/pages/node.tsx
src/pages/tunnel.tsx` *(fails due to pre-existing a11y labels in
`forward.tsx` lines 3202/3223)*
2026-02-23 22:55:49 +08:00
sagitchu c94ab84ab9 fix(frontend): keep mobile batch toolbar right-aligned
Use an overflow wrapper with an inner min-w-full flex row so controls stay visually right-aligned when space is sufficient, while Android can still horizontally swipe when the toolbar overflows.
2026-02-23 22:53:48 +08:00
sagit 84a03215f4 fix(frontend): restore Android swipe for mobile batch toolbar (#202)
## Summary
- fix mobile batch action toolbar in forward/node/tunnel pages to keep
horizontal overflow reachable on Android
- switch toolbar alignment to start on mobile and keep right alignment
on `sm+` to avoid `justify-end` overflow reachability issues
- add `touch-pan-x` to improve horizontal swipe handling on mobile
browsers while preserving existing desktop layout

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint` *(fails due to pre-existing a11y errors in unrelated
files and in existing forward labels)*
2026-02-23 22:26:11 +08:00
sagitchu def93749eb fix(frontend): restore Android swipe for mobile batch toolbar
Switch mobile batch toolbar alignment to start and enable horizontal pan gestures so overflow actions remain reachable on Android while desktop right alignment stays unchanged.
2026-02-23 22:24:28 +08:00
sagit 945a1c0dfc fix(frontend): remove border from batch toggle button (#199) 2026-02-23 22:03:29 +08:00
sagit d752e096a3 fix(frontend): move batch operations into top toolbar (#198)
## Summary
- switch batch mode interactions to the top-right action bar on forward,
tunnel, and node pages
- remove bottom floating batch toolbars and restore normal action
toolbar when exiting batch mode
- stabilize toolbar layout and improve batch button visibility to avoid
visual jitter on toggle
2026-02-23 21:30:57 +08:00
sagitchu 880a3b81b0 fix(frontend): move batch actions into top toolbar
Switching batch controls to the top-right action bar with stable single-row layout improves clarity and prevents visual jumps while entering batch mode.
2026-02-23 21:28:50 +08:00
sagit 191aface2e fix(frontend): extend h5 tabbar safe-area coverage (#197)
Ensure the PWA bottom tab bar and tab hit areas fully cover the safe-area inset while keeping related frontend formatting updates consistent.
2026-02-23 07:45:14 +00:00
sagit e121dadb90 fix(backend): allow WHMCS API login when captcha is enabled (#196)
## Summary
- bypass captcha verification for machine API clients tagged as WHMCS
while keeping captcha enforcement for normal login flows
- add a backend contract test that verifies WHMCS-tagged login succeeds
when captcha is enabled
- keep WHMCS module repository changes out of this PR so only backend
behavior is merged to main
2026-02-23 14:42:24 +08:00
sagitchu bafcfbde3a fix(backend): allow WHMCS API login when captcha is enabled 2026-02-23 14:40:29 +08:00
sagit 98c463c62b feat(frontend): add installable PWA support and refresh app icons (#195) 2026-02-23 06:26:29 +00:00
sagit daf34d0f6c fix(backend): prevent login block when captcha enabled without cloudflare key (#194)
When captcha_enabled=true but cloudflare_secret_key is not configured,
the login flow would block users with "未配置Cloudflare Site Key" error.
Now captcha is treated as disabled if the secret key is missing, allowing
users to log in normally on fresh PostgreSQL installations.

Fixes login issue on new panel setups with PostgreSQL.
2026-02-22 22:54:51 +08:00
sagit bb505d461d fix(frontend): enable modal scroll on panel sharing page (#193)
Add scrollBehavior="inside" to Create Share and Edit Share modals
to allow content scrolling on mobile devices when form fields
exceed viewport height.
2026-02-22 21:53:29 +08:00
sagit 00be0ac31e fix(frontend): enable content scroll on mobile (#192)
Main content area had overflow-hidden on mobile which prevented
scrolling when content exceeded viewport height. Changed to
overflow-y-auto for consistent scroll behavior across all devices.
2026-02-22 21:12:45 +08:00
sagit fc5624a190 fix(frontend): modal footer buttons visible on mobile (#190)
ModalFooter used flex-col-reverse which caused buttons to display in
reverse order on mobile and potentially pushed cancel button out of
view. Changed to flex-wrap justify-end for consistent cross-platform
button display.
2026-02-22 20:05:02 +08:00
sagit 42ae3457b5 feat(frontend): persist filter state across page reloads (#189)
* feat(frontend): persist filter state across page reloads

Add useLocalStorageState hook to persist filter/search state in
forward, node, tunnel, user, and limit pages. Reset filter clears
the persisted value from localStorage.

* feat(ui): add collapsible sidebar and smooth animations
2026-02-22 19:01:03 +08:00
sagit 088027da7b fix(frontend): resolve remaining font blur in node and forward cards (#188)
## Summary
- apply the same anti-blur drag transform strategy used in tunnel cards
to node cards
- update forward card and table-row sortable transforms to round x/y
pixels and reduce subpixel text blur
- limit `willChange` usage to active dragging so text does not remain on
a promoted layer while idle

## Verification
- npm run build (vite-frontend)
2026-02-22 16:55:08 +08:00
sagitchu d37adee5df fix(frontend): resolve remaining font blur in node and forward cards 2026-02-22 16:53:35 +08:00
sagit c147e52d72 fix(frontend): ship pending card-view animation and interaction refinements (#186)
## Summary
- include the full pending frontend refinements across card views,
search/filter interactions, and bridge components
- keep sortable/card animation behavior aligned with the latest
anti-blur adjustments in tunnel card rendering
- bundle related UI consistency updates across tunnel, forward, node,
user, and dashboard pages

## Verification
- npm run build (vite-frontend)
2026-02-22 15:54:30 +08:00
sagitchu d483258eef fix(frontend): ship pending card-view animation and interaction refinements 2026-02-22 15:52:48 +08:00
sagit 79c28103d5 fix(frontend): prevent font blur in sortable card components (#185)
Add backface-visibility: hidden and font-smoothing properties to SortableItem components in tunnel, forward, and node pages to prevent GPU subpixel rendering blur during drag operations.

- tunnel.tsx: SortableItem wrapper anti-blur fix
- forward.tsx: SortableCard wrapper anti-blur fix
- node.tsx: SortableItem component anti-blur fix

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 15:11:47 +08:00
sagit f36bf1437c fix(frontend): prevent font blurriness caused by scale animations (#183)
Remove scale transforms from Framer Motion animations that cause subpixel rendering issues on text elements. Replace with opacity + translateY for smooth animations without blur.

- Remove scale from FadeIn component (animated-page.tsx)
- Remove scale from login page entrance animation (index.tsx)
- Remove scale from search bar button animation (search-bar.tsx)
- Remove whileTap scale from sidebar menu buttons (admin.tsx)
- Remove scale from captcha modal animation (globals.css)
- Add .gpu-accelerated utility class for future use

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 13:28:47 +08:00
sagit 4ad3aa2c06 feat: redesign forward card view completely syncing filters and sorti… (#179)
…ng logic
2026-02-21 21:19:53 +08:00
sagitchu 357a4b165e Merge remote-tracking branch 'origin/main' into card-view 2026-02-21 21:18:20 +08:00
sagitchu a15be253f5 feat: add framer-motion animations (page transitions, modals, dropdowns, buttons, search bar) 2026-02-21 21:16:42 +08:00
sagitchu 572d1c16a6 feat: Implement toggleable search input and data filtering across tunnel, user, and forward pages. 2026-02-21 20:43:32 +08:00
sagit 39e22c07de feat(backend): auto redeploy tunnel and forward config after node upgrade (#180) 2026-02-21 12:29:34 +00:00
sagitchu ca24573803 feat: redesign forward card view completely syncing filters and sorting logic 2026-02-21 20:08:29 +08:00
sagit 0cb3263a2e feat(frontend): support markdown in dashboard announcements (#176)
## Summary
- render dashboard announcements with Markdown using `react-markdown` +
`remark-gfm` while sanitizing output with `rehype-sanitize`
- add styled Markdown element mappings in the announcement banner for
links, lists, code blocks, and blockquotes
- update announcement management hint text to communicate Markdown
support in the config page

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

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

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

* fix(frontend): rebalance config card header spacing

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

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

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

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

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

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

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

* style: fix vertical alignment of logo and title in announcement banner
2026-02-20 10:31:39 +00:00
sagit 45bfd35a20 fix(backend): respect tunnel IP preference in forward diagnosis (#160)
Forward diagnosis chain-hop probes now inherit the tunnel ipPreference so v6-priority tunnels test IPv6 targets instead of defaulting to IPv4. Add a contract test to lock IPv6 target selection for entry->chain and chain->exit diagnostics.
2026-02-20 05:56:12 +00:00
sagit 5a1b72387d fix(frontend): polish batch actions and sharing page guidance (#157)
* fix(frontend): polish batch actions and sharing page guidance

* fix(frontend): add top spacing above sharing section banners

* fix(frontend): allow manual and calendar expiry date input

* fix(frontend): switch batch action buttons to warning tone

* fix(frontend): switch batch action buttons to default tone
2026-02-20 12:50:17 +08:00
sagit 66de566a00 fix(frontend): stabilize multiselect behavior and UI spacing (#154)
* fix(frontend): left-align announcement logo in homepage banner

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

* fix(frontend): rebalance config card header spacing

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

* fix(frontend): restore config divider spacing on desktop
2026-02-19 20:50:12 +08:00
sagit 18c2da7c7e fix(frontend): prevent multiselect overflow and normalize card spacing (#152)
## Summary
- fix multi-select trigger overflow in shared select bridge by making
trigger/value flex children shrink correctly
- harden grouped assignment summaries against long selected-value text
wrapping overflow
- normalize card header/body spacing and node card IP row height so card
layouts stay visually consistent across modules

## Verification
- ran `npm run build` in `vite-frontend` successfully
- checked diagnostics on changed frontend files (no diagnostics)
2026-02-19 18:23:28 +08:00
Sagit c4d807f1c4 fix(frontend): align card body spacing with node cards 2026-02-19 10:16:59 +00:00
Sagit d1460ab9c7 fix(frontend): tighten remaining card header spacing 2026-02-19 09:52:14 +00:00
Sagit 9189c68800 fix(frontend): normalize card spacing and multiselect overflow 2026-02-19 09:17:04 +00:00
sagit efbdabceca fix(frontend): align diagnosis status and permission layout (#148)
* fix(frontend): align diagnosis status and permission layout

* fix(frontend): polish batch toolbar controls on cards

* fix(user): stabilize tunnel permission checkbox interactions
2026-02-19 16:46:37 +08:00
sagit 6e5a71f489 fix(frontend): resolve scroll, diagnosis layout, and multi-select regressions (#147)
* fix(frontend): restore modal scroll and bridged multi-select UI

* fix(frontend): collapse multi-select panel and clean diagnosis labels
2026-02-19 16:10:02 +08:00
sagit e5c57f81ad docs(readme): refresh Modifications section for rewrite scope (#146)
## Summary
- Clarify that FLVX is a deeply reworked fork rather than a light patch.
- Update the Modifications section to reflect backend rewrite and
frontend rework scope.
- Align infrastructure notes with current deployment and installer
workflow wording.
2026-02-19 15:17:32 +08:00
Sagit 0b1609c6cb docs(repo): refresh README Modifications for rewrite scope 2026-02-19 07:15:43 +00:00
sagit a10c68ef20 docs(repo): refresh AGENTS knowledge for 2.1.4-rc2 (#145)
## Summary
- update root `AGENTS.md` metadata and notes to reflect `main@137c34e`
and tag `2.1.4-rc2`
- refresh `vite-frontend/AGENTS.md` to document the shadcn bridge
architecture and Tailwind v4 semantic token wiring
- add current frontend conventions/anti-patterns to prevent regressions
(raw JWT header and token import requirements)
2026-02-19 15:08:37 +08:00
Sagit 9aedeab406 docs(repo): refresh AGENTS docs for 2.1.4-rc2 2026-02-19 07:06:41 +00:00
92 changed files with 4802 additions and 1588 deletions
+3
View File
@@ -268,3 +268,6 @@ sql/
!go-backend/internal/store/postgres/sql/
!go-backend/internal/store/postgres/sql/schema.sql
!go-backend/internal/store/postgres/sql/data.sql
go-backend/gost.db-shm
.gitignore
go-backend/gost.db-wal
+12 -4
View File
@@ -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.
+17 -16
View File
@@ -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,
})
}
+151 -27
View File
@@ -766,6 +766,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, port := range usedPorts {
if port == req.RemotePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
}
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if runtimeOnPort != nil {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existsOnNodePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
now := time.Now().UnixMilli()
tunnelID, err := h.repo.CreateFederationTunnel(
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
@@ -780,6 +811,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
runtime := &repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: "",
Protocol: defaultString(req.Protocol, "tcp"),
Strategy: "fifo",
Port: req.RemotePort,
Target: strings.TrimSpace(req.Target),
Applied: 0,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
@@ -1211,9 +1266,88 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
}
response.WriteJSON(w, response.OK(res))
}
type federationForwardServiceBinding struct {
Name string
Port int
}
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
if data == nil {
return nil
}
if entries := asMapSlice(data); len(entries) > 0 {
return entries
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return nil
}
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
return entries
}
return nil
}
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
serviceList := extractFederationServiceEntries(data)
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
continue
}
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
}
return bindings
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil || runtime == nil || runtime.Status != 1 {
continue
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 {
continue
}
_ = h.repo.UpdatePeerShareRuntimeServiceName(runtime.ID, binding.Name, now)
}
}
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
@@ -1236,36 +1370,26 @@ func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) err
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
return nil
}
dataMap, ok := data.(map[string]interface{})
if !ok {
serviceList := extractFederationServiceEntries(data)
if len(serviceList) == 0 {
return nil
}
if services, ok := dataMap["services"]; ok {
serviceList, ok := services.([]interface{})
if !ok {
return fmt.Errorf("invalid services format")
for _, svcMap := range serviceList {
addr := asString(svcMap["addr"])
if addr == "" {
continue
}
for _, svc := range serviceList {
svcMap, ok := svc.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid service entry format")
}
addr, ok := svcMap["addr"].(string)
if !ok || addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
}
@@ -414,6 +414,328 @@ func TestFederationShareResetFlow(t *testing.T) {
}
}
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
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, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-forward-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-forward-share",
NodeID: nodeID,
Token: "federation-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24000,
PortRangeEnd: 24020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("federation-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
body, err := json.Marshal(federationTunnelRequest{
Protocol: "tcp",
RemotePort: 24001,
Target: "1.1.1.1:443",
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
if runtimeCount != 1 {
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
}
}
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
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, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-port-check-share",
NodeID: nodeID,
Token: "federation-port-check-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24100,
PortRangeEnd: 24120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
create := func() response.R {
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer federation-port-check-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload
}
first := create()
if first.Code != 0 {
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
}
second := create()
if second.Code != 403 {
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
}
if second.Msg != "Port already in use" {
t.Fatalf("expected occupied port message, got %q", second.Msg)
}
}
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-forward-share",
NodeID: 1,
Token: "delete-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 25000,
PortRangeEnd: 25020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("delete-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
if err := h.deleteTunnelByID(1); err != nil {
t.Fatalf("delete tunnel: %v", err)
}
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
if activeCount != 0 {
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
}
}
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-forward-role-share",
NodeID: 1,
Token: "bind-forward-role-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26000,
PortRangeEnd: 26020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-forward-role-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtimes: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
},
})
forwardServiceName := ""
middleServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
t.Fatalf("load middle runtime service name: %v", err)
}
if forwardServiceName != "77_2_10" {
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
}
if middleServiceName != "" {
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-array-share",
NodeID: 1,
Token: "bind-array-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26100,
PortRangeEnd: 26120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-array-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
})
forwardServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if forwardServiceName != "99_2_10" {
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
}
}
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
share := &repo.PeerShare{
PortRangeStart: 26200,
PortRangeEnd: 26210,
}
err := validateFederationCommandPorts(share, []interface{}{
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
})
if err != nil {
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
}
}
func TestFederationRemoteUsageList(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -40,6 +40,7 @@ func (h *Handler) processFlowItem(item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
h.processPeerShareFlowFromForward(forwardID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
@@ -87,6 +88,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
return runtimeID, true
}
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, 0, false
}
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
port, err := strconv.Atoi(portValue)
if err != nil || port <= 0 {
return 0, 0, false
}
return shareID, port, true
}
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, false
}
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
@@ -113,6 +153,95 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(serviceName, item)
return
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil {
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
_ = h.repo.AddPeerShareCurrentFlow(shareID, delta)
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
name := strings.TrimSpace(serviceName)
if strings.HasSuffix(name, "_tcp") {
return strings.TrimSuffix(name, "_tcp")
}
if strings.HasSuffix(name, "_udp") {
return strings.TrimSuffix(name, "_udp")
}
return name
}
func (h *Handler) processPeerShareFlowByServiceName(serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
if len(runtimes) != 1 {
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
@@ -271,11 +400,34 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
return
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
parts := strings.Split(name, "_")
if len(parts) >= 3 {
@@ -2,6 +2,7 @@ package handler
import (
"path/filepath"
"strconv"
"testing"
"time"
@@ -61,3 +62,240 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
}
}
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-share",
NodeID: 1,
Token: "forward-share-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
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, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
`, now+24*60*60*1000, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
`, now+24*60*60*1000).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-service-share",
NodeID: 1,
Token: "forward-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31000,
PortRangeEnd: 31010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-a",
NodeID: 1,
Token: "ambiguous-token-a",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31100,
PortRangeEnd: 31110,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share A: %v", err)
}
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-b",
NodeID: 1,
Token: "ambiguous-token-b",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31200,
PortRangeEnd: 31210,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share B: %v", err)
}
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert ambiguous runtimes: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "99_2_10_tcp", U: 120, D: 80})
updatedA, _ := r.GetPeerShare(shareA.ID)
updatedB, _ := r.GetPeerShare(shareB.ID)
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
}
}
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-runtime-share",
NodeID: 1,
Token: "cleanup-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31300,
PortRangeEnd: 31310,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
}
+26 -6
View File
@@ -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 {
@@ -213,7 +219,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled {
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
captchaID := strings.TrimSpace(req.CaptchaID)
if captchaID == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
@@ -988,6 +994,20 @@ func (h *Handler) captchaEnabled() (bool, error) {
return strings.EqualFold(cfg.Value, "true"), nil
}
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
if r == nil {
return false
}
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
switch client {
case "whmcs", "whmcs-module":
return true
default:
return false
}
}
func (h *Handler) markCaptchaToken(token string) {
if h == nil {
return
+90 -68
View File
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
var req struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
secret, err := h.repo.GetNodeSecret(id)
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
version, err := resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
secret, err := h.repo.GetNodeSecret(req.ID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd))
}
@@ -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}))
}
@@ -2786,7 +2794,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
}
func (h *Handler) deleteTunnelByID(id int64) error {
return h.repo.DeleteTunnelCascade(id)
if h == nil || h.repo == nil {
return errors.New("repository not initialized")
}
tunnelName, _ := h.repo.GetTunnelName(id)
if err := h.repo.DeleteTunnelCascade(id); err != nil {
return err
}
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
if !ok {
return nil
}
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
}
func (h *Handler) deleteForwardByID(id int64) error {
+195 -79
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -19,8 +20,104 @@ const (
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
var (
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
)
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func normalizeReleaseChannel(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case releaseChannelDev:
return releaseChannelDev
default:
return releaseChannelStable
}
}
func releaseChannelFromTag(tag string) string {
normalized := strings.ToLower(strings.TrimSpace(tag))
if normalized == "" {
return releaseChannelDev
}
if testKeywordPattern.MatchString(normalized) {
return releaseChannelDev
}
if stableVersionPattern.MatchString(normalized) {
return releaseChannelStable
}
return releaseChannelDev
}
func releaseChannelLabel(channel string) string {
if normalizeReleaseChannel(channel) == releaseChannelDev {
return "测试版"
}
return "正式版"
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func resolveLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int64 `json:"id"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -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)
}
}
}
@@ -1270,6 +1270,98 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
return ports, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.PeerShareRuntime
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
Order("id ASC").
Find(&items).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
}
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var names []string
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
Pluck("service_name", &names).Error
if err != nil {
return nil, err
}
if names == nil {
names = make([]string, 0)
}
return names, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var item model.PeerShareRuntime
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
"service_name": serviceName,
"applied": 1,
"updated_time": updatedTime,
}).Error
}
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if shareID <= 0 || port <= 0 {
return nil
}
if updatedTime <= 0 {
updatedTime = unixMilliNow()
}
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
"status": 0,
"applied": 0,
"service_name": "",
"updated_time": updatedTime,
}).Error
}
// ─── FederationTunnelBinding ─────────────────────────────────────────
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
@@ -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")
+20 -3
View File
@@ -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)
@@ -45,6 +45,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
assertCodeMsg(t, resp, -1, "验证码校验失败")
})
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FLVX-API-Client", "whmcs")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
+2 -1
View File
@@ -1 +1,2 @@
VITE_API_BASE=
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
VITE_APP_VERSION=beta
+39 -28
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 114 KiB

+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FLVX" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>FLVX</title>
<script>
+6 -2
View File
@@ -13,6 +13,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@internationalized/date": "^3.10.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
@@ -25,8 +27,6 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@internationalized/date": "^3.10.0",
"@react-aria/i18n": "^3.12.12",
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
@@ -43,8 +43,11 @@
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "6.30.3",
"recharts": "^3.1.1",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0",
@@ -75,6 +78,7 @@
"prettier": "3.5.3",
"typescript": "5.6.3",
"vite": "npm:rolldown-vite@^7.3.1",
"vite-plugin-pwa": "^1.1.0",
"vite-tsconfig-paths": "^6.0.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

+20 -8
View File
@@ -31,6 +31,8 @@ import axios from "axios";
import Network from "./network";
export type ReleaseChannel = "stable" | "dev";
// 登陆相关接口
export interface LoginData {
username: string;
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
export const updateNode = (data: NodeMutationPayload) =>
Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) =>
Network.post<string>("/node/install", { id });
export const getNodeInstallCommand = (
id: number,
channel: ReleaseChannel = "stable",
) => Network.post<string>("/node/install", { id, channel });
export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data);
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
return Network.post("/node/check-status", params);
};
export const upgradeNode = (id: number, version?: string) =>
export const upgradeNode = (
id: number,
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post(
"/node/upgrade",
{ id, version: version || "" },
{ id, version: version || "", channel },
{ timeout: 5 * 60 * 1000 },
);
export const batchUpgradeNodes = (ids: number[], version?: string) =>
export const batchUpgradeNodes = (
ids: number[],
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post(
"/node/batch-upgrade",
{ ids, version: version || "" },
{ ids, version: version || "", channel },
{ timeout: 15 * 60 * 1000 },
);
export const getNodeReleases = () =>
Network.post<NodeReleaseApiItem[]>("/node/releases");
export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id });
+1
View File
@@ -172,6 +172,7 @@ export interface NodeReleaseApiItem {
name: string;
publishedAt: string;
prerelease: boolean;
channel: "stable" | "dev";
}
export interface UserPackageInfoApiData {
@@ -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>
);
+2 -2
View File
@@ -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";
+101
View File
@@ -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"
+15 -5
View File
@@ -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}
/>
);
}
+10 -6
View File
@@ -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}
/>
);
+17 -3
View File
@@ -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,
};
+6 -3
View File
@@ -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" />
+34 -13
View File
@@ -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}
/>
+4 -1
View File
@@ -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(
+8 -2
View File
@@ -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>
+22 -7
View File
@@ -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)}
+4 -1
View File
@@ -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(
+26 -6
View File
@@ -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}
/>
);
}
+21 -5
View File
@@ -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;
};
+127 -29
View File
@@ -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,9 @@ 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 +207,14 @@ 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 +315,96 @@ 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
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
transition-colors duration-200 min-h-[44px]
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${
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"
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
`}
title={isCollapsed ? item.label : undefined}
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 +417,49 @@ export default function AdminLayout({
</a>
</p>
</div>
{/* 桌面端折叠按钮 */}
{!isMobile && (
<Button
isIconOnly
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"
size="sm"
variant="light"
onPress={toggleCollapse}
>
{isCollapsed ? (
// 向右扩展的提示
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 5l7 7-7 7M5 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
) : (
// 向左收起的提示
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
)}
</Button>
)}
</div>
</aside>
@@ -475,10 +564,19 @@ export default function AdminLayout({
</header>
{/* 主内容 */}
<main
className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? "" : "overflow-y-auto"}`}
>
{children}
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
<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 -1
View File
@@ -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";
+3 -3
View File
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
<div aria-hidden className="h-16 safe-bottom" />
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
{/* 底部Tabbar */}
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
{filteredTabItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<button
key={item.path}
className={`
flex flex-col items-center justify-center flex-1 h-full
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
transition-colors duration-200 min-h-[44px]
${
isActive
+3
View File
@@ -1,10 +1,13 @@
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { registerSW } from "virtual:pwa-register";
import App from "./App.tsx";
import { Provider } from "./provider.tsx";
import "@/styles/globals.css";
registerSW({ immediate: true });
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Provider>
+3 -3
View File
@@ -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";
+14 -11
View File
@@ -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>
+11 -5
View File
@@ -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 || "获取套餐信息失败");
}
File diff suppressed because it is too large Load Diff
+37 -15
View File
@@ -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">
不选择任何用户并保存将清空该分组成员。
+11 -5
View File
@@ -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
View File
@@ -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>
);
}
+271 -99
View File
@@ -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,59 +1062,94 @@ 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" />
<div className="flex gap-2 items-center">
<Button
color={selectMode ? "warning" : "default"}
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
{selectMode ? "取消多选" : "多选"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<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>
{/* 批量操作浮动工具栏 */}
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm font-medium shrink-0">
已选 {selectedIds.size} 项
</span>
<Button size="sm" variant="flat" onPress={selectAll}>
全选
</Button>
<Button size="sm" variant="flat" onPress={deselectAll}>
清空
</Button>
<Button
color="warning"
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="warning"
isDisabled={selectedIds.size === 0}
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{!wsConnected && (
<Alert
@@ -1083,7 +1187,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 +1247,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 +1259,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 +1461,7 @@ export default function NodePage() {
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
onPress={() => openInstallSelector(node)}
>
安装
</Button>
@@ -1801,6 +1905,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 +2034,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 +2071,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 +2089,7 @@ export default function NodePage() {
<p className="text-sm text-default-500">
{selectedVersion
? `将升级到版本 ${selectedVersion}`
: "未选择版本,将自动使用最新稳定版"}
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
</p>
</div>
)}
@@ -1979,6 +2151,6 @@ export default function NodePage() {
)}
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
+98 -20
View File
@@ -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"
@@ -604,7 +674,11 @@ export default function PanelSharingPage() {
</Tabs>
{/* Create Share Modal */}
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
<Modal
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
>
<ModalContent>
<ModalHeader>创建分享</ModalHeader>
<ModalBody>
@@ -707,7 +781,11 @@ export default function PanelSharingPage() {
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
<Modal
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
>
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
+3 -3
View File
@@ -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";
+3 -3
View File
@@ -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,
+149 -111
View File
@@ -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,58 +697,94 @@ 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" />
<div className="flex items-center gap-2">
<Button
color={selectMode ? "warning" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<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>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button size="sm" variant="flat" onPress={selectAll}>
全选
</Button>
<Button size="sm" variant="flat" onPress={deselectAll}>
清空
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
@@ -740,9 +802,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 +846,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 +1066,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 +1159,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 +1411,7 @@ export default function TunnelPage() {
.filter((id) => id !== -1)
.map((id) => id.toString()),
]}
dropdownPlacement="top"
label="节点"
placeholder="选择节点(可多选)"
selectedKeys={groupNodes
@@ -1589,6 +1627,7 @@ export default function TunnelPage() {
id.toString(),
),
]}
dropdownPlacement="top"
errorMessage={errors.outNodeId}
isInvalid={!!errors.outNodeId}
label="节点"
@@ -2075,7 +2114,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 +2156,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 +2320,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 +2473,6 @@ export default function TunnelPage() {
)}
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
+255 -222
View File
@@ -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 -1
View File
@@ -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-wrap justify-end gap-2",
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}
+230 -28
View File
@@ -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();
+10 -2
View File
@@ -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);
}
}
+1
View File
@@ -1,4 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
+42 -1
View File
@@ -2,9 +2,50 @@ import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "auto",
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
manifest: {
name: "FLVX",
short_name: "FLVX",
description: "FLVX forwarding management panel",
theme_color: "#2563eb",
background_color: "#f6f7fb",
display: "standalone",
start_url: "/",
scope: "/",
icons: [
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "pwa-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
navigateFallback: "/index.html",
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
}),
],
base: "/",
resolve: {
alias: {