Compare commits

...

33 Commits

Author SHA1 Message Date
sagit 1987960fce fix(ui): refine glassmorphism and page transition animations (#422)
Added custom light/dark background images to replace unsplash URLs.
Enhanced active sidebar item shadow and backdrop blur for better
visibility. Fixed double loading flickers on user, node, group, and
panel-sharing pages. Removed opacity transitions from AnimatedPage to
prevent backdrop-filter rendering issues during page navigation.
2026-04-06 12:28:25 +08:00
sagitchu 4c782eb815 fix(ui): refine glassmorphism and page transition animations
- Added custom light/dark background images to replace unsplash URLs.
- Enhanced active sidebar item shadow and backdrop blur for better visibility.
- Fixed double loading flickers on user, node, group, and panel-sharing pages.
- Removed opacity transitions from AnimatedPage to prevent backdrop-filter rendering issues during page navigation.
2026-04-06 12:26:29 +08:00
sagit 1e3b38f427 style: apply frosted glass backgrounds to inner cards and nested elements (#421)
Replaced solid backgrounds (bg-white, bg-default-50) with high-blur
translucent glass (backdrop-blur-3xl) inside tunnel topology nodes,
dashboard forward rules, and flow charts. Matched the aesthetic of the
inner components to inherit the adaptive light/dark mode root
backgrounds.
2026-04-06 11:04:16 +08:00
sagitchu 2166936f57 style: apply frosted glass backgrounds to inner cards and nested elements
- Replaced solid backgrounds (bg-white, bg-default-50) with high-blur translucent glass (backdrop-blur-3xl) inside tunnel topology nodes, dashboard forward rules, and flow charts.
- Matched the aesthetic of the inner components to inherit the adaptive light/dark mode root backgrounds.
2026-04-06 11:02:32 +08:00
sagit 53e1efcdc1 style: improve glassmorphism UI for background adaptability (#420)
Increase backdrop blur and lower white/black background opacity on inner
elements (tables, charts, tabs). Apply higher blur to admin layout
sidebar and H5 layout navigation bar. Remove solid backgrounds inside
nested cards to inherit background themes.
2026-04-06 10:23:16 +08:00
sagitchu 71d5c36ec3 style: improve glassmorphism UI for background adaptability
- Increase backdrop blur and lower white/black background opacity on inner elements (tables, charts, tabs)
- Apply higher blur to admin layout sidebar and H5 layout navigation bar
- Remove solid backgrounds inside nested cards to inherit background themes
2026-04-06 10:21:06 +08:00
sagit be4b1f5f25 fix(ui): improve background handling and PWA update prompts (#419)
## Changes
- Add adaptive solid color background support for dark/light themes.
- Replace mesh gradient with semi-transparent blurred background images.
- Fix background scrolling issue on mobile browsers.
- Remove borders from specific cards for consistent UI styling.
- Prevent caching of PWA service worker files in Nginx.
- Introduce interactive toast prompt for PWA updates instead of silent
refresh.
2026-04-05 20:55:15 +08:00
sagitchu cd4238274a fix(ui): improve background handling and PWA update prompts
- Add adaptive solid color background support for dark/light themes.
- Replace mesh gradient with semi-transparent blurred background images.
- Fix background scrolling issue on mobile browsers.
- Remove borders from specific cards for consistent UI styling.
- Prevent caching of PWA service worker files in Nginx.
- Introduce interactive toast prompt for PWA updates instead of silent refresh.
2026-04-05 20:53:25 +08:00
sagit 25dc03c894 style: optimize card and table backgrounds for better custom background visibility (#418)
Changed multiple bg-white/40 and bg-white/60 classes to more transparent
variants like bg-white/10 and bg-white/20, or bg-transparent to allow
the custom background image to show through.
2026-04-05 18:47:13 +08:00
sagitchu 4cf9c9174a style: optimize card and table backgrounds for better custom background visibility 2026-04-05 18:45:17 +08:00
sagit a45bd43e73 feat: add global background image setting to config page (#417)
Add app_bg_image setting to /config page to allow global background
customization.
2026-04-05 18:36:31 +08:00
sagitchu 364bace87f fix: remove unused imports and leftover jsx in settings 2026-04-05 18:35:07 +08:00
sagitchu 2caafab937 feat: add global background image setting to config page 2026-04-05 18:31:59 +08:00
sagit 901b903a70 fix: frontend browser caching issue (#416)
Disable caching for index.html and reduce static assets cache to 30d.
2026-04-05 17:51:56 +08:00
sagitchu 930cc86614 fix: frontend browser caching issue
Disable caching for index.html and reduce static assets cache to 30d.
2026-04-05 17:50:12 +08:00
sagit 101fd5805a feat(ui): add persistent custom background image upload feature (#415) 2026-04-05 17:44:19 +08:00
sagit 938e9ff4fd feat(ui): redesign with transparent liquid glass (#414)
* docs: add design spec for iOS 26 Liquid Glass UI redesign

* docs: add implementation plan for iOS 26 Liquid Glass UI redesign

* feat(ui): add liquid glass CSS variables and update root layouts

* feat(ui): update Card and Modal components to iOS 26 Liquid Glass style

* feat(ui): redesign dashboard page to Liquid Glass style

* feat(ui): redesign node management page cards and charts

* feat(ui): redesign monitor page to Liquid Glass style

* feat(ui): redesign group and panel-sharing cards to Liquid Glass

* feat(ui): redesign settings and user components to Liquid Glass

* feat(ui): redesign profile and auth pages to Liquid Glass

* fix(ui): revert to LineChart to fix compilation issues

* refactor(ui): extract dashboard user header and cleanup admin layout

* feat(ui): refine monitor page hero metrics and fix build

* fix(ui): real data for monitor hero metrics and card padding adjustments

* fix(ui): apply global card padding and fix index layouts

* feat(ui): implement liquid-glass-react for all Card components

* fix(ui): resolve liquid-glass-react rendering issues causing invisible text and broken layout

* fix(ui): native CSS liquid-glass effect replacing NPM package

* fix(ui): cleanup duplicate closing tags and complete liquid glass tables

* fix(ui): apply perfect rounded corners to list views and table headers in non-compact mode

* fix(ui): apply liquid glass styling to grouped rules view in forward page

* fix(ui): re-implement liquid-glass natively to prevent text distortion and remove package dependency

* fix(ui): remove mouse distortion and preserve proper liquid glass texture

* fix(ui): remove svg filter causing text distortion and replace with pure css liquid glass

* fix(ui): apply properly scoped border radius to table headers

* fix(ui): remove hardcoded bg-default-50/60 from TableHeader

* fix(ui): apply global liquid glass styling and transparent overlays to all modals and dialogs

* fix(ui): remove absolute/relative class conflicts hiding Dialog modales and implement pure transparent layout

* feat(ui): apply global liquid glass styling to cards, buttons, and diagnosis modals
2026-04-05 13:22:12 +08:00
sagit ebf412b9df fix(ui): mobile modal scroll behavior (#412)
* docs: add announcement popup design spec

* docs: add announcement popup implementation plan

* fix(ui): change modal scroll behavior to inside for mobile screens

Fixes layout issue where modal footer and action buttons are pushed off screen on mobile devices.
2026-04-04 19:34:40 +08:00
sagit 9a85363e44 feat: Announcement Popup Notification (#411)
* docs: add announcement popup design spec

* docs: add announcement popup implementation plan

* feat(api): include update_time in announcement response

* feat(ui): add update_time to AnnouncementData interface

* feat(ui): create AnnouncementModal component

* feat(ui): manage announcement modal state in dashboard hook

* feat(ui): add announcement modal to dashboard layout
2026-04-04 13:00:11 +08:00
sagit 7b9b59644e fix(ui): restore missing UI transition animations (#408)
- Wrap `Routes` with `AnimatePresence` to enable framer-motion page transition exit animations and fix harsh route changes.
- Replace `tw-animate-css` with `tailwindcss-animate` to properly bundle transition classes (like `animate-in`, `animate-accordion-down`) under Tailwind v4.
- This restores animation to modals, accordions, and all page navigations.
- Fix accessibility warning on table cell span element.

Fixes #283
2026-04-03 20:55:43 +08:00
sagit 4e088afb29 feat: show license expiry date when commercial license is activated (#407)
## Summary
- Extracted the `expiry` attribute from Keygen's `ValidateResponse`.
- Updated `licenseActivate` and `validateLicenseJob` to store
`license_expiry` in the database.
- Read `license_expiry` into the frontend `siteConfig` map.
- Updated the description in the commercial license input to dynamically
show the expiry date: '已激活商业版授权 (有效期至: YYYY-MM-DD)' or '已激活商业版授权 (永久有效)'
if it doesn't expire.
2026-04-03 17:39:07 +08:00
sagitchu 1b3ae44940 feat: show license expiry date when commercial license is activated 2026-04-03 17:37:05 +08:00
sagit a91abbfebd feat(ui): move commercial brand settings to license card (#406)
## Summary
- Conditionally render brand settings (`app_name`, `app_logo`,
`app_favicon`, `hide_footer_brand`) inside the Commercial License card.
- Hide them from the main Basic Settings list.
- Only show these options if the user has successfully activated a
commercial license.
2026-04-03 17:31:00 +08:00
sagitchu 513591fe67 feat(ui): conditionally render brand settings inside commercial license card 2026-04-03 17:27:48 +08:00
sagit 9412d24c02 feat: show tunnel traffic ratio in forward list (#405)
## Summary
- Display the tunnel's traffic ratio in the forward (rules) list UI to
save administrators an extra click to the tunnels page.
- Resolves #forward-show-tunnel-ratio

## Changes
- Updated the table row in `forward.tsx` to show the traffic multiplier.
- It will only display when the ratio is not `1x` to prevent UI clutter.
- The backend `forwardList` API was already returning `trafficRatio`
from a `LEFT JOIN tunnel`, so no backend modifications were required.
2026-04-03 16:34:04 +08:00
sagit ab3ca019d2 fix: prevent idle transaction timeout in postgresql during tunnel update (#404)
## Summary
- Moved `tx := h.repo.BeginTx()` below `applyFederationRuntime` in
`tunnelUpdate`
- This prevents the database transaction from remaining idle for an
extended period of time while making HTTP network requests to federation
nodes, which can trigger the `idle_in_transaction_session_timeout` or
cause connection pool exhaustion in PostgreSQL.

## Test Plan
- [x] Backend tests passed (`go test ./...`)
- [ ] Verify tunnel updates no longer timeout with PostgreSQL
2026-04-03 16:30:35 +08:00
sagitchu b892b2640e feat(ui): display tunnel traffic ratio in forward list 2026-04-03 16:18:01 +08:00
sagitchu 3da9b14bfe fix(backend): prevent idle transaction timeout in postgresql during tunnel update 2026-04-03 15:52:48 +08:00
sagit 49ab2915ee feat: commercial white-label support (#403)
* fix: increase updateTunnel timeout to 120s

Editing tunnel entry nodes triggers forward sync to all entry nodes.
If nodes are offline or many forwards exist, the sync can exceed
the default 30s timeout. Match the timeout used by other heavy
operations like batchDeleteTunnels.

* docs: add commercial white-label design spec

* docs: add commercial white-label implementation plan

* feat: add license activation endpoint and authorization check for commercial config keys

* feat: add frontend api and update site config state for license

* feat: conditionally hide flvx footer brand

* feat: ui settings for commercial white-label and license activation

* fix: add missing licenseActivateRequest and fix GetConfig in handler.go

* docs: add keygen.sh license integration design spec

* docs: add keygen.sh integration implementation plan

* feat: add machine fingerprint generation

* feat: add keygen.sh api client

* feat: integrate keygen into license activation endpoint

* feat: add periodic license validation job

* fix: remove accidentally leaked dash kernel test codes that caused compilation failures

* fix: correct keygen validation scope and binding logic

* feat: hardcode Keygen.sh account ID

* fix: relax strict validation matching after successful machine activation
2026-04-03 07:23:22 +00:00
sagit becf87118f fix(backend): randomize new tunnel relay ports safely (#402)
## Summary
- randomize auto-assigned ports only for newly created tunnel relay and
exit nodes
- keep tunnel updates stable while tightening batch forward
tunnel-switch validation
- update contract coverage for deferred offline runtime handling and
missing entry-node fixtures

## Test Plan
- go test ./... -count=1
2026-04-01 20:19:10 +08:00
sagitchu 608fbf74de fix(backend): randomize new tunnel relay ports safely 2026-04-01 20:16:31 +08:00
sagit 8b9cdef0e4 feat: add configurable GitHub proxy settings (#401)
* docs: add GitHub proxy config design spec

* docs: add GitHub proxy config implementation plan

* feat(backend): use configurable github proxy for node upgrades

* feat(backend): use configurable github proxy for node install command

* feat(frontend): add github proxy config settings

* feat(script): support configurable github proxy in installer flows

Honor custom GitHub mirror settings across interactive and env-driven installer/update paths so script downloads match the panel configuration. Add shell regressions to lock down proxy prompting, URL recomputation, and non-interactive fallback behavior.
2026-04-01 15:59:44 +08:00
sagit 3c10727e08 feat(config): add floating save button (FAB) for issue #266 (#400)
* docs: add floating save button design spec for issue #266

* docs: add implementation plan for floating save button

* feat(config): add floating save button (FAB) for issue #266
2026-04-01 01:50:32 +00:00
70 changed files with 5142 additions and 579 deletions
+1
View File
@@ -67,6 +67,7 @@ go-gost/ss/
.entire/
bin/
tmp/
.worktrees/
*.swp
*.bak
@@ -0,0 +1,132 @@
# Floating Save Button Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a floating save button (FAB) to the config page that appears when configuration changes are detected.
**Architecture:** Inline FAB implementation using framer-motion AnimatePresence for enter/exit animations. Fixed-position circular button with slide-up animation, reusing existing hasChanges state and handleSave function.
**Tech Stack:** React, framer-motion (v11.18.2), shadcn-bridge/heroui Button, Tailwind CSS
---
## File Structure
| File | Action | Purpose |
|------|--------|---------|
| `vite-frontend/src/pages/config.tsx` | Modify | Add FAB imports and component at page bottom |
---
### Task 1: Add framer-motion Imports
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx:1-5`
- [ ] **Step 1: Add AnimatePresence and motion imports**
Add import statement after existing framer-motion imports (or at top if none exist).
Current imports at line 1-2:
```typescript
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
```
Add new import after line 2:
```typescript
import { AnimatePresence, motion } from "framer-motion";
```
- [ ] **Step 2: Commit import addition**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(config): add framer-motion imports for FAB animation"
```
---
### Task 2: Add FAB Component
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx:1220-1224` (end of component)
- [ ] **Step 1: Add FAB at end of component (before closing div)**
Locate the end of `ConfigPage` component (line ~1223, the closing `</div>` after all modals).
Insert FAB component before the closing `</div>`:
```tsx
{/* Floating Save Button (FAB) */}
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
```
- [ ] **Step 2: Run dev server to verify**
```bash
cd vite-frontend && npm run dev
```
Manual verification checklist:
- Open config page at http://localhost:3000/config
- Modify any config field
- Verify FAB appears with slide-up animation
- Click FAB to save
- Verify FAB disappears with slide-down animation after save
- Scroll page and verify FAB stays fixed in viewport corner
- Test on mobile viewport (resize browser or use dev tools)
- [ ] **Step 3: Commit FAB implementation**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(config): add floating save button (FAB) for issue #266"
```
---
## Verification Summary
| Requirement | Verification Method |
|-------------|---------------------|
| FAB hidden by default | Visual: no FAB on page load with no changes |
| FAB appears on change | Visual: modify field → FAB slides up |
| Fixed position | Visual: scroll page → FAB stays in corner |
| Slide-up animation | Visual: observe animation timing/bounce |
| Slide-down on save | Visual: click save → FAB slides down |
| Loading state | Visual: click save → spinner shown during save |
| Mobile compatibility | Visual: resize to mobile viewport → same behavior |
---
## Self-Review Checklist
- [x] Spec coverage: All requirements from design doc covered (imports + FAB component, animation params, button style, interaction behavior)
- [x] No placeholders: All code shown, no TBD/TODO
- [x] Type consistency: SaveIcon (line 45-59), handleSave (line 372-434), hasChanges (line 214), saving (line 213) all exist in config.tsx
@@ -0,0 +1,520 @@
# GitHub 加速地址自定义配置实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速,配置影响全部下载场景。
**Architecture:** 使用现有 `vite_config` 表存储配置,后端 Handler 读取配置替换硬编码,前端复用现有配置项渲染逻辑,安装脚本支持环境变量和交互式询问。
**Tech Stack:** Go 1.24, React/TypeScript, Shell/Bash
---
## 文件结构
| 文件 | 操作 | 说明 |
|------|------|------|
| `go-backend/internal/http/handler/upgrade.go` | 修改 | 移除硬编码,添加配置读取函数 |
| `go-backend/internal/http/handler/mutations.go` | 修改 | `nodeInstall` 函数使用动态配置 |
| `vite-frontend/src/pages/config.tsx` | 修改 | 添加两个新配置项 |
| `install.sh` | 修改 | 支持交互式询问和环境变量 |
| `panel_install.sh` | 修改 | 支持交互式询问和环境变量 |
| `test-install-scripts-proxy.sh` | 新增 | 覆盖代理交互与下载 URL 回归 |
---
## Task 1: 后端 - upgrade.go 修改
**Files:**
- Modify: `go-backend/internal/http/handler/upgrade.go`
- [x] **Step 1: 移除硬编码常量,添加配置读取函数**
在 `upgrade.go` 中,移除 `githubProxy` 常量,添加 `getGithubProxyConfig` 函数:
找到第 16-26 行:
```go
const (
githubRepo = "Sagit-chu/flvx"
githubProxy = "https://gcode.hostcentral.cc"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
```
替换为:
```go
const (
githubRepo = "Sagit-chu/flvx"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
defaultGithubProxyEnabled = true
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
)
```
然后在 `releaseChannelLabel` 函数后(约第 71 行之后)添加新函数:
```go
// getGithubProxyConfig 获取 GitHub 加速配置
// 返回: (是否开启加速, 加速地址)
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = defaultGithubProxyEnabled
proxyURL = defaultGithubProxyURL
if h == nil || h.repo == nil {
return
}
// 读取开启状态
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
// 读取加速地址
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
// 确保 URL 格式正确
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
// buildGithubDownloadURL 构建 GitHub 下载地址
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
```
- [x] **Step 2: 修改 nodeUpgrade 函数使用动态配置**
找到第 152-159 行:
```go
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
```
替换为:
```go
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
```
- [x] **Step 3: 修改 nodeBatchUpgrade 函数使用动态配置**
找到第 216-223 行:
```go
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
```
替换为:
```go
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
```
- [x] **Step 4: 验证编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功,无错误
- [x] **Step 5: 提交**
```bash
git add go-backend/internal/http/handler/upgrade.go
git commit -m "feat(backend): use configurable github proxy for node upgrades"
```
---
## Task 2: 后端 - mutations.go 修改
**Files:**
- Modify: `go-backend/internal/http/handler/mutations.go`
- [x] **Step 1: 修改 nodeInstall 函数使用动态配置**
找到第 456 行:
```go
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)
```
替换为:
```go
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
} else {
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret)
}
```
- [x] **Step 2: 验证编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功,无错误
- [x] **Step 3: 提交**
```bash
git add go-backend/internal/http/handler/mutations.go
git commit -m "feat(backend): use configurable github proxy for node install command"
```
---
## Task 3: 前端 - config.tsx 添加配置项
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- [x] **Step 1: 在 CONFIG_ITEMS 数组中添加配置项**
找到第 158 行(`CONFIG_ITEMS` 数组的结束位置):
```go
{
key: "cloudflare_secret_key",
label: "Cloudflare Secret Key",
placeholder: "请输入 Cloudflare Secret Key",
description: "Cloudflare Turnstile 密钥",
type: "input",
dependsOn: "captcha_enabled",
dependsValue: "true",
},
];
```
在 `];` 之前添加:
```typescript
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
```
- [x] **Step 2: 在缓存键列表中添加新键**
找到第 179-190 行:
```typescript
const configKeys = [
"app_name",
"captcha_enabled",
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"monitor_tunnel_quality_enabled",
"ip",
"panel_domain",
"app_logo",
"app_favicon",
];
```
在 `"app_favicon",` 之后添加:
```typescript
"github_proxy_enabled",
"github_proxy_url",
```
- [x] **Step 3: 验证前端编译**
Run: `cd vite-frontend && npm run build`
Expected: 编译成功,无错误
- [x] **Step 4: 提交**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat(frontend): add github proxy config settings"
```
---
## Task 4: 安装脚本 - install.sh 修改
**Files:**
- Modify: `install.sh`
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
找到第 28-32 行:
```bash
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
}
```
替换为:
```bash
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
# 询问加速配置(如果未由面板传入)
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
```
- [x] **Step 2: 修改 install_flux_agent 函数添加询问**
找到第 211-214 行:
```bash
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
get_config_params
```
替换为:
```bash
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
# 询问加速配置(如果未由面板传入)
ask_proxy_config
get_config_params
```
- [ ] **Step 3: 提交**
```bash
git add install.sh
git commit -m "feat(script): add configurable github proxy for install.sh"
```
---
## Task 5: 安装脚本 - panel_install.sh 修改
**Files:**
- Modify: `panel_install.sh`
- [x] **Step 1: 添加环境变量声明和修改 maybe_proxy_url 函数**
找到第 16-20 行:
```bash
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
}
```
替换为:
```bash
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
# 询问加速配置(如果未由面板传入)
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
```
- [x] **Step 2: 修改 install_panel 函数添加询问**
找到第 375-378 行:
```bash
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
check_docker
get_config_params
```
替换为:
```bash
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
# 询问加速配置(如果未由面板传入)
ask_proxy_config
check_docker
get_config_params
```
- [ ] **Step 3: 提交**
```bash
git add panel_install.sh
git commit -m "feat(script): add configurable github proxy for panel_install.sh"
```
---
## Task 6: 最终验证和提交
- [x] **Step 1: 验证后端编译**
Run: `cd go-backend && go build ./...`
Expected: 编译成功
- [x] **Step 2: 验证前端编译**
Run: `cd vite-frontend && npm run build`
Expected: 编译成功
- [x] **Step 3: 验证脚本语法**
Run: `bash -n install.sh && bash -n panel_install.sh && bash test-install-scripts-proxy.sh`
Expected: 无语法错误,且脚本代理回归测试通过
- [ ] **Step 4: 推送所有提交**
```bash
git push
```
---
## 验收标准
1. 面板设置页面显示 GitHub 加速配置项
2. 开关关闭后,下载地址直连 GitHub
3. 自定义加速地址后,节点更新和安装命令使用自定义地址
4. 安装脚本支持交互式询问加速配置
5. 面板生成的安装命令包含加速配置环境变量
@@ -0,0 +1,220 @@
# Commercial White-Label Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow users with a valid license key to activate commercial white-label features, enabling them to remove FLVX branding and use their own app name, logos, and footer.
**Architecture:** Backend API handles license validation and stores state (`is_commercial`). Both frontend and backend check this state to conditionally render or allow modifications to brand config.
**Tech Stack:** Go (Backend API), React + Vite (Frontend UI).
---
### Task 1: Backend License Activation Endpoint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add license request struct**
Add the `licenseActivateRequest` struct in `handler.go`.
```go
type licenseActivateRequest struct {
LicenseKey string `json:"license_key"`
}
```
- [ ] **Step 2: Add `licenseActivate` handler method**
Add the method to validate the key in `handler.go`.
```go
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req licenseActivateRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
key := strings.TrimSpace(req.LicenseKey)
if !strings.HasPrefix(key, "FLVX-") {
response.WriteJSON(w, response.ErrDefault("无效的商业授权码"))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
- [ ] **Step 3: Register the route**
In `handler.go` inside `Register(mux *http.ServeMux)`, add the route.
```go
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
```
- [ ] **Step 4: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add license activation endpoint"
```
### Task 2: Backend Config Update Validation
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add permission check in `updateConfigs`**
In `updateConfigs`, fetch `isCommercial := h.repo.GetConfig("is_commercial")`. Inside the loop, check if the user is trying to update protected keys.
```go
isCommercial, _ := h.repo.GetConfig("is_commercial")
protectedKeys := map[string]bool{
"app_name": true,
"app_logo": true,
"app_favicon": true,
"hide_footer_brand": true,
}
```
Inside `for k, v := range payload`:
```go
if protectedKeys[key] && isCommercial.Value != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
```
- [ ] **Step 2: Add permission check in `updateSingleConfig`**
In `updateSingleConfig`, do the same check before calling `normalizeAndValidateConfigValue`.
```go
isCommercial, _ := h.repo.GetConfig("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial.Value != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
```
- [ ] **Step 3: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add authorization check for commercial config keys"
```
### Task 3: Frontend API & Site Config Update
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/config/site.ts`
- [ ] **Step 1: Add `activateLicense` API**
In `vite-frontend/src/api/index.ts`:
```typescript
export const activateLicense = (licenseKey: string) =>
Network.post("/license/activate", { license_key: licenseKey });
```
- [ ] **Step 2: Update `siteConfig` defaults**
In `vite-frontend/src/config/site.ts`, inside `getInitialConfig()`, add properties.
```typescript
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
is_commercial: configCache.get("is_commercial") === "true",
hide_footer_brand: configCache.get("hide_footer_brand") === "true",
```
- [ ] **Step 3: Update `updateSiteConfig`**
In `updateSiteConfig` inside `site.ts`, extract and update `is_commercial` and `hide_footer_brand`.
```typescript
const isCommercial = resolvedConfigMap.is_commercial === "true";
const hideFooterBrand = resolvedConfigMap.hide_footer_brand === "true";
siteConfig.is_commercial = isCommercial;
siteConfig.hide_footer_brand = hideFooterBrand;
```
- [ ] **Step 4: Commit**
```bash
git add vite-frontend/src/api/index.ts vite-frontend/src/config/site.ts
git commit -m "feat: add frontend api and update site config state for license"
```
### Task 4: Frontend Footer Component Update
**Files:**
- Modify: `vite-frontend/src/components/version-footer.tsx`
- [ ] **Step 1: Conditionally hide "Powered by FLVX"**
In the render block, wrap the `Powered by FLVX` text.
```tsx
{siteConfig.hide_footer_brand !== true && (
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
)}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/components/version-footer.tsx
git commit -m "feat: conditionally hide flvx footer brand"
```
### Task 5: Frontend Settings Page UI Update
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- [ ] **Step 1: Add config keys to initialization**
In `getInitialConfigs`, add `"is_commercial"` and `"hide_footer_brand"` to `configKeys`.
- [ ] **Step 2: Add `hide_footer_brand` switch field**
Add it to the `CONFIG_ITEMS` array.
```typescript
{
key: "hide_footer_brand",
label: "隐藏页面底部 FLVX 版权信息",
description: "需商业版授权才能生效",
type: "switch",
},
```
- [ ] **Step 3: Add license activation UI**
Above the System Config Card (near `value="configs"`), add a new `Card` for "商业版授权". You will need a local state `licenseKey` and an `handleActivateLicense` function that calls `activateLicense(licenseKey)` and refetches configs on success.
- [ ] **Step 4: Disable brand settings when not commercial**
In `renderConfigItem`, compute `isDisabled` and pass it to the `<Input>`, `<Switch>`, and `BrandUploading` UI. Update the logic to disable modifications and add a lock icon or a tooltip explaining that a commercial license is required.
```typescript
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
```
- [ ] **Step 5: Commit**
```bash
git add vite-frontend/src/pages/config.tsx
git commit -m "feat: ui settings for commercial white-label and license activation"
```
@@ -0,0 +1,347 @@
# Commercial White-Label (Keygen) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Keygen.sh license activation and periodic validation to manage commercial white-label features, replacing the temporary mock logic.
**Architecture:** The backend generates a machine fingerprint, validates the license via the Keygen.sh API, and creates a machine associated with the license. A periodic job verifies the license status to support remote revocation.
**Tech Stack:** Go (Backend API), Keygen.sh API.
---
### Task 1: Generate and Store Machine Fingerprint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add `getOrCreateMachineFingerprint` helper function**
Add a helper function in `handler.go` (or a dedicated license file) to get or generate the machine fingerprint. Use `github.com/google/uuid`.
```go
import "github.com/google/uuid"
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
return "", err
}
return newFp, nil
}
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: add machine fingerprint generation"
```
### Task 2: Create Keygen Client Package
**Files:**
- Create: `go-backend/internal/license/keygen.go`
- [ ] **Step 1: Create Keygen client structs and interface**
Create the file and define the request/response structs for Keygen's `/licenses/actions/validate-key` and `/machines` endpoints. Also define an interface for the client.
```go
package license
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type KeygenClient struct {
AccountID string
Token string
HTTPClient *http.Client
}
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
Code string `json:"code"`
} `json:"meta"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
type ActivateMachineRequest struct {
Data struct {
Type string `json:"type"`
Attributes struct {
Fingerprint string `json:"fingerprint"`
} `json:"attributes"`
Relationships struct {
License struct {
Data struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"data"`
} `json:"license"`
} `json:"relationships"`
} `json:"data"`
}
```
- [ ] **Step 2: Implement `ValidateKey`**
Add the `ValidateKey` method.
```go
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
reqBody := map[string]interface{}{
"meta": map[string]string{
"key": key,
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
```
- [ ] **Step 3: Implement `ActivateMachine`**
Add the `ActivateMachine` method.
```go
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
reqBody.Data.Attributes.Fingerprint = fingerprint
reqBody.Data.Relationships.License.Data.Type = "licenses"
reqBody.Data.Relationships.License.Data.ID = licenseID
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusConflict { // 409 usually means fingerprint already exists
return nil // Machine might already be registered
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
```
- [ ] **Step 4: Commit**
```bash
git add go-backend/internal/license/keygen.go
git commit -m "feat: add keygen.sh api client"
```
### Task 3: Integrate Keygen into License Activation Endpoint
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Update `licenseActivate` logic**
Modify `licenseActivate` to use the Keygen client instead of the mock logic. Note: For this implementation, we will use an environment variable `KEYGEN_ACCOUNT_ID`. We can use `os.Getenv` directly for simplicity, or hardcode a fallback if not present.
```go
import (
"go-backend/internal/license"
"os"
)
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
// ... (keep request parsing)
key := strings.TrimSpace(req.LicenseKey)
if key == "" {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
if accountID == "" {
// Fallback for mock/development if no keygen account configured
if strings.HasPrefix(key, "FLVX-") {
now := time.Now().UnixMilli()
h.repo.UpsertConfig("license_key", key, now)
h.repo.UpsertConfig("is_commercial", "true", now)
response.WriteJSON(w, response.OKEmpty())
return
}
response.WriteJSON(w, response.ErrDefault("系统未配置 Keygen 账号 ID"))
return
}
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "") // Token may be optional for validate-key depending on policy, or can be passed if needed
valResp, err := client.ValidateKey(key)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
// Try to activate machine
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat: integrate keygen into license activation endpoint"
```
### Task 4: Add Periodic License Validation Job
**Files:**
- Modify: `go-backend/internal/http/handler/jobs.go`
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Add `validateLicenseJob` function in `jobs.go`**
Create a new function that performs the background validation.
```go
import "os"
func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := os.Getenv("KEYGEN_ACCOUNT_ID")
if accountID == "" {
return // Skip if not configured
}
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKey(key)
if err != nil {
// Network error or timeout. We implement a grace period by NOT revoking immediately here.
// In a production system, you might count consecutive failures.
// For now, we skip revocation on network errors.
return
}
if !valResp.Meta.Valid {
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
// We could optionally clear brand configs here, or just let them be disabled in UI
}
}
```
- [ ] **Step 2: Register the job in `RunJobs`**
In `handler.go` or `jobs.go`, wherever the periodic cron jobs are registered (usually `go h.runJobs()`), ensure `validateLicenseJob` is called periodically (e.g., every 12 hours). Look for `h.startCronJobs()` or similar in `handler.go`.
If a central `RunJobs` loop exists in `jobs.go` (like a `for` loop with a `time.Ticker`), add it there. If not, create a simple goroutine in `Register` or `NewHandler`.
*Assuming there's a `startJobs` or `Init` block in `handler.go`:*
```go
// Inside handler initialization or Register:
go func() {
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.validateLicenseJob()
}
}
}()
```
- [ ] **Step 3: Commit**
```bash
git add go-backend/internal/http/handler/jobs.go go-backend/internal/http/handler/handler.go
git commit -m "feat: add periodic license validation job"
```
@@ -0,0 +1,262 @@
# Announcement Popup Notification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a popup modal for announcements that automatically shows to users when a new or updated announcement is published.
**Architecture:** We will modify the Go backend to return `update_time` along with the announcement data. In the Vite frontend, we will store the user's `flvx_announcement_seen_time` in `localStorage`. If the fetched `update_time` is greater than the stored timestamp, we trigger a NextUI Modal displaying the announcement content.
**Tech Stack:** Go, Vite, React, TailwindCSS, NextUI.
---
### Task 1: Update API Response in Go Backend
**Files:**
- Modify: `go-backend/internal/http/handler/handler.go`
- [ ] **Step 1: Write the minimal implementation**
Modify the `getAnnouncement` function in `go-backend/internal/http/handler/handler.go`.
Find the response map inside `getAnnouncement` and add the `update_time` key:
```go
if ann == nil {
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": "",
"enabled": 0,
"update_time": 0,
}))
return
}
updateTime := ann.CreatedTime
if ann.UpdatedTime.Valid {
updateTime = ann.UpdatedTime.Int64
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": ann.Content,
"enabled": ann.Enabled,
"update_time": updateTime,
}))
```
- [ ] **Step 2: Commit**
```bash
git add go-backend/internal/http/handler/handler.go
git commit -m "feat(api): include update_time in announcement response"
```
---
### Task 2: Update Frontend API Interface
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- [ ] **Step 1: Write the minimal implementation**
Modify the `AnnouncementData` interface in `vite-frontend/src/api/index.ts` to include `update_time`.
```typescript
export interface AnnouncementData {
content: string;
enabled: number;
update_time?: number;
}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/api/index.ts
git commit -m "feat(ui): add update_time to AnnouncementData interface"
```
---
### Task 3: Create AnnouncementModal Component
**Files:**
- Create: `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx`
- [ ] **Step 1: Write the minimal implementation**
Create `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx` with the following content:
```tsx
import type { AnnouncementData } from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AnnouncementModalProps {
announcement: AnnouncementData;
isOpen: boolean;
onClose: () => void;
onDontShowAgain: () => void;
}
export const AnnouncementModal = ({
announcement,
isOpen,
onClose,
onDontShowAgain,
}: AnnouncementModalProps) => {
return (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody>
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{announcement.content}
</ReactMarkdown>
</div>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onDontShowAgain}>
不再提示
</Button>
<Button color="primary" onPress={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/pages/dashboard/components/announcement-modal.tsx
git commit -m "feat(ui): create AnnouncementModal component"
```
---
### Task 4: Integrate Modal State in Dashboard Custom Hook
**Files:**
- Modify: `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
- [ ] **Step 1: Update the hook return type interface**
At the top of `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` where `DashboardData` is or similar, add the new properties (if it uses an explicit return type). If it's inferred, skip this. Wait, let's check the code:
```typescript
isAnnouncementModalOpen: boolean;
setIsAnnouncementModalOpen: (isOpen: boolean) => void;
dismissAnnouncementModal: () => void;
```
Ensure they are added to the returned object at the bottom of the `useDashboardData` hook.
Find the `const loadAnnouncement` function.
- [ ] **Step 2: Write the minimal implementation**
First, add state at the top of the hook:
```typescript
const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false);
```
Then, modify the `loadAnnouncement` logic inside `useDashboardData`:
```typescript
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
try {
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
const updateTime = res.data.update_time || 0;
if (updateTime > storedTime) {
setIsAnnouncementModalOpen(true);
}
} catch (err) {
console.warn("Failed to read localStorage for announcement state", err);
setIsAnnouncementModalOpen(true);
}
} else {
setAnnouncement(null);
}
```
Add the dismiss handler inside the hook:
```typescript
const dismissAnnouncementModal = useCallback(() => {
setIsAnnouncementModalOpen(false);
if (announcement && announcement.update_time) {
try {
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
} catch (err) {
console.warn("Failed to set localStorage for announcement state", err);
}
}
}, [announcement]);
```
Ensure these are included in the return object of the hook:
```typescript
isAnnouncementModalOpen,
setIsAnnouncementModalOpen,
dismissAnnouncementModal,
```
- [ ] **Step 3: Commit**
```bash
git add vite-frontend/src/pages/dashboard/use-dashboard-data.ts
git commit -m "feat(ui): manage announcement modal state in dashboard hook"
```
---
### Task 5: Add Modal to Dashboard Layout
**Files:**
- Modify: `vite-frontend/src/pages/dashboard.tsx`
- [ ] **Step 1: Write the minimal implementation**
Import the modal component at the top:
```tsx
import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
```
Add the new properties to the destructured `useDashboardData` object:
```tsx
isAnnouncementModalOpen,
setIsAnnouncementModalOpen,
dismissAnnouncementModal,
```
Add the modal instance near the end of the dashboard rendering (just below `{announcement && <AnnouncementBanner ... />}` or inside the main `<div>`):
```tsx
{announcement && (
<AnnouncementModal
announcement={announcement}
isOpen={isAnnouncementModalOpen}
onClose={() => setIsAnnouncementModalOpen(false)}
onDontShowAgain={dismissAnnouncementModal}
/>
)}
```
- [ ] **Step 2: Commit**
```bash
git add vite-frontend/src/pages/dashboard.tsx
git commit -m "feat(ui): add announcement modal to dashboard layout"
```
@@ -0,0 +1,140 @@
# Flvx iOS 26 Liquid Glass UI Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Transform the Flvx frontend interface entirely into an "Apple iOS 26 Liquid Glass" visual style by utilizing high-radius squircles, heavy background blurs, mesh gradients, and highly semantic translucent containers.
**Architecture:** We will approach this from the ground up: first defining the global TailwindCSS design tokens and the base mesh-gradient layout, then systematically replacing the structural styling inside each React page component (`vite-frontend/src/pages/*.tsx`).
**Tech Stack:** React DOM, TailwindCSS (v4), shadcn-bridge (HeroUI), Vite
---
### Task 1: Setup Global CSS Variables and App Shell
**Files:**
- Modify: `vite-frontend/src/styles/globals.css` (or `index.css`)
- Modify: `vite-frontend/tailwind.config.js`
- Modify: `vite-frontend/src/App.tsx` (or `main.tsx` / `layouts` depending on structural entry point)
- [ ] **Step 1: Inject Liquid Glass theme variables**
Open the main CSS file and add variables for the new blur radius, box-shadows, and background gradients.
```css
:root {
--glass-bg: rgba(255, 255, 255, 0.6);
--glass-border: rgba(255, 255, 255, 0.8);
--glass-card: rgba(255, 255, 255, 0.7);
--glass-overlay: rgba(0, 0, 0, 0.3);
}
.dark {
--glass-bg: rgba(30, 30, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.15);
--glass-card: rgba(40, 40, 40, 0.6);
}
.bg-mesh-gradient {
background: radial-gradient(at 0% 0%, #ff9a9e 0%, transparent 50%),
radial-gradient(at 100% 0%, #fecfef 0%, transparent 50%),
radial-gradient(at 100% 100%, #c2e9fb 0%, transparent 50%),
radial-gradient(at 0% 100%, #a1c4fd 0%, transparent 50%);
background-color: #f2f2f7;
}
```
- [ ] **Step 2: Update App Layout**
Modify the root app container to use `.bg-mesh-gradient` and ensure the main container occupies `min-h-screen`.
### Task 2: Refactor Global Components (Card & Modal)
**Files:**
- Modify: `vite-frontend/src/shadcn-bridge/heroui/card.tsx`
- Modify: `vite-frontend/src/shadcn-bridge/heroui/modal.tsx`
- [ ] **Step 1: Liquid Card Base**
Update the default className string for `Card` to incorporate: `backdrop-blur-3xl bg-white/60 dark:bg-zinc-900/60 border border-white/80 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]`.
- [ ] **Step 2: Modal Overlay Base**
Update the default overlay className for `Modal` to use `bg-black/30 backdrop-blur-sm`, and its content panel to use the same `glass_card` classes as the Card component but with `rounded-3xl`.
### Task 3: Redesign Dashboard Page
**Files:**
- Modify: `vite-frontend/src/pages/dashboard.tsx`
- [ ] **Step 1: Replace hardcoded borders/bg with glass semantics**
Find hardcoded `bg-white`, `border-gray-200`, `shadow-md` inside `DashboardPage` and `MetricCard`, replace with `bg-white/60 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] border-white/80 rounded-2xl`.
- [ ] **Step 2: Adjust spacing**
Ensure all metric cards have uniform `h-48` equivalent height and are strictly `rounded-2xl` with `p-6` padding.
- [ ] **Step 3: Update Flow Chart Card**
Replace standard grid backgrounds in the flow chart with transparent spacing and vibrant `bg-blue-500` squircle bars without harsh borders.
### Task 4: Redesign Node Management Page
**Files:**
- Modify: `vite-frontend/src/pages/node.tsx`
- [ ] **Step 1: Replace standard List/Table view with Grid Cards**
Update the node rendering map to output `glass_card` containers (`rounded-2xl`, blur, padding `p-6`).
- [ ] **Step 2: Apply semantic status highlights**
Refactor the Online/Offline badges into pill-shapes (`rounded-full`) using the defined semantic colors (e.g. `bg-green-500/20 text-green-600` with a 6px inner dot `bg-green-500`).
- [ ] **Step 3: Embed Micro-charts**
For CPU/RAM data inside the node card, switch standard progress bars to ultra-thin (height 4px) continuous lines utilizing standard brand colors.
### Task 5: Redesign Tunnels & Rules Configuration
**Files:**
- Modify: `vite-frontend/src/pages/tunnel.tsx`
- Modify: `vite-frontend/src/pages/forward.tsx`
- [ ] **Step 1: Update Tunnel lists into nested Glass Panels**
Encapsulate each tunnel configuration into a wide `glass_card`.
- [ ] **Step 2: Create Visual Rule Tags**
For the Forwarding rules, wrap the target IP/Port logic into visual badges: `bg-green-500/20` for Entry and `bg-blue-500/20` for Target.
- [ ] **Step 3: Refactor the "Add Rule" Floating action**
Ensure the plus button follows the squircle format (`rounded-full`) with a prominent diffused shadow (`shadow-[0_4px_12px_rgba(0,122,255,0.3)]`).
### Task 6: Redesign Monitor Page
**Files:**
- Modify: `vite-frontend/src/pages/monitor.tsx`
- [ ] **Step 1: Style the Top Hero Metrics**
Replace flat stat boxes with high-contrast, large typography inside `glass_card` backgrounds.
- [ ] **Step 2: Refactor Latency Indicators**
Format the connection list rows as `bg-white/50 dark:bg-black/30` strips with pill-shaped status tags (`Healthy`, `Warning`, `Offline`) mapping exactly to the green/orange/red semantics from the design spec.
### Task 7: Redesign Group & Sharing Pages
**Files:**
- Modify: `vite-frontend/src/pages/group.tsx`
- Modify: `vite-frontend/src/pages/panel-sharing.tsx`
- [ ] **Step 1: Update Tab Switchers**
Refactor the internal navigation tabs (e.g., "Tunnel Groups" vs "User Groups") into an encapsulated `p-1 rounded-xl bg-white/40 backdrop-blur-lg` container with animated active states (`shadow-sm bg-white`).
- [ ] **Step 2: Style Share Cards**
Transform flat panel sharing list items into rich `glass_card` entities. Highlight expiration dates with the accent text color.
### Task 8: Redesign Settings, Config, and User Management
**Files:**
- Modify: `vite-frontend/src/pages/config.tsx`
- Modify: `vite-frontend/src/pages/settings.tsx`
- Modify: `vite-frontend/src/pages/user.tsx`
- Modify: `vite-frontend/src/pages/limit.tsx`
- [ ] **Step 1: Flatten Forms**
Convert traditional input groups into `rounded-xl bg-white/50 border border-white/60` containers. Remove outer boxing for standard `label + input` pairs.
- [ ] **Step 2: iOS Toggle Switches**
Ensure that any `<Switch>` or `<Checkbox>` components use the new Accent brand color (`#007aff`) with full `rounded-full` geometry.
- [ ] **Step 3: Refactor User Badges**
In `user.tsx`, replace text-based role columns with circular Avatar badges (e.g., `w-10 h-10 rounded-full bg-blue-500 text-white` with the first two letters of the username).
### Task 9: Profile & Password Modal Restyling
**Files:**
- Modify: `vite-frontend/src/pages/profile.tsx`
- Modify: `vite-frontend/src/pages/change-password.tsx`
- [ ] **Step 1: Apply Profile Card Structure**
Create a split view on desktop using flex: Left side (Avatar + User Info + Admin Shortcuts), Right side (Password Form).
- [ ] **Step 2: Restyle Auth Inputs**
Ensure all password inputs use `bg-white/50 backdrop-blur-md border border-white/60` and the update button has heavy shadow-glow.
@@ -0,0 +1,162 @@
# Floating Save Button Design
**Date:** 2026-04-01
**Issue:** https://github.com/Sagit-chu/flvx/issues/266
**Status:** Approved
## Overview
Add a Floating Action Button (FAB) to the config page (`vite-frontend/src/pages/config.tsx`) that appears when configuration changes are detected, allowing users to save without scrolling to the top.
## Requirements
From Issue #266:
1. **Default hidden**: FAB not visible when no config changes
2. **Show on change**: Auto-display when `hasChanges` becomes true
3. **Fixed position**: Suspended at bottom-right corner, does not scroll with page
4. **Mobile compatible**: Same behavior on desktop and mobile devices
## Design Decisions
### 1. Implementation Approach
**Inline FAB in config.tsx** (not a reusable component)
- Rationale: Current need is limited to config page only
- State management (`hasChanges`, `saving`) already exists in the page
- framer-motion patterns already established in project
- Avoids over-abstraction (YAGNI)
### 2. UI Structure
Position: `fixed bottom-6 right-6` (24px from viewport edges)
Visual layout:
```
┌──────────────────────────────────────┐
│ [页面内容,可滚动] │
│ │
│ [●] │ ← FAB (fixed position)
└──────────────────────────────────────┘
```
### 3. Button Appearance
- Shape: Circular (`w-12 h-12 rounded-full`)
- Color: Primary (matches existing save button)
- Icon: SaveIcon (already defined in config.tsx)
- Shadow: `shadow-lg` for visual hierarchy
- Style: Icon-only (no text label)
### 4. Animation
Using framer-motion with `AnimatePresence`:
| Phase | Properties |
|-------|------------|
| `initial` | `{ y: 100, opacity: 0 }` - starts below viewport |
| `animate` | `{ y: 0, opacity: 1 }` - slides up to position |
| `exit` | `{ y: 100, opacity: 0 }` - slides back down on hide |
Transition config:
```typescript
transition={{ type: "spring", damping: 20, stiffness: 300 }}
```
Spring parameters produce Material Design-like feel: smooth entrance, slight bounce settle.
### 5. Interaction Details
- **Click**: Calls existing `handleSave()` function
- **Loading state**: Button shows Spinner when `saving === true`
- **Hover**: Inherits Button component's primary color hover behavior
- **z-index**: `z-50` (above page content, below modals)
- **Prevent duplicate click**: Button disabled when `saving === true`
## Technical Implementation
### Code Location
File: `vite-frontend/src/pages/config.tsx`
### Required Imports
```typescript
import { AnimatePresence, motion } from "framer-motion";
```
### FAB Component Structure
```tsx
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
```
### Placement
Insert FAB at the end of the component, before the closing `</div>` (after all Cards and Modals).
### Dependencies
- framer-motion: Already installed (v11.18.2)
- Button: Already imported from `@/shadcn-bridge/heroui/button`
- SaveIcon: Already defined in config.tsx
## Behavior Matrix
| State | FAB Visibility | Button Enabled |
|-------|----------------|----------------|
| `hasChanges = false` | Hidden (not rendered) | N/A |
| `hasChanges = true, saving = false` | Visible, animating in | Yes |
| `hasChanges = true, saving = true` | Visible | No (loading) |
| Save success | Hidden (animating out) | N/A |
## Responsive Behavior
No special handling needed. `fixed bottom-6 right-6` works identically on:
- Desktop browsers
- Mobile browsers
- H5/WebView mode
The FAB maintains consistent 24px margin from viewport edges regardless of screen size.
## Edge Cases
1. **Multiple rapid toggles**: AnimatePresence handles gracefully - exit animation completes before new enter animation
2. **Page unload with unsaved changes**: Not addressed in this design (separate concern)
3. **FAB covers existing warning banner**: z-50 places FAB above the warning banner at line 1004-1013
## Testing Checklist
After implementation, verify:
- [ ] FAB appears when any config field is modified
- [ ] FAB slides up from bottom on appearance
- [ ] FAB slides down to bottom on disappearance
- [ ] FAB fixed position during page scroll
- [ ] FAB triggers save on click
- [ ] FAB shows spinner during save
- [ ] FAB disappears after successful save
- [ ] FAB works on mobile viewport
- [ ] FAB does not interfere with Modal dialogs
@@ -0,0 +1,274 @@
# GitHub 加速地址自定义配置设计
**日期**: 2026-04-01
**状态**: 待审核
**作者**: AI Assistant
## 概述
允许用户在面板设置中自定义 GitHub 加速地址,支持开启/关闭加速功能。配置后,面板更新节点、生成安装命令以及安装脚本都使用配置的加速地址。
## 背景
当前 `gcode.hostcentral.cc` 硬编码在多个位置:
- `go-backend/internal/http/handler/upgrade.go` - 节点升级下载 URL
- `go-backend/internal/http/handler/mutations.go` - 节点安装命令生成
- `install.sh` - 节点安装脚本
- `panel_install.sh` - 面板安装脚本
用户无法自定义加速地址或关闭加速功能。
## 目标
1. 面板设置中支持配置加速开关和加速地址
2. 配置影响全部下载场景(面板端 + 安装脚本)
3. 安装脚本支持交互式询问加速配置
4. 面板生成的安装命令自动嵌入加速配置
## 影响范围
### 后端
- `go-backend/internal/http/handler/upgrade.go`
- `go-backend/internal/http/handler/mutations.go`
### 前端
- `vite-frontend/src/pages/config.tsx`
- `vite-frontend/src/config/site.ts`(缓存配置键)
### 安装脚本
- `install.sh`
- `panel_install.sh`
## 详细设计
### 1. 数据存储
使用现有 `vite_config` 表存储两个配置项:
| name | value | 说明 |
|------|-------|------|
| `github_proxy_enabled` | `"true"` / `"false"` | 是否开启加速,默认 `"true"` |
| `github_proxy_url` | URL 字符串 | 加速地址,默认 `"https://gcode.hostcentral.cc"` |
### 2. 后端 Handler 修改
#### upgrade.go
移除硬编码常量,新增辅助函数:
```go
// getGithubProxyConfig 获取 GitHub 加速配置
// 返回: (是否开启, 加速地址)
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = true // 默认开启
proxyURL = "https://gcode.hostcentral.cc" // 默认地址
if h == nil || h.repo == nil {
return
}
// 读取开启状态
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
// 读取加速地址
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
// 确保 URL 格式正确
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
// buildDownloadURL 构建下载地址
func (h *Handler) buildDownloadURL(version, arch string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("https://github.com/%s/releases/download/%s/gost-%s", githubRepo, version, arch)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
```
修改 `nodeUpgrade` 和 `nodeBatchUpgrade` 使用动态配置。
#### mutations.go
修改 `getNodeInstallCmd` 函数(约第 440-456 行):
```go
func (h *Handler) getNodeInstallCmd(w http.ResponseWriter, r *http.Request) {
// ... 现有逻辑 ...
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf(
"curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret,
)
} else {
cmd = fmt.Sprintf(
"curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret,
)
}
response.WriteJSON(w, response.OK(cmd))
}
```
### 3. 前端修改
#### config.tsx
在 `CONFIG_ITEMS` 数组中添加配置项(约第 87-158 行之后):
```typescript
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
```
在 `getInitialConfigs` 函数的 `configKeys` 数组中添加缓存键:
```typescript
"github_proxy_enabled",
"github_proxy_url",
```
### 4. 安装脚本修改
#### install.sh
在脚本开头添加配置变量和环境变量读取:
```bash
# 镜像加速配置(可由面板传入)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
```
修改 `maybe_proxy_url` 函数:
```bash
# 镜像加速
maybe_proxy_url() {
local url="$1"
# 如果明确关闭加速
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
# 默认开启加速
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
# 处理 URL 格式
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}" # 移除末尾斜杠
else
proxy="https://${proxy}"
fi
echo "${proxy}/${url}"
}
```
在 `install_flux_agent` 函数开头添加交互式询问:
```bash
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
# 询问加速配置(如果未由面板传入)
if [[ -z "$PROXY_ENABLED" ]]; then
echo ""
read -p "是否开启 GitHub 加速? (Y/n): " proxy_choice
case "$proxy_choice" in
n|N) PROXY_ENABLED="false" ;;
*)
PROXY_ENABLED="true"
read -p "加速地址 (默认 gcode.hostcentral.cc): " input_url
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
;;
esac
fi
# ... 现有安装逻辑 ...
}
```
#### panel_install.sh
类似修改,在 `install_panel` 函数开头添加询问逻辑。
### 5. 配置缓存
#### site.ts
在配置缓存键列表中添加新键(如果需要前端缓存加速配置)。
## 默认行为
- `github_proxy_enabled`: 默认 `"true"`(开启加速)
- `github_proxy_url`: 默认 `"https://gcode.hostcentral.cc"`
## 测试要点
1. **后端 API 测试**:
- 未配置时使用默认值
- 配置后正确读取并应用
- 关闭加速后直连 GitHub
2. **前端 UI 测试**:
- Switch 开关正确切换
- 关闭加速时隐藏地址输入框
- 保存配置后正确持久化
3. **安装脚本测试**:
- 交互式询问正常工作
- 环境变量传入时跳过询问
- 加速关闭时直连 GitHub
4. **集成测试**:
- 面板生成安装命令正确包含加速配置
- 节点升级下载使用配置的加速地址
## 风险与缓解
| 风险 | 缓解措施 |
|------|----------|
| 用户输入无效加速地址 | 后端验证 URL 格式,前端添加格式提示 |
| 旧版本安装脚本不兼容 | 保持 `maybe_proxy_url` 函数签名不变,仅修改内部逻辑 |
| 配置缺失时行为不一致 | 在 `getGithubProxyConfig` 中提供合理的默认值 |
## 任务清单
- [ ] 后端:upgrade.go 修改
- [ ] 后端:mutations.go 修改
- [ ] 前端:config.tsx 添加配置项
- [ ] 脚本:install.sh 修改
- [ ] 脚本:panel_install.sh 修改
- [ ] 测试:验证功能正常
@@ -0,0 +1,49 @@
# FLVX 商业版白标授权功能设计方案
## 1. 目标
通过在设置面板中引入商业版激活码(License Key),允许已授权的用户去除前端页面的 FLVX 品牌标识,并使用自己的 App Name、Logo、Favicon 和隐藏版权信息,从而实现“白标”定制。
## 2. 功能范围
* **授权校验(服务端)**:提供一个激活码输入与验证的接口。初始版本采用**在线 Mock 验证**,后续可通过替换验证服务器地址实现真实的在线发卡与吊销逻辑。
* **配置存储(服务端)**:一旦授权成功,在数据库(如 `vite_config` 或现有的配置表)中记录授权状态(例如 `license_key`、`is_commercial` 等),并放开商业白标相关字段的写入权限(`app_name`, `app_logo`, `app_favicon`, `hide_footer_brand`)。
* **权限拦截(服务端)**:拦截未授权用户的请求,禁止他们更新相关的品牌字段。
* **前端 UI(客户端)**:
* 在配置页面(或单独的“授权/个性化” Tab)提供激活码输入框。
* 如果未激活:界面仅展示默认品牌配置,并提示“需要商业授权以解锁自定义品牌”。
* 如果已激活:展示站名、Logo、Favicon 的上传和替换表单,提供隐藏“Powered by FLVX”脚标的开关。
## 3. 架构设计
### 3.1 数据库/配置结构
扩展配置系统中的以下字段:
* `license_key` (String):存储用户激活的商业版密钥。
* `is_commercial` (String/Boolean):标识是否为合法的商业授权状态("true" 或 "false")。
* `hide_footer_brand` (String/Boolean):是否隐藏底部的 FLVX 信息。
注意:现有的 `app_name`, `app_logo`, `app_favicon` 字段将收紧修改权限。
### 3.2 服务端 API 变更
* **新增 API `POST /api/license/activate` (或将逻辑集成到现有配置修改接口)**:
* 接收 `{ "license_key": "FLVX-xxxx" }`。
* **Mock 逻辑**:如果是 `FLVX-` 开头则视为合法。
* 合法则更新系统配置,设置 `license_key` 并将状态标为 `is_commercial: "true"`。
* **修改 API 权限校验(如保存系统设置的接口)**:
* 当接收到更新 `app_name`、`app_logo`、`app_favicon`、`hide_footer_brand` 的请求时,检查当前系统中的 `is_commercial` 状态。
* 如果未授权且尝试修改白标字段,返回错误(如 `403 Forbidden`)提示需要商业授权。
### 3.3 前端设计
* **授权卡片**:在全局设置(Settings / Config)页加入「商业版授权」或「个性化」区块。
* **表单按需显示**:使用配置中的 `is_commercial === "true"` 来控制相关表单组件的展示:
* 如果未授权,白标字段(Logo、Favicon、App Name、Hide Footer)不可修改(呈 Disabled)或覆盖了一层“锁”图标。
* 底部 Footer 组件读取 `hide_footer_brand === "true"` 决定是否渲染 `Powered by FLVX`。
* **全局状态同步**:当用户激活或上传完 Logo 后,通过现有的 `syncLogo` / `syncFavicon` 等机制全局刷新外观。
## 4. 安全与降级
* **本地缓存失效**:如果后台在线验证服务器(未来)判断该 key 被吊销,可以在后续获取 config 的接口中重置白标配置为空,强制回退到默认 FLVX 主题。
* **接口防绕过**:所有跟商业字段相关的变更,必须经过后端 API 的鉴权,确保纯前端绕过是无效的。
## 5. 测试策略
1. **输入非法激活码**,提示错误,白标设置项仍被锁定。
2. **输入合法激活码 (`FLVX-...`)**,提示成功,白标设置项解锁。
3. **成功后上传 Logo 和修改站名**,刷新页面,前端应正常应用新配置且没有 FLVX 标记。
4. **接口测试**:在未授权状态下,尝试强行通过 API 更新 `app_logo`,接口应返回权限不足。
@@ -0,0 +1,59 @@
# FLVX 商业版 Keygen.sh 授权集成设计方案
## 1. 目标
使用 [Keygen.sh](https://keygen.sh/) 替换当前 FLVX 中基于 Mock 的商业版授权验证逻辑。通过接入 Keygen.sh,实现安全、可控的许可证分发、设备绑定(防止一码多用)、定期验证以及远程吊销功能,为 FLVX 的商业化白标功能提供生产级支持。
## 2. Keygen.sh 核心概念映射
* **Account (账户)**:您在 Keygen 注册的商户账号。
* **Product (产品)**:在 Keygen 中创建一个名为 `FLVX Panel` 的产品。
* **Policy (策略)**:定义授权规则。例如,创建一个 `White-Label Policy`,限制每个 License 只能绑定 **1 个 Machine**(即一个 FLVX 面板实例),并可配置有效期(如按年订阅或永久有效)。
* **License (许可证)**:发给客户的激活码(Key),格式可自定义(如 `FLVX-XXXX-XXXX`)。
* **Machine (机器/设备)**:运行 FLVX 的具体服务器或面板实例。为了防止一码多开,FLVX 激活时需要向 Keygen 注册一台 Machine。
## 3. 架构设计与集成流程
### 3.1 唯一设备标识 (Machine Fingerprint)
为了在 Keygen 中标识不同的 FLVX 面板,FLVX 后端需要生成并持久化一个唯一的机器指纹(Fingerprint)。
* **生成时机**:FLVX 首次启动或首次激活时,生成一个 UUID v4。
* **存储**:保存在数据库 `vite_config` 表中,键名为 `machine_fingerprint`。
### 3.2 激活流程 (License Activation)
当用户在前端输入激活码并点击“激活”时:
1. **FLVX 后端验证 Key**:调用 Keygen API `POST /v1/accounts/{account}/licenses/actions/validate-key`,传入 `key`。
2. **检查 License 状态**:如果返回 `valid: true`,说明 License 合法且未过期。
3. **激活 Machine (设备绑定)**:
* 调用 Keygen API `POST /v1/accounts/{account}/machines`。
* 关联刚才验证的 `licenseId`,并传入 FLVX 的 `machine_fingerprint`。
* *异常处理*:如果该 License 已绑定了其他 Machine(达到 Policy 上限),Keygen 会报错,FLVX 后端需返回“该授权码已在其他设备使用”。
4. **持久化状态**:激活成功后,在本地数据库保存 `license_key`、`is_commercial: "true"`,以及从 Keygen 返回的额外信息(如过期时间 `license_expiry`)。
### 3.3 定期心跳与验证 (Periodic Validation)
为了防止用户激活后断网或通过修改数据库绕过,以及实现**远程吊销**:
* **定时任务**:FLVX 后端增加一个后台协程(如每天运行一次,或每 12 小时运行一次)。
* **验证逻辑**:调用 Keygen API 验证当前的 `license_key` 和 `machine_fingerprint`。
* **吊销/过期处理**:如果 Keygen 明确返回 License 已吊销(Suspended/Revoked/Banned)或已过期,或者当前 Machine 不再属于该 License,FLVX 后端需将 `is_commercial` 强制设为 `"false"`,并清空本地缓存,恢复官方品牌展示。
* **宽限期 (Grace Period)**:考虑到用户服务器可能偶尔网络不通,如果请求 Keygen 超时或失败,不应立刻吊销。可设置一个宽限期(如连续 3 天请求失败才降级)。
## 4. 后端 API 改造计划 (`go-backend`)
### 4.1 新增环境变量/配置
* `KEYGEN_ACCOUNT_ID`: 您的 Keygen 账户 ID(打包时可硬编码,或作为全局环境变量)。
* (可选)`KEYGEN_PRODUCT_TOKEN` 或仅使用 License Key 进行验证(取决于 Keygen 验证方式的选择,推荐直接使用 License Key 进行无状态验证)。
### 4.2 改造 `/api/v1/license/activate`
* 引入 HTTP 客户端向 `api.keygen.sh` 发起请求。
* 实现上述提到的 Validate Key 和 Activate Machine 两步走逻辑。
* 返回具体的错误信息给前端(例如:“授权码不存在”、“授权码已过期”、“激活设备数达上限”)。
## 5. 前端改造计划 (`vite-frontend`)
前端在目前的 UI 基础上几乎不需要大改,只需配合后端的增强:
1. **展示过期时间**:如果后端返回了 `license_expiry`,可以在“商业版授权”卡片中展示“授权有效期至:YYYY-MM-DD”。
2. **错误提示优化**:透传后端返回的 Keygen 验证错误,给予用户明确的指引。
3. **解绑/停用功能(可选)**:未来可增加“停用授权”按钮,调用后端接口在 Keygen 中删除 Machine 绑定,以便用户将 License 迁移到新的服务器。
## 6. 实施步骤建议
1. 在 Keygen.sh 注册账号,创建 Product 和 Policy,生成测试用的 License Key。
2. 在 FLVX 的 `go-backend` 中新建一个 `pkg/keygen` 或 `internal/license` 包,封装 Keygen API 的调用(Validate, Activate Machine)。
3. 修改现有的 `licenseActivate` 接口,接入真正的验证逻辑。
4. 添加定期验证的 Cron Job。
5. 测试激活、吊销、过期、断网等各种场景。
@@ -0,0 +1,36 @@
# Announcement Popup Notification Design
## Overview
This feature implements a popup notification modal for important dashboard announcements to ensure users see them immediately, addressing GitHub Issue #169.
## Requirements
1. Automatic display of a popup modal when opening the dashboard page if a new/updated announcement exists.
2. Includes a "Don't show again" option to remember the user's choice to dismiss it.
3. Smart triggering: Only pops up for *new* or *updated* announcements.
4. Support Markdown formatting for the announcement content.
5. Retain the existing permanent top banner as a fallback.
## Backend Changes (Go)
The `/api/v1/announcement/get` API currently only returns `content` and `enabled`. It must be updated to return the timestamp of the last update to enable the frontend to detect changes.
1. **Repository (`internal/store/repo/repository.go`)**: Ensure `GetAnnouncement` retrieves `UpdatedTime` (or falls back to `CreatedTime`).
2. **Handler (`internal/http/handler/handler.go`)**: Modify `getAnnouncement` to include an `update_time` (int64) field in its JSON response.
## Frontend Changes (Vite/React/Tailwind)
1. **API Interface (`src/api/index.ts`)**:
* Update `AnnouncementData` to include `update_time: number`.
2. **Storage Mechanism**:
* Use browser `localStorage` to persist the user's view state. Key: `flvx_announcement_seen_time`.
3. **UI Component (`AnnouncementModal`)**:
* Create a new modal component for the dashboard.
* The modal content will render the markdown of the announcement.
* It will feature two primary actions:
* **"Close"**: Closes the modal temporarily for this session (does NOT update `localStorage`). It will pop up again on the next page load.
* **"Don't show again"**: Closes the modal AND sets `localStorage.setItem('flvx_announcement_seen_time', announcement.update_time)`.
4. **Integration (`src/pages/dashboard.tsx` & `use-dashboard-data.ts`)**:
* Add state to manage the modal visibility (e.g., `isAnnouncementModalOpen`).
* On data load, compare the fetched `update_time` with the stored `flvx_announcement_seen_time`. If the fetched time is greater (or if no stored time exists), set `isAnnouncementModalOpen(true)`.
## Error Handling and Edge Cases
* If `localStorage` is unavailable or throws an error (e.g., Private Browsing mode restrictions), the modal may show repeatedly. The code should safely catch `localStorage` access errors.
* If `update_time` is missing from an old database record, the backend should gracefully fall back to the creation time or a safe default (like 0) to ensure the logic doesn't break.
@@ -0,0 +1,60 @@
# Flvx iOS 26 Liquid Glass UI Redesign Spec
## 1. Overview
This document specifies the comprehensive UI/UX redesign of the Flvx frontend using an "Apple iOS 26 Liquid Glass" design language. The goal is to elevate the visual quality of the entire application, making it modern, spatially aware, and highly legible through extensive use of blur, translucency, squircle borders, and semantic contrast.
## 2. Scope
The redesign covers 100% of the frontend routing pages and overlay components under `vite-frontend/src/pages/` and global UI modules:
- Dashboard (`dashboard.tsx`)
- Node Management (`node.tsx`)
- Tunnel & Rule Configurations (`tunnel.tsx`, `forward.tsx`)
- System Monitor (`monitor.tsx`)
- User Management (`user.tsx`)
- Speed Limit Management (`limit.tsx`)
- Group Management (`group.tsx`)
- Panel Sharing (`panel-sharing.tsx`)
- Global Settings & Config (`config.tsx`, `settings.tsx`)
- Profile & Change Password (`profile.tsx`, `change-password.tsx`)
- All related Modals, Drawers, and floating UI (e.g., "Create Node", "Add Rule" forms).
## 3. Design System & Tokens
The new UI replaces traditional solid-color borders and flat surfaces with the following spatial design tokens:
### 3.1. Corner Radii (Squircles)
- **Outer Shell / Viewports**: 32px (`rounded-3xl` equivalent)
- **Cards / Containers**: 24px (`rounded-2xl`)
- **Buttons / Inputs**: 16px (`rounded-xl` or `rounded-full`)
- **Badges / Tags**: 6px or fully rounded.
### 3.2. Backgrounds & Blurs
- **Global Background**: A mesh gradient blending soft pinks and blues (`#ff9a9e`, `#fecfef`, `#a1c4fd`, `#c2e9fb`).
- **Glass Base (Primary Containers)**: `backdrop-blur-3xl` with an ultra-thin white overlay (`rgba(255, 255, 255, 0.6)` or `#ffffff99`).
- **Glass Inner Glow (Borders)**: 1px solid `rgba(255, 255, 255, 0.8)` (`#ffffffcc`).
- **Modals Background Overlay**: 30% black overlay (`#0000004d`).
### 3.3. Semantic Colors
- **Brand / Active / Primary / TCP**: Blue `#007aff`
- **Healthy / Success / Online**: Green `#34c759`
- **Warning / Wait / UDP**: Orange `#ff9500`
- **Danger / Offline / Delete**: Red `#ff3b30`
- **Secondary / Purple / Data**: Purple `#af52de`
- **Text**: Primary (`#1d1d1f`), Secondary (`#86868b`).
### 3.4. Elevation & Shadow
- Soft, highly diffused drop shadows rather than sharp lines: e.g., `box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1)`.
## 4. Implementation Strategy
We will implement the redesign systematically across the React + TailwindCSS + shadcn/HeroUI stack:
1. **CSS Variables / Tailwind Config**: Inject the new Liquid Glass design tokens (colors, extended radiuses, customized backdrop blurs, box shadows) into `tailwind.config.js` and `globals.css`.
2. **Global App Shell**: Update the root layout (`index.tsx` or main `App` layout) to host the dynamic mesh gradient background and the new translucent sidebar.
3. **Component Re-styling**:
- Override HeroUI default card, input, and modal styles using custom `classNames`.
- Update `MetricCard`, `PageEmptyState`, `PageLoadingState`, and other base components to support the `glass_card` spec.
4. **Page-by-Page Integration**: Rewrite the JSX of each page to utilize the new layout structure, ensuring all existing interactive state and API logic is seamlessly preserved.
5. **Modal System Update**: Apply the transparent `#0000004d` overlay and 480px width glassy card style to all global dialogs.
## 5. Success Criteria
- [ ] No regression in business logic; all forms, interactions, and data rendering operate exactly as before.
- [ ] The visual system consistently employs the `glass_bg`, `glass_card`, and corresponding squircle radiuses across 100% of the UI.
- [ ] Modals and Overlays correctly blur the background mesh gradient.
- [ ] All responsive layouts appropriately wrap the card components on smaller displays.
+5 -5
View File
@@ -5,16 +5,17 @@ go 1.24.0
toolchain go1.24.4
require (
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.3
modernc.org/sqlite v1.37.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -28,9 +29,8 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.29.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
)
+127 -6
View File
@@ -19,12 +19,14 @@ import (
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/license"
"go-backend/internal/metrics"
"go-backend/internal/security"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
"github.com/google/uuid"
)
type Handler struct {
repo *repo.Repository
jwtSecret string
@@ -68,6 +70,10 @@ type configSingleRequest struct {
Value string `json:"value"`
}
type licenseActivateRequest struct {
LicenseKey string `json:"license_key"`
}
type changePasswordRequest struct {
NewUsername string `json:"newUsername"`
CurrentPassword string `json:"currentPassword"`
@@ -137,6 +143,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
@@ -788,6 +795,94 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("machine_fingerprint", newFp, now); err != nil {
return "", err
}
return newFp, nil
}
func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req licenseActivateRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
key := strings.TrimSpace(req.LicenseKey)
if key == "" {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
// Needs machine activation
client.Token = key
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
// Translate specific error messages or log them
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
// we can consider the license valid for our simple usecase
} else {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
expiry := valResp.Data.Attributes.Expiry
if expiry == "" {
expiry = "never"
}
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -804,6 +899,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
return
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
protectedKeys := map[string]bool{
"app_name": true,
"app_logo": true,
"app_favicon": true,
"hide_footer_brand": true,
}
now := time.Now().UnixMilli()
for k, v := range payload {
key := strings.TrimSpace(k)
@@ -811,6 +914,11 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
continue
}
if protectedKeys[key] && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
value, err := normalizeAndValidateConfigValue(key, v)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -843,13 +951,19 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
return
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
return
}
value, err := normalizeAndValidateConfigValue(name, req.Value)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if value == "" && name != "app_logo" && name != "app_favicon" {
if value == "" && name != "app_logo" && name != "app_favicon" && name != "app_bg_image" {
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
return
}
@@ -1423,15 +1537,22 @@ func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
if ann == nil {
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": "",
"enabled": 0,
"content": "",
"enabled": 0,
"update_time": 0,
}))
return
}
updateTime := ann.CreatedTime
if ann.UpdatedTime.Valid {
updateTime = ann.UpdatedTime.Int64
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": ann.Content,
"enabled": ann.Enabled,
"content": ann.Content,
"enabled": ann.Enabled,
"update_time": updateTime,
}))
}
+56 -1
View File
@@ -3,6 +3,8 @@ package handler
import (
"context"
"time"
"go-backend/internal/license"
)
func (h *Handler) StartBackgroundJobs() {
@@ -18,7 +20,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(6)
h.jobsWG.Add(7)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -27,6 +29,59 @@ func (h *Handler) StartBackgroundJobs() {
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
go h.runValidateLicenseJob(ctx)
}
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
defer h.jobsWG.Done()
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.validateLicenseJob()
}
}
}
func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
// Network error or timeout. Grace period by not revoking immediately here.
return
}
if !valResp.Meta.Valid {
// License is invalid (e.g., revoked, suspended, expired). Downgrade the system.
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
} else {
now := time.Now().UnixMilli()
expiry := valResp.Data.Attributes.Expiry
if expiry == "" {
expiry = "never"
}
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
}
}
func (h *Handler) StopBackgroundJobs() {
+43 -14
View File
@@ -453,7 +453,16 @@ 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/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
enabled, proxyURL := h.getGithubProxyConfig()
var cmd string
if enabled {
cmd = fmt.Sprintf("curl -L %s/https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=true PROXY_URL=%s VERSION=%s ./install.sh -a %s -s %s",
proxyURL, githubRepo, version, proxyURL, version, processServerAddress(panelAddr), secret)
} else {
cmd = fmt.Sprintf("curl -L https://github.com/%s/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && PROXY_ENABLED=false VERSION=%s ./install.sh -a %s -s %s",
githubRepo, version, version, processServerAddress(panelAddr), secret)
}
response.WriteJSON(w, response.OK(cmd))
}
@@ -769,14 +778,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
ipPreference := asString(req["ipPreference"])
localDomain := h.federationLocalDomain()
tx := h.repo.BeginTx()
if tx.Error != nil {
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
return
}
defer func() { tx.Rollback() }()
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)
runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
@@ -795,6 +797,14 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
}
applyTunnelPortsToRequest(req, runtimeState)
tx := h.repo.BeginTx()
if tx.Error != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
return
}
defer func() { tx.Rollback() }()
if err := h.repo.UpdateTunnelTx(
tx,
id,
@@ -2297,13 +2307,24 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
nd, ndErr := h.getNodeRecord(nid)
if ndErr != nil {
portRangeErr = ndErr
continue
portRangeOk = false
break
}
if validateErr := validateRemoteNodePort(nd, p); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
if validateErr := validateLocalNodePort(nd, p); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
if validateErr := h.validateForwardPortAvailability(nd, p, id); validateErr != nil {
portRangeOk = false
portRangeErr = validateErr
break
}
}
if !portRangeOk {
fail++
@@ -2723,7 +2744,11 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
if !isRemote {
var err error
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
if excludeTunnelID > 0 {
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
} else {
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
}
if err != nil {
return nil, err
}
@@ -2758,7 +2783,11 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
if !isRemote {
var err error
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
if excludeTunnelID > 0 {
port, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
} else {
port, err = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, excludeTunnelID)
}
if err != nil {
return nil, err
}
@@ -3632,7 +3661,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
port := asInt(n["port"], 0)
if port <= 0 {
var pickErr error
port, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
if pickErr != nil {
return pickErr
}
@@ -3662,7 +3691,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
port := asInt(n["port"], 0)
if port <= 0 {
var pickErr error
port, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)
port, pickErr = h.repo.PickRandomNodePortTx(tx, nodeID, allocated, 0)
if pickErr != nil {
return pickErr
}
+40 -17
View File
@@ -15,7 +15,6 @@ import (
const (
githubRepo = "Sagit-chu/flvx"
githubProxy = "https://gcode.hostcentral.cc"
githubAPIBase = "https://api.github.com"
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
@@ -23,6 +22,9 @@ const (
releaseChannelStable = "stable"
releaseChannelDev = "dev"
defaultGithubProxyEnabled = true
defaultGithubProxyURL = "https://gcode.hostcentral.cc"
)
var (
@@ -70,6 +72,39 @@ func releaseChannelLabel(channel string) string {
return "正式版"
}
func (h *Handler) getGithubProxyConfig() (enabled bool, proxyURL string) {
enabled = defaultGithubProxyEnabled
proxyURL = defaultGithubProxyURL
if h == nil || h.repo == nil {
return
}
if enabledCfg, err := h.repo.GetConfigByName("github_proxy_enabled"); err == nil && enabledCfg != nil {
enabled = enabledCfg.Value != "false"
}
if urlCfg, err := h.repo.GetConfigByName("github_proxy_url"); err == nil && urlCfg != nil && urlCfg.Value != "" {
proxyURL = strings.TrimSpace(urlCfg.Value)
if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") {
proxyURL = "https://" + proxyURL
}
proxyURL = strings.TrimSuffix(proxyURL, "/")
}
return
}
func (h *Handler) buildGithubDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", githubHTMLBase, githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
@@ -149,14 +184,8 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
}
}
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
"downloadUrl": downloadURL,
@@ -213,14 +242,8 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
}
}
downloadURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
githubHTMLBase, githubRepo, version,
)
checksumURL := fmt.Sprintf(
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
githubHTMLBase, githubRepo, version,
)
downloadURL := h.buildGithubDownloadURL(version, "gost-{ARCH}")
checksumURL := h.buildGithubDownloadURL(version, "gost-{ARCH}.sha256")
type upgradeResult struct {
ID int64 `json:"id"`
+187
View File
@@ -0,0 +1,187 @@
package license
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type KeygenClient struct {
AccountID string
Token string
HTTPClient *http.Client
}
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
Code string `json:"code"`
} `json:"meta"`
Data struct {
ID string `json:"id"`
Attributes struct {
Expiry string `json:"expiry"`
} `json:"attributes"`
} `json:"data"`
}
type ActivateMachineRequest struct {
Data struct {
Type string `json:"type"`
Attributes struct {
Fingerprint string `json:"fingerprint"`
} `json:"attributes"`
Relationships struct {
License struct {
Data struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"data"`
} `json:"license"`
} `json:"relationships"`
} `json:"data"`
}
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
meta := map[string]interface{}{
"key": key,
}
if fingerprint != "" {
meta["scope"] = map[string]interface{}{
"fingerprint": fingerprint,
}
}
reqBody := map[string]interface{}{
"meta": meta,
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
reqBody := map[string]interface{}{
"meta": map[string]string{
"key": key,
},
}
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
if err := json.NewDecoder(resp.Body).Decode(&valResp); err != nil {
return nil, err
}
return &valResp, nil
}
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
reqBody.Data.Attributes.Fingerprint = fingerprint
reqBody.Data.Relationships.License.Data.Type = "licenses"
reqBody.Data.Relationships.License.Data.ID = licenseID
bodyBytes, _ := json.Marshal(reqBody)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
// Machine already registered to this license or limit reached because it's already us.
// The subsequent ValidateKey check will determine if the existing machine is actually us.
return nil
}
}
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
@@ -1,9 +1,11 @@
package repo
import (
"crypto/rand"
"database/sql"
"errors"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
@@ -453,6 +455,14 @@ func (r *Repository) IsRemoteNodeTx(tx *gorm.DB, nodeID int64) (bool, error) {
}
func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
return r.pickNodePortTx(tx, nodeID, allocated, excludeTunnelID, false)
}
func (r *Repository) PickRandomNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
return r.pickNodePortTx(tx, nodeID, allocated, excludeTunnelID, true)
}
func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int64]int, excludeTunnelID int64, randomPick bool) (int, error) {
if tx == nil {
return 0, errors.New("database unavailable")
}
@@ -505,6 +515,7 @@ func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
}
}
var available []int
for _, candidate := range candidates {
if candidate <= 0 {
continue
@@ -512,11 +523,25 @@ func (r *Repository) PickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
if _, ok := used[candidate]; ok {
continue
}
allocated[nodeID] = candidate
return candidate, nil
available = append(available, candidate)
}
return 0, errors.New("节点端口已满,无可用端口")
if len(available) == 0 {
return 0, errors.New("节点端口已满,无可用端口")
}
if !randomPick {
allocated[nodeID] = available[0]
return available[0], nil
}
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
if err != nil {
allocated[nodeID] = available[0]
return available[0], nil
}
port := available[idx.Int64()]
allocated[nodeID] = port
return port, nil
}
func (r *Repository) GetTunnelIPPreference(tunnelID int64) string {
@@ -244,6 +244,21 @@ func TestForwardCreateAllowedWhenBelowUserNumLimit(t *testing.T) {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES('num-ok-entry', 'num-ok-secret', '10.50.0.1', '10.50.0.1', '', '10000-10010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "num-ok-entry")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 10001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.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, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
@@ -302,6 +317,21 @@ func TestForwardCreateAllowedWhenNumZero(t *testing.T) {
t.Fatalf("insert tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES('num-zero-entry', 'num-zero-secret', '10.60.0.1', '10.60.0.1', '', '11000-11010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "num-zero-entry")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 11001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.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, ?, ?, NULL, 0, 99999, 0, 0, 1, 2727251700000, 1)
@@ -343,4 +373,4 @@ func TestForwardCreateAllowedWhenNumZero(t *testing.T) {
if out.Code != 0 {
t.Fatalf("expected success (code=0) when num=0 (unlimited), got code=%d msg=%q", out.Code, out.Msg)
}
}
}
@@ -50,21 +50,18 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure when nodes are offline")
}
if !strings.Contains(out.Msg, "节点") {
t.Fatalf("expected node-related error, got %q", out.Msg)
if out.Code != 0 {
t.Fatalf("expected create success (runtime deferred when nodes offline), got code=%d msg=%q", out.Code, out.Msg)
}
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
if tunnelCount != 0 {
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
if tunnelCount != 1 {
t.Fatalf("expected tunnel record preserved (runtime deferred), found %d records", tunnelCount)
}
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
if chainCount != 0 {
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
if chainCount != 3 {
t.Fatalf("expected 3 chain_tunnel records preserved (in/chain/out), found %d records", chainCount)
}
}
+69 -5
View File
@@ -25,10 +25,62 @@ get_architecture() {
# 安装目录
INSTALL_DIR="/etc/flux_agent"
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy%/}"
fi
echo "${proxy}/${url}"
}
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
if [[ -n "$PROXY_URL" ]]; then
PROXY_ENABLED="true"
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
if ! read -r -p "是否开启 GitHub 加速? (Y/n): " proxy_choice; then
proxy_choice=""
fi
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
if ! read -r -p "加速地址 (默认 gcode.hostcentral.cc): " input_url; then
input_url=""
fi
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
resolve_latest_release_tag() {
@@ -81,9 +133,14 @@ build_download_url() {
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
}
# 解析版本并构建下载地址
RESOLVED_VERSION=$(resolve_version) || exit 1
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
ensure_download_url_initialized() {
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
return 0
fi
RESOLVED_VERSION=$(resolve_version) || return 1
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
}
@@ -210,6 +267,10 @@ done
# 安装功能
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
ask_proxy_config
ensure_download_url_initialized || exit 1
get_config_params
# 检查并安装 tcpkill
@@ -308,6 +369,9 @@ update_flux_agent() {
echo "❌ flux_agent 未安装,请先选择安装。"
return 1
fi
ask_proxy_config
ensure_download_url_initialized || return 1
echo "📥 使用下载地址: $DOWNLOAD_URL"
+71 -5
View File
@@ -13,10 +13,62 @@ REPO="Sagit-chu/flux-panel"
# 固定版本号(Release 构建时自动填充,留空则获取最新版)
PINNED_VERSION=""
# 镜像加速(所有下载均经过镜像源,以支持 IPv6)
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
# 镜像加速
maybe_proxy_url() {
local url="$1"
echo "https://gcode.hostcentral.cc/${url}"
if [[ "$PROXY_ENABLED" == "false" ]]; then
echo "$url"
return
fi
local proxy="${PROXY_URL:-gcode.hostcentral.cc}"
if [[ "$proxy" == https://* || "$proxy" == http://* ]]; then
proxy="${proxy%/}"
else
proxy="https://${proxy%/}"
fi
echo "${proxy}/${url}"
}
ask_proxy_config() {
if [[ -n "$PROXY_ENABLED" ]]; then
return
fi
if [[ -n "$PROXY_URL" ]]; then
PROXY_ENABLED="true"
return
fi
echo ""
echo "==============================================="
echo " GitHub 加速配置"
echo "==============================================="
if ! read -r -p "是否开启 GitHub 加速? (Y/n): " proxy_choice; then
proxy_choice=""
fi
case "$proxy_choice" in
n|N)
PROXY_ENABLED="false"
echo "已关闭加速,将直连 GitHub"
;;
*)
PROXY_ENABLED="true"
if ! read -r -p "加速地址 (默认 gcode.hostcentral.cc): " input_url; then
input_url=""
fi
PROXY_URL="${input_url:-gcode.hostcentral.cc}"
echo "已开启加速: $PROXY_URL"
;;
esac
echo "==============================================="
}
resolve_latest_release_tag() {
@@ -70,9 +122,14 @@ set_compose_urls_by_version() {
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
}
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
RESOLVED_VERSION=$(resolve_version) || exit 1
set_compose_urls_by_version "$RESOLVED_VERSION"
ensure_compose_urls_initialized() {
if [[ -n "${DOCKER_COMPOSEV4_URL:-}" && -n "${DOCKER_COMPOSEV6_URL:-}" ]]; then
return 0
fi
RESOLVED_VERSION=$(resolve_version) || return 1
set_compose_urls_by_version "$RESOLVED_VERSION"
}
@@ -374,6 +431,10 @@ get_config_params() {
# 安装功能
install_panel() {
echo "🚀 开始安装面板..."
ask_proxy_config
ensure_compose_urls_initialized || return 1
check_docker
get_config_params
@@ -425,6 +486,7 @@ EOF
# 更新功能
update_panel() {
echo "🔄 开始更新面板..."
ask_proxy_config
check_docker
if [[ ! -f ".env" ]]; then
@@ -506,6 +568,8 @@ migrate_to_postgres() {
if [[ ! -f "docker-compose.yml" ]]; then
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..."
ask_proxy_config
ensure_compose_urls_initialized || return 1
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
@@ -581,6 +645,8 @@ uninstall_panel() {
if [[ ! -f "docker-compose.yml" ]]; then
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载以完成卸载..."
ask_proxy_config
ensure_compose_urls_initialized || return 1
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
+314
View File
@@ -0,0 +1,314 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
fail() {
echo "FAIL: $1" >&2
exit 1
}
assert_equals() {
local expected="$1"
local actual="$2"
local message="$3"
if [[ "$actual" != "$expected" ]]; then
fail "$message (expected: $expected, actual: $actual)"
fi
}
load_script_without_main() {
local script_path="$1"
local temp_file
temp_file=$(mktemp)
sed '/^# 执行主函数$/,$d' "$script_path" > "$temp_file"
VERSION="v-test"
source "$temp_file"
rm -f "$temp_file"
}
test_install_script_respects_disabled_proxy() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED="false"
PROXY_URL=""
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://github.com/example/release" \
"$actual" \
"install.sh should bypass the proxy when disabled"
)
test_install_script_asks_for_proxy_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED=""
PROXY_URL=""
ask_proxy_config >/dev/null < <(printf '\nmirror.example.com/\n')
assert_equals "true" "$PROXY_ENABLED" "install.sh should enable proxy by default"
assert_equals "mirror.example.com/" "$PROXY_URL" "install.sh should keep the entered proxy URL"
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://mirror.example.com/https://github.com/example/release" \
"$actual" \
"install.sh should normalize the entered proxy URL"
)
test_install_script_recomputes_download_url_after_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED=""
PROXY_URL=""
DOWNLOAD_URL=""
ask_proxy_config >/dev/null < <(printf 'n\n')
ensure_download_url_initialized
local expected
expected=$(build_download_url)
assert_equals \
"$expected" \
"$DOWNLOAD_URL" \
"install.sh should build the final download URL after the interactive proxy choice"
)
test_update_flux_agent_asks_for_proxy_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -d)
cat > "$INSTALL_DIR/flux_agent" <<'EOF'
#!/bin/bash
echo "old version"
EOF
chmod +x "$INSTALL_DIR/flux_agent"
local ask_called="0"
ask_proxy_config() {
ask_called="1"
PROXY_ENABLED="false"
DOWNLOAD_URL=""
}
check_and_install_tcpkill() { :; }
systemctl() {
return 0
}
curl() {
local output=""
while [[ $# -gt 0 ]]; do
if [[ "$1" == "-o" ]]; then
output="$2"
shift 2
continue
fi
shift
done
cat > "$output" <<'EOF'
#!/bin/bash
echo "new version"
EOF
chmod +x "$output"
}
update_flux_agent >/dev/null
assert_equals "1" "$ask_called" "update_flux_agent should ask for proxy config before downloading"
assert_equals "$(build_download_url)" "$DOWNLOAD_URL" "update_flux_agent should honor the prompted proxy choice"
)
test_update_flux_agent_skips_proxy_prompt_when_not_installed() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
INSTALL_DIR=$(mktemp -u)
local ask_called="0"
ask_proxy_config() {
ask_called="1"
}
local rc="0"
update_flux_agent >/dev/null || rc="$?"
assert_equals "1" "$rc" "update_flux_agent should fail when the agent is not installed"
assert_equals "0" "$ask_called" "update_flux_agent should not prompt for proxy config when the agent is missing"
)
test_install_script_accepts_proxy_url_env_without_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
PROXY_ENABLED=""
PROXY_URL="mirror.example.com"
ask_proxy_config >/dev/null < <(printf '\n\n')
assert_equals "true" "$PROXY_ENABLED" "install.sh should treat PROXY_URL as enabling the proxy"
assert_equals "mirror.example.com" "$PROXY_URL" "install.sh should preserve PROXY_URL when provided via env"
)
test_panel_install_script_can_disable_proxy() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED=""
PROXY_URL=""
ask_proxy_config >/dev/null < <(printf 'n\n')
assert_equals "false" "$PROXY_ENABLED" "panel_install.sh should allow disabling proxy"
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://github.com/example/release" \
"$actual" \
"panel_install.sh should bypass the proxy after disabling it"
)
test_panel_install_script_recomputes_compose_urls_after_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED=""
PROXY_URL=""
DOCKER_COMPOSEV4_URL=""
DOCKER_COMPOSEV6_URL=""
ask_proxy_config >/dev/null < <(printf 'n\n')
ensure_compose_urls_initialized
assert_equals \
"https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml" \
"$DOCKER_COMPOSEV4_URL" \
"panel_install.sh should build the compose URL after the interactive proxy choice"
)
test_update_panel_asks_for_proxy_config() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
local ask_called="0"
ask_proxy_config() {
ask_called="1"
PROXY_ENABLED="false"
DOCKER_COMPOSEV4_URL=""
DOCKER_COMPOSEV6_URL=""
}
check_docker() {
DOCKER_CMD="true"
}
get_current_db_type() {
echo "sqlite"
}
resolve_latest_release_tag() {
echo "v-test"
}
upsert_env_var() { :; }
check_ipv6_support() { return 1; }
configure_docker_ipv6() { :; }
docker() { return 0; }
wait_for_backend_healthy() { return 0; }
sleep() { :; }
curl() { :; }
update_panel >/dev/null
assert_equals "1" "$ask_called" "update_panel should ask for proxy config before downloading"
assert_equals \
"https://github.com/${REPO}/releases/download/v-test/docker-compose-v4.yml" \
"$DOCKER_COMPOSEV4_URL" \
"update_panel should honor the prompted proxy choice"
)
test_panel_install_script_accepts_proxy_url_env_without_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED=""
PROXY_URL="mirror.example.com"
ask_proxy_config >/dev/null < <(printf '\n\n')
assert_equals "true" "$PROXY_ENABLED" "panel_install.sh should treat PROXY_URL as enabling the proxy"
assert_equals "mirror.example.com" "$PROXY_URL" "panel_install.sh should preserve PROXY_URL when provided via env"
)
test_panel_install_script_defaults_proxy_on_eof() {
local rc="0"
local output
local temp_script
temp_script=$(mktemp)
sed '/^# 执行主函数$/,$d' "$ROOT_DIR/panel_install.sh" > "$temp_script"
cat >> "$temp_script" <<'EOF'
PROXY_ENABLED=""
PROXY_URL=""
ask_proxy_config >/dev/null < /dev/null
printf '%s\n%s\n' "$PROXY_ENABLED" "$PROXY_URL"
EOF
output=$(bash "$temp_script") || rc="$?"
rm -f "$temp_script"
assert_equals "0" "$rc" "panel_install.sh should not fail when proxy prompt receives EOF"
assert_equals $'true\ngcode.hostcentral.cc' "$output" "panel_install.sh should fall back to the default proxy on EOF"
}
test_panel_install_script_uses_default_proxy() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
PROXY_ENABLED="true"
PROXY_URL=""
local actual
actual=$(maybe_proxy_url "https://github.com/example/release")
assert_equals \
"https://gcode.hostcentral.cc/https://github.com/example/release" \
"$actual" \
"panel_install.sh should keep the default proxy when enabled"
)
test_install_script_respects_disabled_proxy
test_install_script_asks_for_proxy_config
test_install_script_recomputes_download_url_after_prompt
test_update_flux_agent_asks_for_proxy_config
test_update_flux_agent_skips_proxy_prompt_when_not_installed
test_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_can_disable_proxy
test_panel_install_script_recomputes_compose_urls_after_prompt
test_update_panel_asks_for_proxy_config
test_panel_install_script_uses_default_proxy
test_panel_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_defaults_proxy_on_eof
echo "install script proxy tests passed"
+3
View File
@@ -2,6 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<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" />
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -43,12 +43,28 @@ http {
root /usr/share/nginx/html;
index index.html;
# PWA 和 Service Worker 不缓存
location ~* (sw\.js|service-worker\.js|workbox-.*\.js|manifest\.webmanifest)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
expires 30d;
add_header Cache-Control "public, immutable";
}
# HTML 文件不缓存
location ~* \.(?:htm|html)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri $uri/ /index.html;
}
# Vue Router的history模式支持
location / {
try_files $uri $uri/ /index.html;
+1 -1
View File
@@ -52,7 +52,7 @@
"tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.11",
"tw-animate-css": "^1.3.0"
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/compat": "1.2.8",
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

+144 -103
View File
@@ -1,5 +1,6 @@
import { Route, Routes, useNavigate } from "react-router-dom";
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import { AnimatePresence } from "framer-motion";
import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
@@ -45,7 +46,7 @@ const ProtectedRoute = ({
if (!authenticated) {
return (
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
<div className="flex items-center justify-center min-h-screen bg-mesh-gradient">
<div className="text-lg text-gray-700 dark:text-gray-200" />
</div>
);
@@ -77,7 +78,7 @@ const LoginRoute = () => {
if (authenticated) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
<div className="flex items-center justify-center min-h-screen bg-mesh-gradient">
<div className="text-lg text-gray-700 dark:text-gray-200" />
</div>
);
@@ -87,6 +88,44 @@ const LoginRoute = () => {
};
function App() {
const location = useLocation();
// 处理自定义背景图片
useEffect(() => {
const updateBg = () => {
const customBg = siteConfig.app_bg_image;
if (customBg) {
if (customBg === "theme") {
document.documentElement.style.removeProperty("--custom-bg-image");
document.documentElement.style.removeProperty("--custom-bg-color");
document.documentElement.classList.add("has-theme-bg");
document.documentElement.classList.remove("has-custom-bg");
} else if (customBg.startsWith("http") || customBg.startsWith("data:") || customBg.startsWith("/") || customBg.startsWith("blob:")) {
document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`);
document.documentElement.style.setProperty("--custom-bg-color", "transparent");
document.documentElement.classList.add("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg");
} else {
// Assume solid color like "#ffffff", "white", etc.
document.documentElement.style.setProperty("--custom-bg-image", "none");
document.documentElement.style.setProperty("--custom-bg-color", customBg);
document.documentElement.classList.add("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg");
}
} else {
document.documentElement.style.removeProperty("--custom-bg-image");
document.documentElement.style.removeProperty("--custom-bg-color");
document.documentElement.classList.remove("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg");
}
};
updateBg();
window.addEventListener("site-config-updated", updateBg);
return () => {
window.removeEventListener("site-config-updated", updateBg);
};
}, []);
// 立即设置页面标题(使用已从缓存读取的配置)
useEffect(() => {
document.title = siteConfig.name;
@@ -105,106 +144,108 @@ function App() {
}, []);
return (
<Routes>
<Route element={<LoginRoute />} path="/" />
<Route
element={
<ProtectedRoute skipLayout={true}>
<ChangePasswordPage />
</ProtectedRoute>
}
path="/change-password"
/>
<Route
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
path="/dashboard"
/>
<Route
element={
<ProtectedRoute>
<MonitorPage />
</ProtectedRoute>
}
path="/monitor"
/>
<Route
element={
<ProtectedRoute>
<ForwardPage />
</ProtectedRoute>
}
path="/forward"
/>
<Route
element={
<ProtectedRoute>
<TunnelPage />
</ProtectedRoute>
}
path="/tunnel"
/>
<Route
element={
<ProtectedRoute>
<NodePage />
</ProtectedRoute>
}
path="/node"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<UserPage />
</ProtectedRoute>
}
path="/user"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<GroupPage />
</ProtectedRoute>
}
path="/group"
/>
<Route
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
path="/profile"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<LimitPage />
</ProtectedRoute>
}
path="/limit"
/>
<Route
element={
<ProtectedRoute>
<ConfigPage />
</ProtectedRoute>
}
path="/config"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<PanelSharingPage />
</ProtectedRoute>
}
path="/panel-sharing"
/>
<Route element={<SettingsPage />} path="/settings" />
</Routes>
<AnimatePresence mode="wait">
<Routes key={location.pathname} location={location}>
<Route element={<LoginRoute />} path="/" />
<Route
element={
<ProtectedRoute skipLayout={true}>
<ChangePasswordPage />
</ProtectedRoute>
}
path="/change-password"
/>
<Route
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
path="/dashboard"
/>
<Route
element={
<ProtectedRoute>
<MonitorPage />
</ProtectedRoute>
}
path="/monitor"
/>
<Route
element={
<ProtectedRoute>
<ForwardPage />
</ProtectedRoute>
}
path="/forward"
/>
<Route
element={
<ProtectedRoute>
<TunnelPage />
</ProtectedRoute>
}
path="/tunnel"
/>
<Route
element={
<ProtectedRoute>
<NodePage />
</ProtectedRoute>
}
path="/node"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<UserPage />
</ProtectedRoute>
}
path="/user"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<GroupPage />
</ProtectedRoute>
}
path="/group"
/>
<Route
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
path="/profile"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<LimitPage />
</ProtectedRoute>
}
path="/limit"
/>
<Route
element={
<ProtectedRoute>
<ConfigPage />
</ProtectedRoute>
}
path="/config"
/>
<Route
element={
<ProtectedRoute useSimpleLayout={true}>
<PanelSharingPage />
</ProtectedRoute>
}
path="/panel-sharing"
/>
<Route element={<SettingsPage />} path="/settings" />
</Routes>
</AnimatePresence>
);
}
+5 -1
View File
@@ -134,7 +134,7 @@ export const getTunnelList = () =>
export const getTunnelById = (id: number) =>
Network.post<TunnelApiItem>("/tunnel/get", { id });
export const updateTunnel = (data: TunnelMutationPayload) =>
Network.post("/tunnel/update", data);
Network.post("/tunnel/update", data, { timeout: 120_000 });
export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id });
export const previewTunnelDelete = (id: number) =>
@@ -251,6 +251,9 @@ export const updateConfigs = (configMap: Record<string, string>) =>
export const updateConfig = (name: string, value: string) =>
Network.post("/config/update-single", { name, value });
export const activateLicense = (licenseKey: string) =>
Network.post("/license/activate", { license_key: licenseKey });
export const exportBackupData = () => Network.post("/backup/export");
export const importBackupData = (data: BackupImportPayload) =>
Network.post("/backup/import", data);
@@ -407,6 +410,7 @@ export const importBackup = (data: BackupImportPayload) =>
export interface AnnouncementData {
content: string;
enabled: number;
update_time?: number;
}
export const getAnnouncement = () =>
@@ -13,10 +13,10 @@ export const AnimatedPage = ({
className?: string;
}) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
animate={{ y: 0 }}
className={className}
exit={{ opacity: 0, y: -8 }}
initial={{ opacity: 0, y: 16 }}
exit={{ y: -8 }}
initial={{ y: 16 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
@@ -28,9 +28,8 @@ export const AnimatedPage = ({
* Children should use `staggerItem` as their `variants` prop.
*/
export const staggerContainer = {
hidden: { opacity: 0 },
hidden: {},
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.05,
@@ -42,9 +41,8 @@ export const staggerContainer = {
* Individual stagger item variant — fade-in + slide-up.
*/
export const staggerItem = {
hidden: { opacity: 0, y: 12 },
hidden: { y: 12 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
},
@@ -54,7 +54,7 @@ export const ThemeSettings: React.FC = () => {
};
return (
<Card className="border border-gray-200 dark:border-gray-700">
<Card className="shadow-md">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-5">
主题设置
+12 -5
View File
@@ -1,14 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
function Card({ className, style, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"rounded-xl border border-default-200 bg-white text-card-foreground shadow-sm dark:bg-default-50/20",
className,
"relative flex flex-col transition-all duration-300",
"rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]",
className
)}
style={{
...style,
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
}}
data-slot="card"
{...props}
/>
@@ -57,7 +64,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("p-6 pt-0", className)}
className={cn("p-6", className)}
data-slot="card-content"
{...props}
/>
+19 -9
View File
@@ -35,7 +35,7 @@ function DialogOverlay({
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/30 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
data-slot="dialog-overlay"
@@ -57,19 +57,29 @@ 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 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",
"fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] 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",
"flex flex-col transition-all duration-300",
"rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10",
className,
)}
style={{
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
}}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
<div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto">
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</div>
</DialogPrimitive.Content>
</DialogPortal>
);
+13 -11
View File
@@ -94,17 +94,19 @@ export function VersionFooter({
</span>
)}
</p>
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
{siteConfig.hide_footer_brand !== true && (
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
)}
</div>
);
}
+26 -1
View File
@@ -20,6 +20,9 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: "",
app_favicon: "",
app_bg_image: "",
is_commercial: false,
hide_footer_brand: false,
};
}
@@ -27,6 +30,10 @@ const getInitialConfig = () => {
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
const cachedAppFavicon =
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const cachedAppBgImage =
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
if (cachedAppName) {
return {
@@ -36,6 +43,9 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
app_bg_image: cachedAppBgImage,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
}
@@ -46,6 +56,9 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
app_bg_image: cachedAppBgImage,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
};
@@ -115,7 +128,7 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
// 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
// 尝试从缓存获取所有配置
const configKeys = ["app_name", "app_logo", "app_favicon"];
const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"];
const cachedConfigs: Record<string, string> = {};
let hasCachedData = false;
@@ -255,6 +268,10 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
resolvedConfigMap,
"app_favicon",
);
const hasAppBgImage = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_bg_image",
);
const appName = hasAppName
? String(resolvedConfigMap.app_name || "").trim()
@@ -265,6 +282,9 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
const appFavicon = hasAppFavicon
? String(resolvedConfigMap.app_favicon || "").trim()
: (siteConfig.app_favicon || "").trim();
const appBgImage = hasAppBgImage
? String(resolvedConfigMap.app_bg_image || "").trim()
: (siteConfig.app_bg_image || "").trim();
if (appName && appName !== siteConfig.name) {
siteConfig.name = appName;
@@ -272,8 +292,13 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon;
siteConfig.app_bg_image = appBgImage;
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
if (typeof document !== "undefined") {
document.title = siteConfig.name;
window.dispatchEvent(new Event("site-config-updated"));
}
updateDocumentFavicon(siteConfig.app_favicon);
};
+57 -144
View File
@@ -4,12 +4,6 @@ import { toast } from "react-hot-toast";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
} from "@/shadcn-bridge/heroui/dropdown";
import {
Modal,
ModalContent,
@@ -25,7 +19,7 @@ import { getMonitorAccess, updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
import { getAdminFlag, getSessionName } from "@/utils/session";
import { getAdminFlag } from "@/utils/session";
interface MenuItem {
path: string;
@@ -48,14 +42,12 @@ export default function AdminLayout({
}) {
const navigate = useNavigate();
const location = useLocation();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const { isOpen, 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 [isAdmin, setIsAdmin] = useState(() => getAdminFlag());
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
null,
@@ -196,10 +188,8 @@ export default function AdminLayout({
useEffect(() => {
// 获取用户信息
const name = getSessionName() || "Admin";
const adminFlag = getAdminFlag();
setUsername(name);
setIsAdmin(adminFlag);
// Monitor permission is not strictly role-based; non-admin users may be
@@ -368,13 +358,13 @@ export default function AdminLayout({
return (
<div
className={`flex ${isMobile ? "min-h-screen" : "h-screen"} bg-gray-100 dark:bg-black`}
className={`flex ${isMobile ? "min-h-screen p-0" : "h-screen p-6 gap-6"} bg-mesh-gradient overflow-hidden`}
>
{/* 移动端遮罩层 */}
{isMobile && mobileMenuVisible && (
<button
aria-label="关闭菜单"
className="fixed inset-0 backdrop-blur-sm bg-white/50 dark:bg-black/30 z-40"
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40"
type="button"
onClick={hideMobileMenu}
/>
@@ -383,36 +373,33 @@ export default function AdminLayout({
{/* 左侧菜单栏 */}
<aside
className={`
${isMobile ? "fixed" : "relative"}
${isMobile ? "fixed h-screen top-0 left-0 rounded-r-3xl" : "relative h-full rounded-3xl"}
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
${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
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-[260px]"}
bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl
shadow-[0_10px_30px_rgba(0,0,0,0.1)]
border border-white/80 dark:border-white/10
z-50
transition-all duration-300 ease-in-out
flex flex-col
${isMobile ? "h-screen" : "h-full"}
${isMobile ? "top-0 left-0" : ""}
`}
flex flex-col flex-shrink-0 `}
>
{/* Logo 区域 */}
<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">
<BrandLogo size={28} />
<div className="px-6 py-8 flex items-center overflow-hidden whitespace-nowrap box-border">
<div className="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-primary text-white">
<BrandLogo size={24} />
</div>
<div
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-3"}`}
>
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
<h1 className="text-xl font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
{siteConfig.name}
</h1>
</div>
</div>
{/* 菜单导航 */}
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
<ul className="space-y-1">
<nav className="flex-1 px-4 overflow-y-auto overflow-x-hidden [scrollbar-width:none]">
<ul className="space-y-2">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
const isMonitor = item.path === "/monitor";
@@ -422,15 +409,15 @@ export default function AdminLayout({
<li key={item.path}>
<motion.button
className={`
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
w-full flex items-center p-3 rounded-2xl text-left
relative min-h-[48px] overflow-hidden transition-colors
${isMonitorBlocked ? "opacity-60" : ""}
${
isActive
? "text-primary-600 dark:text-primary-300"
? "text-primary dark:text-primary-400 font-semibold"
: isMonitorBlocked
? "text-gray-500 dark:text-gray-400"
: "text-gray-700 dark:text-gray-200"
? "text-gray-500 dark:text-gray-400 font-medium"
: "text-gray-600 dark:text-gray-300 font-medium"
}
`}
aria-disabled={isMonitorBlocked}
@@ -446,7 +433,7 @@ export default function AdminLayout({
>
{isActive && (
<motion.div
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
className="absolute inset-0 rounded-2xl bg-white/60 dark:bg-white/10 backdrop-blur-xl shadow-[0_12px_36px_rgba(0,0,0,0.18)] border border-white dark:border-white/20"
layoutId="sidebar-active"
transition={{
type: "spring",
@@ -457,18 +444,18 @@ export default function AdminLayout({
)}
{!isActive && (
<motion.div
className="absolute inset-0 rounded-lg bg-gray-100 dark:bg-gray-900 opacity-0"
className="absolute inset-0 rounded-2xl bg-white/40 dark:bg-white/5 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">
<div className="flex-shrink-0 w-6 h-6 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"}`}
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-3"}`}
>
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
<span className="relative z-10 whitespace-nowrap">
{item.label}
</span>
</div>
@@ -480,7 +467,7 @@ export default function AdminLayout({
</nav>
{/* 底部版权信息和折叠按钮 */}
<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="px-5 py-6 mt-auto flex-shrink-0 flex flex-col gap-4 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"}`}
>
@@ -496,13 +483,12 @@ export default function AdminLayout({
{!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"
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 mx-auto"
size="sm"
variant="light"
onPress={toggleCollapse}
>
{isCollapsed ? (
// 向右扩展的提示
<svg
className="w-5 h-5"
fill="none"
@@ -517,7 +503,6 @@ export default function AdminLayout({
/>
</svg>
) : (
// 向左收起的提示
<svg
className="w-5 h-5"
fill="none"
@@ -539,106 +524,34 @@ export default function AdminLayout({
{/* 主内容区域 */}
<div
className={`flex flex-col flex-1 ${isMobile ? "min-h-0" : "h-full overflow-hidden"}`}
className={`flex flex-col flex-1 ${isMobile ? "min-h-0" : "h-full overflow-hidden"} relative`}
>
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-md border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 lg:px-6 relative z-10">
<div className="flex items-center gap-4">
{/* 移动端菜单按钮 */}
{isMobile && (
<Button
isIconOnly
className="lg:hidden"
variant="light"
onPress={toggleMobileMenu}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 6h16M4 12h16M4 18h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
)}
</div>
<div className="flex items-center gap-3">
{/* 用户菜单 */}
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button
className="text-sm font-medium text-foreground"
variant="light"
>
{username}
<svg
className="w-4 h-4 ml-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
fillRule="evenodd"
/>
</svg>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单">
<DropdownItem
key="change-password"
startContent={
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
fillRule="evenodd"
/>
</svg>
}
onPress={onOpen}
>
修改密码
</DropdownItem>
<DropdownItem
key="logout"
className="text-danger"
color="danger"
startContent={
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
fillRule="evenodd"
/>
</svg>
}
onPress={handleLogout}
>
退出登录
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</header>
{/* 移动端菜单按钮 (替代Header) */}
{isMobile && (
<Button
isIconOnly
className="absolute top-4 left-4 z-40 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-md shadow-sm border border-white/80 dark:border-white/10"
variant="flat"
onPress={toggleMobileMenu}
>
<svg
className="w-5 h-5 text-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 6h16M4 12h16M4 18h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
)}
{/* 主内容 */}
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
<main className="flex-1 overflow-y-auto [scrollbar-width:none]">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
@@ -662,7 +575,7 @@ export default function AdminLayout({
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={() => {
onOpenChange();
+1 -1
View File
@@ -6,7 +6,7 @@ export default function DefaultLayout({
children: React.ReactNode;
}) {
return (
<div className="relative flex flex-col min-h-screen bg-white dark:bg-black">
<div className="relative flex flex-col min-h-screen bg-mesh-gradient">
<Navbar />
<main className="container mx-auto max-w-7xl px-4 sm:px-6 flex-grow pt-4 sm:pt-16">
{children}
+3 -3
View File
@@ -21,9 +21,9 @@ export default function H5SimpleLayout({
};
return (
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
<div className="flex flex-col min-h-screen bg-mesh-gradient">
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<header className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-xl shadow-sm border-b border-white/80 dark:border-white/10 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="light" onPress={handleBack}>
<BackIcon className="w-5 h-5" />
@@ -38,7 +38,7 @@ export default function H5SimpleLayout({
</header>
{/* 主内容区域 */}
<main className="flex-1 bg-gray-100 dark:bg-black pb-0">{children}</main>
<main className="flex-1 pb-0">{children}</main>
</div>
);
}
+5 -5
View File
@@ -18,7 +18,7 @@ interface TabItem {
export default function H5Layout({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const [isAdmin, setIsAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(() => getAdminFlag());
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
null,
@@ -166,9 +166,9 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
);
return (
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
<div className="flex flex-col min-h-screen bg-mesh-gradient">
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<header className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-xl shadow-sm border-b border-white/80 dark:border-white/10 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2">
<BrandLogo size={20} />
<h1 className="text-sm font-bold text-foreground">
@@ -180,13 +180,13 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
</header>
{/* 主内容区域 */}
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
<main className="flex-1">{children}</main>
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
<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-[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">
<nav className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-2xl border-t border-white/80 dark:border-white/10 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;
const isMonitor = item.path === "/monitor";
+31 -1
View File
@@ -1,12 +1,42 @@
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { registerSW } from "virtual:pwa-register";
import toast from "react-hot-toast";
import App from "./App.tsx";
import { Provider } from "./provider.tsx";
import "@/styles/globals.css";
registerSW({ immediate: true });
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
toast(
(t) => (
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground">发现新版本,是否立即刷新以应用更新?</span>
<div className="flex gap-2 justify-end">
<button
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
onClick={() => {
updateSW(true);
toast.dismiss(t.id);
}}
>
刷新
</button>
<button
className="px-3 py-1.5 text-xs font-medium bg-default-200 text-default-700 hover:bg-default-300 rounded-md transition-colors"
onClick={() => toast.dismiss(t.id)}
>
稍后
</button>
</div>
</div>
),
{ duration: Infinity, position: "bottom-right" }
);
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
+313 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -20,6 +21,7 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import {
updateConfigs,
activateLicense,
exportBackup,
importBackup,
getAnnouncement,
@@ -63,7 +65,7 @@ interface ConfigItem {
label: string;
placeholder?: string;
description?: string;
type: "input" | "switch" | "select";
type: "input" | "switch" | "select" | "bg_image";
options?: { label: string; value: string; description?: string }[];
dependsOn?: string; // 依赖的配置项key
dependsValue?: string; // 依赖的配置项值
@@ -84,6 +86,12 @@ const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
// 网站配置项定义
const CONFIG_ITEMS: ConfigItem[] = [
{
key: "app_bg_image",
label: "自定义背景",
description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
type: "bg_image",
},
{
key: "ip",
label: "面板后端地址",
@@ -118,6 +126,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
type: "input",
},
{
key: "hide_footer_brand",
label: "隐藏页面底部 FLVX 版权信息",
description: "需商业版授权才能生效",
type: "switch",
},
{
key: "forward_compact_mode",
label: "规则页面精简模式",
@@ -154,6 +168,21 @@ const CONFIG_ITEMS: ConfigItem[] = [
dependsOn: "captcha_enabled",
dependsValue: "true",
},
{
key: "github_proxy_enabled",
label: "开启 GitHub 加速",
description: "用于节点更新和安装脚本下载,解决部分地区 GitHub 访问受限问题",
type: "switch",
},
{
key: "github_proxy_url",
label: "加速地址",
placeholder: "https://gcode.hostcentral.cc",
description: "GitHub 下载加速代理地址,开启加速后生效",
type: "input",
dependsOn: "github_proxy_enabled",
dependsValue: "true",
},
];
const BACKUP_TYPE_OPTIONS = [
@@ -186,6 +215,8 @@ const getInitialConfigs = (): Record<string, string> => {
"panel_domain",
"app_logo",
"app_favicon",
"github_proxy_enabled",
"github_proxy_url",
];
const initialConfigs: Record<string, string> = {};
@@ -223,8 +254,14 @@ export default function ConfigPage() {
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
const [importFileName, setImportFileName] = useState("");
const backupFileInputRef = useRef<HTMLInputElement>(null);
const [activatingLicense, setActivatingLicense] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState("");
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
const bgImageFileInputRef = useRef<HTMLInputElement>(null);
const [bgImageUploading, setBgImageUploading] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData>({
content: "",
@@ -348,6 +385,29 @@ export default function ConfigPage() {
);
};
const handleActivateLicense = async () => {
if (!licenseKeyInput.trim()) {
toast.error("请输入有效的商业授权码");
return;
}
setActivatingLicense(true);
try {
const res = await activateLicense(licenseKeyInput.trim());
if (res.code === 0) {
toast.success("商业版授权激活成功!");
setLicenseKeyInput("");
await loadConfigs();
window.dispatchEvent(new CustomEvent("configUpdated"));
} else {
toast.error(res.msg || "授权激活失败");
}
} catch (e: any) {
toast.error(e.message || "授权激活出错");
} finally {
setActivatingLicense(false);
}
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
@@ -435,6 +495,11 @@ export default function ConfigPage() {
// 检查配置项是否应该显示(依赖检查)
const shouldShowItem = (item: ConfigItem): boolean => {
// 隐藏商业版专属的设置项,它们会在专门的卡片中展示
if (["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)) {
return false;
}
if (!item.dependsOn || !item.dependsValue) {
return true;
}
@@ -490,6 +555,155 @@ export default function ConfigPage() {
}
};
const handleBgImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("只能上传图片文件");
return;
}
setBgImageUploading(true);
try {
const compressedImage = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
let width = img.width;
let height = img.height;
const MAX_WIDTH = 1920;
const MAX_HEIGHT = 1080;
if (width > height) {
if (width > MAX_WIDTH) {
height = Math.round((height * MAX_WIDTH) / width);
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width = Math.round((width * MAX_HEIGHT) / height);
height = MAX_HEIGHT;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas context is null"));
return;
}
ctx.drawImage(img, 0, 0, width, height);
// Output as webp for better compression
const dataUrl = canvas.toDataURL("image/webp", 0.8);
resolve(dataUrl);
};
img.onerror = () => reject(new Error("图片加载失败"));
img.src = event.target?.result as string;
};
reader.onerror = () => reject(new Error("文件读取失败"));
reader.readAsDataURL(file);
});
handleConfigChange("app_bg_image", compressedImage);
toast.success("自定义背景上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片处理失败");
} finally {
setBgImageUploading(false);
e.target.value = "";
}
};
const renderBgImageUploader = () => {
const bgImage = configs["app_bg_image"] || "";
const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || bgImage.startsWith("/") || bgImage.startsWith("blob:");
const isTheme = bgImage === "theme";
const isSolidColor = bgImage && !isImage && !isTheme;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex flex-wrap items-center gap-4">
<input
type="file"
accept="image/*"
ref={bgImageFileInputRef}
className="hidden"
onChange={handleBgImageUpload}
/>
<Button
color="primary"
variant="flat"
onPress={() => bgImageFileInputRef.current?.click()}
isLoading={bgImageUploading}
>
上传图片
</Button>
<Button
color="secondary"
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "theme")}
isDisabled={bgImageUploading || isTheme}
>
自适应纯色 (跟随深色模式)
</Button>
<Button
color="default"
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "#ffffff")}
isDisabled={bgImageUploading || bgImage === "#ffffff"}
>
白色纯色
</Button>
{bgImage && (
<Button
color="danger"
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "")}
isDisabled={bgImageUploading}
>
恢复默认
</Button>
)}
</div>
{bgImage && isImage && (
<div className="relative rounded-xl overflow-hidden border border-divider">
<img
src={bgImage}
alt="背景预览"
className="w-full max-h-48 object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
<span className="text-white text-sm font-medium">预览效果</span>
</div>
</div>
)}
{bgImage && isTheme && (
<div className="relative rounded-xl border border-divider bg-background h-32 flex items-center justify-center">
<span className="text-foreground text-sm font-medium">当前为自适应纯色背景</span>
</div>
)}
{bgImage && isSolidColor && (
<div
className="relative rounded-xl border border-divider h-32 flex items-center justify-center"
style={{ backgroundColor: bgImage }}
>
<span className="text-gray-500 bg-white/80 dark:bg-black/80 px-2 py-1 rounded text-sm font-medium border border-gray-200 dark:border-gray-800 shadow-sm">
纯色背景 ({bgImage})
</span>
</div>
)}
</div>
);
};
const renderBrandPreview = (key: BrandPreviewKey) => {
const previewUrl = (configs[key] || "").trim();
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
@@ -573,6 +787,7 @@ export default function ConfigPage() {
const value = (configs[key] || "").trim();
const uploading = brandUploading[key] === true;
const isLogo = key === "app_logo";
const isCommercialDisabled = configs.is_commercial !== "true";
return (
<div
@@ -586,6 +801,7 @@ export default function ConfigPage() {
ref={getBrandInputRef(key)}
accept={BRAND_FILE_ACCEPT}
className="hidden"
disabled={uploading || isCommercialDisabled}
type="file"
onChange={(event) => {
void handleBrandFileChange(key, event);
@@ -596,6 +812,7 @@ export default function ConfigPage() {
<Button
color="primary"
isLoading={uploading}
isDisabled={isCommercialDisabled}
size="sm"
variant="flat"
onPress={() => triggerBrandFilePicker(key)}
@@ -636,8 +853,12 @@ export default function ConfigPage() {
const renderConfigItem = (item: ConfigItem) => {
const isChanged =
hasChanges && configs[item.key] !== originalConfigs[item.key];
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
switch (item.type) {
case "bg_image":
return renderBgImageUploader();
case "input":
if (isBrandPreviewKey(item.key)) {
return renderBrandAssetUploader(item.key, isChanged);
@@ -656,6 +877,8 @@ export default function ConfigPage() {
value={configs[item.key] || ""}
variant="bordered"
onChange={(e) => handleConfigChange(item.key, e.target.value)}
isDisabled={isCommercialDisabled}
description={isCommercialDisabled ? "需商业版授权才能修改此项" : undefined}
/>
);
@@ -665,12 +888,12 @@ export default function ConfigPage() {
classNames={{
wrapper: isChanged ? "border-warning-300" : "",
}}
color="primary"
isSelected={configs[item.key] === "true"}
size="md"
size="sm"
onValueChange={(checked) =>
handleConfigChange(item.key, checked ? "true" : "false")
}
isDisabled={isCommercialDisabled}
>
<span className="text-sm text-gray-700 dark:text-gray-300">
{configs[item.key] === "true" ? "已启用" : "已禁用"}
@@ -897,6 +1120,69 @@ export default function ConfigPage() {
</div>
</div>
<Card className="shadow-md mb-6">
<CardHeader className="pb-6">
<div className="flex items-center w-full">
<div>
<h2 className="text-xl font-semibold">商业版授权</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等)
</p>
</div>
</div>
</CardHeader>
<Divider />
<CardBody className="pt-8">
<div className="flex items-end gap-3 max-w-lg mb-6">
<Input
label="授权激活码"
placeholder="请输入 FLVX- 开头的商业授权码"
value={licenseKeyInput}
variant="bordered"
onChange={(e) => setLicenseKeyInput(e.target.value)}
isDisabled={configs.is_commercial === "true"}
description={configs.is_commercial === "true" ? (configs.license_expiry && configs.license_expiry !== "never" ? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})` : "已激活商业版授权 (永久有效)") : "需商业授权才能修改站名、图标并隐藏页脚品牌"}
/>
<Button
color="primary"
className="mb-6"
isDisabled={configs.is_commercial === "true" || !licenseKeyInput.trim()}
isLoading={activatingLicense}
onPress={handleActivateLicense}
>
{configs.is_commercial === "true" ? "已授权" : "激活授权"}
</Button>
</div>
{configs.is_commercial === "true" && (
<div className="space-y-6">
<Divider className="my-2" />
<h3 className="text-md font-medium text-default-700">白标与品牌设置</h3>
{CONFIG_ITEMS.filter(item => ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)).map((item, index) => (
<div key={item.key}>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]">
<div className="space-y-1">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{item.label}
</label>
{item.description && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{item.description}
</p>
)}
</div>
<div className="flex items-start">
{renderConfigItem(item)}
</div>
</div>
{index < 3 && <Divider className="mt-6" />}
</div>
))}
</div>
)}
</CardBody>
</Card>
<Card className="shadow-md">
<CardHeader className="pb-6">
<div className="flex items-center w-full">
@@ -1220,6 +1506,30 @@ export default function ConfigPage() {
)}
</ModalContent>
</Modal>
{/* Floating Save Button (FAB) */}
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
isIconOnly
color="primary"
size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave}
>
{!saving && <SaveIcon className="w-5 h-5" />}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+78 -6
View File
@@ -1,9 +1,16 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { AnimatedPage } from "@/components/animated-page";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
} from "@/shadcn-bridge/heroui/dropdown";
import {
Modal,
ModalContent,
@@ -12,8 +19,11 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card";
import { getSessionName } from "@/utils/session";
import { safeLogout } from "@/utils/logout";
import {
formatNodeRenewalTime,
getNodeRenewalCycleLabel,
@@ -34,6 +44,15 @@ interface AddressItem {
}
export default function DashboardPage() {
const navigate = useNavigate();
const username = getSessionName() || "User";
const handleLogout = () => {
safeLogout();
toast.success("已退出登录");
navigate("/");
};
const {
loading,
userInfo,
@@ -43,6 +62,9 @@ export default function DashboardPage() {
nodeExpiryReminders,
isAdmin,
announcement,
isAnnouncementModalOpen,
setIsAnnouncementModalOpen,
dismissAnnouncementModal,
} = useDashboardData();
const [addressModalOpen, setAddressModalOpen] = useState(false);
@@ -626,7 +648,57 @@ export default function DashboardPage() {
return (
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
{/* 顶部个人状态和下拉菜单 */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">Good morning, {username}</h1>
<p className="text-sm text-default-500 mt-1">Here's what's happening with your network today.</p>
</div>
<div className="flex items-center gap-3">
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
isIconOnly
variant="solid"
>
{username.slice(0, 2).toUpperCase()}
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单" className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
<DropdownItem
key="profile"
startContent={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
}
onPress={() => navigate("/profile")}
>
个人资料
</DropdownItem>
<DropdownItem
key="logout"
className="text-danger"
color="danger"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
}
onPress={handleLogout}
>
退出登录
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
{announcement && <AnnouncementBanner announcement={announcement} />}
{announcement && (
<AnnouncementModal
announcement={announcement}
isOpen={isAnnouncementModalOpen}
onClose={() => setIsAnnouncementModalOpen(false)}
onDontShowAgain={dismissAnnouncementModal}
/>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<MetricCard
icon={
@@ -811,7 +883,7 @@ export default function DashboardPage() {
{/* 隧道权限 - 管理员不显示 */}
{!isAdmin && (
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<Card className="mb-6 lg:mb-8">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg
@@ -933,7 +1005,7 @@ export default function DashboardPage() {
)}
{/* 规则配置 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg
@@ -995,7 +1067,7 @@ export default function DashboardPage() {
{group.forwards.map((forward) => (
<div
key={forward.id}
className="bg-white dark:bg-default-100/50 border border-gray-200 dark:border-default-200 rounded-lg p-3 hover:shadow-md transition-shadow"
className="bg-white/10 dark:bg-black/10 backdrop-blur-md border border-white/20 dark:border-white/10 rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300"
>
<div className="space-y-3">
<div>
@@ -1004,7 +1076,7 @@ export default function DashboardPage() {
</h4>
<div className="space-y-1">
<button
className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30" : ""}`}
className={`block px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded-lg border border-white/20 dark:border-white/10 font-mono text-xs truncate text-foreground transition-all duration-300 ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-white/30 dark:hover:bg-white/10" : ""}`}
disabled={!hasMultipleIps(forward.inIp)}
title={formatInAddress(
forward.inIp,
@@ -1025,7 +1097,7 @@ export default function DashboardPage() {
↓
</div>
<button
className={`block px-2 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded font-mono text-xs truncate ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-500/30" : ""}`}
className={`block px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded-lg border border-white/20 dark:border-white/10 font-mono text-xs truncate text-foreground transition-all duration-300 ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-white/30 dark:hover:bg-white/10" : ""}`}
disabled={
!hasMultipleRemoteAddresses(
forward.remoteAddr,
@@ -1091,7 +1163,7 @@ export default function DashboardPage() {
backdrop="blur"
isOpen={addressModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setAddressModalOpen(false)}
>
@@ -0,0 +1,48 @@
import type { AnnouncementData } from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AnnouncementModalProps {
announcement: AnnouncementData;
isOpen: boolean;
onClose: () => void;
onDontShowAgain: () => void;
}
export const AnnouncementModal = ({
announcement,
isOpen,
onClose,
onDontShowAgain,
}: AnnouncementModalProps) => {
return (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody>
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{announcement.content}
</ReactMarkdown>
</div>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onDontShowAgain}>
不再提示
</Button>
<Button color="primary" onPress={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
@@ -1,5 +1,4 @@
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
@@ -28,7 +27,7 @@ export const FlowChartCard = ({
formatFlow,
}: FlowChartCardProps) => {
return (
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<Card className="mb-6 lg:mb-8">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg
@@ -51,17 +50,16 @@ export const FlowChartCard = ({
) : (
<div className="h-64 lg:h-80 w-full">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={chartData}>
<CartesianGrid className="opacity-30" strokeDasharray="3 3" />
<LineChart data={chartData} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
<XAxis
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
axisLine={false}
dataKey="time"
tick={{ fontSize: 12 }}
tick={{ fill: "currentColor", opacity: 0.5, fontSize: 12 }}
tickLine={false}
/>
<YAxis
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
tick={{ fontSize: 12 }}
axisLine={false}
tick={{ fill: "currentColor", opacity: 0.5, fontSize: 12 }}
tickFormatter={(value: number | string) => {
const v = typeof value === "number" ? value : Number(value);
@@ -97,27 +95,20 @@ export const FlowChartCard = ({
: 0;
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<div className="bg-white/20 dark:bg-black/20 backdrop-blur-3xl border border-white/50 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-4">
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
<p className="text-primary">{`流量: ${formatFlow(flowValue)}`}</p>
<p className="text-primary font-semibold mt-1">{`流量: ${formatFlow(flowValue)}`}</p>
</div>
);
}
return null;
}}
cursor={{ fill: "rgba(0, 122, 255, 0.05)" }}
/>
<Line
activeDot={{
r: 4,
stroke: "#8b5cf6",
strokeWidth: 2,
fill: "#fff",
}}
dataKey="flow"
dot={false}
stroke="#8b5cf6"
strokeWidth={3}
stroke="#007aff"
type="monotone"
/>
</LineChart>
@@ -18,22 +18,24 @@ export const MetricCard = ({
bottomContent,
}: MetricCardProps) => {
return (
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<Card className="h-48 flex flex-col justify-between overflow-hidden">
<CardBody className="!p-4 md:!p-6 h-full flex flex-col justify-between overflow-visible">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">
<p className="text-sm font-medium text-default-600 truncate">
{title}
</p>
<div
className={`p-1.5 lg:p-2 rounded-lg flex-shrink-0 ${iconClassName}`}
className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}
>
{icon}
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">
<p className="text-2xl lg:text-3xl font-bold text-foreground truncate mt-2">
{value}
</p>
</div>
<div className="mt-auto pt-4">
{bottomContent}
</div>
</CardBody>
@@ -96,6 +96,9 @@ interface DashboardDataState {
nodeExpiryReminders: DashboardNodeExpiryItem[];
isAdmin: boolean;
announcement: AnnouncementData | null;
isAnnouncementModalOpen: boolean;
setIsAnnouncementModalOpen: (isOpen: boolean) => void;
dismissAnnouncementModal: () => void;
}
const checkExpirationNotifications = (
@@ -267,6 +270,7 @@ export const useDashboardData = (): DashboardDataState => {
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
null,
);
const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false);
const isMountedRef = useRef(true);
const packageRequestInFlightRef = useRef(false);
const nodeExpiryRequestInFlightRef = useRef(false);
@@ -345,6 +349,19 @@ export const useDashboardData = (): DashboardDataState => {
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
try {
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
const updateTime = res.data.update_time || 0;
if (updateTime > storedTime) {
setIsAnnouncementModalOpen(true);
}
} catch (err) {
console.warn("Failed to read localStorage for announcement state", err);
setIsAnnouncementModalOpen(true);
}
} else {
setAnnouncement(null);
}
@@ -355,6 +372,17 @@ export const useDashboardData = (): DashboardDataState => {
}
}, []);
const dismissAnnouncementModal = useCallback(() => {
setIsAnnouncementModalOpen(false);
if (announcement && announcement.update_time) {
try {
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
} catch (err) {
console.warn("Failed to set localStorage for announcement state", err);
}
}
}, [announcement]);
const loadNodeExpiryData = useCallback(async () => {
if (nodeExpiryRequestInFlightRef.current) {
return;
@@ -438,5 +466,8 @@ export const useDashboardData = (): DashboardDataState => {
nodeExpiryReminders,
isAdmin,
announcement,
isAnnouncementModalOpen,
setIsAnnouncementModalOpen,
dismissAnnouncementModal,
};
};
+39 -34
View File
@@ -973,12 +973,13 @@ const SortableCompactTableRow = ({
<TableCell
className={`whitespace-nowrap text-foreground ${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`}
>
<span
className="cursor-pointer hover:text-primary transition-colors"
<button
className="cursor-pointer hover:text-primary transition-colors text-left bg-transparent border-none p-0 outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1 rounded-sm"
type="button"
onClick={() => copyToClipboard(forward.name, "规则名")}
>
{forward.name}
</span>
</button>
</TableCell>
<TableCell
className={`whitespace-nowrap ${selectedIds.has(forward.id) ? "bg-primary-50/70 dark:bg-primary-900/40" : ""}`}
@@ -987,9 +988,12 @@ const SortableCompactTableRow = ({
<span className="font-medium text-default-700 text-sm">
{forward.tunnelName}
</span>
<span className="text-success font-bold text-[12px] mr-1.5">
‾{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
{forward.tunnelTrafficRatio !== undefined &&
normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 && (
<span className="text-success font-bold text-[12px] ml-1.5 border border-success/30 rounded px-1 bg-success/10">
{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
)}
</div>
</TableCell>
<TableCell
@@ -3685,7 +3689,7 @@ export default function ForwardPage() {
return (
<Card
key={forward.id}
className="group h-full flex flex-col shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
className="group h-full flex flex-col overflow-hidden bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -3742,7 +3746,7 @@ export default function ForwardPage() {
{/* 地址信息 */}
<div className="space-y-1">
<button
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
className={`cursor-pointer px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
@@ -3782,7 +3786,7 @@ export default function ForwardPage() {
</button>
<button
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
className={`cursor-pointer px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
@@ -3962,7 +3966,7 @@ export default function ForwardPage() {
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 flex items-center gap-2">
<Button
color={activeFilterCount > 0 ? "primary" : "default"}
className="bg-default/10 dark:bg-default/20 backdrop-blur-md text-default-700 dark:text-default-300 font-semibold hover:bg-default/20 dark:hover:bg-default/30"
startContent={
<svg
className="w-4 h-4"
@@ -3978,7 +3982,6 @@ export default function ForwardPage() {
/>
</svg>
}
variant={activeFilterCount > 0 ? "flat" : "bordered"}
onPress={() => setIsSearchModalOpen(true)}
>
高级筛选 {activeFilterCount > 0 ? `(${activeFilterCount})` : ""}
@@ -4173,7 +4176,7 @@ export default function ForwardPage() {
{sortedForwards.length} 条规则
</span>
</div>
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
<Card className="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<DndContext
collisionDetection={pointerWithin}
sensors={sensors}
@@ -4186,9 +4189,10 @@ export default function ForwardPage() {
<Table
aria-label="全部规则列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors group",
}}
>
<TableHeader>
@@ -4249,10 +4253,10 @@ export default function ForwardPage() {
</Table>
</SortableContext>
</DndContext>
</div>
</Card>
</>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<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">
暂无规则配置
@@ -4298,7 +4302,7 @@ export default function ForwardPage() {
</DndContext>
</>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<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">
暂无规则配置
@@ -4323,9 +4327,9 @@ export default function ForwardPage() {
return (
<div
key={`grouped-table-${group.userId}-${group.userName}`}
className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md"
className="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
>
<div className="flex items-center justify-between border-b border-divider bg-default-100/40 px-4 py-3">
<div className="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-5 py-4">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{group.userName}
@@ -4411,10 +4415,10 @@ export default function ForwardPage() {
collapsed={collapsed}
countClassName="text-xs text-default-600"
groupUserId={group.userId}
headerClassName="flex items-center justify-between border-b border-divider bg-default-100/60 px-4 py-2.5"
headerClassName="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-4 py-2.5"
titleClassName="truncate text-sm font-semibold text-default-700"
tunnel={tunnel}
wrapperClassName="overflow-hidden rounded-lg border border-divider bg-default-50/60"
wrapperClassName="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
@@ -4435,9 +4439,10 @@ export default function ForwardPage() {
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
@@ -4557,7 +4562,7 @@ export default function ForwardPage() {
})}
</div>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<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">
暂无规则配置
@@ -4633,10 +4638,10 @@ export default function ForwardPage() {
collapsed={collapsed}
countClassName="text-xs text-default-600"
groupUserId={group.userId}
headerClassName="flex items-center justify-between rounded-lg bg-default-100/60 px-3 py-2"
headerClassName="flex items-center justify-between rounded-lg bg-white/10 dark:bg-black/10 backdrop-blur-md border border-white/20 dark:border-white/10 px-3 py-2"
titleClassName="truncate text-sm font-semibold text-default-700"
tunnel={tunnel}
wrapperClassName="rounded-xl border border-divider bg-default-50/60 space-y-3"
wrapperClassName="rounded-xl border border-divider bg-white/5 dark:bg-black/5 space-y-3"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
@@ -4678,7 +4683,7 @@ export default function ForwardPage() {
})}
</div>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<Card>
<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">
暂无规则配置
@@ -4698,7 +4703,7 @@ export default function ForwardPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -4913,7 +4918,7 @@ export default function ForwardPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
@@ -4958,7 +4963,7 @@ export default function ForwardPage() {
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={addressModalOpen}
scrollBehavior="outside"
scrollBehavior="inside"
size="lg"
onClose={() => setAddressModalOpen(false)}
>
@@ -5003,7 +5008,7 @@ export default function ForwardPage() {
}}
isOpen={exportModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => {
setExportModalOpen(false);
@@ -5162,7 +5167,7 @@ export default function ForwardPage() {
}}
isOpen={importModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setImportModalOpen(false)}
>
@@ -5411,7 +5416,7 @@ export default function ForwardPage() {
</div>
)}
</ModalHeader>
<ModalBody className="bg-content1">
<ModalBody className="bg-transparent">
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
@@ -5935,7 +5940,7 @@ export default function ForwardPage() {
</div>
)}
</ModalBody>
<ModalFooter className="bg-content1 border-t border-divider">
<ModalFooter className="bg-transparent border-t border-white/20 dark:border-white/10">
<Button variant="light" onPress={onClose}>
关闭
</Button>
+39 -13
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { PageLoadingState } from "@/components/page-state";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -22,7 +24,7 @@ import {
TableRow,
} from "@/shadcn-bridge/heroui/table";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import {
assignGroupPermission,
assignTunnelsToGroup,
@@ -469,14 +471,12 @@ export default function GroupPage() {
}
return (
<div className="px-3 lg:px-6 py-8 space-y-6">
{loading && (
<div className="flex justify-center py-10">
<Spinner size="lg" />
</div>
)}
<Card>
<AnimatedPage className="px-3 lg:px-6 py-8 space-y-6">
{loading ? (
<PageLoadingState message="正在加载..." />
) : (
<>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button
@@ -489,7 +489,15 @@ export default function GroupPage() {
</Button>
</CardHeader>
<CardBody>
<Table aria-label="隧道分组列表">
<Table
aria-label="隧道分组列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
@@ -561,7 +569,15 @@ export default function GroupPage() {
</Button>
</CardHeader>
<CardBody>
<Table aria-label="用户分组列表">
<Table
aria-label="用户分组列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
@@ -665,7 +681,15 @@ export default function GroupPage() {
</Button>
</div>
<Table aria-label="分组权限列表">
<Table
aria-label="分组权限列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
@@ -898,6 +922,8 @@ export default function GroupPage() {
</ModalFooter>
</ModalContent>
</Modal>
</div>
</>
)}
</AnimatedPage>
);
}
+27 -20
View File
@@ -8,9 +8,7 @@ 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 { VersionFooter } from "@/components/version-footer";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
import { useWebViewMode } from "@/hooks/useWebViewMode";
@@ -152,29 +150,36 @@ 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="relative flex flex-col min-h-screen bg-mesh-gradient overflow-hidden">
<section className="flex flex-col items-center justify-center flex-1 w-full p-4 relative z-10">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md px-4 sm:px-0"
className="w-full max-w-[420px] 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">
<Card className="w-full bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.15)] border-white/80 dark:border-white/10 rounded-[32px] p-2 sm:p-4">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1>
<p className="text-small text-default-500 mt-2">
请输入您的账号信息
<div className="w-14 h-14 bg-primary rounded-2xl flex items-center justify-center mb-4 shadow-sm text-white">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">Welcome to Flvx</h1>
<p className="text-sm text-default-500 mt-2 font-medium">
Sign in to manage your networks
</p>
</CardHeader>
<CardBody className="px-6 py-6">
<div className="flex flex-col gap-4">
<CardBody className="px-6 py-6 mt-2">
<div className="flex flex-col gap-5">
<Input
classNames={{
inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
input: "text-base font-medium",
}}
errorMessage={errors.username}
isDisabled={loading}
isInvalid={!!errors.username}
label="用户名"
placeholder="请输入用户名"
label="Username"
placeholder="admin"
value={form.username}
variant="bordered"
onChange={(e) =>
@@ -184,10 +189,14 @@ export default function IndexPage() {
/>
<Input
classNames={{
inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
input: "text-base font-medium tracking-wider",
}}
isDisabled={loading}
isInvalid={!!errors.password}
label="密码"
placeholder="请输入密码"
label="Password"
placeholder="••••••••"
type="password"
value={form.password}
variant="bordered"
@@ -198,14 +207,12 @@ export default function IndexPage() {
/>
<Button
className="mt-2"
color="primary"
className="mt-4 h-12 rounded-xl bg-primary text-white font-bold text-base shadow-[0_8px_16px_rgba(0,122,255,0.3)] transition-transform active:scale-[0.98]"
disabled={loading}
isLoading={loading}
size="lg"
onPress={handleLogin}
>
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
{loading ? (showCaptcha ? "Verifying..." : "Signing in...") : "Sign In"}
</Button>
</div>
</CardBody>
@@ -275,6 +282,6 @@ export default function IndexPage() {
</div>
)}
</section>
</DefaultLayout>
</div>
);
}
+7 -6
View File
@@ -267,9 +267,10 @@ export default function LimitPage() {
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
@@ -326,7 +327,7 @@ export default function LimitPage() {
<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">
<Card className="shadow-sm overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
@@ -412,7 +413,7 @@ export default function LimitPage() {
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<Card className="shadow-sm 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">
暂无限速规则
@@ -432,7 +433,7 @@ export default function LimitPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -510,7 +511,7 @@ export default function LimitPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
+150 -11
View File
@@ -10,6 +10,7 @@ import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { getMonitorNodes } from "@/api";
import { MonitorView } from "@/pages/node/monitor-view";
import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
type MonitorNode = {
id: number;
@@ -20,6 +21,16 @@ type MonitorNode = {
type MonitorTab = "nodes" | "tunnels";
const formatBytesPerSecond = (bytesPerSecond: number): string => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "0 B/s";
const k = 1024;
const sizes = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s"];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return `${parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
export default function MonitorPage() {
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
@@ -27,6 +38,55 @@ export default function MonitorPage() {
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<Record<number, "online" | "offline">>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<Record<number, any>>({});
const handleRealtimeMessage = useCallback((message: any) => {
const nodeId = Number(message?.id ?? 0);
if (!nodeId || Number.isNaN(nodeId)) return;
const type = String(message?.type ?? "");
const payload = message?.data;
if (type === "status") {
const status = Number(payload);
setRealtimeNodeStatus((prev) => ({
...prev,
[nodeId]: status === 1 ? "online" : "offline",
}));
return;
}
if (type === "metric") {
let raw = payload;
if (typeof raw === "string") {
try {
raw = JSON.parse(raw);
} catch {
return;
}
}
if (!raw || typeof raw !== "object") return;
const metric = raw as any;
setRealtimeNodeMetrics((prev) => ({
...prev,
[nodeId]: {
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
}
}));
}
}, []);
useNodeRealtime({
onMessage: handleRealtimeMessage,
enabled: true,
});
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setNodesLoading(true);
@@ -73,22 +133,101 @@ export default function MonitorPage() {
.map((n) => ({
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: n.status === 1 ? "online" : "offline",
connectionStatus: realtimeNodeStatus[Number(n.id)] || (n.status === 1 ? "online" : "offline"),
version: n.version,
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
}, [nodes]);
}, [nodes, realtimeNodeStatus]);
const aggregateMetrics = useMemo(() => {
let totalCpu = 0;
let totalConns = 0;
let totalNetIn = 0;
let totalNetOut = 0;
let onlineCount = 0;
nodeMap.forEach((node) => {
if (node.connectionStatus === "online") {
onlineCount++;
const metric = realtimeNodeMetrics[node.id];
if (metric) {
totalCpu += metric.cpuUsage;
totalConns += metric.tcpConns + metric.udpConns;
totalNetIn += metric.netInSpeed;
totalNetOut += metric.netOutSpeed;
}
}
});
const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0;
return {
avgCpu,
totalConns,
totalBandwidth: totalNetIn + totalNetOut,
};
}, [nodeMap, realtimeNodeMetrics]);
return (
<AnimatedPage className="px-3 lg:px-6 py-8">
{/* 顶部英雄数据指标 (Hero Metrics) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm">System Load</span>
</div>
<div className="z-10 relative">
<span className="text-4xl font-bold text-foreground">{aggregateMetrics.avgCpu.toFixed(1)}%</span>
</div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-primary/40 h-2 rounded-t-sm" />
<div className="w-full bg-primary/40 h-3 rounded-t-sm" />
<div className="w-full bg-primary/40 h-1.5 rounded-t-sm" />
<div className="w-full bg-primary/40 h-4 rounded-t-sm" />
<div className="w-full bg-primary/40 h-2.5 rounded-t-sm" />
<div className="w-full bg-primary h-5 rounded-t-sm shadow-[0_0_10px_rgba(0,122,255,0.5)]" />
</div>
</div>
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm">Active Connections</span>
</div>
<div className="z-10 relative">
<span className="text-4xl font-bold text-foreground">{aggregateMetrics.totalConns}</span>
</div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-success/40 h-3.5 rounded-t-sm" />
<div className="w-full bg-success/40 h-4.5 rounded-t-sm" />
<div className="w-full bg-success/40 h-2.5 rounded-t-sm" />
<div className="w-full bg-success/40 h-6 rounded-t-sm" />
<div className="w-full bg-success/40 h-5.5 rounded-t-sm" />
<div className="w-full bg-success h-7 rounded-t-sm shadow-[0_0_10px_rgba(52,199,89,0.5)]" />
</div>
</div>
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm">Bandwidth</span>
</div>
<div className="z-10 relative">
<span className="text-4xl font-bold text-foreground">{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}</span>
</div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-secondary/40 h-2 rounded-t-sm" />
<div className="w-full bg-secondary/40 h-3 rounded-t-sm" />
<div className="w-full bg-secondary/40 h-5 rounded-t-sm" />
<div className="w-full bg-secondary/40 h-8 rounded-t-sm" />
<div className="w-full bg-secondary/40 h-7 rounded-t-sm" />
<div className="w-full bg-secondary h-10 rounded-t-sm shadow-[0_0_10px_rgba(175,82,222,0.5)]" />
</div>
</div>
</div>
<div className="mb-6 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xl font-semibold truncate">监控</h2>
<div className="text-xs text-default-500 truncate">
实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
</div>
<h2 className="text-xl font-semibold truncate">监控面板</h2>
</div>
<div className="flex items-center gap-2">
<Button
@@ -114,11 +253,11 @@ export default function MonitorPage() {
</div>
{/* Tab Switcher */}
<div className="flex items-center gap-1 p-1 rounded-xl bg-default-100 dark:bg-default-50/10 w-fit">
<div className="flex items-center gap-1 p-1 rounded-2xl bg-white/20 dark:bg-black/20 backdrop-blur-3xl border border-white/50 dark:border-white/10 w-fit shadow-sm">
<button
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
className={`flex items-center gap-1.5 px-5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 ${
activeTab === "nodes"
? "bg-background shadow-sm text-foreground"
? "bg-white dark:bg-zinc-800 shadow-[0_2px_8px_rgba(0,0,0,0.08)] text-foreground"
: "text-default-500 hover:text-foreground"
}`}
onClick={() => setActiveTab("nodes")}
@@ -127,9 +266,9 @@ export default function MonitorPage() {
节点
</button>
<button
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
className={`flex items-center gap-1.5 px-5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 ${
activeTab === "tunnels"
? "bg-background shadow-sm text-foreground"
? "bg-white dark:bg-zinc-800 shadow-[0_2px_8px_rgba(0,0,0,0.08)] text-foreground"
: "text-default-500 hover:text-foreground"
}`}
onClick={() => setActiveTab("tunnels")}
+24 -16
View File
@@ -288,7 +288,7 @@ const SortableItem = ({
export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState(
"node-search-keyword-local",
"",
@@ -1767,9 +1767,10 @@ export default function NodePage() {
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
}}
>
<TableHeader>
@@ -1949,7 +1950,7 @@ export default function NodePage() {
{(listeners) => (
<Card
key={node.id}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${node.expiryReminderDismissed ? "" : expiryMeta.accentClassName}`}
className={`group relative overflow-visible h-full flex flex-col ${node.expiryReminderDismissed ? "" : expiryMeta.accentClassName}`}
data-node-card="true"
>
<CardHeader className="pb-3 md:pb-3">
@@ -1979,13 +1980,20 @@ export default function NodePage() {
)}
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm leading-5">
<h3 className="font-semibold text-foreground truncate text-lg leading-5">
{node.name}
</h3>
<span
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${connectionStatusMeta.color === "success" ? "bg-emerald-500" : "bg-rose-500"}`}
<div
className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0
${connectionStatusMeta.color === "success" ? "bg-emerald-500/20 text-emerald-600" : "bg-rose-500/20 text-rose-600"}
`}
title={connectionStatusMeta.text}
/>
>
<span
className={`h-1.5 w-1.5 rounded-full ${connectionStatusMeta.color === "success" ? "bg-emerald-500" : "bg-rose-500"}`}
/>
{connectionStatusMeta.text}
</div>
</div>
</div>
</div>
@@ -2241,7 +2249,7 @@ export default function NodePage() {
)}
</span>
</div>
<div className="max-h-20 overflow-y-auto rounded bg-white/70 dark:bg-black/20 p-1.5 [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1">
<div className="max-h-20 overflow-y-auto rounded bg-white/10 dark:bg-black/10 p-1.5 [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1">
{remoteUsage.usedPorts.length > 0 ? (
<div className="flex flex-wrap gap-1">
{remoteUsage.usedPorts.map((port) => (
@@ -2277,7 +2285,7 @@ export default function NodePage() {
remoteUsage.bindings.map((binding) => (
<div
key={binding.bindingId}
className="rounded border border-default-200 dark:border-default-100/30 bg-white/70 dark:bg-black/20 p-2"
className="rounded border border-default-200 dark:border-default-100/30 bg-white/10 dark:bg-black/10 p-2"
>
<div className="flex items-center justify-between gap-2">
<span
@@ -2410,7 +2418,7 @@ export default function NodePage() {
}}
isOpen={dialogVisible}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setDialogVisible(false)}
>
@@ -2831,7 +2839,7 @@ export default function NodePage() {
}}
isOpen={rollbackModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setRollbackModalOpen}
>
@@ -2872,7 +2880,7 @@ export default function NodePage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
@@ -2968,7 +2976,7 @@ export default function NodePage() {
}}
isOpen={installCommandModal}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setInstallCommandModal(false)}
>
@@ -3025,7 +3033,7 @@ export default function NodePage() {
}}
isOpen={upgradeModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="md"
onOpenChange={setUpgradeModalOpen}
>
@@ -3131,7 +3139,7 @@ export default function NodePage() {
}}
isOpen={batchDeleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="md"
onOpenChange={setBatchDeleteModalOpen}
>
+11 -8
View File
@@ -174,7 +174,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
return (
<Card
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
className="group relative hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.15)] transition-all duration-300 h-full flex flex-col cursor-pointer"
onClick={onPress}
>
{/* Dynamic top gradient bar based on status */}
@@ -208,8 +208,8 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
<span className="font-mono font-medium">{isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}</span>
</div>
<Progress
className="h-1"
color={getColorByUsage(metric?.cpuUsage)}
size="sm"
value={isOnline && metric ? metric.cpuUsage : 0}
/>
</div>
@@ -219,6 +219,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
<span className="font-mono font-medium">{isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}</span>
</div>
<Progress
className="h-1"
color={getColorByUsage(metric?.memoryUsage)}
size="sm"
value={isOnline && metric ? metric.memoryUsage : 0}
@@ -1157,9 +1158,10 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
@@ -1321,7 +1323,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
{ label: "↑ 上行速度", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const },
{ label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const },
].map((item) => (
<Card key={item.label} className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<Card key={item.label} className="hover:shadow-[0_15px_40px_rgba(0,0,0,0.15)] transition-shadow">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5">{item.label}</span>
<span className={`text-sm font-semibold font-mono ${item.color === 'danger' ? 'text-danger' : item.color === 'warning' ? 'text-warning' : item.color === 'success' ? 'text-success' : item.color === 'primary' ? 'text-primary' : ''}`}>
@@ -1444,7 +1446,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
{/* Active monitor info bar */}
{resolvedActiveMonitor && (
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-default-50 dark:bg-default-100/30 border border-divider">
<div className="flex items-center justify-between gap-3 p-3 rounded-2xl bg-white/20 dark:bg-black/20 backdrop-blur-3xl border border-white/50 dark:border-white/10 shadow-[0_4px_12px_rgba(0,0,0,0.05)]">
<div className="flex items-center gap-3 min-w-0 flex-wrap">
<Chip size="sm" color="primary" variant="flat">{resolvedActiveMonitor.type.toUpperCase()}</Chip>
<span className="font-mono text-xs text-default-500">{resolvedActiveMonitor.target}</span>
@@ -1584,9 +1586,10 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
aria-label="监控记录"
className="w-full overflow-x-auto"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
@@ -987,9 +987,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
+3 -3
View File
@@ -89,7 +89,7 @@ export default function PanelSharingPage() {
[],
);
const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [remoteUsageLoading, setRemoteUsageLoading] = useState(false);
// Modals
@@ -459,7 +459,7 @@ export default function PanelSharingPage() {
{shares.map((share) => (
<Card
key={share.id}
className="border border-divider shadow-sm"
className="overflow-hidden"
>
<CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{share.name}</h3>
@@ -606,7 +606,7 @@ export default function PanelSharingPage() {
{remoteUsageNodes.map((node) => (
<Card
key={node.nodeId}
className="border border-divider shadow-sm"
className="overflow-hidden"
>
<CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{node.nodeName}</h3>
+3 -3
View File
@@ -202,7 +202,7 @@ export default function ProfilePage() {
<div className="px-3 lg:px-6 py-8 flex flex-col h-full">
<div className="space-y-6 flex-1">
{/* 用户信息卡片 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<Card>
<CardBody className="p-4">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
@@ -242,7 +242,7 @@ export default function ProfilePage() {
</Card>
{/* 功能网格 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<Card>
<CardBody className="p-4">
<div className="grid grid-cols-3 gap-3">
{/* 管理员功能 */}
@@ -332,7 +332,7 @@ export default function ProfilePage() {
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={() => {
onOpenChange();
+4 -4
View File
@@ -183,7 +183,7 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card className="border border-gray-200 dark:border-gray-700">
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
@@ -213,7 +213,7 @@ export const SettingsPage = () => {
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-gray-700">
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
@@ -247,7 +247,7 @@ export const SettingsPage = () => {
</Card>
{/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700">
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
添加新面板地址
@@ -275,7 +275,7 @@ export const SettingsPage = () => {
</Card>
{/* 地址列表 */}
<Card className="border border-gray-200 dark:border-gray-700">
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
已保存的面板地址
+16 -15
View File
@@ -1578,14 +1578,15 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
<Card>
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
}}
>
<TableHeader>
@@ -1720,7 +1721,7 @@ export default function TunnelPage() {
{(listeners) => (
<Card
key={tunnel.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
className="group overflow-hidden bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -1770,7 +1771,7 @@ export default function TunnelPage() {
<div className="pt-2 border-t border-divider">
<div className="flex items-center justify-center gap-2 text-xs">
{/* 入口节点 */}
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50/30 dark:bg-primary-100/20 backdrop-blur-md rounded border border-primary-200/50 dark:border-primary-300/20">
<svg
aria-hidden="true"
className="w-3 h-3 text-primary-600"
@@ -1805,7 +1806,7 @@ export default function TunnelPage() {
</svg>
{/* 转发链 */}
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50 dark:bg-secondary-100/20 rounded border border-secondary-200 dark:border-secondary-300/20">
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50/30 dark:bg-secondary-100/20 backdrop-blur-md rounded border border-secondary-200/50 dark:border-secondary-300/20">
<svg
aria-hidden="true"
className="w-3 h-3 text-secondary-600"
@@ -1843,7 +1844,7 @@ export default function TunnelPage() {
</svg>
{/* 出口节点 */}
<div className="flex items-center gap-1 px-2 py-1 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="flex items-center gap-1 px-2 py-1 bg-success-50/30 dark:bg-success-100/20 backdrop-blur-md rounded border border-success-200/50 dark:border-success-300/20">
<svg
aria-hidden="true"
className="w-3 h-3 text-success-600"
@@ -1870,7 +1871,7 @@ export default function TunnelPage() {
<div
className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}
>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
<div className="text-center p-1.5 bg-white/5 dark:bg-black/5 backdrop-blur-3xl rounded-lg border border-divider">
<div className="text-xs text-default-500">
流量计算
</div>
@@ -1878,7 +1879,7 @@ export default function TunnelPage() {
{getTunnelFlowDisplay(tunnel.flow)}
</div>
</div>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
<div className="text-center p-1.5 bg-white/5 dark:bg-black/5 backdrop-blur-3xl rounded-lg border border-divider">
<div className="text-xs text-default-500">
流量倍率
</div>
@@ -1887,7 +1888,7 @@ export default function TunnelPage() {
</div>
</div>
{tunnel.type === 2 && tunnel.ipPreference && (
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
<div className="text-center p-1.5 bg-white/5 dark:bg-black/5 backdrop-blur-3xl rounded-lg border border-divider">
<div className="text-xs text-default-500">
连接偏好
</div>
@@ -1985,7 +1986,7 @@ export default function TunnelPage() {
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<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">
暂无隧道配置
@@ -2005,7 +2006,7 @@ export default function TunnelPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -2942,7 +2943,7 @@ export default function TunnelPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={handleDeleteModalOpenChange}
>
@@ -3150,7 +3151,7 @@ export default function TunnelPage() {
</div>
)}
</ModalHeader>
<ModalBody className="bg-content1">
<ModalBody className="bg-transparent">
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
@@ -3672,7 +3673,7 @@ export default function TunnelPage() {
</div>
)}
</ModalBody>
<ModalFooter className="bg-content1 border-t border-divider">
<ModalFooter className="bg-transparent border-t border-white/20 dark:border-white/10">
<Button variant="light" onPress={onClose}>
关闭
</Button>
+32 -32
View File
@@ -185,7 +185,7 @@ const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"user-search-keyword",
"",
@@ -466,10 +466,6 @@ export default function UserPage() {
void loadMonitorPermissions();
}, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
@@ -1084,9 +1080,10 @@ export default function UserPage() {
aria-label="用户列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
@@ -1108,14 +1105,11 @@ export default function UserPage() {
return (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
userStatus.color === "success"
? "bg-success"
: "bg-danger"
}`}
/>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()}
<span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
</div>
<div className="flex flex-col">
<span className="font-medium text-foreground text-sm">
{user.name || user.user}
@@ -1252,16 +1246,22 @@ export default function UserPage() {
return (
<StaggerItem key={user.id}>
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
<Card className="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 className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()}
<span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
</div>
<div className="flex flex-col 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>
<div className="flex items-center gap-1.5 ml-2">
<Chip
@@ -1455,7 +1455,7 @@ export default function UserPage() {
}}
isOpen={isUserModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onUserModalClose}
>
@@ -1698,7 +1698,7 @@ export default function UserPage() {
isDismissable={false}
isOpen={isTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onTunnelModalClose}
>
@@ -1896,9 +1896,9 @@ export default function UserPage() {
<Table
aria-label="用户隧道权限列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
@@ -2035,7 +2035,7 @@ export default function UserPage() {
isDismissable={false}
isOpen={isEditTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onEditTunnelModalClose}
>
@@ -2215,7 +2215,7 @@ export default function UserPage() {
}}
isOpen={isDeleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onDeleteModalClose}
>
@@ -2261,7 +2261,7 @@ export default function UserPage() {
}}
isOpen={isDeleteTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onDeleteTunnelModalClose}
>
@@ -2309,7 +2309,7 @@ export default function UserPage() {
}}
isOpen={isResetFlowModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onResetFlowModalClose}
>
@@ -2401,7 +2401,7 @@ export default function UserPage() {
}}
isOpen={isResetTunnelFlowModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onResetTunnelFlowModalClose}
>
@@ -84,14 +84,22 @@ function mapSize(
const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
default:
"bg-default-200 text-default-800 hover:bg-default-300 dark:bg-default-100 dark:text-default-800 dark:hover:bg-default-200",
success: "bg-success text-white hover:bg-success-600",
warning: "bg-warning text-warning-900 hover:bg-warning-400",
"bg-default/10 dark:bg-default/20 backdrop-blur-md text-default-800 dark:text-default-300 hover:bg-default/20 dark:hover:bg-default/30",
success:
"bg-success/10 dark:bg-success/20 backdrop-blur-md text-success-700 dark:text-success-400 hover:bg-success/20 dark:hover:bg-success/30",
warning:
"bg-warning/10 dark:bg-warning/20 backdrop-blur-md text-warning-700 dark:text-warning-400 hover:bg-warning/20 dark:hover:bg-warning/30",
primary:
"bg-primary/10 dark:bg-primary/20 backdrop-blur-md text-primary-700 dark:text-primary-400 hover:bg-primary/20 dark:hover:bg-primary/30",
secondary:
"bg-secondary/10 dark:bg-secondary/20 backdrop-blur-md text-secondary-700 dark:text-secondary-400 hover:bg-secondary/20 dark:hover:bg-secondary/30",
danger:
"bg-danger/10 dark:bg-danger/20 backdrop-blur-md text-danger-700 dark:text-danger-400 hover:bg-danger/20 dark:hover:bg-danger/30",
};
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",
"bg-default/10 dark:bg-default/20 backdrop-blur-md text-default-700 dark:text-default-300 hover:bg-default/20 dark:hover:bg-default/30",
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:
@@ -121,17 +129,17 @@ const lightColorClassMap: Record<HeroButtonColor, string> = {
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",
"bg-default/10 dark:bg-default/20 backdrop-blur-md text-default-700 dark:text-default-300 hover:bg-default/20 dark:hover:bg-default/30",
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",
"bg-primary/10 dark:bg-primary/20 backdrop-blur-md text-primary-700 dark:text-primary-400 hover:bg-primary/20 dark:hover:bg-primary/30",
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",
"bg-secondary/10 dark:bg-secondary/20 backdrop-blur-md text-secondary-700 dark:text-secondary-400 hover:bg-secondary/20 dark:hover:bg-secondary/30",
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",
"bg-success/10 dark:bg-success/20 backdrop-blur-md text-success-700 dark:text-success-400 hover:bg-success/20 dark:hover:bg-success/30",
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",
"bg-warning/10 dark:bg-warning/20 backdrop-blur-md text-warning-700 dark:text-warning-400 hover:bg-warning/20 dark:hover:bg-warning/30",
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",
"bg-danger/10 dark:bg-danger/20 backdrop-blur-md text-danger-700 dark:text-danger-400 hover:bg-danger/20 dark:hover:bg-danger/30",
};
const shadowColorClassMap: Record<HeroButtonColor, string> = {
@@ -21,7 +21,7 @@ export function CardHeader({
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<CardContent
className={cn("p-4 pt-0 md:p-6 md:pt-0", className)}
className={cn("p-4 md:p-6", className)}
{...props}
/>
);
@@ -44,13 +44,14 @@ export function DropdownTrigger({ children }: { children: React.ReactNode }) {
export interface DropdownMenuProps {
"aria-label"?: string;
className?: string;
children: React.ReactNode;
}
export function DropdownMenu({ children }: DropdownMenuProps) {
export function DropdownMenu({ children, className }: DropdownMenuProps) {
const { align } = React.useContext(DropdownContext);
return <DropdownMenuContent align={align}>{children}</DropdownMenuContent>;
return <DropdownMenuContent align={align} className={className}>{children}</DropdownMenuContent>;
}
export interface DropdownItemProps {
@@ -149,10 +149,10 @@ export function ModalContent({
mapSize(resolvedSize),
context?.classNames?.base,
resolvedScrollBehavior === "outside"
? "max-h-[90vh] overflow-y-auto"
? "max-h-[90vh] overflow-y-auto scrollbar-hide"
: "",
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"
? "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 [&_[data-slot=modal-body]]:scrollbar-hide"
: "",
className,
)}
@@ -57,7 +57,7 @@ export function TableHeader({
);
return (
<thead className={cn("border-b bg-default-50/60", className)} {...props}>
<thead className={cn("border-b", className)} {...props}>
{hasRow ? children : <TableRow>{children}</TableRow>}
</thead>
);
+81 -1
View File
@@ -1,6 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./tailwind-theme.pcss";
@plugin "tailwindcss-animate";
/* GPU acceleration helper - prevents font blurriness during transforms */
.gpu-accelerated {
@@ -211,3 +211,83 @@ body {
opacity: 1;
}
}
/* Liquid Glass Variables */
:root {
--glass-bg: rgba(255, 255, 255, 0.6);
--glass-border: rgba(255, 255, 255, 0.8);
--glass-card: rgba(255, 255, 255, 0.7);
--glass-overlay: rgba(0, 0, 0, 0.3);
}
.dark {
--glass-bg: rgba(30, 30, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.15);
--glass-card: rgba(40, 40, 40, 0.6);
}
.bg-mesh-gradient {
background-color: #f2f2f7;
position: relative;
z-index: 0;
}
.bg-mesh-gradient::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
pointer-events: none;
background: url('/images/bg-light.jpg') no-repeat center center;
background-size: cover;
}
.dark .bg-mesh-gradient {
background-color: #0b1020;
}
.dark .bg-mesh-gradient::before {
background: url('/images/bg-dark.jpg') no-repeat center center;
background-size: cover;
}
:root.has-theme-bg .bg-mesh-gradient {
background-image: none !important;
background-color: #f6f7fb !important;
}
:root.dark.has-theme-bg .bg-mesh-gradient {
background-color: #0b1020 !important;
}
:root.has-theme-bg .bg-mesh-gradient::before {
background-image: none !important;
}
:root.has-custom-bg .bg-mesh-gradient {
background-image: none !important;
background-color: var(--custom-bg-color, transparent) !important;
}
:root.has-custom-bg .bg-mesh-gradient::before {
background-image: var(--custom-bg-image, none) !important;
background-size: cover !important;
background-position: center center !important;
background-repeat: no-repeat !important;
}
@layer utilities {
.scrollbar-hide {
/* IE and Edge */
-ms-overflow-style: none;
/* Firefox */
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
registerType: "prompt",
injectRegister: "auto",
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
manifest: {