Compare commits

...

9 Commits

Author SHA1 Message Date
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
43 changed files with 2156 additions and 462 deletions
@@ -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,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.
+20 -4
View File
@@ -871,6 +871,15 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
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())
}
@@ -1528,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,
}))
}
+7
View File
@@ -74,6 +74,13 @@ func (h *Handler) validateLicenseJob() {
// 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)
}
}
+4 -1
View File
@@ -30,7 +30,10 @@ type ValidateResponse struct {
Code string `json:"code"`
} `json:"meta"`
Data struct {
ID string `json:"id"`
ID string `json:"id"`
Attributes struct {
Expiry string `json:"expiry"`
} `json:"attributes"`
} `json:"data"`
}
File diff suppressed because one or more lines are too long
+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",
+127 -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,27 @@ const LoginRoute = () => {
};
function App() {
const location = useLocation();
// 处理自定义背景图片
useEffect(() => {
const updateBg = () => {
const customBg = siteConfig.app_bg_image;
if (customBg) {
document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`);
document.documentElement.classList.add("has-custom-bg");
} else {
document.documentElement.style.removeProperty("--custom-bg-image");
document.documentElement.classList.remove("has-custom-bg");
}
};
updateBg();
window.addEventListener("site-config-updated", updateBg);
return () => {
window.removeEventListener("site-config-updated", updateBg);
};
}, []);
// 立即设置页面标题(使用已从缓存读取的配置)
useEffect(() => {
document.title = siteConfig.name;
@@ -105,106 +127,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>
);
}
+1
View File
@@ -410,6 +410,7 @@ export const importBackup = (data: BackupImportPayload) =>
export interface AnnouncementData {
content: string;
enabled: number;
update_time?: number;
}
export const getAnnouncement = () =>
+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>
);
+15 -1
View File
@@ -20,6 +20,7 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: "",
app_favicon: "",
app_bg_image: "",
is_commercial: false,
hide_footer_brand: false,
};
@@ -29,6 +30,8 @@ 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";
@@ -40,6 +43,7 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
app_bg_image: cachedAppBgImage,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
@@ -52,6 +56,7 @@ const getInitialConfig = () => {
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
app_bg_image: cachedAppBgImage,
is_commercial: isCommercial,
hide_footer_brand: hideFooterBrand,
};
@@ -123,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;
@@ -263,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()
@@ -273,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;
@@ -280,11 +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);
};
+55 -141
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,13 +42,11 @@ 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 [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | 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,34 @@ 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
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-[260px]"}
bg-white/60 dark:bg-zinc-900/60 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 +410,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 +434,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/80 dark:bg-white/10 shadow-[0_2px_8px_rgba(0,0,0,0.04)] border border-white/50 dark:border-white/5"
layoutId="sidebar-active"
transition={{
type: "spring",
@@ -457,18 +445,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 +468,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 +484,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 +504,6 @@ export default function AdminLayout({
/>
</svg>
) : (
// 向左收起的提示
<svg
className="w-5 h-5"
fill="none"
@@ -539,106 +525,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/60 dark:bg-zinc-900/60 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 +576,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/60 dark:bg-zinc-900/60 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>
);
}
+4 -4
View File
@@ -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/60 dark:bg-zinc-900/60 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/70 dark:bg-zinc-900/70 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";
+34 -2
View File
@@ -487,6 +487,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;
}
@@ -968,7 +973,7 @@ export default function ConfigPage() {
</CardHeader>
<Divider />
<CardBody className="pt-8">
<div className="flex items-end gap-3 max-w-lg">
<div className="flex items-end gap-3 max-w-lg mb-6">
<Input
label="授权激活码"
placeholder="请输入 FLVX- 开头的商业授权码"
@@ -976,7 +981,7 @@ export default function ConfigPage() {
variant="bordered"
onChange={(e) => setLicenseKeyInput(e.target.value)}
isDisabled={configs.is_commercial === "true"}
description={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"
@@ -988,6 +993,33 @@ export default function ConfigPage() {
{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>
+77 -5
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
@@ -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-default-50/50 dark:bg-default-100/20 backdrop-blur-md rounded-lg border border-default-200/50 dark:border-default-700/50 font-mono text-xs truncate text-foreground transition-colors duration-200 ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-default-100 dark:hover:bg-default-200/50" : ""}`}
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-default-50/50 dark:bg-default-100/20 backdrop-blur-md rounded-lg border border-default-200/50 dark:border-default-700/50 font-mono text-xs truncate text-foreground transition-colors duration-200 ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-default-100 dark:hover:bg-default-200/50" : ""}`}
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/70 dark:bg-zinc-900/70 backdrop-blur-xl 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,
};
};
+37 -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,11 +988,12 @@ const SortableCompactTableRow = ({
<span className="font-medium text-default-700 text-sm">
{forward.tunnelName}
</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>
)}
{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
@@ -3687,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"
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -3964,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"
@@ -3980,7 +3982,6 @@ export default function ForwardPage() {
/>
</svg>
}
variant={activeFilterCount > 0 ? "flat" : "bordered"}
onPress={() => setIsSearchModalOpen(true)}
>
高级筛选 {activeFilterCount > 0 ? `(${activeFilterCount})` : ""}
@@ -4175,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/40 dark:bg-zinc-900/40 backdrop-blur-2xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<DndContext
collisionDetection={pointerWithin}
sensors={sensors}
@@ -4188,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/40 dark:hover:bg-white/10 transition-colors group",
}}
>
<TableHeader>
@@ -4251,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>
<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">
暂无规则配置
@@ -4300,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>
<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">
暂无规则配置
@@ -4325,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/60 dark:bg-zinc-900/60 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/40 dark:bg-black/40 backdrop-blur-md px-5 py-4">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{group.userName}
@@ -4413,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/40 dark:bg-black/40 backdrop-blur-md 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/40 dark:bg-zinc-900/40 backdrop-blur-2xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
@@ -4437,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>
@@ -4559,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>
<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">
暂无规则配置
@@ -4635,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/40 dark:bg-black/40 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/20 dark:bg-black/20 space-y-3"
onToggleCollapsed={() =>
toggleTunnelGroupCollapsed(
group.userId,
@@ -4680,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">
暂无规则配置
@@ -4700,7 +4703,7 @@ export default function ForwardPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -4915,7 +4918,7 @@ export default function ForwardPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
@@ -4960,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)}
>
@@ -5005,7 +5008,7 @@ export default function ForwardPage() {
}}
isOpen={exportModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => {
setExportModalOpen(false);
@@ -5164,7 +5167,7 @@ export default function ForwardPage() {
}}
isOpen={importModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setImportModalOpen(false)}
>
@@ -5413,7 +5416,7 @@ export default function ForwardPage() {
</div>
)}
</ModalHeader>
<ModalBody className="bg-content1">
<ModalBody className="bg-transparent">
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
@@ -5937,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>
+27 -3
View File
@@ -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>
+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/60 dark:bg-zinc-900/60 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>
);
}
+5 -4
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>
@@ -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/60 dark:bg-zinc-900/60 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/60 dark:bg-zinc-900/60 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/60 dark:bg-zinc-900/60 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/40 dark:bg-black/40 backdrop-blur-lg 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")}
+21 -13
View File
@@ -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/40 dark:hover:bg-white/10 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>
@@ -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/40 dark:bg-black/40 backdrop-blur-md 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>
+2 -2
View File
@@ -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();
+174 -5
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, ChangeEvent } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
@@ -23,6 +23,7 @@ import {
deletePanelAddress,
validatePanelAddress,
} from "@/utils/panel";
import { siteConfig, updateSiteConfig } from "@/config/site";
interface PanelAddress {
name: string;
@@ -46,9 +47,100 @@ export const SettingsPage = () => {
const [forwardCompactMode, setForwardCompactMode] = useState(false);
const [forwardCompactModeSaving, setForwardCompactModeSaving] =
useState(false);
const [bgImage, setBgImage] = useState(() => siteConfig.app_bg_image || "");
const [bgImageSaving, setBgImageSaving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const admin = isAdmin();
const handleBgImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("只能上传图片文件");
return;
}
setBgImageSaving(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);
});
setBgImage(compressedImage);
// Optional: automatically save after processing
// void handleBgImageSave(compressedImage);
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片处理失败");
} finally {
setBgImageSaving(false);
// Reset input value so the same file can be selected again if needed
e.target.value = "";
}
};
const handleBgImageSave = async (valueToSave?: string) => {
if (!admin || bgImageSaving) return;
setBgImageSaving(true);
try {
const finalValue = typeof valueToSave === "string" ? valueToSave : bgImage;
const response = await updateConfig("app_bg_image", finalValue);
if (response.code === 0) {
siteConfig.app_bg_image = finalValue;
// 触发配置更新,刷新相关页面或样式
await updateSiteConfig();
window.dispatchEvent(new Event("site-config-updated"));
toast.success("背景图片设置已保存");
} else {
toast.error(response.msg || "保存背景失败");
}
} catch {
toast.error("保存背景失败");
} finally {
setBgImageSaving(false);
}
};
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
};
@@ -183,7 +275,84 @@ 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">
外观设置
</h2>
<div className="space-y-4">
<div className="flex flex-col gap-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
自定义背景图片
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
留空则使用系统默认的网格渐变背景。推荐上传 1920x1080 尺寸的图片。
</p>
<div className="flex items-center gap-4">
{bgImage ? (
<div className="relative w-40 h-24 rounded-md overflow-hidden border border-gray-200 dark:border-gray-700 group">
<img src={bgImage} alt="Background Preview" className="w-full h-full object-cover" />
</div>
) : (
<div className="flex items-center justify-center w-40 h-24 rounded-md border border-dashed border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-400 text-xs">
无自定义背景
</div>
)}
<div>
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleBgImageUpload}
/>
<Button
color="primary"
variant="flat"
size="sm"
isDisabled={!admin || bgImageSaving}
onPress={() => fileInputRef.current?.click()}
>
选择图片...
</Button>
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
{bgImage && (
<Button
color="danger"
variant="flat"
isDisabled={!admin || bgImageSaving}
onPress={() => {
setBgImage("");
void handleBgImageSave("");
}}
>
恢复默认
</Button>
)}
<Button
color="primary"
isDisabled={!admin}
isLoading={bgImageSaving}
onPress={() => handleBgImageSave()}
>
保存背景
</Button>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</div>
</CardBody>
</Card>
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
@@ -213,7 +382,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 +416,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 +444,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">
已保存的面板地址
+9 -8
View File
@@ -1583,9 +1583,10 @@ export default function TunnelPage() {
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>
@@ -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"
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -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>
<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>
+31 -27
View File
@@ -1084,9 +1084,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 +1109,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 +1250,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 +1459,7 @@ export default function UserPage() {
}}
isOpen={isUserModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onUserModalClose}
>
@@ -1698,7 +1702,7 @@ export default function UserPage() {
isDismissable={false}
isOpen={isTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onTunnelModalClose}
>
@@ -1896,9 +1900,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 +2039,7 @@ export default function UserPage() {
isDismissable={false}
isOpen={isEditTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onEditTunnelModalClose}
>
@@ -2215,7 +2219,7 @@ export default function UserPage() {
}}
isOpen={isDeleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onDeleteModalClose}
>
@@ -2261,7 +2265,7 @@ export default function UserPage() {
}}
isOpen={isDeleteTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onDeleteTunnelModalClose}
>
@@ -2309,7 +2313,7 @@ export default function UserPage() {
}}
isOpen={isResetFlowModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onResetFlowModalClose}
>
@@ -2401,7 +2405,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>
);
+49 -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,51 @@ 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: 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;
}
.dark .bg-mesh-gradient {
background: radial-gradient(at 0% 0%, #4a2b30 0%, transparent 50%),
radial-gradient(at 100% 0%, #493b45 0%, transparent 50%),
radial-gradient(at 100% 100%, #354756 0%, transparent 50%),
radial-gradient(at 0% 100%, #2f394f 0%, transparent 50%);
background-color: #1c1c1e;
}
:root.has-custom-bg .bg-mesh-gradient {
background: var(--custom-bg-image) no-repeat center center fixed !important;
background-size: cover !important;
}
@layer utilities {
.scrollbar-hide {
/* IE and Edge */
-ms-overflow-style: none;
/* Firefox */
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}