mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 08:36:07 +00:00
Compare commits
311 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f3202f431 | |||
| 0cb6568fd5 | |||
| 6a90f98412 | |||
| 9318c2105f | |||
| e642f7324e | |||
| 7214ffafc5 | |||
| 88061bac10 | |||
| b5479f3f30 | |||
| d8aedcdde4 | |||
| 5f3e9ed0ea | |||
| 3e8a0eb93e | |||
| 4c2915586c | |||
| 9f06bffbea | |||
| e20d73ba7e | |||
| 8834e5fbbe | |||
| 6d732d8d32 | |||
| e4900f1bd4 | |||
| 04828246fc | |||
| c1efc48694 | |||
| f1760b0a28 | |||
| 745e394c74 | |||
| 737300b14b | |||
| 30469fcd10 | |||
| 887fca86ec | |||
| 6efc4b0665 | |||
| 94a7dbfe3c | |||
| e2649f98df | |||
| 3d839e0ee1 | |||
| a96612f595 | |||
| 8e7d215b4a | |||
| 24cd271486 | |||
| dee2525d5f | |||
| 81b4ae5661 | |||
| 9cbba130ab | |||
| cf5767acd1 | |||
| 444b05cac9 | |||
| f70e131dfe | |||
| 14165fc54d | |||
| 7cd26a0583 | |||
| 267fb1c866 | |||
| 5ac88271af | |||
| b776b33497 | |||
| 1478124712 | |||
| 9735d26b3d | |||
| 113a29733e | |||
| 3505430e57 | |||
| f68a14a3ca | |||
| 60e2af088d | |||
| 917f9b307e | |||
| 61c84e8223 | |||
| 72d8ebd269 | |||
| b885a1f8a6 | |||
| 439f4cf1e8 | |||
| bc00d37ad8 | |||
| 12c10dbd98 | |||
| 2fd2cd0af1 | |||
| 37fb48ffff | |||
| d8198f543b | |||
| f2bc4938b7 | |||
| 7f703f927e | |||
| f2c79b57fa | |||
| c394938f01 | |||
| b84b58ef21 | |||
| 10ebc6cbdc | |||
| a1b2382877 | |||
| 59c55dfc92 | |||
| 28a3dddb60 | |||
| 39bf31bd56 | |||
| 42b2ebc00b | |||
| 3b64a62137 | |||
| 79a7e7a5b5 | |||
| 3349dcbc13 | |||
| ad30298700 | |||
| 9be11e109e | |||
| 7117d19fd1 | |||
| c88627a839 | |||
| c718e7ca5b | |||
| 6a483fa987 | |||
| 47163c1418 | |||
| 09f4f09b84 | |||
| 3313086071 | |||
| 03d8ad4d5a | |||
| a8dff126c7 | |||
| 74e97fec4c | |||
| 50603fd430 | |||
| 8bea0fde2b | |||
| b2d32f588f | |||
| 8177f6dc66 | |||
| 77d94b25d0 | |||
| 32b7ada549 | |||
| 6099a07ff0 | |||
| e9806832ec | |||
| 15ebf3df10 | |||
| d44b70682c | |||
| fb75e3d7c7 | |||
| e9979b6774 | |||
| 2b83dc047b | |||
| c90f8a05bf | |||
| 9f96ef83ec | |||
| e19061d513 | |||
| 51e2fb6dbf | |||
| f21ed92296 | |||
| 22de983752 | |||
| 0b5c239f98 | |||
| 03393c9f52 | |||
| b56db67759 | |||
| 6d05702d00 | |||
| 9791b05a4e | |||
| 0aca2d3b3d | |||
| 8529f4f0cf | |||
| abc5cf3439 | |||
| a7e7788e29 | |||
| 8620344925 | |||
| 47e229e323 | |||
| 4521beab7c | |||
| a62c637632 | |||
| 35609b7b13 | |||
| a4b1b3d06d | |||
| 5f7c7c5f3d | |||
| 6bcaf61c44 | |||
| ff25072690 | |||
| 530c1597b8 | |||
| c8e16d8c41 | |||
| 17f67ef3a5 | |||
| eb4791a1cd | |||
| 71ac920436 | |||
| e6d0c33937 | |||
| eef2d311f4 | |||
| 772d2b6de4 | |||
| 8f30d14716 | |||
| 9611c9def6 | |||
| 292eb992f4 | |||
| 814e6ad69c | |||
| 0a38624ba7 | |||
| b86473df02 | |||
| 15be803da9 | |||
| c79b45e512 | |||
| 86a8eb16b4 | |||
| 0fd0389d5c | |||
| 2983ac3f8e | |||
| 975d6d1bad | |||
| ab7a7f7c6b | |||
| 733f44ef0f | |||
| faec3ca038 | |||
| df163854bd | |||
| 1af795fad8 | |||
| 085cb8c216 | |||
| 2a9ba2badc | |||
| 53fb4fe8f9 | |||
| 8d512d55e5 | |||
| a9d8905393 | |||
| ca2fd3814f | |||
| 394fafd29b | |||
| 9f0055d193 | |||
| 88dafa6cdf | |||
| 2b3b2770b4 | |||
| 094ea9faaa | |||
| eb16cca551 | |||
| aef0503f8f | |||
| 86304226a9 | |||
| 6d0e7ec495 | |||
| 04b4fb4384 | |||
| ae5ad505d0 | |||
| c188056f64 | |||
| 0a424a9f16 | |||
| 36b2a58675 | |||
| 59e9859225 | |||
| 4e5f144def | |||
| ea53da9341 | |||
| 3e1a102e9d | |||
| d580086361 | |||
| 1e3b366fba | |||
| c2a2a36f56 | |||
| e986a133f8 | |||
| 7466916e02 | |||
| 96b568b838 | |||
| fec714a243 | |||
| e02f78ac68 | |||
| 169b216d7e | |||
| 7e6d80efa5 | |||
| 38d87230d3 | |||
| f0f98c7122 | |||
| 554981d9d3 | |||
| a08f1c6c13 | |||
| 7f7ae0c547 | |||
| 60abeaad66 | |||
| a6d0100381 | |||
| 6767f76ccf | |||
| e4add73c9e | |||
| ff72090e1a | |||
| a3e1bd59df | |||
| 5bbb48a8fd | |||
| ee84d585f9 | |||
| 7b03346cfc | |||
| 258b08fff3 | |||
| a2097ad062 | |||
| 52fdf5d429 | |||
| 34d8885075 | |||
| 5740996436 | |||
| 874aae8080 | |||
| 842fae18d7 | |||
| ccd223aeea | |||
| 96b8fe472c | |||
| 59b695ba83 | |||
| 159b85f979 | |||
| 3ec5b3589f | |||
| 2b1d3e7347 | |||
| 37f0880f8f | |||
| 5b796672e9 | |||
| 3fa0da38c9 | |||
| 8eb1225734 | |||
| e5c0fe3edf | |||
| f4057989f5 | |||
| 84013b0b3f | |||
| 511adffc5b | |||
| fc6344b840 | |||
| b3555ce1b8 | |||
| c2f409c3c4 | |||
| 0994f8756f | |||
| 4779939424 | |||
| 4a455aa532 | |||
| 25f64738e4 | |||
| 5bb87fd3d4 | |||
| 491e3f9f8b | |||
| d8fb09faae | |||
| f87c68ea68 | |||
| 687e8cf1ba | |||
| 03f04194f2 | |||
| 248700a8a3 | |||
| ff128a7275 | |||
| e8d2973be7 | |||
| f3d47ebb3f | |||
| 06c49b92f8 | |||
| e35213bc73 | |||
| aa6a886977 | |||
| 9d603c5ad2 | |||
| a973fa6d68 | |||
| 3af6497577 | |||
| c59f54bb0e | |||
| 6b3da4fe5e | |||
| ea0da32e81 | |||
| d5ea8d0f38 | |||
| fd5f591737 | |||
| 8a4c9a98cb | |||
| 70b365171f | |||
| 328ba3b45e | |||
| 5370b6943a | |||
| d8c783a296 | |||
| 809f69729a | |||
| 93b7ce199f | |||
| 2a76cec804 | |||
| 88eab032be | |||
| 20ec863f51 | |||
| 2f4018bbe5 | |||
| f273708f6d | |||
| e6318d57e4 | |||
| 77fa976ee9 | |||
| 8098d2b1b1 | |||
| a691eaea8d | |||
| da447e5669 | |||
| f8c9aac97c | |||
| e42c17f2b2 | |||
| 427b7b67d8 | |||
| ccf08086ac | |||
| 7b0a3929ff | |||
| 570ab8e5e0 | |||
| 1240e4c962 | |||
| c117b8b272 | |||
| 6041d10e3d | |||
| 4800f8fb70 | |||
| a9770e1da2 | |||
| 3f15d21f13 | |||
| a6b3623634 | |||
| 947fd4fae1 | |||
| e69a31dd59 | |||
| 719ae0e014 | |||
| 5bcf6a8aeb | |||
| 945fefde12 | |||
| 313a2acbf6 | |||
| b747730211 | |||
| 692a73788a | |||
| 3287fa4d80 | |||
| 1393f981bc | |||
| 9a2c1c6b43 | |||
| 278aa1c85c | |||
| 8fe297ef9d | |||
| c881d1015a | |||
| c061337ce7 | |||
| 260eedf8c4 | |||
| 69ccdba734 | |||
| 4c797dc154 | |||
| f000322a06 | |||
| 0ea8b5352a | |||
| 68240061aa | |||
| 0695f677ba | |||
| 70f6d6b21a | |||
| e8c509c720 | |||
| 83a1c721c7 | |||
| 7ccc0877a1 | |||
| ad659e48cf | |||
| 784ed39930 | |||
| 538f7fd5d7 | |||
| cf38226b5d | |||
| 575ee854c8 | |||
| 9936af80dd | |||
| 4a75bd0a48 | |||
| b0c223c631 | |||
| 313b51f96f | |||
| 020cd63e22 | |||
| 6e46e9b16e | |||
| 713a7328f6 |
@@ -0,0 +1,4 @@
|
||||
XUI_DEBUG=true
|
||||
XUI_DB_FOLDER=x-ui
|
||||
XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
@@ -0,0 +1,158 @@
|
||||
# 3X-UI Development Guide
|
||||
|
||||
## Project Overview
|
||||
3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
|
||||
- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
|
||||
- **xray/**: Xray-core process management and API communication for traffic monitoring
|
||||
- **database/**: GORM-based SQLite database with models in `database/model/`
|
||||
- **sub/**: Subscription server running alongside main web server (separate port)
|
||||
- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
|
||||
- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
|
||||
- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
|
||||
|
||||
### Key Architectural Patterns
|
||||
1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
|
||||
- `web/assets` → `assetsFS`
|
||||
- `web/html` → `htmlFS`
|
||||
- `web/translation` → `i18nFS`
|
||||
|
||||
2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
|
||||
|
||||
3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
|
||||
|
||||
4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
|
||||
|
||||
5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
|
||||
|
||||
## Development Workflows
|
||||
|
||||
### Building & Running
|
||||
```bash
|
||||
# Build (creates bin/3x-ui.exe)
|
||||
go run tasks.json → "go: build" task
|
||||
|
||||
# Run with debug logging
|
||||
XUI_DEBUG=true go run ./main.go
|
||||
# Or use task: "go: run"
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Command-Line Operations
|
||||
The main.go accepts flags for admin tasks:
|
||||
- `-reset` - Reset all panel settings to defaults
|
||||
- `-show` - Display current settings (port, paths)
|
||||
- Use these by running the binary directly, not via web interface
|
||||
|
||||
### Database Management
|
||||
- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
|
||||
- Models: Located in `database/model/model.go` - Auto-migrated on startup
|
||||
- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
|
||||
- Default credentials: admin/admin (hashed with bcrypt)
|
||||
|
||||
### Telegram Bot Development
|
||||
- Bot instance in `web/service/tgbot.go` (3700+ lines)
|
||||
- Uses `telego` library with long polling
|
||||
- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
|
||||
- Bot handlers use `telegohandler.BotHandler` for routing
|
||||
- i18n via embedded `i18nFS` passed to bot startup
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Service Layer Pattern
|
||||
Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
|
||||
```go
|
||||
type InboundService struct {
|
||||
xrayApi xray.XrayAPI
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||
// Business logic here
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Pattern
|
||||
Controllers use Gin context and inherit from BaseController:
|
||||
```go
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
// Use I18nWeb(c, "key") for translations
|
||||
// Check auth via checkLogin middleware
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Management
|
||||
- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
|
||||
- Config embedded files: `config/version`, `config/name`
|
||||
- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
|
||||
|
||||
### Internationalization
|
||||
- Translation files: `web/translation/<lang>.json` (one nested-namespace file per locale,
|
||||
e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
|
||||
and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
|
||||
- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
|
||||
`web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
|
||||
- Use `locale.I18nType` enum (Web, Bot).
|
||||
|
||||
## External Dependencies & Integration
|
||||
|
||||
### Xray-core
|
||||
- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
|
||||
- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
|
||||
- Process control: Start/stop via `xray/process.go`
|
||||
- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
|
||||
|
||||
### Critical External Paths
|
||||
- Xray binary: `{bin_folder}/xray-{os}-{arch}`
|
||||
- Xray config: `{bin_folder}/config.json`
|
||||
- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
|
||||
- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
|
||||
|
||||
### Job Scheduling
|
||||
Uses `robfig/cron/v3` for periodic tasks:
|
||||
- Traffic monitoring: `xray_traffic_job.go`
|
||||
- CPU alerts: `check_cpu_usage.go`
|
||||
- IP tracking: `check_client_ip_job.go`
|
||||
- LDAP sync: `ldap_sync_job.go`
|
||||
|
||||
Jobs registered in `web/web.go` during server initialization
|
||||
|
||||
## Deployment & Scripts
|
||||
|
||||
### Installation Script Pattern
|
||||
Both `install.sh` and `x-ui.sh` follow these patterns:
|
||||
- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
|
||||
- Port detection with `is_port_in_use()` using ss/netstat/lsof
|
||||
- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
|
||||
|
||||
### Docker Build
|
||||
Multi-stage Dockerfile:
|
||||
1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
|
||||
2. **Final**: Alpine-based with fail2ban pre-configured
|
||||
|
||||
### Key File Locations (Production)
|
||||
- Binary: `/usr/local/x-ui/`
|
||||
- Database: `/etc/x-ui/x-ui.db`
|
||||
- Logs: `/var/log/x-ui/`
|
||||
- Service: `/etc/systemd/system/x-ui.service.*`
|
||||
|
||||
## Testing & Debugging
|
||||
- Set `XUI_DEBUG=true` for detailed logging
|
||||
- Check Xray process: `x-ui.sh` script provides menu for status/logs
|
||||
- Database inspection: Direct SQLite access to x-ui.db
|
||||
- Traffic debugging: Check `3xipl.log` for IP limit tracking
|
||||
- Telegram bot: Logs show bot initialization and command handling
|
||||
|
||||
## Common Gotchas
|
||||
1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
|
||||
2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
|
||||
3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
|
||||
4. **Port Binding**: Subscription server uses different port from main panel
|
||||
5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
|
||||
6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
|
||||
7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
|
||||
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Cleanup Caches
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # every day
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Delete caches older than 1 day
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
|
||||
echo "Deleting caches older than: $CUTOFF_DATE"
|
||||
|
||||
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
||||
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
|
||||
|
||||
if [ -z "$CACHE_IDS" ]; then
|
||||
echo "No old caches found to delete."
|
||||
else
|
||||
echo "$CACHE_IDS" | while read CACHE_ID; do
|
||||
echo "Deleting cache: $CACHE_ID"
|
||||
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
|
||||
done
|
||||
echo "Old caches deleted successfully."
|
||||
fi
|
||||
@@ -0,0 +1,65 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "18 2 * * 2"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
env:
|
||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
|
||||
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
|
||||
# "pattern all:dist: no matching files found" unless vite emits it first.
|
||||
- name: Setup Node.js
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hsanaeii/3x-ui
|
||||
@@ -32,28 +32,26 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -2,22 +2,23 @@ name: Release 3X-UI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "**"
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.css'
|
||||
- '**.html'
|
||||
- '**.sh'
|
||||
- '**.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'x-ui.service'
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
@@ -44,6 +45,23 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
# Frontend dist must be built BEFORE go build — Go's //go:embed
|
||||
# all:dist directive in web/web.go requires web/dist/ to exist
|
||||
# at compile time. web/dist/ is .gitignored, so on a fresh CI
|
||||
# checkout it doesn't exist until vite emits it.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build 3X-UI
|
||||
run: |
|
||||
export CGO_ENABLED=1
|
||||
@@ -75,17 +93,19 @@ jobs:
|
||||
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
||||
file xui-release
|
||||
ldd xui-release || echo "Static binary confirmed"
|
||||
|
||||
|
||||
mkdir x-ui
|
||||
cp xui-release x-ui/
|
||||
cp x-ui.service x-ui/
|
||||
cp x-ui.service.debian x-ui/
|
||||
cp x-ui.service.arch x-ui/
|
||||
cp x-ui.service.rhel x-ui/
|
||||
cp x-ui.sh x-ui/
|
||||
mv x-ui/xui-release x-ui/x-ui
|
||||
mkdir x-ui/bin
|
||||
cd x-ui/bin
|
||||
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.10.15/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -124,24 +144,22 @@ jobs:
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
mv xray xray-linux-${{ matrix.platform }}
|
||||
cd ../..
|
||||
|
||||
|
||||
- name: Package
|
||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-linux-${{ matrix.platform }}
|
||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: |
|
||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
overwrite: true
|
||||
@@ -161,7 +179,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
@@ -169,21 +187,59 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Build 3X-UI for Windows
|
||||
# Frontend dist must be built BEFORE go build — see comment on the
|
||||
# Linux job above. This step is identical except npm runs on the
|
||||
# Windows runner here.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:CGO_ENABLED="1"
|
||||
$env:GOOS="windows"
|
||||
$env:GOARCH="amd64"
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-x86_64-gcc
|
||||
mingw-w64-x86_64-sqlite3
|
||||
mingw-w64-x86_64-pkg-config
|
||||
|
||||
- name: Build 3X-UI for Windows (CGO)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export CC=x86_64-w64-mingw32-gcc
|
||||
|
||||
which go
|
||||
go version
|
||||
gcc --version
|
||||
|
||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||
|
||||
|
||||
- name: Copy and download resources
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir x-ui
|
||||
Copy-Item xui-release.exe x-ui\
|
||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||
mkdir x-ui\bin
|
||||
cd x-ui\bin
|
||||
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v25.10.15/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
@@ -205,20 +261,18 @@ jobs:
|
||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-windows-amd64
|
||||
path: ./x-ui-windows-amd64.zip
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: |
|
||||
(github.event_name == 'release' && github.event.action == 'published') ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-windows-amd64.zip
|
||||
asset_name: x-ui-windows-amd64.zip
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
prerelease: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Ignore editor and IDE settings
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
.cache/
|
||||
.sync*
|
||||
|
||||
@@ -32,6 +33,8 @@ Thumbs.db
|
||||
# Ignore Go build files
|
||||
*.exe
|
||||
x-ui.db
|
||||
x-ui.db-shm
|
||||
x-ui.db-wal
|
||||
|
||||
# Ignore Docker specific files
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
## Local Development Setup
|
||||
|
||||
- Create a directory named `x-ui` in the project root
|
||||
- Rename `.env.example` to `.env `
|
||||
- Run `main.go`
|
||||
+56
-2
@@ -1,7 +1,61 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Start fail2ban
|
||||
[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
|
||||
# Start fail2ban with the 3x-ipl jail
|
||||
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
|
||||
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||
mkdir -p "$LOG_FOLDER"
|
||||
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
|
||||
|
||||
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
|
||||
|
||||
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
|
||||
[3x-ipl]
|
||||
enabled=true
|
||||
backend=auto
|
||||
filter=3x-ipl
|
||||
action=3x-ipl
|
||||
logpath=$LOG_FOLDER/3xipl.log
|
||||
maxretry=1
|
||||
findtime=32
|
||||
bantime=30m
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[Definition]
|
||||
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
|
||||
[Definition]
|
||||
actionstart = <iptables> -N f2b-<name>
|
||||
<iptables> -A f2b-<name> -j <returntype>
|
||||
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
||||
|
||||
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
<actionflush>
|
||||
<iptables> -X f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
EOF
|
||||
|
||||
fail2ban-client -x start
|
||||
fi
|
||||
|
||||
# Run x-ui
|
||||
exec /app/x-ui
|
||||
|
||||
+8
-8
@@ -27,14 +27,14 @@ case $1 in
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.10.15/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
cd ../../
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
cd ../../
|
||||
|
||||
+18
-3
@@ -1,17 +1,29 @@
|
||||
# ========================================================
|
||||
# Stage: Frontend (Vite)
|
||||
# ========================================================
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
COPY web/translation /src/web/translation
|
||||
RUN npm run build
|
||||
|
||||
# ========================================================
|
||||
# Stage: Builder
|
||||
# ========================================================
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk --no-cache --update add \
|
||||
build-base \
|
||||
gcc \
|
||||
wget \
|
||||
curl \
|
||||
unzip
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /src/web/dist ./web/dist
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
||||
@@ -29,11 +41,14 @@ RUN apk add --no-cache --update \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
fail2ban \
|
||||
bash
|
||||
bash \
|
||||
curl \
|
||||
openssl
|
||||
|
||||
COPY --from=builder /app/build/ /app/
|
||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
||||
COPY --from=builder /app/web/translation /app/web/translation
|
||||
|
||||
|
||||
# Configure fail2ban
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -12,8 +12,8 @@
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
|
||||
|
||||
@@ -53,4 +53,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
|
||||
## النجوم عبر الزمن
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -12,8 +12,8 @@
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
|
||||
|
||||
@@ -54,4 +54,4 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
|
||||
|
||||
## Estrellas a lo Largo del Tiempo
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -12,8 +12,8 @@
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکلهای مختلف VPN و پراکسی ارائه میدهد.
|
||||
|
||||
@@ -54,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
|
||||
## ستارهها در طول زمان
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -12,13 +12,13 @@
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is only for personal using, please do not use it for illegal purposes, please do not use it in a production environment.
|
||||
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||
|
||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -12,8 +12,8 @@
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
|
||||
|
||||
@@ -54,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
|
||||
## Звезды с течением времени
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -12,8 +12,8 @@
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v2)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
|
||||
|
||||
@@ -54,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
|
||||
## 随时间变化的星标数
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ func GetLogFolder() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(".", "log")
|
||||
}
|
||||
return "/var/log"
|
||||
return "/var/log/x-ui"
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.8.5
|
||||
3.0.1
|
||||
+40
-12
@@ -6,16 +6,16 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/config"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -38,6 +38,8 @@ func initModels() error {
|
||||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
@@ -87,11 +89,17 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
return db.Create(hashSeeder).Error
|
||||
} else {
|
||||
var seedersHistory []string
|
||||
db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory)
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
db.Find(&users)
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
@@ -99,7 +107,10 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
db.Model(&user).Update("password", hashedPassword)
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
@@ -122,7 +133,7 @@ func isTableEmpty(tableName string) (bool, error) {
|
||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||
func InitDB(dbPath string) error {
|
||||
dir := path.Dir(dbPath)
|
||||
err := os.MkdirAll(dir, fs.ModePerm)
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -138,11 +149,29 @@ func InitDB(dbPath string) error {
|
||||
c := &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
}
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), c)
|
||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := initModels(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -175,9 +204,8 @@ func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// IsNotFound checks if the given error is a GORM record not found error.
|
||||
func IsNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||
|
||||
+90
-19
@@ -4,8 +4,8 @@ package model
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
// Protocol represents the protocol type for Xray inbounds.
|
||||
@@ -21,8 +21,21 @@ const (
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
|
||||
// settings.version to discriminate. Imports from outside the panel
|
||||
// can carry the literal "hysteria2" string, so IsHysteria below
|
||||
// accepts both.
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
||||
// with the literal v2 string would otherwise fall through (#4081).
|
||||
func IsHysteria(p Protocol) bool {
|
||||
return p == Hysteria || p == Hysteria2
|
||||
}
|
||||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
@@ -53,6 +66,12 @@ type Inbound struct {
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
|
||||
// NodeID points at the remote panel (Node) where this inbound's xray
|
||||
// actually runs. NULL means the inbound runs on the local xray (the
|
||||
// pre-multi-node behaviour). Existing rows migrate to NULL with no
|
||||
// backfill.
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
@@ -80,9 +99,12 @@ type HistoryOfSeeders struct {
|
||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
if listen != "" {
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
// Default to 0.0.0.0 (all interfaces) when listen is empty
|
||||
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
return &xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(listen),
|
||||
Port: i.Port,
|
||||
@@ -101,21 +123,70 @@ type Setting struct {
|
||||
Value string `json:"value" form:"value"`
|
||||
}
|
||||
|
||||
// Node represents a remote 3x-ui panel registered with the central panel.
|
||||
// The central panel polls each node's existing /panel/api/server/status
|
||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
// truthful without us having to read LastHeartbeat separately.
|
||||
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
XrayVersion string `json:"xrayVersion"`
|
||||
CpuPct float64 `json:"cpuPct"`
|
||||
MemPct float64 `json:"memPct"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||
LastError string `json:"lastError"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
type CustomGeoResource struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||
Url string `json:"url" gorm:"not null"`
|
||||
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password"` // Client password
|
||||
Flow string `json:"flow"` // Flow control (XTLS)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsHysteria(t *testing.T) {
|
||||
cases := []struct {
|
||||
in Protocol
|
||||
want bool
|
||||
}{
|
||||
{Hysteria, true},
|
||||
{Hysteria2, true},
|
||||
{VLESS, false},
|
||||
{Shadowsocks, false},
|
||||
{Protocol(""), false},
|
||||
{Protocol("hysteria3"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsHysteria(c.in); got != c.want {
|
||||
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,4 +13,4 @@ services:
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
tty: true
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.vite/
|
||||
*.log
|
||||
@@ -0,0 +1,75 @@
|
||||
# 3x-ui frontend
|
||||
|
||||
Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
|
||||
panel route — built into `../web/dist/` and embedded into the Go binary
|
||||
via `embed.FS`.
|
||||
|
||||
## Dev
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
|
||||
proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
|
||||
first (`go run main.go`) and then Vite.
|
||||
|
||||
The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
||||
`/panel/xray` to the matching Vite-served HTML in dev mode (see
|
||||
`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
|
||||
production-style links work without round-tripping through Go.
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
||||
`assets/`). The Go binary embeds this directory at compile time and
|
||||
`web/controller/dist.go` serves the per-page HTML.
|
||||
|
||||
## Lint
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
|
||||
ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
|
||||
plus a few rule overrides for the project's formatting style.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html # Vite entry HTML, one per panel route
|
||||
├── eslint.config.js
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── entries/ # Per-page bootstrap (createApp + mount)
|
||||
├── pages/ # One folder per route, each with the page
|
||||
│ ├── index/ # component + helpers + sub-components
|
||||
│ ├── login/
|
||||
│ ├── inbounds/
|
||||
│ ├── xray/
|
||||
│ ├── settings/
|
||||
│ └── sub/
|
||||
├── components/ # Cross-page Vue components
|
||||
├── composables/ # Reusable reactive logic (useTheme, …)
|
||||
├── api/ # Axios setup, CSRF interceptor
|
||||
├── i18n/ # vue-i18n init (locales live in web/translation/)
|
||||
├── models/ # Inbound, Outbound, Status, … domain classes
|
||||
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
||||
```
|
||||
|
||||
## Adding a new page
|
||||
|
||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
|
||||
2. Add `src/entries/<page>.js` that imports the page component and
|
||||
mounts it.
|
||||
3. Add the page component under `src/pages/<page>/`.
|
||||
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add
|
||||
it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
|
||||
6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
import js from '@eslint/js';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import vueParser from 'vue-eslint-parser';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.{js,vue}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: false },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
// Legacy script tags inject a couple of helpers on window before
|
||||
// the SPA boots; declared here so no-undef stops flagging them.
|
||||
getRandomRealityTarget: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-case-declarations': 'off',
|
||||
|
||||
// Stylistic rules from vue/recommended that don't match the
|
||||
// existing codebase formatting. Disable rather than churn the
|
||||
// whole tree to satisfy them.
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/html-closing-bracket-newline': 'off',
|
||||
'vue/attributes-order': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/order-in-components': 'off',
|
||||
'vue/attribute-hyphenation': 'off',
|
||||
'vue/v-on-event-hyphenation': 'off',
|
||||
|
||||
// Pervasive in form components ported from the Vue 2 codebase
|
||||
// (parent passes a reactive object; child mutates it in place).
|
||||
// Properly fixing this means rewiring those components to emit
|
||||
// updates — a meaningful architectural change, separate task.
|
||||
'vue/no-mutating-props': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/inbounds.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>3x-ui — Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/nodes.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2742
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.20",
|
||||
"otpauth": "^9.5.1",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.4",
|
||||
"vue3-persian-datetime-picker": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.11",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"moment-jalaali": "^0.10.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,126 @@
|
||||
import axios from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
// Public CSRF endpoint — works pre-login (the panel-scoped
|
||||
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
|
||||
// login page that hasn't authenticated yet).
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
// Cached session CSRF token. The legacy panel injects it via a
|
||||
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
|
||||
// fetch it once from /panel/csrf-token instead. Module-level so
|
||||
// every axios POST sees the latest value.
|
||||
let csrfToken = null;
|
||||
let csrfFetchPromise = null;
|
||||
|
||||
function readMetaToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
// Fetch the token via a bare fetch() (not axios) so the call doesn't
|
||||
// recurse through this same interceptor.
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||
: CSRF_TOKEN_PATH);
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCsrfToken() {
|
||||
if (csrfToken) return csrfToken;
|
||||
const meta = readMetaToken();
|
||||
if (meta) {
|
||||
csrfToken = meta;
|
||||
return csrfToken;
|
||||
}
|
||||
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
|
||||
const fetched = await csrfFetchPromise;
|
||||
csrfFetchPromise = null;
|
||||
if (fetched) csrfToken = fetched;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
// Apply the panel's axios defaults + interceptors. Call once at app
|
||||
// startup before any HTTP call goes out.
|
||||
export function setupAxios() {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
|
||||
// Seed the cache from the meta tag if a server-rendered page injected
|
||||
// one — saves a round trip on legacy templates that still embed it.
|
||||
csrfToken = readMetaToken();
|
||||
|
||||
axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
config.headers = config.headers || {};
|
||||
const method = (config.method || 'get').toUpperCase();
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) config.headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
// 401 → session is gone. In production, the panel routes
|
||||
// are gated by Go's checkLogin which redirects to base_path
|
||||
// serving the login page; a reload is enough. In dev, Vite
|
||||
// serves /index.html directly at "/", so a reload would put
|
||||
// the user right back on the dashboard and the interceptor
|
||||
// would loop. Navigate to the dev login entry instead.
|
||||
if (import.meta.env.DEV) {
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.href = `${basePath}login.html`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||
csrfToken = null;
|
||||
cfg.__csrfRetried = true;
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers['X-CSRF-Token'] = token;
|
||||
// axios re-stringifies on retry, so unwind our qs.stringify before
|
||||
// letting the same request flow through the interceptor again.
|
||||
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
||||
return axios(cfg);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* WebSocket client for real-time panel updates.
|
||||
*
|
||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
||||
* - connect() — open the connection (idempotent)
|
||||
* - disconnect() — close and stop reconnecting
|
||||
* - on(event, callback) — subscribe to event
|
||||
* - off(event, callback) — unsubscribe
|
||||
* - send(data) — send JSON to the server
|
||||
* - isConnected — boolean, current state
|
||||
* - reconnectAttempts — number, attempts since last success
|
||||
* - maxReconnectAttempts — number, give-up threshold
|
||||
*
|
||||
* Built-in events:
|
||||
* 'connected', 'disconnected', 'error', 'message',
|
||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
||||
// cadence rather than giving up forever — a panel that recovers an hour
|
||||
// later should reconnect without a manual page reload.
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map(); // event → Set<callback>
|
||||
}
|
||||
|
||||
// Open the connection. Safe to call repeatedly — no-op if already
|
||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
||||
// any pending reconnect timer so an external connect() can't race a
|
||||
// delayed retry into spawning a second socket.
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
// Unsubscribe from an event.
|
||||
off(event, callback) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
// Send JSON to the server. Drops silently if not connected — callers
|
||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── internals ─────
|
||||
|
||||
#openSocket() {
|
||||
const url = this.#buildUrl();
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
// Every handler must check `this.ws !== socket` first. A previous socket
|
||||
// can still fire events (especially `close`) after we've moved on to a
|
||||
// new one — e.g. connect() called while the old socket is in CLOSING
|
||||
// state. Without the guard, a stale close would null out the freshly
|
||||
// opened socket and silently break send().
|
||||
socket.addEventListener('open', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
this.#onMessage(event);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||
// — bad networks would flood the console otherwise.
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||
// by the Go binary in production. In dev (Vite serves directly) the
|
||||
// global is missing and basePath would be '' — without the fallback to
|
||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||
// constructor throws a SyntaxError.
|
||||
let basePath = this.basePath || '/';
|
||||
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
||||
if (!basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event) {
|
||||
const data = event.data;
|
||||
// Reject oversized payloads up front. We compare actual UTF-8 byte
|
||||
// length (via Blob.size) against the limit — string.length counts
|
||||
// UTF-16 code units, which can undercount real bytes by up to 4× for
|
||||
// payloads with non-ASCII characters and bypass the cap.
|
||||
if (typeof data === 'string') {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
this.#emit(message.type, message.payload, message.time);
|
||||
this.#emit('message', message);
|
||||
}
|
||||
|
||||
#emit(event, ...args) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
// Exponential backoff inside the active window.
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
// Active window exhausted — keep trying once a minute. The page-level
|
||||
// polling fallback runs in parallel; this just brings WS back when the
|
||||
// network recovers.
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
// clearTimeout doesn't cancel a callback that has already fired but
|
||||
// whose macrotask hasn't run yet — re-check shouldReconnect here so
|
||||
// disconnect() called in that window can't be overridden.
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
ClusterOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
MenuOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
|
||||
const props = defineProps({
|
||||
basePath: { type: String, default: '' },
|
||||
// Current request URI so the matching menu item highlights.
|
||||
requestUri: { type: String, default: '' },
|
||||
});
|
||||
|
||||
|
||||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
||||
]);
|
||||
|
||||
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
|
||||
const activeTab = ref([props.requestUri]);
|
||||
const drawerOpen = ref(false);
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
const drawerWidth = 'min(82vw, 320px)';
|
||||
|
||||
function openLink(key) {
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapse(isCollapsed, type) {
|
||||
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
|
||||
collapsed.value = isCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
drawerOpen.value = !drawerOpen.value;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('theme-cycle');
|
||||
if (!theme.isDark) {
|
||||
toggleTheme();
|
||||
if (theme.isUltra) toggleUltra();
|
||||
} else if (!theme.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ant-sidebar">
|
||||
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
|
||||
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||
:wrap-style="{ padding: 0 }" :width="drawerWidth"
|
||||
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||
:header-style="{ display: 'none' }" @close="closeDrawer">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-brand">3X-UI</span>
|
||||
<div class="drawer-header-actions">
|
||||
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
|
||||
@click="toggleDrawer">
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed sider only has room for the '3X' brand — center it and
|
||||
* hide the theme cycle button (which is `v-if`-ed out in template). */
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item .anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-content) {
|
||||
color: var(--dark-color-text-primary);
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-title) {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import PersianDatePicker from 'vue3-persian-datetime-picker';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
|
||||
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
|
||||
// when the panel's "Calendar Type" setting is `jalalian`.
|
||||
//
|
||||
// The v-model contract matches AD-Vue: the parent works with a dayjs
|
||||
// object (or null). For the persian picker we serialize to/from the
|
||||
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
|
||||
// know which renderer is active.
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [Object, null], default: null },
|
||||
showTime: { type: Boolean, default: true },
|
||||
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value']);
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
const isJalali = computed(() => datepicker.value === 'jalalian');
|
||||
|
||||
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
// Persian picker's display format — `j…` tokens come from moment-jalaali
|
||||
// and render Jalali year/month/day.
|
||||
const persianDisplayFormat = computed(() =>
|
||||
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
|
||||
);
|
||||
|
||||
// Persian picker stores the date as a Gregorian string in the format
|
||||
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
|
||||
// dayjs(...) round-trips cleanly.
|
||||
const stringValue = computed({
|
||||
get() {
|
||||
const v = props.value;
|
||||
if (!v) return '';
|
||||
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
|
||||
},
|
||||
set(next) {
|
||||
if (!next) {
|
||||
emit('update:value', null);
|
||||
return;
|
||||
}
|
||||
const parsed = dayjs(next, ISO_FORMAT);
|
||||
emit('update:value', parsed.isValid() ? parsed : null);
|
||||
},
|
||||
});
|
||||
|
||||
function onAntChange(next) {
|
||||
emit('update:value', next || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
|
||||
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
|
||||
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
|
||||
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
|
||||
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jalali-datepicker {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
|
||||
by default (its tokens live in JS), so we hardcode hexes per theme
|
||||
class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
|
||||
for the neutral ultra-dark variant. The popup stays inside the
|
||||
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
|
||||
<style>
|
||||
/* ===== Light (default) =================================================== */
|
||||
|
||||
.persian-datepicker-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 11px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Light theme keeps the picker's brand-blue calendar button (set via
|
||||
* inline style on .vpd-icon-btn) — only its border + corner radius are
|
||||
* normalized so it sits flush with the input. Dark/ultra-dark themes
|
||||
* below override the inline blue so the control matches the form. */
|
||||
.vpd-main .vpd-icon-btn {
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
/* Match the input's left edge (no rounded left, no double border at the
|
||||
* seam) so it sits flush against the icon-btn. */
|
||||
.persian-datepicker-input {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.vpd-main .vpd-clear-btn {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
|
||||
* fits flush. Don't add `border` here — box-sizing: border-box would
|
||||
* eat 2px from the content width and the 7th day-cell of each row
|
||||
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
|
||||
.vpd-wrapper .vpd-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header .vpd-year-label,
|
||||
.vpd-wrapper .vpd-header .vpd-date,
|
||||
.vpd-wrapper .vpd-header .vpd-locales li {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-month-label,
|
||||
.vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-week,
|
||||
.vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* The picker's <arrow> component renders an inline SVG with a hardcoded
|
||||
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
|
||||
* is visible in every theme. */
|
||||
.vpd-wrapper .vpd-next svg path,
|
||||
.vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #1677ff;
|
||||
}
|
||||
|
||||
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
|
||||
* is invisible on white and dark themes alike. Reset the day text color
|
||||
* across all states so days are always readable. */
|
||||
.vpd-wrapper .vpd-day,
|
||||
.vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.88) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day[disabled='true'],
|
||||
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
|
||||
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list,
|
||||
.vpd-wrapper .vpd-addon-list-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
.vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ===== Dark (navy) ======================================================= */
|
||||
|
||||
body.dark .persian-datepicker-input {
|
||||
background: #252526;
|
||||
border-color: #3c3c3c;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
body.dark .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #3c3c3c !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-content {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-week,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-next svg path,
|
||||
body.dark .vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day,
|
||||
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'],
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Ultra-dark (neutral black) ======================================= */
|
||||
|
||||
html[data-theme='ultra-dark'] .persian-datepicker-input {
|
||||
background: #0a0a0a;
|
||||
border-color: #303030;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #303030 !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
|
||||
background: #141414;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #141414;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,510 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
|
||||
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
|
||||
// inbound and outbound modals — they share the same StreamSettings
|
||||
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
|
||||
// component handles both. The host modal passes its protocol through
|
||||
// so we know whether to show only the Hysteria-specific UDP types.
|
||||
const props = defineProps({
|
||||
stream: { type: Object, required: true },
|
||||
protocol: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
|
||||
const network = computed(() => props.stream?.network || '');
|
||||
|
||||
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
|
||||
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
|
||||
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
|
||||
|
||||
// Reset the per-row settings shape when the user picks a different
|
||||
// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
|
||||
function changeMaskType(mask, type) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
// Special case from the legacy form: switching a UDP mask to xdns
|
||||
// shrinks the kcp MTU; everything else needs the default 1350.
|
||||
function changeUdpMaskType(mask, type) {
|
||||
changeMaskType(mask, type);
|
||||
if (network.value === 'kcp' && props.stream.kcp) {
|
||||
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
}
|
||||
|
||||
// header-custom and noise rows share the same per-item shape — the
|
||||
// type select rewires the packet field. Pulled out so the click
|
||||
// handlers in the template stay readable.
|
||||
function changeItemType(item, type) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') { item.rand = 0; item.packet = []; }
|
||||
else item.packet = '';
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
|
||||
props.stream.addUdpMask(def);
|
||||
}
|
||||
|
||||
function newClientServerItem() {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem() {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem() {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
|
||||
:wrapper-col="{ md: { span: 14 } }">
|
||||
<!-- ============================== TCP MASKS ============================== -->
|
||||
<template v-if="showTcp">
|
||||
<a-form-item label="TCP Masks">
|
||||
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
TCP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delTcpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
|
||||
<a-select-option value="fragment">Fragment</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Fragment -->
|
||||
<template v-if="mask.type === 'fragment'">
|
||||
<a-form-item label="Packets">
|
||||
<a-select v-model:value="mask.settings.packets">
|
||||
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||
<a-select-option value="1-3">1-3</a-select-option>
|
||||
<a-select-option value="1-5">1-5</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Length">
|
||||
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Split">
|
||||
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Sudoku -->
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom — clients/servers as 2D groups -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<!-- Clients -->
|
||||
<a-form-item label="Clients">
|
||||
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Clients Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.clients.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Servers -->
|
||||
<a-form-item label="Servers">
|
||||
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Servers Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.servers.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== UDP MASKS ============================== -->
|
||||
<template v-if="showUdp">
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delUdpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
|
||||
<template v-if="isHysteria">
|
||||
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
|
||||
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
|
||||
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
|
||||
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
|
||||
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
|
||||
:token-separators="[',']" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Noise -->
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model:value="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise {{ ni + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.noise.splice(ni, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="n.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="n.type === 'base64'" compact>
|
||||
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="n.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="n.delay" placeholder="10-20" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom (UDP) — flat client/server lists -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client {{ ci + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.client.splice(ci, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="c.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="c.type === 'base64'" compact>
|
||||
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="c.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-divider :style="{ margin: '0' }" />
|
||||
<a-form-item label="Server">
|
||||
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server {{ si + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.server.splice(si, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="s.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="s.type === 'base64'" compact>
|
||||
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="s.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- xICMP -->
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model:value="mask.settings.id" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== QUIC PARAMS ============================== -->
|
||||
<template v-if="showQuic">
|
||||
<a-form-item label="QUIC Params">
|
||||
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
|
||||
<a-form-item label="Congestion">
|
||||
<a-select v-model:value="stream.finalmask.quicParams.congestion">
|
||||
<a-select-option value="reno">Reno</a-select-option>
|
||||
<a-select-option value="bbr">BBR</a-select-option>
|
||||
<a-select-option value="brutal">Brutal</a-select-option>
|
||||
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Debug">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
|
||||
</a-form-item>
|
||||
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
|
||||
<a-form-item label="Brutal Up">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Brutal Down">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="UDP Hop">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
|
||||
<a-form-item label="Hop Ports">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Hop Interval (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Max Idle Timeout (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Disable Path MTU Dis">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Incoming Streams">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
|
||||
placeholder="1024 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
|
||||
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
|
||||
// so the inbound list and client row table use this SVG instead. The
|
||||
// path matches what the legacy panel embedded.
|
||||
defineProps({
|
||||
width: { type: [String, Number], default: 14 },
|
||||
height: { type: [String, Number], default: 10 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
|
||||
style="vertical-align: -1px; display: inline-block;">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Generic prompt modal — used by features like "import inbound" that
|
||||
// need a free-form text/textarea input and a confirm callback. The
|
||||
// parent owns the action; this component only surfaces the value via
|
||||
// the `confirm` event when the user clicks OK.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
okText: { type: String, default: 'OK' },
|
||||
// 'text' = single-line input; 'textarea' = multi-line.
|
||||
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
|
||||
initialValue: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const value = ref('');
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) value.value = props.initialValue;
|
||||
});
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function ok() { emit('confirm', value.value); }
|
||||
|
||||
// Enter submits when single-line; ctrl+S submits in textarea mode
|
||||
// (matches legacy keybindings).
|
||||
function onKeydown(e) {
|
||||
if (props.type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
return;
|
||||
}
|
||||
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
|
||||
:confirm-loading="loading" @ok="ok" @cancel="close">
|
||||
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
|
||||
@keydown="onKeydown" />
|
||||
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
paddings: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['small', 'default'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const padding = computed(() =>
|
||||
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list-item :style="{ padding }">
|
||||
<a-row :gutter="[8, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<template #description>
|
||||
<slot name="description" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<slot name="control" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, required: true },
|
||||
labels: { type: Array, default: () => [] },
|
||||
vbWidth: { type: Number, default: 320 },
|
||||
height: { type: Number, default: 80 },
|
||||
stroke: { type: String, default: '#008771' },
|
||||
strokeWidth: { type: Number, default: 2 },
|
||||
maxPoints: { type: Number, default: 120 },
|
||||
showGrid: { type: Boolean, default: true },
|
||||
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||
fillOpacity: { type: Number, default: 0.15 },
|
||||
showMarker: { type: Boolean, default: true },
|
||||
markerRadius: { type: Number, default: 2.8 },
|
||||
showAxes: { type: Boolean, default: false },
|
||||
yTickStep: { type: Number, default: 25 },
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 32 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
showTooltip: { type: Boolean, default: false },
|
||||
// Value-range customization. When valueMax is null the chart auto-scales
|
||||
// to the running max of the data (useful for unbounded series like
|
||||
// network throughput or online clients). Defaults preserve the legacy
|
||||
// 0..100 percent behavior so existing callers don't need to change.
|
||||
valueMin: { type: Number, default: 0 },
|
||||
valueMax: { type: [Number, null], default: 100 },
|
||||
// Y-axis tick formatter. Receives the raw value, returns the label.
|
||||
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
|
||||
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
|
||||
tooltipFormatter: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const hoverIdx = ref(-1);
|
||||
|
||||
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
|
||||
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
|
||||
// stretches the X axis and squashes axis text horizontally on narrow
|
||||
// containers (mobile). Falls back to the prop until the first measure.
|
||||
const svgRef = ref(null);
|
||||
const measuredWidth = ref(0);
|
||||
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
|
||||
|
||||
let resizeObserver = null;
|
||||
function measure() {
|
||||
const el = svgRef.value;
|
||||
if (!el) return;
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) measuredWidth.value = Math.round(w);
|
||||
}
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
|
||||
resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(svgRef.value);
|
||||
} else {
|
||||
window.addEventListener('resize', measure);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
else window.removeEventListener('resize', measure);
|
||||
});
|
||||
|
||||
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
|
||||
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
|
||||
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||
|
||||
const dataSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
return props.data.slice(props.data.length - n);
|
||||
});
|
||||
|
||||
const labelsSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (!props.labels?.length || n === 0) return [];
|
||||
const start = Math.max(0, props.labels.length - n);
|
||||
return props.labels.slice(start);
|
||||
});
|
||||
|
||||
// Resolved domain. When valueMax is null we auto-scale; pad the upper
|
||||
// bound by 10% so the line never touches the top edge — looks more
|
||||
// natural and gives the axis a sane ceiling. Floor the dynamic range
|
||||
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
|
||||
const yDomain = computed(() => {
|
||||
const min = props.valueMin;
|
||||
if (props.valueMax != null) return { min, max: props.valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice.value) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
});
|
||||
|
||||
function project(v) {
|
||||
const { min, max } = yDomain.value;
|
||||
const span = max - min;
|
||||
if (span <= 0) return props.paddingTop + drawHeight.value;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
|
||||
}
|
||||
|
||||
const pointsArr = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const slice = dataSlice.value;
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
return slice.map((v, i) => {
|
||||
const x = Math.round(props.paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
});
|
||||
|
||||
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
|
||||
|
||||
const areaPath = computed(() => {
|
||||
if (pointsArr.value.length === 0) return '';
|
||||
const first = pointsArr.value[0];
|
||||
const last = pointsArr.value[pointsArr.value.length - 1];
|
||||
const baseY = props.paddingTop + drawHeight.value;
|
||||
const line = pointsStr.value.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
});
|
||||
|
||||
const gridLines = computed(() => {
|
||||
if (!props.showGrid) return [];
|
||||
const h = drawHeight.value;
|
||||
const w = drawWidth.value;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(props.paddingTop + h * r);
|
||||
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
|
||||
});
|
||||
});
|
||||
|
||||
const lastPoint = computed(() => {
|
||||
if (pointsArr.value.length === 0) return null;
|
||||
return pointsArr.value[pointsArr.value.length - 1];
|
||||
});
|
||||
|
||||
// Y-axis tick rendering. We pick a small number of evenly spaced values
|
||||
// inside the resolved domain and run them through yFormatter — that's
|
||||
// what makes "MB/s" / "clients" / "%" all render correctly without the
|
||||
// caller having to subclass the component.
|
||||
const yTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const { min, max } = yDomain.value;
|
||||
const out = [];
|
||||
// For percent-style domains keep the legacy fixed step; otherwise
|
||||
// default to 4 evenly spaced ticks (5 lines including the bottom).
|
||||
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += props.yTickStep) {
|
||||
const y = project(p);
|
||||
out.push({ y, label: props.yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: props.yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const xTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const labels = labelsSlice.value;
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const m = Math.max(2, props.tickCountX);
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
const out = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (n - 1)) / (m - 1));
|
||||
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
|
||||
const x = Math.round(props.paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onMouseMove(evt) {
|
||||
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth.value;
|
||||
const n = nPoints.value;
|
||||
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||
hoverIdx.value = idx;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hoverIdx.value = -1;
|
||||
}
|
||||
|
||||
function fmtHoverText() {
|
||||
const idx = hoverIdx.value;
|
||||
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||
const raw = Number(dataSlice.value[idx] || 0);
|
||||
const fmt = props.tooltipFormatter || props.yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}
|
||||
|
||||
// Stable per-instance gradient id so multiple sparklines on a page
|
||||
// don't clobber each other's <defs id="spkGrad">.
|
||||
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
|
||||
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<defs>
|
||||
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
|
||||
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g v-if="showGrid">
|
||||
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
|
||||
stroke-width="1" class="cpu-grid-line" />
|
||||
</g>
|
||||
|
||||
<g v-if="showAxes">
|
||||
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
|
||||
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
|
||||
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
|
||||
text-anchor="middle" font-size="10">{{ t.label }}</text>
|
||||
</g>
|
||||
|
||||
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
|
||||
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
|
||||
|
||||
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
|
||||
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
|
||||
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
|
||||
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
|
||||
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
|
||||
font-size="11">{{ fmtHoverText() }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
|
||||
reliably hash-attribute SVG descendants, so the dark-mode overrides
|
||||
have to live in a non-scoped block to actually take effect. The
|
||||
numbers are also small, so the dark-theme fills run at ~85% opacity
|
||||
for legibility (the previous 55% was washed out on navy backgrounds). -->
|
||||
<style>
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-h-line {
|
||||
stroke: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script>
|
||||
// Use defineComponent so we can keep the parent + child components in
|
||||
// the same file with the provide() <-> inject relationship intact.
|
||||
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
|
||||
import { DragOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const ROW_CLASS = 'sortable-row';
|
||||
|
||||
// Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||
//
|
||||
// Why a custom component:
|
||||
// - Old impl set draggable: true on every row, which broke text selection
|
||||
// in cells and let HTML5 start drags from anywhere on the row. This
|
||||
// version only initiates drag from an explicit handle, via Pointer
|
||||
// Events (one API for mouse + touch + pen).
|
||||
// - During drag, data-source is reordered live; the source row visually
|
||||
// slides into the target slot. The live reorder IS the visual feedback.
|
||||
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
|
||||
// before so existing call sites stay unchanged.
|
||||
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
|
||||
// one; Escape cancels an in-flight drag.
|
||||
|
||||
export const TableSortableTrigger = defineComponent({
|
||||
name: 'TableSortableTrigger',
|
||||
props: {
|
||||
itemIndex: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const sortable = inject('sortable', null);
|
||||
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
|
||||
|
||||
function onPointerDown(e) {
|
||||
sortable?.startDrag?.(e, props.itemIndex);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const move = sortable?.moveByKeyboard;
|
||||
if (!move) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1, props.itemIndex);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(+1, props.itemIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return () => h(DragOutlined, {
|
||||
class: 'sortable-icon',
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-label': ariaLabel.value,
|
||||
onPointerdown: onPointerDown,
|
||||
onKeydown: onKeyDown,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableSortable',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
customRow: { type: Function, default: null },
|
||||
rowKey: { type: [String, Function], default: null },
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
|
||||
},
|
||||
},
|
||||
emits: ['onsort'],
|
||||
setup(props, { emit, slots, attrs, expose }) {
|
||||
// null when idle; while dragging:
|
||||
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||
const drag = ref(null);
|
||||
const rootRef = ref(null);
|
||||
|
||||
const isDragging = computed(() => drag.value !== null);
|
||||
|
||||
// Resolve the row key for a record. Used to identify the source row
|
||||
// even after data-source is reordered live during drag.
|
||||
function keyOf(record, fallback) {
|
||||
const rk = props.rowKey;
|
||||
if (typeof rk === 'function') return rk(record);
|
||||
if (typeof rk === 'string') return record?.[rk];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function attachListeners() {
|
||||
document.addEventListener('pointermove', onPointerMove, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', cancelDrag, true);
|
||||
document.addEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', cancelDrag, true);
|
||||
document.removeEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function startDrag(e, sourceIndex) {
|
||||
// Primary button only (mouse left / first touch).
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const record = props.dataSource?.[sourceIndex];
|
||||
drag.value = {
|
||||
sourceIndex,
|
||||
targetIndex: sourceIndex,
|
||||
pointerId: e.pointerId,
|
||||
sourceKey: keyOf(record, sourceIndex),
|
||||
};
|
||||
// Capture the pointer so move/up keep firing even if the cursor
|
||||
// leaves the icon. Try/catch — some older browsers throw on capture.
|
||||
if (e.target?.setPointerCapture && e.pointerId != null) {
|
||||
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
}
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
|
||||
if (!rows.length) return;
|
||||
const y = e.clientY;
|
||||
const firstRect = rows[0].getBoundingClientRect();
|
||||
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||
let target = d.targetIndex;
|
||||
if (y < firstRect.top) {
|
||||
target = 0;
|
||||
} else if (y > lastRect.bottom) {
|
||||
target = rows.length - 1;
|
||||
} else {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect();
|
||||
if (y >= rect.top && y <= rect.bottom) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target !== d.targetIndex) {
|
||||
drag.value = { ...d, targetIndex: target };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
detachListeners();
|
||||
const captured = d;
|
||||
drag.value = null;
|
||||
if (captured.sourceIndex !== captured.targetIndex) {
|
||||
emit('onsort', captured.sourceIndex, captured.targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDrag(e) {
|
||||
// Triggered by pointercancel and keydown. For keydown only act on
|
||||
// Escape; otherwise let the event propagate.
|
||||
if (e?.type === 'keydown' && e.key !== 'Escape') return;
|
||||
detachListeners();
|
||||
drag.value = null;
|
||||
}
|
||||
|
||||
function moveByKeyboard(direction, sourceIndex) {
|
||||
const target = sourceIndex + direction;
|
||||
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
|
||||
emit('onsort', sourceIndex, target);
|
||||
}
|
||||
|
||||
function customRowRender(record, index) {
|
||||
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
|
||||
const d = drag.value;
|
||||
const isSource = d && keyOf(record, index) === d.sourceKey;
|
||||
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
|
||||
// no nested props/on like Vue 2.
|
||||
return {
|
||||
...parent,
|
||||
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||
// When idle the original list is returned unchanged so a-table can
|
||||
// diff against a stable reference.
|
||||
const records = computed(() => {
|
||||
const d = drag.value;
|
||||
const src = props.dataSource ?? [];
|
||||
if (!d || d.sourceIndex === d.targetIndex) return src;
|
||||
const list = src.slice();
|
||||
const [item] = list.splice(d.sourceIndex, 1);
|
||||
list.splice(d.targetIndex, 0, item);
|
||||
return list;
|
||||
});
|
||||
|
||||
expose({ startDrag, moveByKeyboard });
|
||||
|
||||
return {
|
||||
rootRef, drag, isDragging, records, slots, attrs,
|
||||
startDrag, moveByKeyboard, customRowRender,
|
||||
};
|
||||
},
|
||||
// provide() needs to live at the options level so child components in
|
||||
// the rendered subtree resolve the same instance methods.
|
||||
provide() {
|
||||
return {
|
||||
sortable: {
|
||||
startDrag: (...a) => this.startDrag(...a),
|
||||
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
|
||||
},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', this.cancelDrag, true);
|
||||
document.removeEventListener('keydown', this.cancelDrag, true);
|
||||
},
|
||||
render() {
|
||||
// Forward every passed slot to a-table by reusing the slot fn
|
||||
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
|
||||
const tableSlots = {};
|
||||
for (const name of Object.keys(this.slots)) {
|
||||
tableSlots[name] = this.slots[name];
|
||||
}
|
||||
// Resolved at runtime so the user's app.use(Antd) registration wins;
|
||||
// avoids importing Table directly here.
|
||||
const ATable = resolveComponent('a-table');
|
||||
return h(
|
||||
'div',
|
||||
{ ref: 'rootRef' },
|
||||
[h(
|
||||
ATable,
|
||||
{
|
||||
...this.attrs,
|
||||
'data-source': this.records,
|
||||
'row-key': this.rowKey,
|
||||
customRow: this.customRowRender,
|
||||
locale: this.locale,
|
||||
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
|
||||
},
|
||||
tableSlots,
|
||||
)],
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sortable-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sortable-icon:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sortable-icon:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sortable-icon:focus-visible {
|
||||
outline: 2px solid #008771;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.light .sortable-icon {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.light .sortable-icon:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row>td {
|
||||
background: rgba(0, 135, 113, 0.10) !important;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-row>td {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging,
|
||||
.sortable-table-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
// Read-only text modal — used to surface multi-line export blobs
|
||||
// (subscription URLs, raw inbound JSON, generated share links) the
|
||||
// way the legacy txtModal did.
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
// When set, surfaces a download button that writes `content` to a
|
||||
// text file with this name.
|
||||
fileName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
async function copy(value) {
|
||||
const ok = await ClipboardManager.copyText(value || '');
|
||||
if (ok) {
|
||||
message.success('Copied');
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function download(content, name) {
|
||||
if (!name) return;
|
||||
FileManager.downloadTextFile(content, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
|
||||
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
|
||||
<template #footer>
|
||||
<a-button v-if="fileName" @click="download(content, fileName)">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
{{ fileName }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="copy(content)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-modal-content {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
|
||||
// Loaded from /panel/setting/defaultSettings on first use, so any
|
||||
// component (modals, inbound forms, future pages) can read the same
|
||||
// value without prop-drilling and without re-fetching.
|
||||
//
|
||||
// useInbounds (which already reads defaultSettings for its own state)
|
||||
// calls setDatepicker() after its fetch so we don't issue a second
|
||||
// HTTP round-trip on the inbounds page.
|
||||
|
||||
import { readonly, ref } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const datepicker = ref('gregorian');
|
||||
let fetched = false;
|
||||
let pending = null;
|
||||
|
||||
async function loadOnce() {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (msg?.success) {
|
||||
datepicker.value = msg.obj?.datepicker || 'gregorian';
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value) {
|
||||
fetched = true;
|
||||
datepicker.value = value || 'gregorian';
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
loadOnce();
|
||||
return { datepicker: readonly(datepicker) };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
|
||||
// `isMobile` ref that updates on window resize. Use inside <script setup>:
|
||||
//
|
||||
// const { isMobile } = useMediaQuery();
|
||||
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
|
||||
const compute = () => window.innerWidth <= breakpoint;
|
||||
const isMobile = ref(compute());
|
||||
|
||||
const onResize = () => {
|
||||
isMobile.value = compute();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Lightweight composable that fetches the node list once on mount and
|
||||
// exposes id→name + id→online lookups. Used by the Inbounds page so it
|
||||
// can render a Node selector and a Node column without pulling the
|
||||
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
|
||||
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export function useNodeList() {
|
||||
const nodes = ref([]);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||
if (msg?.success) {
|
||||
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
|
||||
const byId = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of nodes.value) m.set(n.id, n);
|
||||
return m;
|
||||
});
|
||||
|
||||
function nameFor(id) {
|
||||
if (id == null) return null;
|
||||
return byId.value.get(id)?.name || null;
|
||||
}
|
||||
|
||||
function isOnline(id) {
|
||||
if (id == null) return true;
|
||||
const n = byId.value.get(id);
|
||||
return n != null && n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// Polls /panel/api/server/status and exposes a reactive Status object
|
||||
// + a `fetched` flag so consumers can show a spinner before the first
|
||||
// successful fetch.
|
||||
//
|
||||
// WebSocket integration is intentionally deferred to a later sub-phase.
|
||||
// Polling at 2s is the same fallback the legacy panel falls back to
|
||||
// when its websocket link drops, so we're shipping the proven path
|
||||
// first and adding the websocket on top later.
|
||||
export function useStatus() {
|
||||
const status = shallowRef(new Status());
|
||||
const fetched = ref(false);
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
status.value = new Status(msg.obj);
|
||||
if (!fetched.value) fetched.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { reactive, computed, watchEffect } from 'vue';
|
||||
import { theme as antdTheme } from 'ant-design-vue';
|
||||
|
||||
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
|
||||
// from any component to read/toggle. Boot side-effects (apply current
|
||||
// theme to <body>/<html>) run once at module load so the page is in the
|
||||
// right theme before Vue mounts.
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key, fallback) {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
const isDark = readBool(STORAGE_DARK, true);
|
||||
const isUltra = readBool(STORAGE_ULTRA, false);
|
||||
|
||||
export const theme = reactive({
|
||||
isDark,
|
||||
isUltra,
|
||||
});
|
||||
|
||||
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
||||
|
||||
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
||||
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
|
||||
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
|
||||
// so the panel reads as a familiar modern IDE rather than the older
|
||||
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#1e1e1e',
|
||||
colorBgLayout: '#1e1e1e',
|
||||
colorBgContainer: '#252526',
|
||||
colorBgElevated: '#2d2d30',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#0a0a0a',
|
||||
colorBgElevated: '#141414',
|
||||
};
|
||||
|
||||
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
|
||||
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||
// index.js). Override at the component-token level so the sider blends
|
||||
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
|
||||
// `#252526` / `#333333` tones VS Code does for its activity bar.
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#252526',
|
||||
colorBgTrigger: '#333333',
|
||||
colorBgBody: '#1e1e1e',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0a0a0a',
|
||||
colorBgTrigger: '#141414',
|
||||
colorBgBody: '#000',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#252526',
|
||||
colorSubItemBg: '#1e1e1e',
|
||||
menuSubMenuBg: '#252526',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0a0a0a',
|
||||
colorSubItemBg: '#000',
|
||||
menuSubMenuBg: '#0a0a0a',
|
||||
};
|
||||
|
||||
export const antdThemeConfig = computed(() => {
|
||||
if (!theme.isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function toggleTheme() {
|
||||
theme.isDark = !theme.isDark;
|
||||
}
|
||||
|
||||
export function toggleUltra() {
|
||||
theme.isUltra = !theme.isUltra;
|
||||
}
|
||||
|
||||
// Briefly disable theme transition animations while a toggle is in
|
||||
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
|
||||
// behavior of preventing flicker when hovering the theme menu.
|
||||
export function pauseAnimationsUntilLeave(elementId) {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
// Apply theme to DOM and persist whenever it changes.
|
||||
watchEffect(() => {
|
||||
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
|
||||
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
|
||||
|
||||
if (theme.isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
|
||||
|
||||
// Keep the global #message container's class in sync so AD-Vue toasts
|
||||
// pick up the right styling.
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
|
||||
// One client per browser tab (= per multi-page entry). WebSocketClient is
|
||||
// idempotent: repeated connect() calls while the socket is already open
|
||||
// are no-ops, so multiple components on the same page can share a single
|
||||
// underlying connection without each spawning their own.
|
||||
let sharedClient = null;
|
||||
|
||||
function getSharedClient() {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
// useWebSocket lets a Vue component subscribe to live server-pushed
|
||||
// events. Pass a map of { eventName: handler } and the composable wires
|
||||
// connect()/disconnect() into the component lifecycle and unsubscribes
|
||||
// every handler on unmount so a stale closure can't fire after the
|
||||
// page has moved on.
|
||||
//
|
||||
// Example:
|
||||
// useWebSocket({
|
||||
// traffic: (payload) => applyTrafficEvent(payload),
|
||||
// client_stats: (payload) => applyClientStatsEvent(payload),
|
||||
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
|
||||
// });
|
||||
//
|
||||
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
|
||||
// can be subscribed to alongside server-emitted types.
|
||||
export function useWebSocket(handlers) {
|
||||
const client = getSharedClient();
|
||||
const entries = Object.entries(handlers || {});
|
||||
|
||||
onMounted(() => {
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const [event, fn] of entries) client.off(event, fn);
|
||||
// Don't disconnect — another mounted component on the same page may
|
||||
// still be subscribed. The client closes naturally on page unload.
|
||||
});
|
||||
|
||||
return { client };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing this module triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue renders anything.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
// Toasts attach to a #message div the page provides — keeps theme
|
||||
// styling in sync with the rest of the panel.
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
// The sub page is served by the subscription HTTP server (sub/sub.go)
|
||||
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
|
||||
// with the parsed traffic/quota/expiry view-model and the rendered
|
||||
// share links — the SPA reads those at mount.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import SubPage from '@/pages/sub/SubPage.vue';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(SubPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,93 @@
|
||||
// vue-i18n setup. Locale files live in web/translation/*.json — the same
|
||||
// directory the Go binary embeds, so SPA + Telegram bot + subscription
|
||||
// page all read from a single source.
|
||||
//
|
||||
// Usage in a component:
|
||||
// import { useI18n } from 'vue-i18n';
|
||||
// const { t } = useI18n();
|
||||
// ...
|
||||
// <span>{{ t('pages.inbounds.email') }}</span>
|
||||
//
|
||||
// Or via the global helper exposed on the app:
|
||||
// <span>{{ $t('pages.inbounds.email') }}</span>
|
||||
//
|
||||
// The locale follows the `lang` cookie that LanguageManager already
|
||||
// reads/writes — switching language anywhere in the app continues to
|
||||
// trigger a full page reload (matches legacy ergonomics), so we don't
|
||||
// need a runtime locale switcher here.
|
||||
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
|
||||
// Lazy-loaded locales — Vite splits each one into its own chunk. We
|
||||
// eager-load only the active language plus the en-US fallback so the
|
||||
// initial page payload stays small (the inbounds bundle was sitting
|
||||
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
|
||||
//
|
||||
// LanguageManager.setLanguage() does a full reload on change, so
|
||||
// "lazy" here effectively means "load only what this page needs for
|
||||
// its lifetime."
|
||||
const FALLBACK = 'en-US';
|
||||
const lazyModules = import.meta.glob('../../../web/translation/*.json');
|
||||
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
|
||||
|
||||
function moduleKeyFor(code) {
|
||||
return `../../../web/translation/${code}.json`;
|
||||
}
|
||||
|
||||
// Resolve the active locale via LanguageManager so the cookie set on
|
||||
// the legacy panel keeps working after a user upgrades. Falls back
|
||||
// to en-US when the cookie names a language we don't have.
|
||||
let active = LanguageManager.getLanguage();
|
||||
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
const messages = {};
|
||||
// Eagerly include the active locale + the fallback (when distinct)
|
||||
// so the very first render has strings ready. Vite still emits these
|
||||
// as their own chunks so the user pays for at most two locales.
|
||||
for (const code of new Set([active, FALLBACK])) {
|
||||
const mod = eagerModules[moduleKeyFor(code)];
|
||||
if (mod) messages[code] = mod.default || mod;
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
// `composition` mode (legacy: false) so `useI18n()` works in
|
||||
// <script setup> blocks.
|
||||
globalInjection: true,
|
||||
locale: active,
|
||||
fallbackLocale: FALLBACK,
|
||||
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
|
||||
// so vue-i18n's default `.`-delimited lookups walk straight into it.
|
||||
messages,
|
||||
// The Go side sometimes interpolates `#variable#` into translated
|
||||
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
|
||||
// expects `{var}` — disable warnings about strings that look like
|
||||
// they don't use the new syntax.
|
||||
warnHtmlMessage: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
|
||||
// Convenience export for non-component contexts (HTTP error toasts,
|
||||
// stores, etc.) that need to look up a translation outside a setup
|
||||
// scope.
|
||||
export function t(key, params) {
|
||||
return i18n.global.t(key, params || {});
|
||||
}
|
||||
|
||||
// loadLocale fetches a locale module on demand and registers it with
|
||||
// vue-i18n. Pages that switch language at runtime (rather than via
|
||||
// LanguageManager's reload) can call this to swap strings live.
|
||||
export async function loadLocale(code) {
|
||||
const key = moduleKeyFor(code);
|
||||
const loader = lazyModules[key];
|
||||
if (!loader) return false;
|
||||
const mod = await loader();
|
||||
i18n.global.setLocaleMessage(code, mod.default || mod);
|
||||
i18n.global.locale.value = code;
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
class DBInbound {
|
||||
import dayjs from 'dayjs';
|
||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||
import { Inbound, Protocols } from './inbound.js';
|
||||
|
||||
export class DBInbound {
|
||||
|
||||
constructor(data) {
|
||||
this.id = 0;
|
||||
@@ -21,6 +25,9 @@ class DBInbound {
|
||||
this.tag = "";
|
||||
this.sniffing = "";
|
||||
this.clientStats = ""
|
||||
// Optional FK to web/runtime registered Node. null/undefined =
|
||||
// local panel; otherwise the inbound lives on the named node.
|
||||
this.nodeId = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -75,7 +82,7 @@ class DBInbound {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
@@ -90,7 +97,16 @@ class DBInbound {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache() {
|
||||
this._cachedInbound = null;
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toInbound() {
|
||||
if (this._cachedInbound) {
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
||||
settings = JSON.parse(this.settings);
|
||||
@@ -116,7 +132,21 @@ class DBInbound {
|
||||
sniffing: sniffing,
|
||||
clientStats: this.clientStats,
|
||||
};
|
||||
return Inbound.fromJson(config);
|
||||
|
||||
this._cachedInbound = Inbound.fromJson(config);
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
getClientStats(email) {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (this.clientStats && Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
|
||||
isMultiUser() {
|
||||
@@ -124,6 +154,7 @@ class DBInbound {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return this.toInbound().isSSMultiUser;
|
||||
@@ -138,14 +169,15 @@ class DBInbound {
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.SHADOWSOCKS:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
genInboundLinks(remarkModel) {
|
||||
genInboundLinks(remarkModel, hostOverride = '') {
|
||||
const inbound = this.toInbound();
|
||||
return inbound.genInboundLinks(this.remark, remarkModel);
|
||||
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
export const REALITY_TARGETS = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random Reality target configuration from the predefined list
|
||||
* @returns {Object} Object with target and sni properties
|
||||
*/
|
||||
export function getRandomRealityTarget() {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
// Return a copy to avoid reference issues
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
class AllSetting {
|
||||
// Mirrors web/assets/js/model/setting.js — every field on this class is
|
||||
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
|
||||
// so adding a field here without a matching Go-side change will silently
|
||||
// drop it on save. Defaults match the legacy panel.
|
||||
|
||||
import { ObjectUtil } from '@/utils';
|
||||
|
||||
export class AllSetting {
|
||||
|
||||
constructor(data) {
|
||||
this.webListen = "";
|
||||
@@ -29,13 +36,21 @@ class AllSetting {
|
||||
this.subEnable = true;
|
||||
this.subJsonEnable = false;
|
||||
this.subTitle = "";
|
||||
this.subSupportUrl = "";
|
||||
this.subProfileUrl = "";
|
||||
this.subAnnounce = "";
|
||||
this.subEnableRouting = true;
|
||||
this.subRoutingRules = "";
|
||||
this.subListen = "";
|
||||
this.subPort = 2096;
|
||||
this.subPath = "/sub/";
|
||||
this.subJsonPath = "/json/";
|
||||
this.subClashEnable = true;
|
||||
this.subClashPath = "/clash/";
|
||||
this.subDomain = "";
|
||||
this.externalTrafficInformEnable = false;
|
||||
this.externalTrafficInformURI = "";
|
||||
this.restartXrayOnClientDisable = true;
|
||||
this.subCertFile = "";
|
||||
this.subKeyFile = "";
|
||||
this.subUpdates = 12;
|
||||
@@ -43,6 +58,7 @@ class AllSetting {
|
||||
this.subShowInfo = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
this.subJsonFragment = "";
|
||||
this.subJsonNoises = "";
|
||||
this.subJsonMux = "";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
|
||||
export class CurTotal {
|
||||
constructor(current, total) {
|
||||
this.current = current;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
get percent() {
|
||||
if (this.total === 0) return 0;
|
||||
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
|
||||
}
|
||||
|
||||
get color() {
|
||||
// Match AD-Vue 4's semantic palette so the gauges fit the
|
||||
// global blue/gold/red theme instead of the legacy teal/orange.
|
||||
const p = this.percent;
|
||||
if (p < 80) return '#1677ff'; // primary
|
||||
if (p < 90) return '#faad14'; // warning
|
||||
return '#ff4d4f'; // danger
|
||||
}
|
||||
}
|
||||
|
||||
const XRAY_STATE_COLORS = {
|
||||
running: 'green',
|
||||
stop: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
export class Status {
|
||||
constructor(data) {
|
||||
this.cpu = new CurTotal(0, 0);
|
||||
this.cpuCores = 0;
|
||||
this.logicalPro = 0;
|
||||
this.cpuSpeedMhz = 0;
|
||||
this.disk = new CurTotal(0, 0);
|
||||
this.loads = [0, 0, 0];
|
||||
this.mem = new CurTotal(0, 0);
|
||||
this.netIO = { up: 0, down: 0 };
|
||||
this.netTraffic = { sent: 0, recv: 0 };
|
||||
this.publicIP = { ipv4: 0, ipv6: 0 };
|
||||
this.swap = new CurTotal(0, 0);
|
||||
this.tcpCount = 0;
|
||||
this.udpCount = 0;
|
||||
this.uptime = 0;
|
||||
this.appUptime = 0;
|
||||
this.appStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
this.xray = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
this.cpu = new CurTotal(data.cpu, 100);
|
||||
this.cpuCores = data.cpuCores;
|
||||
this.logicalPro = data.logicalPro;
|
||||
this.cpuSpeedMhz = data.cpuSpeedMhz;
|
||||
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
|
||||
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
|
||||
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
|
||||
this.netIO = data.netIO ?? this.netIO;
|
||||
this.netTraffic = data.netTraffic ?? this.netTraffic;
|
||||
this.publicIP = data.publicIP ?? this.publicIP;
|
||||
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
|
||||
this.tcpCount = data.tcpCount ?? 0;
|
||||
this.udpCount = data.udpCount ?? 0;
|
||||
this.uptime = data.uptime ?? 0;
|
||||
this.appUptime = data.appUptime ?? 0;
|
||||
this.appStats = data.appStats ?? this.appStats;
|
||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||
import { sections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
const apiToken = ref('');
|
||||
const tokenLoading = ref(false);
|
||||
const tokenRotating = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateApiToken() {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
tokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
tokenRotating.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copy(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiToken();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<div class="docs-wrapper">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">API Documentation</h1>
|
||||
<p class="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
</div>
|
||||
<a-space size="small" wrap>
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
<EyeOutlined v-else />
|
||||
</template>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
Regenerate
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||
</a-spin>
|
||||
<p class="token-hint">
|
||||
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||
running bots will need the new value.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="curl-card" size="small" title="Quick example">
|
||||
<pre class="code-block">{{ curlExample }}</pre>
|
||||
</a-card>
|
||||
|
||||
<nav class="toc-nav">
|
||||
<span class="toc-label">On this page:</span>
|
||||
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<EndpointSection v-for="s in sections" :key="s.id" :section="s" />
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #0a0a0a;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
color: #1677ff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .token-value,
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors } from './endpoints.js';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
|
||||
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
|
||||
|
||||
const paramColumns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-header">
|
||||
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
|
||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</div>
|
||||
|
||||
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
|
||||
|
||||
<div v-if="hasParams" class="endpoint-block">
|
||||
<div class="block-label">Parameters</div>
|
||||
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.body }">
|
||||
<pre class="code-block">{{ endpoint.body }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.response }">
|
||||
<pre class="code-block">{{ endpoint.response }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.endpoint-row {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
|
||||
defineProps({
|
||||
section: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="section.id" class="api-section">
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
<p v-if="section.description" class="section-description">{{ section.description }}</p>
|
||||
<div class="endpoints">
|
||||
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 6px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.endpoints > :first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .section-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .section-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,548 @@
|
||||
export const sections = [
|
||||
{
|
||||
id: 'auth',
|
||||
title: 'Authentication',
|
||||
description:
|
||||
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/login',
|
||||
summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
|
||||
params: [
|
||||
{ name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
|
||||
{ name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
|
||||
{ name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
|
||||
],
|
||||
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
|
||||
response:
|
||||
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/logout',
|
||||
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/csrf-token',
|
||||
summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/getTwoFactorEnable',
|
||||
summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
|
||||
response: '{\n "success": true,\n "obj": false\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'inbounds',
|
||||
title: 'Inbounds API',
|
||||
description:
|
||||
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/list',
|
||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/get/:id',
|
||||
summary: 'Fetch a single inbound by numeric ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTraffics/:email',
|
||||
summary: 'Traffic counters for a client identified by email.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTrafficsById/:id',
|
||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
|
||||
body:
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/del/:id',
|
||||
summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
summary: 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/setEnable/:id',
|
||||
summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
body: '{\n "enable": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clientIps/:email',
|
||||
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clearClientIps/:email',
|
||||
summary: 'Reset the recorded IP list for a client.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/addClient',
|
||||
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/copyClients',
|
||||
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
|
||||
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
|
||||
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
|
||||
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClient/:clientId',
|
||||
summary: 'Delete a client by its UUID/password from a specific inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClient/:clientId',
|
||||
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
|
||||
params: [
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
||||
summary: 'Zero out upload + download counters for one client.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllTraffics',
|
||||
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
|
||||
summary: 'Reset traffic for every client in one inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/delDepletedClients/:id',
|
||||
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||
params: [
|
||||
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/onlines',
|
||||
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/lastOnline',
|
||||
summary: 'Map of client email → last-seen unix timestamp.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getSubLinks/:subId',
|
||||
summary:
|
||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||
params: [
|
||||
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientLinks/:id/:email',
|
||||
summary:
|
||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
||||
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
|
||||
summary: 'Delete a client identified by email rather than UUID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server API',
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/status',
|
||||
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/cpuHistory/:bucket',
|
||||
summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
|
||||
params: [
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/history/:metric/:bucket',
|
||||
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
|
||||
params: [
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getXrayVersion',
|
||||
summary: 'List Xray binary versions available for install on this host.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getPanelUpdateInfo',
|
||||
summary: 'Check whether a newer 3x-ui release is available on GitHub.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getConfigJson',
|
||||
summary: 'Return the assembled Xray config that’s currently running on this host.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getDb',
|
||||
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewUUID',
|
||||
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewX25519Cert',
|
||||
summary: 'Generate a new X25519 keypair for Reality.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmldsa65',
|
||||
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmlkem768',
|
||||
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewVlessEnc',
|
||||
summary: 'Generate a new VLESS encryption keypair.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/stopXrayService',
|
||||
summary: 'Stop the Xray binary. All proxies go offline immediately.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/restartXrayService',
|
||||
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/installXray/:version',
|
||||
summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
|
||||
params: [
|
||||
{ name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updatePanel',
|
||||
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile',
|
||||
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile/:fileName',
|
||||
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
|
||||
params: [
|
||||
{ name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/logs/:count',
|
||||
summary: 'Return the last N lines of the panel’s own log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
],
|
||||
body: '{\n "level": "info",\n "syslog": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/xraylogs/:count',
|
||||
summary: 'Return the last N lines of the Xray process log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/importDB',
|
||||
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/getNewEchCert',
|
||||
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'nodes',
|
||||
title: 'Nodes API',
|
||||
description:
|
||||
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/list',
|
||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/get/:id',
|
||||
summary: 'Fetch a single node by ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/add',
|
||||
summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
|
||||
body:
|
||||
'{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/update/:id',
|
||||
summary: 'Replace a node’s connection details. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/del/:id',
|
||||
summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/setEnable/:id',
|
||||
summary: 'Pause or resume traffic sync with this node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
body: '{\n "enable": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/test',
|
||||
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/probe/:id',
|
||||
summary: 'Probe an existing node, updating its cached health state.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/history/:id/:metric/:bucket',
|
||||
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'customGeo',
|
||||
title: 'Custom Geo API',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/list',
|
||||
summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/aliases',
|
||||
summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/add',
|
||||
summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
|
||||
body:
|
||||
'{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update/:id',
|
||||
summary: 'Replace a custom geo source. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/delete/:id',
|
||||
summary: 'Remove a custom geo source and its cached file.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/download/:id',
|
||||
summary: 'Re-download one custom geo source on demand.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update-all',
|
||||
summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'backup',
|
||||
title: 'Backup',
|
||||
description: 'Operations that interact with the configured Telegram bot.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/backuptotgbot',
|
||||
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const methodColors = {
|
||||
GET: 'blue',
|
||||
POST: 'green',
|
||||
PUT: 'orange',
|
||||
PATCH: 'orange',
|
||||
DELETE: 'red',
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
import {
|
||||
Inbound,
|
||||
Protocols,
|
||||
USERS_SECURITY,
|
||||
TLS_FLOW_CONTROL,
|
||||
} from '@/models/inbound.js';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
|
||||
// Bulk-add up to 500 clients in one go. The legacy panel offers five
|
||||
// generation modes — this component preserves them all:
|
||||
// 0: Random — N fully-random emails (no prefix)
|
||||
// 1: Random+Prefix — N random emails preceded by `prefix`
|
||||
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
|
||||
// 3: Random+Prefix+Num+Postfix — same + appended postfix
|
||||
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
// === Reactive form state ===========================================
|
||||
// Cloned inbound (so canEnableTlsFlow() works).
|
||||
const inbound = ref(null);
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
emailMethod: 0,
|
||||
firstNum: 1,
|
||||
lastNum: 1,
|
||||
emailPrefix: '',
|
||||
emailPostfix: '',
|
||||
quantity: 1,
|
||||
security: USERS_SECURITY.AUTO,
|
||||
flow: '',
|
||||
subId: '',
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||
reset: 0,
|
||||
});
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (!props.dbInbound) return;
|
||||
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||
// Reset all form fields on every open — bulk add is intentionally
|
||||
// stateless between sessions (legacy resets on .show()).
|
||||
form.emailMethod = 0;
|
||||
form.firstNum = 1;
|
||||
form.lastNum = 1;
|
||||
form.emailPrefix = '';
|
||||
form.emailPostfix = '';
|
||||
form.quantity = 1;
|
||||
form.security = USERS_SECURITY.AUTO;
|
||||
form.flow = '';
|
||||
form.subId = '';
|
||||
form.tgId = 0;
|
||||
form.comment = '';
|
||||
form.limitIp = 0;
|
||||
form.totalGB = 0;
|
||||
form.expiryTime = 0;
|
||||
form.reset = 0;
|
||||
delayedStart.value = false;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function makeNewClient(parsed) {
|
||||
switch (parsed.protocol) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: {
|
||||
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
|
||||
}
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClients() {
|
||||
if (!inbound.value) return [];
|
||||
const out = [];
|
||||
const method = form.emailMethod;
|
||||
let start;
|
||||
let end;
|
||||
if (method > 1) {
|
||||
start = form.firstNum;
|
||||
end = form.lastNum + 1;
|
||||
} else {
|
||||
start = 0;
|
||||
end = form.quantity;
|
||||
}
|
||||
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||
const useNum = method > 1;
|
||||
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const c = makeNewClient(inbound.value);
|
||||
if (!c) continue;
|
||||
if (method === 4) c.email = '';
|
||||
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||
|
||||
if (form.subId.length > 0) c.subId = form.subId;
|
||||
c.tgId = form.tgId;
|
||||
if (form.comment.length > 0) c.comment = form.comment;
|
||||
c.security = form.security;
|
||||
c.limitIp = form.limitIp;
|
||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||
// identically for bulk and single client paths).
|
||||
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
|
||||
c.expiryTime = form.expiryTime;
|
||||
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
|
||||
c.reset = form.reset;
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const clients = buildClients();
|
||||
if (clients.length === 0) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.dbInbound.id,
|
||||
// Clients all serialize via toString() — same shape the single-
|
||||
// client modal posts. Joining with `,` lets the Go side parse the
|
||||
// outer array directly.
|
||||
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('pages.client.method')">
|
||||
<a-select v-model:value="form.emailMethod">
|
||||
<a-select-option :value="0">Random</a-select-option>
|
||||
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
|
||||
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
|
||||
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
|
||||
<a-input v-model:value="form.emailPrefix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
|
||||
<a-input v-model:value="form.emailPostfix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
|
||||
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
|
||||
<a-select v-model:value="form.security">
|
||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select v-model:value="form.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="subEnable">
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||
</template>
|
||||
<a-input v-model:value="form.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="tgBotEnable" label="Telegram ID">
|
||||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.client.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
</template>
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.expiryTime !== 0">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="form.reset" :min="0" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import {
|
||||
HttpUtil,
|
||||
RandomUtil,
|
||||
SizeFormatter,
|
||||
ColorUtils,
|
||||
} from '@/utils';
|
||||
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
|
||||
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
|
||||
// flows through the same modal — same here.
|
||||
//
|
||||
// On submit we serialize the client via its toString() (which is just
|
||||
// JSON.stringify of toJson()) and post it inside a one-element clients
|
||||
// array so the Go side reuses the same parsing path as the inbound
|
||||
// settings update.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||
dbInbound: { type: Object, default: null },
|
||||
clientIndex: { type: Number, default: null },
|
||||
// Sidecar config from the inbounds page — controls visibility of
|
||||
// the Subscription, Telegram, and IP-limit fields.
|
||||
subEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
// === Reactive draft =================================================
|
||||
const inbound = ref(null);
|
||||
const client = ref(null);
|
||||
const oldClientId = ref('');
|
||||
const clientStats = ref(null);
|
||||
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const protocol = computed(() => inbound.value?.protocol);
|
||||
const isVmessOrVless = computed(() =>
|
||||
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
|
||||
);
|
||||
const isTrojanOrSS = computed(() =>
|
||||
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
|
||||
);
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
|
||||
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => {
|
||||
if (!client.value || client.value.expiryTime >= 0) return 0;
|
||||
return client.value.expiryTime / -86400000;
|
||||
},
|
||||
set: (days) => {
|
||||
if (!client.value) return;
|
||||
client.value.expiryTime = -86400000 * (days || 0);
|
||||
},
|
||||
});
|
||||
|
||||
const totalGB = computed({
|
||||
get: () => {
|
||||
if (!client.value || !client.value.totalGB) return 0;
|
||||
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
||||
},
|
||||
set: (gb) => {
|
||||
if (!client.value) return;
|
||||
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
||||
},
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (props.mode !== 'edit' || !client.value) return false;
|
||||
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
||||
});
|
||||
const isTrafficExhausted = computed(() => {
|
||||
if (!clientStats.value || clientStats.value.total <= 0) return false;
|
||||
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
|
||||
});
|
||||
|
||||
function getClientId(proto, c) {
|
||||
switch (proto) {
|
||||
case Protocols.TROJAN: return c.password;
|
||||
case Protocols.SHADOWSOCKS: return c.email;
|
||||
case Protocols.HYSTERIA: return c.auth;
|
||||
default: return c.id;
|
||||
}
|
||||
}
|
||||
|
||||
function makeNewClient(proto, parsed) {
|
||||
switch (proto) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: {
|
||||
const method = parsed.settings.method;
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
||||
method,
|
||||
RandomUtil.randomShadowsocksPassword(method),
|
||||
);
|
||||
}
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (!props.dbInbound) return;
|
||||
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||
inbound.value = parsed;
|
||||
delayedStart.value = false;
|
||||
|
||||
if (props.mode === 'edit') {
|
||||
const idx = props.clientIndex ?? 0;
|
||||
client.value = parsed.clients[idx];
|
||||
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
|
||||
oldClientId.value = getClientId(parsed.protocol, client.value);
|
||||
} else {
|
||||
const c = makeNewClient(parsed.protocol, parsed);
|
||||
if (c) parsed.clients.push(c);
|
||||
client.value = parsed.clients[parsed.clients.length - 1];
|
||||
oldClientId.value = '';
|
||||
}
|
||||
|
||||
clientStats.value = (props.dbInbound.clientStats || []).find(
|
||||
(s) => s.email === client.value?.email,
|
||||
) || null;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function randomEmail() {
|
||||
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
|
||||
}
|
||||
function randomId() {
|
||||
if (client.value) client.value.id = RandomUtil.randomUUID();
|
||||
}
|
||||
function randomPassword() {
|
||||
if (!client.value || !inbound.value) return;
|
||||
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
|
||||
client.value.password = RandomUtil.randomShadowsocksPassword(
|
||||
inbound.value.settings.method,
|
||||
);
|
||||
} else {
|
||||
client.value.password = RandomUtil.randomSeq(10);
|
||||
}
|
||||
}
|
||||
function randomAuth() {
|
||||
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
|
||||
}
|
||||
function randomSubId() {
|
||||
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
const clientIpsText = ref('');
|
||||
async function loadClientIps() {
|
||||
if (!client.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
|
||||
if (!msg?.success) {
|
||||
clientIpsText.value = msg?.obj || '';
|
||||
return;
|
||||
}
|
||||
let ips = msg.obj;
|
||||
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(ips);
|
||||
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
|
||||
} catch (_e) {
|
||||
// leave as raw
|
||||
}
|
||||
}
|
||||
clientIpsText.value = ips || '';
|
||||
}
|
||||
async function clearClientIps() {
|
||||
if (!client.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
|
||||
if (msg?.success) clientIpsText.value = '';
|
||||
}
|
||||
|
||||
async function resetClientTraffic() {
|
||||
if (!clientStats.value || !client.value?.email) return;
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
|
||||
);
|
||||
if (msg?.success) {
|
||||
clientStats.value.up = 0;
|
||||
clientStats.value.down = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!client.value || !inbound.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.dbInbound.id,
|
||||
settings: `{"clients": [${client.value.toString()}]}`,
|
||||
};
|
||||
const url = props.mode === 'edit'
|
||||
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
|
||||
: '/panel/api/inbounds/addClient';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const title = computed(() =>
|
||||
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title"
|
||||
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
|
||||
{{ t('depleted') }}
|
||||
</a-tag>
|
||||
|
||||
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||
:wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('enable')">
|
||||
<a-switch v-model:checked="client.enable" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
{{ t('pages.inbounds.email') }}
|
||||
<SyncOutlined class="random-icon" @click="randomEmail" />
|
||||
</template>
|
||||
<a-input v-model:value="client.email" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isTrojanOrSS">
|
||||
<template #label>
|
||||
{{ t('password') }}
|
||||
<SyncOutlined class="random-icon" @click="randomPassword" />
|
||||
</template>
|
||||
<a-input v-model:value="client.password" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
||||
<template #label>
|
||||
{{ t('password') }}
|
||||
<SyncOutlined class="random-icon" @click="randomAuth" />
|
||||
</template>
|
||||
<a-input v-model:value="client.auth" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isVmessOrVless">
|
||||
<template #label>
|
||||
ID
|
||||
<SyncOutlined class="random-icon" @click="randomId" />
|
||||
</template>
|
||||
<a-input v-model:value="client.id" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
|
||||
<a-select v-model:value="client.security">
|
||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email && subEnable">
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="randomSubId" />
|
||||
</template>
|
||||
<a-input v-model:value="client.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
|
||||
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email" :label="t('comment')">
|
||||
<a-input v-model:value="client.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="client.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
|
||||
:label="t('pages.inbounds.IPLimitlog')">
|
||||
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
|
||||
<a-button type="link" size="small" danger @click="clearClientIps">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
{{ t('pages.inbounds.IPLimitlogclear') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select v-model:value="client.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
|
||||
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
||||
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
||||
{{ SizeFormatter.sizeFormat(clientStats.down) }}
|
||||
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
|
||||
</a-tag>
|
||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.client.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
</template>
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.expiryTime !== 0">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="client.reset" :min="0" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-banner {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.random-icon,
|
||||
.action-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,821 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
InfoCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
RetweetOutlined,
|
||||
DeleteOutlined,
|
||||
EllipsisOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Per-inbound expand-row content. CSS-grid layout (not a nested
|
||||
// <a-table>) so it sits flush inside the parent's expanded cell.
|
||||
// No API calls here — events bubble to the parent's modals.
|
||||
|
||||
const props = defineProps({
|
||||
dbInbound: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
onlineClients: { type: Array, default: () => [] },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'edit-client',
|
||||
'qrcode-client',
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
const inbound = computed(() => props.dbInbound.toInbound());
|
||||
const clients = computed(() => inbound.value?.clients || []);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const paginatedClients = computed(() => {
|
||||
if (!props.pageSize || props.pageSize <= 0) return clients.value;
|
||||
const start = (currentPage.value - 1) * props.pageSize;
|
||||
return clients.value.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
watch([clients, () => props.pageSize], () => {
|
||||
const total = clients.value.length;
|
||||
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
|
||||
const maxPage = Math.max(1, Math.ceil(total / size));
|
||||
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
||||
});
|
||||
|
||||
// === Per-client stats lookup =======================================
|
||||
const statsMap = computed(() => {
|
||||
const m = new Map();
|
||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||
return m;
|
||||
});
|
||||
function statsFor(email) {
|
||||
return email ? statsMap.value.get(email) : null;
|
||||
}
|
||||
|
||||
function getUp(email) { return statsFor(email)?.up || 0; }
|
||||
function getDown(email) { return statsFor(email)?.down || 0; }
|
||||
function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
|
||||
function getRem(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
const r = s.total - s.up - s.down;
|
||||
return r > 0 ? r : 0;
|
||||
}
|
||||
function getAllTime(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
// allTime is the cumulative-historical counter; never let it dip
|
||||
// below up+down (manual edits / partial migrations can push it under).
|
||||
const current = (s.up || 0) + (s.down || 0);
|
||||
return s.allTime > current ? s.allTime : current;
|
||||
}
|
||||
function isClientDepleted(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return false;
|
||||
const total = s.total ?? 0;
|
||||
const used = (s.up ?? 0) + (s.down ?? 0);
|
||||
if (total > 0 && used >= total) return true;
|
||||
const exp = s.expiryTime ?? 0;
|
||||
if (exp > 0 && Date.now() >= exp) return true;
|
||||
return false;
|
||||
}
|
||||
function isClientOnline(email) {
|
||||
return !!email && props.onlineClients.includes(email);
|
||||
}
|
||||
function lastOnlineLabel(email) {
|
||||
const ts = props.lastOnlineMap[email];
|
||||
if (!ts) return '-';
|
||||
return IntlUtil.formatDate(ts, datepicker.value);
|
||||
}
|
||||
|
||||
function statsProgress(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
if (s.total === 0) return 100;
|
||||
return (100 * (s.down + s.up)) / s.total;
|
||||
}
|
||||
function expireProgress(expTime, reset) {
|
||||
const now = Date.now();
|
||||
const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
|
||||
const resetSec = reset * 86400;
|
||||
if (remainedSec >= resetSec) return 0;
|
||||
return 100 * (1 - remainedSec / resetSec);
|
||||
}
|
||||
function clientStatsColor(email) {
|
||||
return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
|
||||
}
|
||||
function statsExpColor(email) {
|
||||
// AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
|
||||
// matches the row's traffic/expiry tags.
|
||||
const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
|
||||
if (!email) return PURPLE;
|
||||
const s = statsFor(email);
|
||||
if (!s) return PURPLE;
|
||||
const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
|
||||
const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
|
||||
if (a === 'red' || b === 'red') return DANGER;
|
||||
if (a === 'orange' || b === 'orange') return WARN;
|
||||
if (a === 'green' || b === 'green') return SUCCESS;
|
||||
return PURPLE;
|
||||
}
|
||||
|
||||
const isRemovable = computed(() => clients.value.length > 1);
|
||||
|
||||
function totalGbDisplay(client) {
|
||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
|
||||
}
|
||||
|
||||
const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
|
||||
|
||||
function statusBadgeColor(client) {
|
||||
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
|
||||
return statsExpColor(client.email);
|
||||
}
|
||||
|
||||
// === Action confirms ==============================================
|
||||
function confirmReset(client) {
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
|
||||
content: t('pages.inbounds.resetTrafficContent'),
|
||||
okText: t('reset'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
|
||||
});
|
||||
}
|
||||
function confirmDelete(client) {
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
|
||||
content: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
|
||||
});
|
||||
}
|
||||
|
||||
// Stable row key for v-for — falls back through email/id/password
|
||||
// because not every protocol fills the same field.
|
||||
function rowKey(client) {
|
||||
return client.email || client.id || client.password || JSON.stringify(client);
|
||||
}
|
||||
|
||||
const selected = ref(new Set());
|
||||
|
||||
const allSelected = computed(() =>
|
||||
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const someSelected = computed(() =>
|
||||
clients.value.some((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
|
||||
function isSelected(key) {
|
||||
return selected.value.has(key);
|
||||
}
|
||||
function toggleSelect(key, next) {
|
||||
const s = new Set(selected.value);
|
||||
if (next) s.add(key); else s.delete(key);
|
||||
selected.value = s;
|
||||
}
|
||||
function selectAll(next) {
|
||||
if (next) {
|
||||
selected.value = new Set(clients.value.map(rowKey));
|
||||
} else {
|
||||
selected.value = new Set();
|
||||
}
|
||||
}
|
||||
function clearSelection() {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
watch(clients, (list) => {
|
||||
if (selected.value.size === 0) return;
|
||||
const valid = new Set(list.map(rowKey));
|
||||
const next = new Set();
|
||||
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
||||
if (next.size !== selected.value.size) selected.value = next;
|
||||
});
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
||||
if (picked.length === 0) return;
|
||||
|
||||
const total = clients.value.length;
|
||||
const keepLast = picked.length === total;
|
||||
const toDelete = keepLast ? picked.slice(0, -1) : picked;
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
Modal.warning({
|
||||
title: t('pages.inbounds.deleteClient'),
|
||||
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
|
||||
okText: t('confirm'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
|
||||
content: keepLast
|
||||
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
|
||||
: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => {
|
||||
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
|
||||
clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-list"
|
||||
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
||||
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
||||
<a-button size="small" danger @click="confirmBulkDelete">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Desktop: grid table ===================== -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="client-row client-list-header">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
||||
@change="(e) => selectAll(e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||
<div class="cell cell-online">{{ t('online') }}</div>
|
||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||
<div class="cell cell-remained">{{ t('remained') }}</div>
|
||||
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">
|
||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||
<RetweetOutlined class="row-icon" @click="confirmReset(client)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
||||
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-enable">
|
||||
<a-switch :checked="client.enable" size="small"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
</div>
|
||||
|
||||
<div class="cell cell-online">
|
||||
<a-popover>
|
||||
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-client">
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||
<template v-else>{{ t('offline') }}</template>
|
||||
</template>
|
||||
<a-badge :color="statusBadgeColor(client)" />
|
||||
</a-tooltip>
|
||||
<div class="client-id-stack">
|
||||
<a-tooltip :title="client.email">
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<span v-if="client.comment && client.comment.trim()" class="client-comment">
|
||||
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-traffic">
|
||||
<a-popover>
|
||||
<template v-if="client.email" #content>
|
||||
<table cellpadding="2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
|
||||
<td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
|
||||
</tr>
|
||||
<tr v-if="client.totalGB > 0">
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<div class="usage-bar">
|
||||
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
|
||||
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
|
||||
:show-info="false" :percent="statsProgress(client.email)" size="small" />
|
||||
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
|
||||
:show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||
:percent="statsProgress(client.email)" size="small" />
|
||||
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
|
||||
<span class="usage-text">
|
||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-remained">
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-alltime">
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-expiry">
|
||||
<template v-if="client.expiryTime !== 0 && client.reset > 0">
|
||||
<a-popover>
|
||||
<template #content>
|
||||
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
|
||||
</template>
|
||||
<div class="usage-bar">
|
||||
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
|
||||
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
|
||||
<span class="usage-text">{{ client.reset }}d</span>
|
||||
</div>
|
||||
</a-popover>
|
||||
</template>
|
||||
<a-popover v-else-if="client.expiryTime !== 0">
|
||||
<template #content>
|
||||
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
|
||||
</template>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
||||
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
|
||||
class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<template v-else>
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div class="client-card-head">
|
||||
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||
<template v-else>{{ t('offline') }}</template>
|
||||
</template>
|
||||
<a-badge :color="statusBadgeColor(client)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="client.email">
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<div class="client-card-actions">
|
||||
<a-switch :checked="client.enable" size="small"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<EllipsisOutlined class="row-icon" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="emit('info-client', { dbInbound, client })">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="client.email" @click="confirmReset(client)">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
|
||||
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
|
||||
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
|
||||
</div>
|
||||
|
||||
<div class="client-card-foot">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="clientStatsColor(client.email)">
|
||||
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
|
||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('remained') }}</span>
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('online') }}</span>
|
||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="client.expiryTime < 0" color="green">
|
||||
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
|
||||
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
|
||||
class="client-list-pagination" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.client-list {
|
||||
margin: -8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: grid;
|
||||
/* Default — no select column (single-client inbounds). The .has-select
|
||||
* modifier below prepends the 40px checkbox column. */
|
||||
grid-template-columns:
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list.has-select .client-row {
|
||||
grid-template-columns:
|
||||
40px
|
||||
/* select */
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
}
|
||||
|
||||
.client-row:last-child {
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list-header {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-top: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
/* allow grid children to shrink instead of overflowing */
|
||||
}
|
||||
|
||||
.cell-select,
|
||||
.cell-actions,
|
||||
.cell-enable,
|
||||
.cell-online,
|
||||
.cell-alltime,
|
||||
.cell-remained {
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cell-client {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cell-traffic,
|
||||
.cell-expiry {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-list-header .cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-list-header .cell-actions,
|
||||
.client-list-header .cell-client {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Action icons */
|
||||
.row-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.row-icon:hover {
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
|
||||
.row-icon.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Client identity stack (badge + email + comment) */
|
||||
.client-id-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-email {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.client-comment {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Traffic / expiry inline bar: text | progress | text */
|
||||
.usage-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-bar :deep(.ant-progress) {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.infinite-tag {
|
||||
min-width: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
|
||||
* sits flush against the inbound row's left/right edges. */
|
||||
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.client-list-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 16px 4px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list =========================================== */
|
||||
.client-list.is-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:global(body.dark) .client-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.client-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-card-head .client-email {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.client-card-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-card-actions .row-icon {
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.client-comment-line {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.client-card-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-card-foot .stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.client-card-foot .stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-card-foot :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Bigger status badge for thumb-readable state at a glance. */
|
||||
.client-card-head :deep(.ant-badge-status-dot) {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,831 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MenuOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
QrcodeOutlined,
|
||||
UserAddOutlined,
|
||||
UsergroupAddOutlined,
|
||||
CopyOutlined,
|
||||
FileDoneOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
ReloadOutlined,
|
||||
RestOutlined,
|
||||
RetweetOutlined,
|
||||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import ClientRowTable from './ClientRowTable.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
dbInbounds: { type: Array, required: true },
|
||||
clientCount: { type: Object, required: true },
|
||||
onlineClients: { type: Array, required: true },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
// Map node id -> node row, supplied by the parent page so each
|
||||
// inbound row can render its node name without an extra fetch.
|
||||
nodesById: { type: Map, default: () => new Map() },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'refresh',
|
||||
'add-inbound',
|
||||
'general-action',
|
||||
'row-action',
|
||||
// Per-client events surfaced from the expand-row table.
|
||||
'edit-client',
|
||||
'qrcode-client',
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
// ============ Toolbar / search & filter =============================
|
||||
const enableFilter = ref(false);
|
||||
const searchKey = ref('');
|
||||
const filterBy = ref('');
|
||||
|
||||
// Toggle the filter mode — flip cleans the other input.
|
||||
function onToggleFilter() {
|
||||
if (enableFilter.value) searchKey.value = '';
|
||||
else filterBy.value = '';
|
||||
}
|
||||
|
||||
// ============ Search / filter projection =============================
|
||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||
// least one client in the requested bucket and reduce their settings
|
||||
// to that bucket.
|
||||
function projectInbound(dbInbound, predicate) {
|
||||
const next = new DBInbound(dbInbound);
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(dbInbound.settings || '{}');
|
||||
} catch (_e) {
|
||||
settings = {};
|
||||
}
|
||||
if (!Array.isArray(settings.clients)) return next;
|
||||
const filtered = settings.clients.filter(predicate);
|
||||
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
|
||||
next.invalidateCache();
|
||||
return next;
|
||||
}
|
||||
|
||||
const visibleInbounds = computed(() => {
|
||||
if (enableFilter.value) {
|
||||
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
|
||||
const out = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
const c = props.clientCount[dbInbound.id];
|
||||
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
|
||||
const list = c[filterBy.value];
|
||||
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
|
||||
const out = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// ============ Columns =================================================
|
||||
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
||||
// `responsive` array still works on column defs. Computed so column
|
||||
// labels react to live locale switches.
|
||||
const hasAnyRemark = computed(() =>
|
||||
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
|
||||
);
|
||||
|
||||
const desktopColumns = computed(() => {
|
||||
const cols = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
|
||||
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
|
||||
];
|
||||
if (hasAnyRemark.value) {
|
||||
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
|
||||
}
|
||||
if (props.nodesById.size > 0) {
|
||||
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
|
||||
}
|
||||
cols.push(
|
||||
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
|
||||
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
|
||||
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
|
||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
|
||||
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
|
||||
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
const columns = computed(() => desktopColumns.value);
|
||||
|
||||
// Mobile expansion state — replaces a-table's expandable() since the
|
||||
// mobile branch renders a hand-rolled card list rather than a table.
|
||||
const expandedIds = ref(new Set());
|
||||
function toggleExpanded(id) {
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds.value = next;
|
||||
}
|
||||
function isExpanded(id) {
|
||||
return expandedIds.value.has(id);
|
||||
}
|
||||
|
||||
// ============ Pagination ============================================
|
||||
function paginationFor(rows) {
|
||||
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
||||
return {
|
||||
pageSize: size,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Per-row enable switch =================================
|
||||
async function onSwitchEnable(dbInbound, next) {
|
||||
const previous = dbInbound.enable;
|
||||
dbInbound.enable = next; // optimistic
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enable', String(next));
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
|
||||
if (!msg?.success) dbInbound.enable = previous;
|
||||
} catch (_e) {
|
||||
dbInbound.enable = previous;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Helpers shared with the templates =====================
|
||||
// Whether to show the "Switch xray" / qrcode menu entry — same predicate
|
||||
// as legacy: SS single-user inbounds and WireGuard inbounds expose
|
||||
// inbound-wide QR codes.
|
||||
function showQrCodeMenu(dbInbound) {
|
||||
if (dbInbound.isWireguard) return true;
|
||||
if (dbInbound.isSS) {
|
||||
try {
|
||||
return !dbInbound.toInbound().isSSMultiUser;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-space direction="horizontal">
|
||||
<a-button type="primary" @click="emit('add-inbound')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
|
||||
</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="primary">
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('general-action', a.key)">
|
||||
<a-menu-item key="import">
|
||||
<ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetInbounds">
|
||||
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||
<!-- Search / filter toolbar -->
|
||||
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
|
||||
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
|
||||
<template #checkedChildren>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
<template #unCheckedChildren>
|
||||
<FilterOutlined />
|
||||
</template>
|
||||
</a-switch>
|
||||
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
|
||||
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
|
||||
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
|
||||
:size="isMobile ? 'small' : 'middle'">
|
||||
<a-radio-button value="">{{ t('none') }}</a-radio-button>
|
||||
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
|
||||
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
|
||||
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
|
||||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
||||
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<div v-if="isMobile" class="inbound-cards">
|
||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
|
||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||
<span class="card-id">#{{ record.id }}</span>
|
||||
<span class="tag-name">{{ record.remark }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item key="edit">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
|
||||
<div class="card-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||
<a-tag>{{ record.port }}</a-tag>
|
||||
</div>
|
||||
<div v-if="nodesById.size > 0" class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||
<a-tag v-if="record.nodeId == null" color="default">
|
||||
{{ t('pages.inbounds.localPanel') }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="nodesById.get(record.nodeId)"
|
||||
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(record.nodeId).name }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||
</div>
|
||||
<div v-if="clientCount[record.id]" class="stat-row">
|
||||
<span class="stat-label">{{ t('clients') }}</span>
|
||||
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-tag v-if="clientCount[record.id].online.length" color="blue">
|
||||
{{ clientCount[record.id].online.length }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
|
||||
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
|
||||
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="record.expiryTime > 0"
|
||||
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
|
||||
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded client list (multi-user only) -->
|
||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||
:page-size="pageSize"
|
||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||
@info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
||||
<!-- Per-inbound client list, expanded by clicking the row's
|
||||
default expand chevron. Hidden via row-class-name for
|
||||
non-multi-user inbounds (matches legacy behavior). -->
|
||||
<template #expandedRowRender="{ record }">
|
||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||
@edit-client="(p) => emit('edit-client', p)"
|
||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</template>
|
||||
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- ============== Action dropdown ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-dropdown :trigger="['click']">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item key="edit">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<!-- ============== Enable switch (desktop) ============== -->
|
||||
<template v-else-if="column.key === 'enable'">
|
||||
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
|
||||
</template>
|
||||
|
||||
<!-- ============== Node deployment tag ============== -->
|
||||
<template v-else-if="column.key === 'node'">
|
||||
<template v-if="record.nodeId == null">
|
||||
<a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="nodesById.get(record.nodeId)">
|
||||
<a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(record.nodeId).name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Node row was deleted but inbound still references it. -->
|
||||
<a-tag color="orange">node #{{ record.nodeId }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============== Protocol tags ============== -->
|
||||
<template v-else-if="column.key === 'protocol'">
|
||||
<div class="protocol-tags">
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Clients tag + popovers ============== -->
|
||||
<template v-else-if="column.key === 'clients'">
|
||||
<template v-if="clientCount[record.id]">
|
||||
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============== Traffic ============== -->
|
||||
<template v-else-if="column.key === 'traffic'">
|
||||
<a-popover>
|
||||
<template #content>
|
||||
<table cellpadding="2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
|
||||
<td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
|
||||
</tr>
|
||||
<tr v-if="record.total > 0 && record.up + record.down < record.total">
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<!-- ============== All-time inbound traffic ============== -->
|
||||
<template v-else-if="column.key === 'allTimeInbound'">
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- ============== Expiry ============== -->
|
||||
<template v-else-if="column.key === 'expiryTime'">
|
||||
<a-popover v-if="record.expiryTime > 0">
|
||||
<template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
|
||||
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
|
||||
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Hide the expand chevron on rows whose inbound has no client list
|
||||
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
|
||||
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Push the expand chevron away from the table's left edge so it has
|
||||
* a little breathing room instead of being flush against the corner. */
|
||||
:deep(.ant-table-tbody .ant-table-cell-with-append) {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-row-expand-icon) {
|
||||
margin-inline-end: 10px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
||||
* token, but the inner header strip and footer touch the edges, so clip
|
||||
* them here. */
|
||||
:deep(.ant-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-container) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr:first-child > *:first-child) {
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr:first-child > *:last-child) {
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child > *:first-child) {
|
||||
border-end-start-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child > *:last-child) {
|
||||
border-end-end-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list ===========================================
|
||||
* <768px renders inbounds as a vertical stack of cards via the
|
||||
* v-if="isMobile" branch above; the desktop <a-table> isn't mounted
|
||||
* so the legacy table-cell tightening rules went away. */
|
||||
.inbound-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:global(body.dark) .inbound-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
transition: transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-clients {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.ant-card-head) {
|
||||
padding: 0 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title),
|
||||
:deep(.ant-card-extra) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,748 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
SwapOutlined,
|
||||
PieChartOutlined,
|
||||
HistoryOutlined,
|
||||
BarsOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import { useNodeList } from '@/composables/useNodeList.js';
|
||||
import InboundList from './InboundList.vue';
|
||||
import InboundFormModal from './InboundFormModal.vue';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
import ClientBulkModal from './ClientBulkModal.vue';
|
||||
import InboundInfoModal from './InboundInfoModal.vue';
|
||||
import QrCodeModal from './QrCodeModal.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
import PromptModal from '@/components/PromptModal.vue';
|
||||
import { useInbounds } from './useInbounds.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
fetched,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
subSettings,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
remarkModel,
|
||||
lastOnlineMap,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
} = useInbounds();
|
||||
|
||||
// Live updates over WebSocket — replaces the old 5s polling loop.
|
||||
// The backend pushes traffic + per-client deltas every ~10s; we merge
|
||||
// them into the local refs in-place so counters and online badges
|
||||
// update without re-fetching the whole list.
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
inbounds: applyInboundsEvent,
|
||||
});
|
||||
const { isMobile } = useMediaQuery();
|
||||
// Node list lives on the central panel; the Inbounds page consumes
|
||||
// the id→node map for the new "Node" column. Fetched once on mount.
|
||||
const { byId: nodesById } = useNodeList();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDefaultSettings();
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// === Add/Edit modal ===================================================
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const formDbInbound = ref(null);
|
||||
|
||||
// === Client modal (single + bulk) =====================================
|
||||
const clientOpen = ref(false);
|
||||
const clientMode = ref('add');
|
||||
const clientDbInbound = ref(null);
|
||||
const clientIndex = ref(null);
|
||||
|
||||
const bulkOpen = ref(false);
|
||||
const bulkDbInbound = ref(null);
|
||||
|
||||
// === Info / QR-code modals ===========================================
|
||||
const infoOpen = ref(false);
|
||||
const infoDbInbound = ref(null);
|
||||
const infoClientIndex = ref(0);
|
||||
|
||||
const qrOpen = ref(false);
|
||||
const qrDbInbound = ref(null);
|
||||
const qrClient = ref(null);
|
||||
|
||||
// hostOverrideFor returns the node's address for a node-managed inbound,
|
||||
// or '' when the inbound runs locally. Wired into the QR / Info modals
|
||||
// and into export-all-links functions so generated share links point at
|
||||
// the node, not the central panel.
|
||||
function hostOverrideFor(dbInbound) {
|
||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||
return nodesById.value.get(dbInbound.nodeId)?.address || '';
|
||||
}
|
||||
|
||||
const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
|
||||
const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
|
||||
|
||||
// === Shared text + prompt modal state =================================
|
||||
const textOpen = ref(false);
|
||||
const textTitle = ref('');
|
||||
const textContent = ref('');
|
||||
const textFileName = ref('');
|
||||
|
||||
const promptOpen = ref(false);
|
||||
const promptTitle = ref('');
|
||||
const promptOkText = ref('OK');
|
||||
const promptType = ref('textarea');
|
||||
const promptInitial = ref('');
|
||||
const promptLoading = ref(false);
|
||||
let promptHandler = null;
|
||||
|
||||
function openText({ title, content, fileName = '' }) {
|
||||
textTitle.value = title;
|
||||
textContent.value = content;
|
||||
textFileName.value = fileName;
|
||||
textOpen.value = true;
|
||||
}
|
||||
|
||||
function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
|
||||
promptTitle.value = title;
|
||||
promptOkText.value = okText || 'OK';
|
||||
promptType.value = type;
|
||||
promptInitial.value = value;
|
||||
promptHandler = confirm;
|
||||
promptOpen.value = true;
|
||||
}
|
||||
|
||||
async function onPromptConfirm(value) {
|
||||
if (!promptHandler) { promptOpen.value = false; return; }
|
||||
promptLoading.value = true;
|
||||
try {
|
||||
const ok = await promptHandler(value);
|
||||
if (ok !== false) promptOpen.value = false;
|
||||
} finally {
|
||||
promptLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Export helpers — mirror legacy txtModal call sites ==============
|
||||
function exportInboundLinks(dbInbound) {
|
||||
const projected = checkFallback(dbInbound);
|
||||
openText({
|
||||
title: 'Export inbound links',
|
||||
content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
|
||||
fileName: projected.remark || 'inbound',
|
||||
});
|
||||
}
|
||||
|
||||
function exportInboundClipboard(dbInbound) {
|
||||
openText({
|
||||
title: 'Inbound JSON',
|
||||
content: JSON.stringify(dbInbound, null, 2),
|
||||
});
|
||||
}
|
||||
|
||||
function exportInboundSubs(dbInbound) {
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const subLinks = [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.value.subURI) {
|
||||
subLinks.push(subSettings.value.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export subscription links',
|
||||
content: [...new Set(subLinks)].join('\n'),
|
||||
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
|
||||
});
|
||||
}
|
||||
|
||||
function exportAllLinks() {
|
||||
const out = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
|
||||
}
|
||||
openText({
|
||||
title: 'Export all inbound links',
|
||||
content: out.join('\r\n'),
|
||||
fileName: 'All-Inbounds',
|
||||
});
|
||||
}
|
||||
|
||||
function exportAllSubs() {
|
||||
const out = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
const inbound = ib.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.value.subURI) {
|
||||
out.push(subSettings.value.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export all subscription links',
|
||||
content: [...new Set(out)].join('\r\n'),
|
||||
fileName: 'All-Inbounds-Subs',
|
||||
});
|
||||
}
|
||||
|
||||
function importInbound() {
|
||||
openPrompt({
|
||||
title: 'Import inbound',
|
||||
okText: 'Import',
|
||||
type: 'textarea',
|
||||
value: '',
|
||||
confirm: async (value) => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
||||
if (msg?.success) {
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// `checkFallback` mirrors the legacy helper: when an inbound listens
|
||||
// on a unix-socket fallback (`@<name>`), point the link generator at
|
||||
// the root inbound that owns the listen address so QRs/links carry
|
||||
// the externally-reachable host:port and the right TLS state.
|
||||
function checkFallback(dbInbound) {
|
||||
// We don't keep parsed Inbounds in state right now (the page works
|
||||
// off DBInbounds); compute on the fly.
|
||||
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds.value) {
|
||||
if (candidate.id === dbInbound.id) continue;
|
||||
const parsed = candidate.toInbound();
|
||||
if (!parsed.isTcp) continue;
|
||||
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
||||
const fallbacks = parsed.settings.fallbacks || [];
|
||||
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
||||
// Build a one-off DBInbound copy with the parent's listen/port +
|
||||
// copied stream so the link gen sees the public endpoint.
|
||||
const projected = JSON.parse(JSON.stringify(dbInbound));
|
||||
projected.listen = candidate.listen;
|
||||
projected.port = candidate.port;
|
||||
const inheritedStream = parsed.stream;
|
||||
const ownInbound = dbInbound.toInbound();
|
||||
ownInbound.stream.security = inheritedStream.security;
|
||||
ownInbound.stream.tls = inheritedStream.tls;
|
||||
ownInbound.stream.externalProxy = inheritedStream.externalProxy;
|
||||
projected.streamSettings = ownInbound.stream.toString();
|
||||
// Re-wrap so callers get the same DBInbound shape they had.
|
||||
return new dbInbound.constructor(projected);
|
||||
}
|
||||
return dbInbound;
|
||||
}
|
||||
|
||||
function findClientIndex(dbInbound, client) {
|
||||
if (!client) return 0;
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = clients.findIndex((c) => {
|
||||
if (!c) return false;
|
||||
switch (dbInbound.protocol) {
|
||||
case 'trojan':
|
||||
case 'shadowsocks':
|
||||
return c.password === client.password && c.email === client.email;
|
||||
default:
|
||||
return c.id === client.id && c.email === client.email;
|
||||
}
|
||||
});
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function getClientId(protocol, client) {
|
||||
switch (protocol) {
|
||||
case 'trojan': return client.password;
|
||||
case 'shadowsocks': return client.email;
|
||||
case 'hysteria': return client.auth;
|
||||
default: return client.id;
|
||||
}
|
||||
}
|
||||
|
||||
// === Per-client handlers (called from the expand-row table) =========
|
||||
function onEditClient({ dbInbound, client }) {
|
||||
clientMode.value = 'edit';
|
||||
clientDbInbound.value = dbInbound;
|
||||
clientIndex.value = findClientIndex(dbInbound, client);
|
||||
clientOpen.value = true;
|
||||
}
|
||||
|
||||
function onQrcodeClient({ dbInbound, client }) {
|
||||
qrDbInbound.value = checkFallback(dbInbound);
|
||||
qrClient.value = client || null;
|
||||
qrOpen.value = true;
|
||||
}
|
||||
|
||||
function onInfoClient({ dbInbound, client }) {
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, client);
|
||||
infoOpen.value = true;
|
||||
}
|
||||
|
||||
async function onResetTrafficClient({ dbInbound, client }) {
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
|
||||
);
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClient({ dbInbound, client }) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClients({ dbInbound, clients }) {
|
||||
for (const client of clients) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||
// client, and post the whole client back through updateClient. This
|
||||
// keeps the wire shape identical to the modal save path.
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = findClientIndex(dbInbound, client);
|
||||
if (idx < 0 || !clients[idx]) return;
|
||||
clients[idx].enable = next;
|
||||
const clientId = getClientId(dbInbound.protocol, clients[idx]);
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
|
||||
id: dbInbound.id,
|
||||
settings: `{"clients": [${clients[idx].toString()}]}`,
|
||||
});
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
function onAddInbound() {
|
||||
formMode.value = 'add';
|
||||
formDbInbound.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(dbInbound) {
|
||||
formMode.value = 'edit';
|
||||
formDbInbound.value = dbInbound;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openAddClient(dbInbound) {
|
||||
clientMode.value = 'add';
|
||||
clientDbInbound.value = dbInbound;
|
||||
clientIndex.value = null;
|
||||
clientOpen.value = true;
|
||||
}
|
||||
|
||||
function openAddBulkClient(dbInbound) {
|
||||
bulkDbInbound.value = dbInbound;
|
||||
bulkOpen.value = true;
|
||||
}
|
||||
|
||||
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||
function confirmDelete(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmResetTraffic(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||
content: 'Resets up/down counters to 0 for this inbound.',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelDepleted(dbInboundId) {
|
||||
Modal.confirm({
|
||||
title: 'Delete depleted clients?',
|
||||
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||
// but a fresh remark/port and an empty client list.
|
||||
function confirmClone(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||
content: 'Creates a copy with a new port and an empty client list.',
|
||||
okText: 'Clone',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const baseInbound = dbInbound.toInbound();
|
||||
const data = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
remark: `${dbInbound.remark} (clone)`,
|
||||
enable: false,
|
||||
expiryTime: 0,
|
||||
listen: '',
|
||||
port: RandomUtil.randomInteger(10000, 60000),
|
||||
protocol: baseInbound.protocol,
|
||||
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
|
||||
streamSettings: baseInbound.stream.toString(),
|
||||
sniffing: baseInbound.sniffing.toString(),
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onGeneralAction(key) {
|
||||
switch (key) {
|
||||
case 'import':
|
||||
importInbound();
|
||||
break;
|
||||
case 'export':
|
||||
exportAllLinks();
|
||||
break;
|
||||
case 'subs':
|
||||
exportAllSubs();
|
||||
break;
|
||||
case 'resetInbounds':
|
||||
Modal.confirm({
|
||||
title: 'Reset all inbound traffic?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'resetClients':
|
||||
Modal.confirm({
|
||||
title: 'Reset all client traffic across all inbounds?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'delDepletedClients':
|
||||
confirmDelDepleted(-1);
|
||||
break;
|
||||
default:
|
||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}
|
||||
|
||||
function onRowAction({ key, dbInbound }) {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
openEdit(dbInbound);
|
||||
break;
|
||||
case 'addClient':
|
||||
openAddClient(dbInbound);
|
||||
break;
|
||||
case 'addBulkClient':
|
||||
openAddBulkClient(dbInbound);
|
||||
break;
|
||||
case 'showInfo':
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||
infoOpen.value = true;
|
||||
break;
|
||||
case 'qrcode':
|
||||
qrDbInbound.value = checkFallback(dbInbound);
|
||||
qrClient.value = null;
|
||||
qrOpen.value = true;
|
||||
break;
|
||||
case 'export':
|
||||
exportInboundLinks(dbInbound);
|
||||
break;
|
||||
case 'subs':
|
||||
exportInboundSubs(dbInbound);
|
||||
break;
|
||||
case 'clipboard':
|
||||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'copyClients':
|
||||
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
|
||||
// (lets you tick clients to copy across inbounds). Defer to a
|
||||
// future commit — surface a friendly message for now.
|
||||
message.info('Copy clients across inbounds — coming soon');
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
break;
|
||||
case 'resetTraffic':
|
||||
confirmResetTraffic(dbInbound);
|
||||
break;
|
||||
case 'clone':
|
||||
confirmClone(dbInbound);
|
||||
break;
|
||||
case 'resetClients':
|
||||
Modal.confirm({
|
||||
title: `Reset client traffic on "${dbInbound.remark}"?`,
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'delDepletedClients':
|
||||
confirmDelDepleted(dbInbound.id);
|
||||
break;
|
||||
default:
|
||||
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||
<template #prefix>
|
||||
<PieChartOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
|
||||
:value="SizeFormatter.sizeFormat(totals.allTime)">
|
||||
<template #prefix>
|
||||
<HistoryOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||
<template #prefix>
|
||||
<BarsOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="4">
|
||||
<CustomStatistic :title="t('clients')" value=" ">
|
||||
<template #prefix>
|
||||
<a-space direction="horizontal">
|
||||
<TeamOutlined />
|
||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag>{{ totals.deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Inbound list — toolbar, search/filter, columns, row actions -->
|
||||
<a-col :span="24">
|
||||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
|
||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
|
||||
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
|
||||
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
||||
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
||||
:node-address="qrNodeAddress" :sub-settings="subSettings" />
|
||||
|
||||
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
||||
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
||||
:initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbounds-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.inbounds-page :deep(.ant-layout),
|
||||
.inbounds-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||
don't reach them — this block has to be unscoped. */
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import QrPanel from './QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
client: { type: Object, default: null },
|
||||
remarkModel: { type: String, default: '-ieo' },
|
||||
nodeAddress: { type: String, default: '' },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const wireguardConfigs = ref([]);
|
||||
const wireguardLinks = ref([]);
|
||||
const subLink = ref('');
|
||||
const subJsonLink = ref('');
|
||||
const activeKeys = ref([]);
|
||||
|
||||
const qrItems = computed(() => {
|
||||
const items = [];
|
||||
if (subLink.value) {
|
||||
items.push({
|
||||
key: 'sub',
|
||||
header: t('subscription.title'),
|
||||
value: subLink.value,
|
||||
});
|
||||
}
|
||||
if (subJsonLink.value) {
|
||||
items.push({
|
||||
key: 'sub-json',
|
||||
header: `${t('subscription.title')} (JSON)`,
|
||||
value: subJsonLink.value,
|
||||
});
|
||||
}
|
||||
links.value.forEach((link, idx) => {
|
||||
items.push({
|
||||
key: `l${idx}`,
|
||||
header: link.remark || `Link ${idx + 1}`,
|
||||
value: link.link,
|
||||
});
|
||||
});
|
||||
wireguardConfigs.value.forEach((cfg, idx) => {
|
||||
items.push({
|
||||
key: `wc${idx}`,
|
||||
header: `Peer ${idx + 1} config`,
|
||||
value: cfg,
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks.value[idx]) {
|
||||
items.push({
|
||||
key: `wl${idx}`,
|
||||
header: `Peer ${idx + 1} link`,
|
||||
value: wireguardLinks.value[idx],
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next || !props.dbInbound) return;
|
||||
const inbound = props.dbInbound.toInbound();
|
||||
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||
const peerRemark = props.client?.email
|
||||
? `${props.dbInbound.remark}-${props.client.email}`
|
||||
: props.dbInbound.remark;
|
||||
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||
wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||
links.value = [];
|
||||
} else {
|
||||
// When a client is provided we generate per-client share links;
|
||||
// otherwise (single-user SS) fall back to the inbound's settings.
|
||||
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
|
||||
wireguardConfigs.value = [];
|
||||
wireguardLinks.value = [];
|
||||
}
|
||||
|
||||
const subId = props.client?.subId;
|
||||
if (props.subSettings?.enable && subId) {
|
||||
subLink.value = (props.subSettings.subURI || '') + subId;
|
||||
subJsonLink.value = props.subSettings.subJsonEnable
|
||||
? (props.subSettings.subJsonURI || '') + subId
|
||||
: '';
|
||||
} else {
|
||||
subLink.value = '';
|
||||
subJsonLink.value = '';
|
||||
}
|
||||
const open = [];
|
||||
if (subLink.value) open.push('sub');
|
||||
if (subJsonLink.value) open.push('sub-json');
|
||||
activeKeys.value = open;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
||||
<template v-if="dbInbound">
|
||||
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
|
||||
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
|
||||
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-collapse :deep(.ant-collapse-content-box) {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, required: true },
|
||||
remark: { type: String, default: '' },
|
||||
downloadName: { type: String, default: '' },
|
||||
size: { type: Number, default: 240 },
|
||||
showQr: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(props.value);
|
||||
if (ok) message.success(t('copied'));
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!props.downloadName) return;
|
||||
FileManager.downloadTextFile(props.value, props.downloadName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qr-panel">
|
||||
<div class="qr-panel-header">
|
||||
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-button size="small" @click="copy">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="downloadName" :title="t('download')">
|
||||
<a-button size="small" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="showQr" class="qr-panel-canvas">
|
||||
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||
:title="t('copy')" @click="copy" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qr-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qr-remark {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
// Loads the inbound list + sidecar data the page needs (online users,
|
||||
// last-online-map, default settings) and computes the per-inbound client
|
||||
// roll-ups the legacy panel surfaces in the popovers.
|
||||
//
|
||||
// Live-update model: initial GET on mount, then the WebSocket delta path
|
||||
// keeps the table fresh — the page subscribes to the server's `traffic`,
|
||||
// `client_stats`, and `invalidate` events and merges them into local
|
||||
// refs in-place. The manual refresh button is kept as a fallback.
|
||||
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil, ObjectUtil } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { setDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
export function useInbounds() {
|
||||
const fetched = ref(false);
|
||||
const refreshing = ref(false);
|
||||
|
||||
// shallowRef because each refresh swaps the array; per-row reactivity is
|
||||
// unnecessary at the page level (modals work on copies).
|
||||
const dbInbounds = shallowRef([]);
|
||||
const clientCount = ref({});
|
||||
const onlineClients = ref([]);
|
||||
const lastOnlineMap = ref({});
|
||||
|
||||
// Default-settings sidecar fields the table needs for color/expiry math.
|
||||
const expireDiff = ref(0);
|
||||
const trafficDiff = ref(0);
|
||||
const subSettings = ref({
|
||||
enable: false,
|
||||
subTitle: '',
|
||||
subURI: '',
|
||||
subJsonURI: '',
|
||||
subJsonEnable: false,
|
||||
});
|
||||
const remarkModel = ref('-ieo');
|
||||
const datepicker = ref('gregorian');
|
||||
const tgBotEnable = ref(false);
|
||||
const ipLimitEnable = ref(false);
|
||||
const pageSize = ref(0);
|
||||
|
||||
function isClientOnline(email) {
|
||||
return onlineClients.value.includes(email);
|
||||
}
|
||||
|
||||
// Roll-up of {clients, active, deactive, depleted, expiring, online,
|
||||
// comments} for a single inbound. Mirrors getClientCounts in the legacy
|
||||
// template. Skipped for protocols that don't have multi-user clients
|
||||
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||
function rollupClients(dbInbound, inbound) {
|
||||
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
||||
const clients = inbound?.clients || [];
|
||||
const active = [];
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
const comments = new Map();
|
||||
const now = Date.now();
|
||||
|
||||
if (dbInbound.enable) {
|
||||
for (const client of clients) {
|
||||
if (client.comment) comments.set(client.email, client.comment);
|
||||
if (client.enable) {
|
||||
active.push(client.email);
|
||||
if (isClientOnline(client.email)) online.push(client.email);
|
||||
} else {
|
||||
deactive.push(client.email);
|
||||
}
|
||||
}
|
||||
for (const stats of clientStats) {
|
||||
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
|
||||
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
|
||||
if (expired || exhausted) {
|
||||
depleted.push(stats.email);
|
||||
} else {
|
||||
const expiringSoon =
|
||||
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
|
||||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
|
||||
if (expiringSoon) expiring.push(stats.email);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const client of clients) deactive.push(client.email);
|
||||
}
|
||||
|
||||
return {
|
||||
clients: clients.length,
|
||||
active,
|
||||
deactive,
|
||||
depleted,
|
||||
expiring,
|
||||
online,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
function setInbounds(rows) {
|
||||
const next = [];
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
const dbInbound = new DBInbound(row);
|
||||
const parsed = dbInbound.toInbound();
|
||||
next.push(dbInbound);
|
||||
const tracked = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
if (tracked.includes(row.protocol)) {
|
||||
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[row.id] = rollupClients(dbInbound, parsed);
|
||||
}
|
||||
}
|
||||
dbInbounds.value = next;
|
||||
clientCount.value = counts;
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
async function fetchOnlineUsers() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
|
||||
if (msg?.success) onlineClients.value = msg.obj || [];
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
|
||||
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
||||
}
|
||||
|
||||
async function fetchDefaultSettings() {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||
tgBotEnable.value = !!s.tgBotEnable;
|
||||
subSettings.value = {
|
||||
enable: !!s.subEnable,
|
||||
subTitle: s.subTitle || '',
|
||||
subURI: s.subURI || '',
|
||||
subJsonURI: s.subJsonURI || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
};
|
||||
pageSize.value = s.pageSize ?? 0;
|
||||
remarkModel.value = s.remarkModel || '-ieo';
|
||||
datepicker.value = s.datepicker || 'gregorian';
|
||||
// Mirror into the global composable so date-pickers in modals can
|
||||
// pick the right calendar without re-fetching the settings.
|
||||
setDatepicker(datepicker.value);
|
||||
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||
}
|
||||
|
||||
// ============ WebSocket live-update merge ===========================
|
||||
// The xray traffic job and the node traffic sync job each broadcast
|
||||
// a `traffic` payload every ~10s. We merge it into onlineClients +
|
||||
// lastOnlineMap; per-inbound counters arrive in the parallel
|
||||
// client_stats event below.
|
||||
function applyTrafficEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (Array.isArray(payload.onlineClients)) {
|
||||
onlineClients.value = payload.onlineClients;
|
||||
}
|
||||
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||
// Merge so a subsequent payload that drops a quiet client doesn't
|
||||
// wipe their last-seen timestamp.
|
||||
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
|
||||
}
|
||||
// Recompute per-inbound rollups so the "online" badges in the
|
||||
// expand-row table flip without waiting for a full refresh.
|
||||
rebuildClientCount();
|
||||
}
|
||||
|
||||
// The client_stats payload carries absolute traffic counters for the
|
||||
// clients that had activity in the latest window plus per-inbound
|
||||
// totals. Both are absolute (not deltas), so we overwrite in place.
|
||||
function applyClientStatsEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
let touched = false;
|
||||
|
||||
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
|
||||
const byId = new Map();
|
||||
for (const row of payload.inbounds) {
|
||||
if (row && row.id != null) byId.set(row.id, row);
|
||||
}
|
||||
for (const ib of dbInbounds.value) {
|
||||
const upd = byId.get(ib.id);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') ib.up = upd.up;
|
||||
if (typeof upd.down === 'number') ib.down = upd.down;
|
||||
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
|
||||
if (typeof upd.total === 'number') ib.total = upd.total;
|
||||
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
|
||||
const byEmail = new Map();
|
||||
for (const row of payload.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
for (const ib of dbInbounds.value) {
|
||||
if (!Array.isArray(ib.clientStats)) continue;
|
||||
for (let i = 0; i < ib.clientStats.length; i++) {
|
||||
const stat = ib.clientStats[i];
|
||||
const upd = byEmail.get(stat.email);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') stat.up = upd.up;
|
||||
if (typeof upd.down === 'number') stat.down = upd.down;
|
||||
if (typeof upd.total === 'number') stat.total = upd.total;
|
||||
if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
|
||||
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
dbInbounds.value = [...dbInbounds.value];
|
||||
rebuildClientCount();
|
||||
}
|
||||
}
|
||||
|
||||
// The hub may decide a payload is too large to push directly and emit
|
||||
// an `invalidate` event with the affected dataType instead. For the
|
||||
// inbounds page that means "the inbound list changed elsewhere — go
|
||||
// re-fetch via REST".
|
||||
function applyInvalidate(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (payload.type === 'inbounds') {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function applyInboundsEvent(payload) {
|
||||
if (!Array.isArray(payload)) return;
|
||||
setInbounds(payload);
|
||||
}
|
||||
|
||||
// Recompute the per-inbound roll-up after any in-place mutation.
|
||||
// Cheap because rollupClients only iterates a single inbound's
|
||||
// clients + clientStats arrays.
|
||||
function rebuildClientCount() {
|
||||
const counts = {};
|
||||
const tracked = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
for (const dbInbound of dbInbounds.value) {
|
||||
const parsed = dbInbound.toInbound();
|
||||
if (!tracked.includes(dbInbound.protocol)) continue;
|
||||
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
|
||||
}
|
||||
clientCount.value = counts;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
if (!msg?.success) return;
|
||||
await fetchLastOnlineMap();
|
||||
await fetchOnlineUsers();
|
||||
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
} finally {
|
||||
// Match legacy: keep the spinning-icon state visible briefly so
|
||||
// a fast network doesn't make the button feel like it didn't fire.
|
||||
setTimeout(() => { refreshing.value = false; }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate totals shown in the dashboard summary card. allTime falls
|
||||
// back to up+down when the per-inbound counter isn't populated yet.
|
||||
const totals = computed(() => {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let allTime = 0;
|
||||
let clients = 0;
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
up += ib.up || 0;
|
||||
down += ib.down || 0;
|
||||
allTime += ib.allTime || (ib.up + ib.down) || 0;
|
||||
const c = clientCount.value[ib.id];
|
||||
if (c) {
|
||||
clients += c.clients;
|
||||
deactive.push(...c.deactive);
|
||||
depleted.push(...c.depleted);
|
||||
expiring.push(...c.expiring);
|
||||
online.push(...c.online);
|
||||
}
|
||||
}
|
||||
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
||||
});
|
||||
|
||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||
// here so the linter doesn't drop it; the legacy search uses it.
|
||||
void ObjectUtil;
|
||||
|
||||
return {
|
||||
fetched,
|
||||
refreshing,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
lastOnlineMap,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
subSettings,
|
||||
remarkModel,
|
||||
datepicker,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
pageSize,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
basePath: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function exportDb() {
|
||||
// The Go endpoint streams x-ui.db as a download. Setting
|
||||
// window.location triggers a browser download without leaving
|
||||
// the page (the Go side responds with Content-Disposition: attachment).
|
||||
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
|
||||
}
|
||||
|
||||
function importDb() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.db';
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const dbFile = e.target.files?.[0];
|
||||
if (!dbFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('db', dbFile);
|
||||
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
|
||||
|
||||
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
if (!upload?.success) {
|
||||
emit('busy', { busy: false });
|
||||
return;
|
||||
}
|
||||
|
||||
emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
|
||||
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
||||
if (restart?.success) {
|
||||
await PromiseUtil.sleep(5000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-list bordered class="backup-list">
|
||||
<a-list-item class="backup-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ t('pages.index.exportDatabase') }}</template>
|
||||
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
|
||||
</a-list-item-meta>
|
||||
<a-button type="primary" @click="exportDb">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
|
||||
<a-list-item class="backup-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ t('pages.index.importDatabase') }}</template>
|
||||
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
|
||||
</a-list-item-meta>
|
||||
<a-button type="primary" @click="importDb">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
// Populate with the record when editing; null/undefined when adding.
|
||||
record: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const form = reactive({ type: 'geosite', alias: '', url: '' });
|
||||
const saving = ref(false);
|
||||
|
||||
const editing = ref(false);
|
||||
const editId = ref(null);
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (props.record) {
|
||||
editing.value = true;
|
||||
editId.value = props.record.id;
|
||||
form.type = props.record.type;
|
||||
form.alias = props.record.alias;
|
||||
form.url = props.record.url;
|
||||
} else {
|
||||
editing.value = false;
|
||||
editId.value = null;
|
||||
form.type = 'geosite';
|
||||
form.alias = '';
|
||||
form.url = '';
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function validate() {
|
||||
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
|
||||
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
|
||||
message.error(t('pages.index.customGeoValidationAlias'));
|
||||
return false;
|
||||
}
|
||||
const u = (form.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const url = editing.value
|
||||
? `/panel/api/custom-geo/update/${editId.value}`
|
||||
: '/panel/api/custom-geo/add';
|
||||
const msg = await HttpUtil.post(url, form);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
|
||||
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
|
||||
@cancel="close">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('pages.index.customGeoType')">
|
||||
<a-select v-model:value="form.type" :disabled="editing">
|
||||
<a-select-option value="geosite">geosite</a-select-option>
|
||||
<a-select-option value="geoip">geoip</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.index.customGeoAlias')">
|
||||
<a-input v-model:value="form.alias" :disabled="editing"
|
||||
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.index.customGeoUrl')">
|
||||
<a-input v-model:value="form.url" placeholder="https://" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||
import CustomGeoFormModal from './CustomGeoFormModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
// Re-fetch the list when the parent collapse expands this section.
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
const updatingAll = ref(false);
|
||||
const actionId = ref(null);
|
||||
|
||||
const formOpen = ref(false);
|
||||
const editingRecord = ref(null);
|
||||
|
||||
// Computed so column titles re-render after a locale swap.
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
|
||||
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
|
||||
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
|
||||
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
|
||||
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
|
||||
]);
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingRecord.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
editingRecord.value = record;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function extDisplay(record) {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
}
|
||||
|
||||
async function copyExt(record) {
|
||||
const text = extDisplay(record);
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) message.success(`${t('copied')}: ${text}`);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Tiny inline relative-time formatter so we don't pull in moment.
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||
return formatTime(ts);
|
||||
}
|
||||
|
||||
function confirmDelete(record) {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.customGeoDelete'),
|
||||
content: t('pages.index.customGeoDeleteConfirm'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg?.success) await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadOne(id) {
|
||||
actionId.value = id;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg?.success) await loadList();
|
||||
} finally {
|
||||
actionId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
updatingAll.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||
const ok = msg?.obj?.succeeded?.length || 0;
|
||||
const failed = msg?.obj?.failed?.length || 0;
|
||||
if (msg?.success || ok > 0) {
|
||||
await loadList();
|
||||
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
||||
}
|
||||
} finally {
|
||||
updatingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load: only fetch when the parent collapse opens this panel.
|
||||
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-geo-section">
|
||||
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
|
||||
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" :loading="loading" @click="openAdd">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.customGeoAdd') }}
|
||||
</a-button>
|
||||
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.geofilesUpdateAll') }}
|
||||
</a-button>
|
||||
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
|
||||
size="small" :scroll="{ x: 760 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'alias'">
|
||||
<div class="custom-geo-alias-cell">
|
||||
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
|
||||
{{ record.type }}
|
||||
</a-tag>
|
||||
<span class="custom-geo-alias">{{ record.alias }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'url'">
|
||||
<a-tooltip placement="topLeft" :title="record.url">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
|
||||
{{ record.url }}
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'extDat'">
|
||||
<a-tooltip :title="t('copy')">
|
||||
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
|
||||
{{ extDisplay(record) }}
|
||||
</code>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastUpdatedAt'">
|
||||
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
|
||||
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
|
||||
</a-tooltip>
|
||||
<span v-else class="custom-geo-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space size="small">
|
||||
<a-tooltip :title="t('pages.index.customGeoEdit')">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.index.customGeoDownload')">
|
||||
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.index.customGeoDelete')">
|
||||
<a-button type="link" size="small" danger @click="confirmDelete(record)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<div class="custom-geo-empty">
|
||||
<InboxOutlined class="custom-geo-empty-icon" />
|
||||
<div>{{ t('pages.index.customGeoEmpty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-geo-count {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-geo-alias-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-geo-alias {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-type-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-geo-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-ext-code {
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.custom-geo-copyable:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-ext-code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-copyable:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.custom-geo-empty {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.custom-geo-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
BarsOutlined,
|
||||
ControlOutlined,
|
||||
CloudServerOutlined,
|
||||
CloudDownloadOutlined,
|
||||
CloudUploadOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
AreaChartOutlined,
|
||||
GlobalOutlined,
|
||||
SwapOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useStatus } from '@/composables/useStatus.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
import XrayStatusCard from './XrayStatusCard.vue';
|
||||
import PanelUpdateModal from './PanelUpdateModal.vue';
|
||||
import LogModal from './LogModal.vue';
|
||||
import BackupModal from './BackupModal.vue';
|
||||
import SystemHistoryModal from './SystemHistoryModal.vue';
|
||||
import XrayLogModal from './XrayLogModal.vue';
|
||||
import VersionModal from './VersionModal.vue';
|
||||
|
||||
const { status, fetched, refresh } = useStatus();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
// `/panel/setting/defaultSettings` returns ipLimitEnable; the xray
|
||||
// card hides its log button when access logs are off.
|
||||
const ipLimitEnable = ref(false);
|
||||
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
|
||||
if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
|
||||
});
|
||||
|
||||
// Panel-update info — fetched once on mount, drives both the badge
|
||||
// in QuickActions and the contents of PanelUpdateModal.
|
||||
const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
|
||||
onMounted(() => {
|
||||
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||
if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
|
||||
});
|
||||
});
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// In production, dist.go injects window.X_UI_CUR_VER at serve time.
|
||||
// In dev, Vite serves the HTML directly so the global is missing — fall
|
||||
// back to currentVersion from the panel-update API once it answers.
|
||||
const displayVersion = computed(
|
||||
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
|
||||
);
|
||||
|
||||
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
|
||||
const showIp = ref(false);
|
||||
|
||||
// Modal open state.
|
||||
const logsOpen = ref(false);
|
||||
const backupOpen = ref(false);
|
||||
const panelUpdateOpen = ref(false);
|
||||
const sysHistoryOpen = ref(false);
|
||||
const xrayLogsOpen = ref(false);
|
||||
const versionOpen = ref(false);
|
||||
const configTextOpen = ref(false);
|
||||
const configText = ref('');
|
||||
|
||||
// Page-level loading overlay; modals can request it via @busy.
|
||||
const loading = ref(false);
|
||||
const loadingTip = ref(t('loading'));
|
||||
function setBusy({ busy, tip }) {
|
||||
loading.value = busy;
|
||||
if (tip) loadingTip.value = tip;
|
||||
}
|
||||
|
||||
// Xray controls
|
||||
async function stopXray() {
|
||||
await HttpUtil.post('/panel/api/server/stopXrayService');
|
||||
await refresh();
|
||||
}
|
||||
async function restartXray() {
|
||||
await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function openSystemHistory() { sysHistoryOpen.value = true; }
|
||||
function openXrayLogs() { xrayLogsOpen.value = true; }
|
||||
function openVersionSwitch() { versionOpen.value = true; }
|
||||
|
||||
// Legacy "Config" action — fetch the rendered xray config and show
|
||||
// it as JSON in the shared TextModal (same UX as main).
|
||||
async function openConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||
if (!msg?.success) return;
|
||||
configText.value = JSON.stringify(msg.obj, null, 2);
|
||||
configTextOpen.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="index-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||
<a-col :span="24">
|
||||
<StatusCard :status="status" :is-mobile="isMobile" />
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
|
||||
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
|
||||
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('menu.link')" hoverable>
|
||||
<template #actions>
|
||||
<a-space class="action" @click="logsOpen = true">
|
||||
<BarsOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
||||
</a-space>
|
||||
<a-space class="action" @click="openConfig">
|
||||
<ControlOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.config') }}</span>
|
||||
</a-space>
|
||||
<a-space class="action" @click="backupOpen = true">
|
||||
<CloudServerOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="3X-UI" hoverable>
|
||||
<template v-if="panelUpdateInfo.updateAvailable" #extra>
|
||||
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
|
||||
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
|
||||
<CloudDownloadOutlined />
|
||||
{{ panelUpdateInfo.latestVersion }}
|
||||
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<div class="link-tags">
|
||||
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
|
||||
<a-tag color="green">v{{ displayVersion }}</a-tag>
|
||||
</a>
|
||||
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
|
||||
<a-tag color="green">@XrayUI</a-tag>
|
||||
</a>
|
||||
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
|
||||
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
|
||||
</a>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.operationHours')" hoverable>
|
||||
<a-tag :color="status.xray.color">
|
||||
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
|
||||
</a-tag>
|
||||
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.systemLoad')" hoverable>
|
||||
<template #extra>
|
||||
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
||||
<AreaChartOutlined />
|
||||
{{ t('pages.index.systemHistoryTitle') }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-tooltip :title="t('pages.index.systemLoadDesc')">
|
||||
<a-tag color="green">
|
||||
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('usage')" hoverable>
|
||||
<a-tag color="green">
|
||||
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
|
||||
</a-tag>
|
||||
<a-tag color="green">
|
||||
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
|
||||
</a-tag>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.overallSpeed')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.upload')"
|
||||
:value="SizeFormatter.sizeFormat(status.netIO.up)">
|
||||
<template #prefix>
|
||||
<ArrowUpOutlined />
|
||||
</template>
|
||||
<template #suffix>/s</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.download')"
|
||||
:value="SizeFormatter.sizeFormat(status.netIO.down)">
|
||||
<template #prefix>
|
||||
<ArrowDownOutlined />
|
||||
</template>
|
||||
<template #suffix>/s</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.totalData')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.sent')"
|
||||
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
|
||||
<template #prefix>
|
||||
<CloudUploadOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic :title="t('pages.index.received')"
|
||||
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
|
||||
<template #prefix>
|
||||
<CloudDownloadOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.ipAddresses')" hoverable>
|
||||
<template #extra>
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
|
||||
<component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showIp = !showIp" />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="isMobile ? 24 : 12">
|
||||
<CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
|
||||
<template #prefix>
|
||||
<GlobalOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="isMobile ? 24 : 12">
|
||||
<CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
|
||||
<template #prefix>
|
||||
<GlobalOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :title="t('pages.index.connectionCount')" hoverable>
|
||||
<a-row :gutter="isMobile ? [8, 8] : 0">
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="TCP" :value="status.tcpCount">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<CustomStatistic title="UDP" :value="status.udpCount">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
|
||||
<LogModal v-model:open="logsOpen" />
|
||||
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
||||
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
||||
<XrayLogModal v-model:open="xrayLogsOpen" />
|
||||
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
||||
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
||||
file-name="config.json" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.index-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.index-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.index-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.index-page :deep(.ant-layout),
|
||||
.index-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.action {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.update-tag {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.link-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.link-tags a {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.link-tags :deep(.ant-tag) {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ip-hidden :deep(.ant-statistic-content-value) {
|
||||
filter: blur(6px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.ip-visible :deep(.ant-statistic-content-value) {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const rows = ref('20');
|
||||
const level = ref('info');
|
||||
const syslog = ref(false);
|
||||
const loading = ref(false);
|
||||
const logs = ref([]);
|
||||
|
||||
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
||||
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
|
||||
|
||||
// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
|
||||
// 3-token header degrade gracefully: the unparsed head becomes the
|
||||
// level so it still gets color-coded.
|
||||
function parseLogLine(line) {
|
||||
const [head, ...rest] = (line || '').split(' - ');
|
||||
const message = rest.join(' - ');
|
||||
const parts = head.split(' ');
|
||||
|
||||
let date = '';
|
||||
let time = '';
|
||||
let levelText;
|
||||
if (parts.length >= 3) {
|
||||
[date, time, levelText] = parts;
|
||||
} else {
|
||||
levelText = head;
|
||||
}
|
||||
|
||||
const li = LEVELS.indexOf(levelText);
|
||||
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
||||
|
||||
let service = '';
|
||||
let body = message || '';
|
||||
if (body.startsWith('XRAY:')) {
|
||||
service = 'XRAY:';
|
||||
body = body.slice('XRAY:'.length).trimStart();
|
||||
} else if (body) {
|
||||
service = 'X-UI:';
|
||||
}
|
||||
|
||||
return { date, time, levelText, levelClass, service, body };
|
||||
}
|
||||
|
||||
const parsedLogs = computed(() => logs.value.map(parseLogLine));
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/server/logs/${rows.value}`, {
|
||||
level: level.value,
|
||||
syslog: syslog.value,
|
||||
});
|
||||
if (msg?.success) {
|
||||
logs.value = msg.obj || [];
|
||||
}
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function download() {
|
||||
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => { if (next) refresh(); });
|
||||
watch([rows, level, syslog], () => { if (props.open) refresh(); });
|
||||
|
||||
const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
|
||||
@cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.logs') }}
|
||||
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
||||
</template>
|
||||
|
||||
<a-form layout="inline" class="log-toolbar">
|
||||
<a-form-item>
|
||||
<a-input-group compact>
|
||||
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
||||
<a-select-option value="10">10</a-select-option>
|
||||
<a-select-option value="20">20</a-select-option>
|
||||
<a-select-option value="50">50</a-select-option>
|
||||
<a-select-option value="100">100</a-select-option>
|
||||
<a-select-option value="500">500</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="level" size="small" :style="{ width: '95px' }">
|
||||
<a-select-option value="debug">Debug</a-select-option>
|
||||
<a-select-option value="info">Info</a-select-option>
|
||||
<a-select-option value="notice">Notice</a-select-option>
|
||||
<a-select-option value="warning">Warning</a-select-option>
|
||||
<a-select-option value="err">Error</a-select-option>
|
||||
</a-select>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item class="download-item">
|
||||
<a-button type="primary" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
||||
<div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
|
||||
|
||||
<template v-else-if="isMobile">
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
||||
<div class="log-card-head">
|
||||
<span v-if="log.date || log.time" class="log-time">
|
||||
<span v-if="log.time">{{ log.time }}</span>
|
||||
<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||
</span>
|
||||
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
||||
{{ log.levelText }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="log.body || log.service" class="log-body">
|
||||
<b v-if="log.service">{{ log.service }}</b>
|
||||
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
||||
<span v-if="log.date || log.time" class="log-stamp">
|
||||
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
|
||||
</span>
|
||||
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
|
||||
{{ log.levelText }}
|
||||
</span>
|
||||
<template v-if="log.body || log.service">
|
||||
<span> - </span>
|
||||
<b v-if="log.service">{{ log.service }} </b>
|
||||
<span>{{ log.body }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
||||
below so each level keeps ≥4.5:1 contrast against the container. */
|
||||
--log-stamp: #3c89e8;
|
||||
--log-debug: #3c89e8;
|
||||
--log-info: #008771;
|
||||
--log-notice: #008771;
|
||||
--log-warning: #f37b24;
|
||||
--log-error: #e04141;
|
||||
--log-unknown: #595959;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.log-stamp {
|
||||
color: var(--log-stamp);
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
color: var(--log-debug);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
color: var(--log-info);
|
||||
}
|
||||
|
||||
.level-notice {
|
||||
color: var(--log-notice);
|
||||
}
|
||||
|
||||
.level-warning {
|
||||
color: var(--log-warning);
|
||||
}
|
||||
|
||||
.level-error {
|
||||
color: var(--log-error);
|
||||
}
|
||||
|
||||
.level-unknown {
|
||||
color: var(--log-unknown);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
white-space: normal;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-line+.log-line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.log-level-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.log-body {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-body-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
:global(body.dark) .log-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
|
||||
--log-stamp: #6aa6ee;
|
||||
--log-debug: #6aa6ee;
|
||||
--log-info: #4ed3a6;
|
||||
--log-notice: #4ed3a6;
|
||||
--log-warning: #ffb872;
|
||||
--log-error: #ff7575;
|
||||
--log-unknown: #b5b5b5;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global([data-theme="ultra-dark"]) .log-container {
|
||||
--log-stamp: #7fb6f1;
|
||||
--log-debug: #7fb6f1;
|
||||
--log-info: #5fd9b0;
|
||||
--log-notice: #5fd9b0;
|
||||
--log-warning: #ffcc88;
|
||||
--log-error: #ff8a8a;
|
||||
--log-unknown: #c4c4c4;
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Mobile: pull the modal flush with the screen edges. */
|
||||
:global(.logmodal-mobile) {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
:global(.logmodal-mobile .ant-modal-content) {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
:global(.logmodal-mobile .ant-modal-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
import axios from 'axios';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function updatePanel() {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const baseTip = t('pages.index.dontRefresh');
|
||||
const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
|
||||
close();
|
||||
emit('busy', { busy: true, tip });
|
||||
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||
if (!msg?.success) {
|
||||
emit('busy', { busy: false });
|
||||
return;
|
||||
}
|
||||
// Wait for the running process to exit, then poll the new panel
|
||||
// until it answers (up to ~90s). Reload as soon as it's back.
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
let back = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||
if (r?.data?.success) { back = true; break; }
|
||||
} catch (_) { /* still restarting */ }
|
||||
await PromiseUtil.sleep(2000);
|
||||
}
|
||||
if (back) {
|
||||
message.success(t('pages.index.panelUpdateStartedPopover'));
|
||||
await PromiseUtil.sleep(800);
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
|
||||
show-icon />
|
||||
|
||||
<a-list bordered class="version-list">
|
||||
<a-list-item class="version-list-item">
|
||||
<span>{{ t('pages.index.currentPanelVersion') }}</span>
|
||||
<a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
|
||||
</a-list-item>
|
||||
<a-list-item v-if="info.updateAvailable" class="version-list-item">
|
||||
<span>{{ t('pages.index.latestPanelVersion') }}</span>
|
||||
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
|
||||
</a-list-item>
|
||||
<a-list-item v-else class="version-list-item">
|
||||
<span>{{ t('pages.index.panelUpToDate') }}</span>
|
||||
<a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
|
||||
<div class="actions-row">
|
||||
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
|
||||
<template #icon>
|
||||
<CloudDownloadOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.updatePanel') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AreaChartOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { CPUFormatter, SizeFormatter } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
// AD-Vue's default 120px dashboard renders the percent text at ~36px
|
||||
// which dwarfs the rest of the card. 70 (60 on mobile) plus the
|
||||
// :deep(.ant-progress-text) override below keep the gauges compact.
|
||||
const gaugeSize = computed(() => (props.isMobile ? 60 : 70));
|
||||
|
||||
// AD-Vue's default unfinished trail (rgba(0,0,0,0.06) /
|
||||
// rgba(255,255,255,0.08)) is invisible against the light card; a
|
||||
// neutral mid-gray reads on both themes.
|
||||
const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<a-row :gutter="[0, isMobile ? 16 : 0]">
|
||||
<!-- CPU + Memory -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-row>
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
|
||||
:percent="status.cpu.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
|
||||
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
|
||||
</div>
|
||||
</template>
|
||||
<AreaChartOutlined />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
|
||||
:percent="status.mem.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
|
||||
{{ SizeFormatter.sizeFormat(status.mem.total) }}
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
|
||||
<!-- Swap + Disk -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-row>
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
|
||||
:percent="status.swap.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
|
||||
{{ SizeFormatter.sizeFormat(status.swap.total) }}
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12" class="text-center">
|
||||
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
|
||||
:percent="status.disk.percent" :width="gaugeSize" />
|
||||
<div>
|
||||
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
|
||||
{{ SizeFormatter.sizeFormat(status.disk.total) }}
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Pin the percent number to a label-sized 14px — AD-Vue scales it
|
||||
* from the SVG's intrinsic size, so :width alone leaves it too big. */
|
||||
:deep(.ant-progress-text) {
|
||||
font-size: 14px !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
status: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
// One tab per system metric. The order here drives the tab order in
|
||||
// the UI; everything else (axis label, tooltip unit, fetch URL) is
|
||||
// looked up from the active key. Adding another metric is one row.
|
||||
const metrics = [
|
||||
{ key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
|
||||
{ key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
|
||||
{ key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
|
||||
{ key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
|
||||
{ key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
|
||||
{ key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
|
||||
{ key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
|
||||
{ key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
|
||||
];
|
||||
|
||||
const activeKey = ref('cpu');
|
||||
const bucket = ref(2);
|
||||
const points = ref([]);
|
||||
const labels = ref([]);
|
||||
|
||||
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
||||
|
||||
// CPU keeps using the status-card color so the modal visually echoes
|
||||
// the dot in StatusCard. Non-CPU tabs each get their own constant color.
|
||||
const strokeColor = computed(() => {
|
||||
const m = activeMetric.value;
|
||||
if (m?.stroke) return m.stroke;
|
||||
return props.status?.cpu?.color || '#008771';
|
||||
});
|
||||
|
||||
function unitFormatter(unit) {
|
||||
if (unit === 'B/s') {
|
||||
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
|
||||
}
|
||||
if (unit === '%') {
|
||||
return (v) => `${Number(v).toFixed(1)}%`;
|
||||
}
|
||||
// Plain numbers: load averages get two decimals, online client count
|
||||
// is integer. Heuristic on the unit-less metric key is good enough.
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
if (activeKey.value === 'online') return String(Math.round(n));
|
||||
return n.toFixed(2);
|
||||
};
|
||||
}
|
||||
|
||||
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
||||
|
||||
async function fetchBucket() {
|
||||
const m = activeMetric.value;
|
||||
if (!m) return;
|
||||
try {
|
||||
const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals = [];
|
||||
const labs = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||
vals.push(Number(p.v) || 0);
|
||||
}
|
||||
labels.value = labs;
|
||||
points.value = vals;
|
||||
} else {
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch history bucket', e);
|
||||
labels.value = [];
|
||||
points.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) {
|
||||
activeKey.value = 'cpu';
|
||||
fetchBucket();
|
||||
}
|
||||
});
|
||||
watch([activeKey, bucket], () => {
|
||||
if (props.open) fetchBucket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.systemHistoryTitle') }}
|
||||
<a-select v-model:value="bucket" size="small" class="bucket-select">
|
||||
<a-select-option :value="2">2m</a-select-option>
|
||||
<a-select-option :value="30">30m</a-select-option>
|
||||
<a-select-option :value="60">1h</a-select-option>
|
||||
<a-select-option :value="120">2h</a-select-option>
|
||||
<a-select-option :value="180">3h</a-select-option>
|
||||
<a-select-option :value="300">5h</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
|
||||
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="cpu-chart-wrap">
|
||||
<div class="cpu-chart-meta">
|
||||
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
||||
</div>
|
||||
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
|
||||
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
|
||||
:y-formatter="yFormatter" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bucket-select {
|
||||
width: 80px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.history-tabs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cpu-chart-wrap {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.cpu-chart-meta {
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import CustomGeoSection from './CustomGeoSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
status: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
const activeKey = ref('1');
|
||||
const versions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Geofiles list is hardcoded in the legacy panel — same set of files
|
||||
// served from /panel/api/server/updateGeofile/{name}.
|
||||
const GEOFILES = ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat'];
|
||||
|
||||
async function fetchVersions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
|
||||
if (msg?.success) versions.value = msg.obj || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function switchXrayVersion(version) {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.xraySwitchVersionDialog'),
|
||||
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
||||
try {
|
||||
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
|
||||
} finally {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateGeofile(fileName) {
|
||||
const isSingle = !!fileName;
|
||||
Modal.confirm({
|
||||
title: t('pages.index.geofileUpdateDialog'),
|
||||
content: isSingle
|
||||
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
|
||||
: t('pages.index.geofilesUpdateDialogDesc'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
|
||||
const url = isSingle
|
||||
? `/panel/api/server/updateGeofile/${fileName}`
|
||||
: '/panel/api/server/updateGeofile';
|
||||
try {
|
||||
await HttpUtil.post(url);
|
||||
} finally {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => { if (next) fetchVersions(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.xrayUpdates')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-spin :spinning="loading">
|
||||
<a-collapse v-model:active-key="activeKey" accordion>
|
||||
<a-collapse-panel key="1" header="Xray">
|
||||
<a-alert type="warning" class="mb-12" :message="t('pages.index.xraySwitchClickDesk')" show-icon />
|
||||
<a-list bordered class="version-list">
|
||||
<a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
|
||||
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
|
||||
<a-radio :checked="version === `v${status?.xray?.version}`" @click="switchXrayVersion(version)" />
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" header="Geofiles">
|
||||
<a-list bordered class="version-list">
|
||||
<a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
|
||||
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
|
||||
<a-tooltip :title="t('update')">
|
||||
<ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
|
||||
</a-tooltip>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
<div class="actions-row">
|
||||
<a-button @click="updateGeofile('')">{{ t('pages.index.geofilesUpdateAll') }}</a-button>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.index.customGeoTitle')">
|
||||
<CustomGeoSection :active="activeKey === '3'" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const rows = ref('20');
|
||||
const filter = ref('');
|
||||
const showDirect = ref(true);
|
||||
const showBlocked = ref(true);
|
||||
const showProxy = ref(true);
|
||||
const loading = ref(false);
|
||||
const logs = ref([]);
|
||||
|
||||
// Newest first.
|
||||
const orderedLogs = computed(() => [...logs.value].reverse());
|
||||
|
||||
const EVENT_LABELS = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||
const EVENT_COLORS = { 0: 'green', 1: 'red', 2: 'blue' };
|
||||
|
||||
function eventLabel(ev) { return EVENT_LABELS[ev] || String(ev ?? ''); }
|
||||
function eventColor(ev) { return EVENT_COLORS[ev] || 'default'; }
|
||||
|
||||
function fullDate(value) {
|
||||
return IntlUtil.formatDate(value, datepicker.value);
|
||||
}
|
||||
function shortTime(value) {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
|
||||
filter: filter.value,
|
||||
showDirect: showDirect.value,
|
||||
showBlocked: showBlocked.value,
|
||||
showProxy: showProxy.value,
|
||||
});
|
||||
if (msg?.success) logs.value = msg.obj || [];
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!Array.isArray(logs.value) || logs.value.length === 0) {
|
||||
FileManager.downloadTextFile('', 'x-ui.log');
|
||||
return;
|
||||
}
|
||||
const lines = logs.value.map((l) => {
|
||||
try {
|
||||
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||
const eventText = eventLabel(l.Event);
|
||||
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||
} catch (_e) {
|
||||
return JSON.stringify(l);
|
||||
}
|
||||
}).join('\n');
|
||||
FileManager.downloadTextFile(lines, 'x-ui.log');
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => { if (next) refresh(); });
|
||||
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
|
||||
|
||||
const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
|
||||
:class="{ 'xraylog-modal-mobile': isMobile }" @cancel="close">
|
||||
<template #title>
|
||||
{{ t('pages.index.logs') }}
|
||||
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
|
||||
</template>
|
||||
|
||||
<a-form layout="inline" class="log-toolbar">
|
||||
<a-form-item>
|
||||
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
|
||||
<a-select-option value="10">10</a-select-option>
|
||||
<a-select-option value="20">20</a-select-option>
|
||||
<a-select-option value="50">50</a-select-option>
|
||||
<a-select-option value="100">100</a-select-option>
|
||||
<a-select-option value="500">500</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('filter')" class="filter-item">
|
||||
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
|
||||
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
|
||||
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item class="download-item">
|
||||
<a-button type="primary" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
|
||||
<div v-if="orderedLogs.length === 0" class="log-empty">No Record...</div>
|
||||
|
||||
<template v-else-if="isMobile">
|
||||
<div v-for="(log, idx) in orderedLogs" :key="idx" class="log-card">
|
||||
<div class="log-card-head">
|
||||
<span class="log-time" :title="fullDate(log.DateTime)">{{ shortTime(log.DateTime) }}</span>
|
||||
<a-tag :color="eventColor(log.Event)" class="log-event-tag">{{ eventLabel(log.Event) }}</a-tag>
|
||||
</div>
|
||||
<div class="log-route">
|
||||
<span class="log-addr">{{ log.FromAddress }}</span>
|
||||
<span class="log-arrow">→</span>
|
||||
<span class="log-addr">{{ log.ToAddress }}</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span v-if="log.Inbound" class="log-meta-pair">
|
||||
<span class="log-meta-key">in</span>
|
||||
<span class="log-meta-val">{{ log.Inbound }}</span>
|
||||
</span>
|
||||
<span v-if="log.Outbound" class="log-meta-pair">
|
||||
<span class="log-meta-key">out</span>
|
||||
<span class="log-meta-val">{{ log.Outbound }}</span>
|
||||
</span>
|
||||
<span v-if="log.Email" class="log-meta-pair">
|
||||
<span class="log-meta-key">email</span>
|
||||
<span class="log-meta-val">{{ log.Email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table v-else class="xraylog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Inbound</th>
|
||||
<th>Outbound</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(log, idx) in orderedLogs" :key="idx" :class="`log-row-${log.Event}`">
|
||||
<td><b>{{ fullDate(log.DateTime) }}</b></td>
|
||||
<td>{{ log.FromAddress }}</td>
|
||||
<td>{{ log.ToAddress }}</td>
|
||||
<td>{{ log.Inbound }}</td>
|
||||
<td>{{ log.Outbound }}</td>
|
||||
<td>{{ log.Email }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .filter-item {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
|
||||
below so blocked/proxy rows keep ≥4.5:1 contrast on darker surfaces. */
|
||||
--log-blocked: #e04141;
|
||||
--log-proxy: #3c89e8;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-event-tag {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-addr {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.log-meta-pair {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-meta-key {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
:global(body.dark) .log-container {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
|
||||
--log-blocked: #ff7575;
|
||||
--log-proxy: #6aa6ee;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global([data-theme="ultra-dark"]) .log-container {
|
||||
--log-blocked: #ff8a8a;
|
||||
--log-proxy: #7fb6f1;
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Mobile: pull the modal flush with the screen edges. */
|
||||
:global(.xraylog-modal-mobile) {
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
|
||||
:global(.xraylog-modal-mobile .ant-modal-content) {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
:global(.xraylog-modal-mobile .ant-modal-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.xraylog-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xraylog-table td,
|
||||
.xraylog-table th {
|
||||
padding: 2px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-1 {
|
||||
color: var(--log-blocked);
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-2 {
|
||||
color: var(--log-proxy);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
BarsOutlined,
|
||||
PoweroffOutlined,
|
||||
ReloadOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['stop-xray', 'restart-xray', 'open-logs', 'open-xray-logs', 'open-version-switch']);
|
||||
|
||||
const XRAY_STATE_KEYS = {
|
||||
running: 'pages.index.xrayStatusRunning',
|
||||
stop: 'pages.index.xrayStatusStop',
|
||||
error: 'pages.index.xrayStatusError',
|
||||
};
|
||||
|
||||
const stateText = computed(() =>
|
||||
t(XRAY_STATE_KEYS[props.status.xray.state] ?? 'pages.index.xrayStatusUnknown'),
|
||||
);
|
||||
|
||||
function badgeAnimationClass(color) {
|
||||
if (color === 'green') return 'xray-running-animation';
|
||||
if (color === 'orange') return 'xray-stop-animation';
|
||||
if (color === 'red') return 'xray-error-animation';
|
||||
return 'xray-processing-animation';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-space direction="horizontal">
|
||||
<span>{{ t('pages.index.xrayStatus') }}</span>
|
||||
<a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green">
|
||||
v{{ status.xray.version }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<template v-if="status.xray.state !== 'error'">
|
||||
<a-badge status="processing" :class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
|
||||
:text="stateText" :color="status.xray.color" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-popover>
|
||||
<template #title>
|
||||
<a-row type="flex" align="middle" justify="space-between">
|
||||
<a-col><span>{{ t('pages.index.xrayStatusError') }}</span></a-col>
|
||||
<a-col>
|
||||
<BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
<template #content>
|
||||
<span v-for="(line, i) in (status.xray.errorMsg || '').split('\n')" :key="i" class="error-line">
|
||||
{{ line }}
|
||||
</span>
|
||||
</template>
|
||||
<a-badge status="processing" :text="stateText" :color="status.xray.color"
|
||||
:class="['xray-processing-animation', 'xray-error-animation']" />
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')">
|
||||
<BarsOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" class="action" @click="$emit('stop-xray')">
|
||||
<PoweroffOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.stopXray') }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" class="action" @click="$emit('restart-xray')">
|
||||
<ReloadOutlined />
|
||||
<span v-if="!isMobile">{{ t('pages.index.restartXray') }}</span>
|
||||
</a-space>
|
||||
<a-space direction="horizontal" class="action" @click="$emit('open-version-switch')">
|
||||
<ToolOutlined />
|
||||
<span v-if="!isMobile">
|
||||
{{ status.xray.version && status.xray.version !== 'Unknown'
|
||||
? `v${status.xray.version}`
|
||||
: t('pages.index.xraySwitch') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
display: block;
|
||||
max-width: 400px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Legacy xray-*-animation classes — they need to be global so they
|
||||
* pierce the AD-Vue badge's internal DOM (.ant-badge-status-*). */
|
||||
.xray-processing-animation .ant-badge-status-dot {
|
||||
animation: xray-pulse 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.xray-running-animation .ant-badge-status-processing::after {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.xray-stop-animation .ant-badge-status-processing::after {
|
||||
border-color: #fa8c16;
|
||||
}
|
||||
|
||||
.xray-error-animation .ant-badge-status-processing::after {
|
||||
border-color: #f5222d;
|
||||
}
|
||||
|
||||
@keyframes xray-pulse {
|
||||
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import {
|
||||
antdThemeConfig,
|
||||
currentTheme,
|
||||
theme as themeState,
|
||||
toggleTheme,
|
||||
toggleUltra,
|
||||
pauseAnimationsUntilLeave,
|
||||
} from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetched = ref(false);
|
||||
const submitting = ref(false);
|
||||
const twoFactorEnable = ref(false);
|
||||
|
||||
const user = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
twoFactorCode: '',
|
||||
});
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
|
||||
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
|
||||
const HEADLINE_INTERVAL_MS = 2000;
|
||||
const headlineIndex = ref(0);
|
||||
let headlineTimer = null;
|
||||
|
||||
onMounted(() => {
|
||||
headlineTimer = window.setInterval(() => {
|
||||
headlineIndex.value = (headlineIndex.value + 1) % headlineWords.value.length;
|
||||
}, HEADLINE_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (headlineTimer != null) window.clearInterval(headlineTimer);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
||||
if (msg.success) twoFactorEnable.value = !!msg.obj;
|
||||
fetched.value = true;
|
||||
});
|
||||
|
||||
async function login() {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/login', user);
|
||||
if (msg.success) window.location.href = basePath + 'panel/';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lang = ref(LanguageManager.getLanguage());
|
||||
function onLangChange(next) {
|
||||
LanguageManager.setLanguage(next);
|
||||
}
|
||||
|
||||
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
|
||||
* button uses, so the login chrome offers a one-click theme toggle
|
||||
* without the popover ceremony. */
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('login-theme-cycle');
|
||||
if (!themeState.isDark) {
|
||||
toggleTheme();
|
||||
if (themeState.isUltra) toggleUltra();
|
||||
} else if (!themeState.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<a-layout-content class="login-content">
|
||||
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
|
||||
plus a language picker hidden behind the gear popover. -->
|
||||
<div class="login-toolbar">
|
||||
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
|
||||
@click="cycleTheme">
|
||||
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
|
||||
trigger="click">
|
||||
<template #content>
|
||||
<a-space direction="vertical" :size="10" class="settings-popover">
|
||||
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
|
||||
<span :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="login-wrapper">
|
||||
<div v-if="!fetched" class="login-loading">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else class="login-card">
|
||||
<div class="brand">
|
||||
<span class="brand-name">3X-UI</span>
|
||||
<span class="brand-accent" aria-hidden="true"></span>
|
||||
</div>
|
||||
<h2 class="welcome">
|
||||
<Transition name="headline" mode="out-in">
|
||||
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
|
||||
</Transition>
|
||||
</h2>
|
||||
|
||||
<a-form layout="vertical" class="login-form" @submit.prevent="login">
|
||||
<a-form-item :label="t('username')">
|
||||
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
|
||||
:placeholder="t('username')" autofocus required>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('password')">
|
||||
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
|
||||
size="large" :placeholder="t('password')" required>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
|
||||
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
|
||||
size="large" :placeholder="t('twoFactorCode')" required>
|
||||
<template #prefix>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item class="submit-row">
|
||||
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
|
||||
{{ submitting ? '' : t('login') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-app {
|
||||
--bg-page: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--color-text: rgba(0, 0, 0, 0.88);
|
||||
--color-text-subtle: rgba(0, 0, 0, 0.55);
|
||||
--color-accent: #1677ff;
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
--blob-1: rgba(99, 102, 241, 0.45);
|
||||
--blob-2: rgba(236, 72, 153, 0.38);
|
||||
--blob-3: rgba(20, 184, 166, 0.32);
|
||||
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.login-app.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
--color-text: rgba(255, 255, 255, 0.92);
|
||||
--color-text-subtle: rgba(255, 255, 255, 0.55);
|
||||
--color-accent: #4096ff;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--blob-1: rgba(64, 150, 255, 0.40);
|
||||
--blob-2: rgba(168, 85, 247, 0.34);
|
||||
--blob-3: rgba(34, 211, 238, 0.22);
|
||||
}
|
||||
|
||||
.login-app.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #141414;
|
||||
--color-border: rgba(255, 255, 255, 0.06);
|
||||
--blob-1: rgba(64, 150, 255, 0.22);
|
||||
--blob-2: rgba(168, 85, 247, 0.18);
|
||||
--blob-3: rgba(34, 211, 238, 0.12);
|
||||
}
|
||||
|
||||
/* Three blurred blobs slowly drift across the page; ::before and
|
||||
* ::after carry two of them, the third lives on .login-content::before
|
||||
* so we can animate it independently. */
|
||||
.login-app::before,
|
||||
.login-app::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70vw;
|
||||
height: 70vw;
|
||||
max-width: 900px;
|
||||
max-height: 900px;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.login-app::before {
|
||||
top: -25vw;
|
||||
left: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
|
||||
animation: blob-drift-a 24s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-app::after {
|
||||
bottom: -25vw;
|
||||
right: -20vw;
|
||||
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
|
||||
animation: blob-drift-b 30s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.login-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
left: 50%;
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
max-width: 700px;
|
||||
max-height: 700px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
animation: blob-drift-c 36s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes blob-drift-a {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(18vw, 10vh) scale(1.15);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(34vw, 22vh) scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-drift-b {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-16vw, -10vh) scale(1.12);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-30vw, -22vh) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob-drift-c {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20%, -20%) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-80%, -10%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.login-app::before,
|
||||
.login-app::after,
|
||||
.login-content::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-app :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-content>* {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--bg-card);
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.login-loading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 40px 32px 28px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 32px 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.brand-accent {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
min-height: 42px;
|
||||
margin: 12px 0 28px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.welcome b {
|
||||
display: inline-block;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.headline-enter-active,
|
||||
.headline-leave-active {
|
||||
transition: opacity 280ms ease, transform 280ms ease;
|
||||
}
|
||||
|
||||
.headline-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.headline-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item-label > label) {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-popover {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add' }, // 'add' | 'edit'
|
||||
node: { type: Object, default: null },
|
||||
testConnection: { type: Function, required: true },
|
||||
save: { type: Function, required: true }, // (payload) => Promise<msg>
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Default form shape — used for "add" mode and to reset between
|
||||
// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
const submitting = ref(false);
|
||||
const testing = ref(false);
|
||||
const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
|
||||
// Reset the form whenever the modal is opened. In edit mode we copy
|
||||
// the existing node into the form fields; in add mode we wipe back
|
||||
// to defaults so a previous edit doesn't leak through.
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
Object.assign(form, defaultForm());
|
||||
testResult.value = null;
|
||||
if (props.mode === 'edit' && props.node) {
|
||||
Object.assign(form, props.node);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const title = computed(() =>
|
||||
props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
|
||||
);
|
||||
|
||||
function close() {
|
||||
if (!submitting.value) emit('update:open', false);
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
id: form.id || 0,
|
||||
name: form.name?.trim() || '',
|
||||
remark: form.remark?.trim() || '',
|
||||
scheme: form.scheme || 'https',
|
||||
address: form.address?.trim() || '',
|
||||
port: Number(form.port) || 0,
|
||||
basePath: form.basePath?.trim() || '/',
|
||||
apiToken: form.apiToken?.trim() || '',
|
||||
enable: !!form.enable,
|
||||
};
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
testing.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload.address || !payload.port) {
|
||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
const msg = await props.testConnection(payload);
|
||||
if (msg?.success) {
|
||||
testResult.value = msg.obj;
|
||||
} else {
|
||||
testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const payload = buildPayload();
|
||||
if (!payload.name || !payload.address || !payload.port) {
|
||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const msg = await props.save(payload);
|
||||
if (msg?.success) {
|
||||
emit('update:open', false);
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :confirm-loading="submitting" :ok-text="t('save')" :cancel-text="t('cancel')"
|
||||
:mask-closable="false" width="640px" @ok="onSave" @cancel="close">
|
||||
<a-form layout="vertical" :model="form">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.nodes.name')" required>
|
||||
<a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.nodes.remark')">
|
||||
<a-input v-model:value="form.remark" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-form-item :label="t('pages.nodes.scheme')">
|
||||
<a-select v-model:value="form.scheme">
|
||||
<a-select-option value="https">https</a-select-option>
|
||||
<a-select-option value="http">http</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.nodes.address')" required>
|
||||
<a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :label="t('pages.nodes.port')" required>
|
||||
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.nodes.basePath')">
|
||||
<a-input v-model:value="form.basePath" placeholder="/" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.nodes.enable')">
|
||||
<a-switch v-model:checked="form.enable" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item :label="t('pages.nodes.apiToken')" required>
|
||||
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<div class="test-row">
|
||||
<a-button :loading="testing" @click="onTest">
|
||||
{{ t('pages.nodes.testConnection') }}
|
||||
</a-button>
|
||||
<div v-if="testResult" class="test-result">
|
||||
<a-alert v-if="testResult.status === 'online'" type="success" show-icon
|
||||
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
|
||||
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined" />
|
||||
<a-alert v-else type="error" show-icon :message="t('pages.nodes.connectionFailed')"
|
||||
:description="testResult.error" />
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.test-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
// Bucket size in seconds — matches the SystemHistoryModal selector.
|
||||
bucket: { type: Number, default: 30 },
|
||||
});
|
||||
|
||||
// Two parallel series so the panel renders CPU and Mem side-by-side
|
||||
// in a single fetch round-trip per refresh.
|
||||
const cpuPoints = ref([]);
|
||||
const cpuLabels = ref([]);
|
||||
const memPoints = ref([]);
|
||||
const memLabels = ref([]);
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
let timer = null;
|
||||
|
||||
function bucketLabel(unixSec) {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
if (props.bucket >= 60) return `${hh}:${mm}`;
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
async function fetchSeries(metric) {
|
||||
try {
|
||||
const url = `/panel/api/nodes/history/${props.node.id}/${metric}/${props.bucket}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals = [];
|
||||
const labs = [];
|
||||
for (const p of msg.obj) {
|
||||
labs.push(bucketLabel(p.t));
|
||||
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
|
||||
}
|
||||
return { vals, labs };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('node history fetch failed', metric, e);
|
||||
}
|
||||
return { vals: [], labs: [] };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
||||
cpuPoints.value = cpu.vals;
|
||||
cpuLabels.value = cpu.labs;
|
||||
memPoints.value = mem.vals;
|
||||
memLabels.value = mem.labs;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, REFRESH_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
// If the parent table re-emits a node row with a different id (rare —
|
||||
// happens when the list is sorted or filtered while the panel is open),
|
||||
// reset and re-fetch.
|
||||
watch(() => props.node?.id, (a, b) => {
|
||||
if (a !== b) refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="node-history-panel">
|
||||
<div class="series">
|
||||
<div class="series-title">{{ t('pages.nodes.cpu') }}</div>
|
||||
<Sparkline :data="cpuPoints" :labels="cpuLabels" :vb-width="640" :height="120" stroke="#008771" :show-grid="true"
|
||||
:show-axes="true" :tick-count-x="4" :max-points="cpuPoints.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="2.6" :show-tooltip="true" />
|
||||
</div>
|
||||
<div class="series">
|
||||
<div class="series-title">{{ t('pages.nodes.mem') }}</div>
|
||||
<Sparkline :data="memPoints" :labels="memLabels" :vb-width="640" :height="120" stroke="#7c4dff" :show-grid="true"
|
||||
:show-axes="true" :tick-count-x="4" :max-points="memPoints.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="2.6" :show-tooltip="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.node-history-panel {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.node-history-panel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.series-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'add',
|
||||
'edit',
|
||||
'delete',
|
||||
'probe',
|
||||
'toggle-enable',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Render the address column as a clickable URL so admins can jump to
|
||||
// the remote panel directly from the list.
|
||||
const dataSource = computed(() =>
|
||||
props.nodes.map((n) => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.id,
|
||||
})),
|
||||
);
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'online': return 'green';
|
||||
case 'offline': return 'red';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// Relative-time formatter — keeps the column compact and avoids
|
||||
// pulling dayjs just for this single use.
|
||||
function relativeTime(unixSeconds) {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
}
|
||||
|
||||
function formatUptime(secs) {
|
||||
if (!secs) return '-';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
const mins = Math.floor((secs % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function formatPct(p) {
|
||||
if (typeof p !== 'number' || isNaN(p)) return '-';
|
||||
return `${p.toFixed(1)}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card size="small" hoverable>
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" @click="emit('add')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.nodes.addNode') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
|
||||
size="middle" row-key="id">
|
||||
<template #expandedRowRender="{ record }">
|
||||
<NodeHistoryPanel :node="record" />
|
||||
</template>
|
||||
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
|
||||
<template #default="{ record }">
|
||||
<div class="name-cell">
|
||||
<span class="name">{{ record.name }}</span>
|
||||
<span v-if="record.remark" class="remark">{{ record.remark }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
|
||||
<template #default="{ record }">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
|
||||
<template #default="{ record }">
|
||||
<a-space :size="4">
|
||||
<a-badge
|
||||
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||
<span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
|
||||
<a-tooltip v-if="record.lastError" :title="record.lastError">
|
||||
<ExclamationCircleOutlined style="color: #faad14" />
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.cpu')" data-index="cpuPct" align="center" :width="90">
|
||||
<template #default="{ record }">{{ formatPct(record.cpuPct) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.mem')" data-index="memPct" align="center" :width="90">
|
||||
<template #default="{ record }">{{ formatPct(record.memPct) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.xrayVersion')" data-index="xrayVersion" align="center">
|
||||
<template #default="{ record }">
|
||||
{{ record.xrayVersion || '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
|
||||
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
|
||||
<template #default="{ record }">
|
||||
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.lastHeartbeat')" data-index="lastHeartbeat" align="center" :width="120">
|
||||
<template #default="{ record }">{{ relativeTime(record.lastHeartbeat) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
|
||||
<template #default="{ record }">
|
||||
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.actions')" align="center" :width="160" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-tooltip :title="t('pages.nodes.probe')">
|
||||
<a-button type="text" size="small" @click="emit('probe', record)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<a-button type="text" size="small" @click="emit('edit', record)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('delete')">
|
||||
<a-button type="text" size="small" danger @click="emit('delete', record)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remark {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import NodeList from './NodeList.vue';
|
||||
import NodeFormModal from './NodeFormModal.vue';
|
||||
import { useNodes } from './useNodes.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
} = useNodes();
|
||||
|
||||
// Live updates — NodeHeartbeatJob pushes the fresh list every 10s.
|
||||
useWebSocket({ nodes: applyNodesEvent });
|
||||
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// === Form modal state =================================================
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const formNode = ref(null);
|
||||
|
||||
function onAdd() {
|
||||
formMode.value = 'add';
|
||||
formNode.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEdit(node) {
|
||||
formMode.value = 'edit';
|
||||
formNode.value = { ...node };
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
// Save callback the modal hands its payload to. We hide the create vs.
|
||||
// update branching here so the modal stays mode-agnostic.
|
||||
async function onSave(payload) {
|
||||
if (formMode.value === 'edit' && formNode.value?.id) {
|
||||
return update(formNode.value.id, payload);
|
||||
}
|
||||
return create(payload);
|
||||
}
|
||||
|
||||
function onDelete(node) {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
|
||||
content: t('pages.nodes.deleteConfirmContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(node.id);
|
||||
if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onProbe(node) {
|
||||
const msg = await probe(node.id);
|
||||
if (msg?.success && msg.obj) {
|
||||
if (msg.obj.status === 'online') {
|
||||
message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||
} else {
|
||||
message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleEnable(node, next) {
|
||||
await setEnable(node.id, next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="nodes-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
||||
<template #prefix>
|
||||
<CloudServerOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined style="color: #52c41a" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
||||
<template #prefix>
|
||||
<CloseCircleOutlined style="color: #ff4d4f" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
||||
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
||||
<template #prefix>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Node table -->
|
||||
<a-col :span="24">
|
||||
<NodeList :nodes="nodes" :loading="loading" :is-mobile="isMobile" @add="onAdd" @edit="onEdit"
|
||||
@delete="onDelete" @probe="onProbe" @toggle-enable="onToggleEnable" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<NodeFormModal v-model:open="formOpen" :mode="formMode" :node="formNode" :test-connection="testConnection"
|
||||
:save="onSave" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nodes-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.nodes-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.nodes-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.nodes-page :deep(.ant-layout),
|
||||
.nodes-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
// Loads the node list and runs CRUD/probe actions against the
|
||||
// /panel/api/nodes/* endpoints. Live updates arrive over WebSocket
|
||||
// (pushed by NodeHeartbeatJob every 10s) so we don't poll.
|
||||
|
||||
import { computed, onMounted, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export function useNodes() {
|
||||
const nodes = shallowRef([]);
|
||||
const loading = ref(false);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||
if (msg?.success) {
|
||||
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces the local list with the snapshot pushed by the heartbeat job.
|
||||
// shallowRef means a fresh assignment is enough to retrigger reactivity;
|
||||
// we always assign a new array so Vue notices.
|
||||
function applyNodesEvent(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
nodes.value = payload;
|
||||
if (!fetched.value) fetched.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/add', payload);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function update(id, payload) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function setEnable(id, enable) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable });
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
// testConnection probes a transient (unsaved) node config so the form
|
||||
// can validate before save. Returns the ProbeResultUI shape from Go.
|
||||
async function testConnection(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// probe forces an immediate heartbeat against an already-saved node.
|
||||
async function probe(id) {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Aggregate cards on the dashboard. Computed off the live list so a
|
||||
// refresh (or a WS push) picks up new totals automatically.
|
||||
const totals = computed(() => {
|
||||
const list = nodes.value;
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
for (const n of list) {
|
||||
if (!n.enable) continue;
|
||||
if (n.status === 'online') {
|
||||
online += 1;
|
||||
if (n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: list.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Initial fetch — WebSocket takes over after the first heartbeat tick
|
||||
// (~10s) but the page should populate immediately on mount.
|
||||
onMounted(refresh);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
refresh,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import SettingListItem from '@/components/SettingListItem.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
// Reactive AllSetting instance shared with the parent page.
|
||||
allSetting: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// Remark model — legacy stores it as a single string where index 0 is
|
||||
// the separator char and the rest is the order of model keys
|
||||
// (i=Inbound, e=Email, o=Other). Surface it as two v-models that read
|
||||
// and write the underlying string.
|
||||
const remarkModels = { i: 'Inbound', e: 'Email', o: 'Other' };
|
||||
const remarkSeparators = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
|
||||
|
||||
const remarkModel = computed({
|
||||
get: () => {
|
||||
const rm = props.allSetting.remarkModel || '';
|
||||
return rm.length > 1 ? rm.substring(1).split('') : [];
|
||||
},
|
||||
set: (value) => {
|
||||
const sep = (props.allSetting.remarkModel || '-').charAt(0);
|
||||
props.allSetting.remarkModel = sep + value.join('');
|
||||
},
|
||||
});
|
||||
|
||||
const remarkSeparator = computed({
|
||||
get: () => {
|
||||
const rm = props.allSetting.remarkModel || '-';
|
||||
return rm.length > 1 ? rm.charAt(0) : '-';
|
||||
},
|
||||
set: (value) => {
|
||||
const tail = (props.allSetting.remarkModel || '-').substring(1);
|
||||
props.allSetting.remarkModel = value + tail;
|
||||
},
|
||||
});
|
||||
|
||||
const remarkSample = computed(() => {
|
||||
const parts = remarkModel.value.map((k) => remarkModels[k]);
|
||||
return parts.length === 0 ? '' : parts.join(remarkSeparator.value);
|
||||
});
|
||||
|
||||
const datepicker = computed({
|
||||
get: () => props.allSetting.datepicker || 'gregorian',
|
||||
set: (value) => { props.allSetting.datepicker = value; },
|
||||
});
|
||||
|
||||
const datepickerList = [
|
||||
{ name: 'Gregorian (Standard)', value: 'gregorian' },
|
||||
{ name: 'Jalalian (شمسی)', value: 'jalalian' },
|
||||
];
|
||||
|
||||
// Language is stored client-side in a cookie, NOT in AllSetting. The
|
||||
// legacy panel reloads on change so the Go side renders templates in
|
||||
// the new language.
|
||||
const lang = ref(LanguageManager.getLanguage());
|
||||
function onLangChange() {
|
||||
LanguageManager.setLanguage(lang.value);
|
||||
}
|
||||
|
||||
// LDAP inbound tags are CSV on the wire; expose as an array so the
|
||||
// multi-select v-model works directly.
|
||||
const ldapInboundTagList = computed({
|
||||
get: () => {
|
||||
const csv = props.allSetting.ldapInboundTags || '';
|
||||
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
},
|
||||
set: (list) => {
|
||||
props.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : '';
|
||||
},
|
||||
});
|
||||
|
||||
const inboundOptions = ref([]);
|
||||
async function loadInboundTags() {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
inboundOptions.value = msg.obj.map((ib) => ({
|
||||
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
|
||||
value: ib.tag,
|
||||
}));
|
||||
} else {
|
||||
inboundOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadInboundTags);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.remarkModel') }}</template>
|
||||
<template #description>{{ t('pages.settings.sampleRemark') }}: <i>#{{ remarkSample }}</i></template>
|
||||
<template #control>
|
||||
<a-input-group :style="{ width: '100%' }">
|
||||
<a-select v-model:value="remarkModel" mode="multiple"
|
||||
:style="{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }">
|
||||
<a-select-option v-for="(label, key) in remarkModels" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="remarkSeparator" :style="{ width: '20%' }">
|
||||
<a-select-option v-for="sep in remarkSeparators" :key="sep" :value="sep">{{ sep }}</a-select-option>
|
||||
</a-select>
|
||||
</a-input-group>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelListeningIP') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelListeningIPDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webListen" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelListeningDomain') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelListeningDomainDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webDomain" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelPort') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelPortDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.webPort" :min="1" :max="65535" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.panelUrlPath') }}</template>
|
||||
<template #description>{{ t('pages.settings.panelUrlPathDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webBasePath" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.sessionMaxAge') }}</template>
|
||||
<template #description>{{ t('pages.settings.sessionMaxAgeDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.sessionMaxAge" :min="60" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
||||
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.pageSize" :min="0" :step="5" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.language') }}</template>
|
||||
<template #control>
|
||||
<a-select v-model:value="lang" :style="{ width: '100%' }" @change="onLangChange">
|
||||
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
|
||||
:label="l.value">
|
||||
<span role="img" :aria-label="l.name">{{ l.icon }}</span>
|
||||
<span>{{ l.name }}</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" :header="t('pages.settings.notifications')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.expireTimeDiff') }}</template>
|
||||
<template #description>{{ t('pages.settings.expireTimeDiffDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.expireDiff" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.trafficDiff') }}</template>
|
||||
<template #description>{{ t('pages.settings.trafficDiffDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.trafficDiff" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.settings.certs')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.publicKeyPath') }}</template>
|
||||
<template #description>{{ t('pages.settings.publicKeyPathDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webCertFile" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.privateKeyPath') }}</template>
|
||||
<template #description>{{ t('pages.settings.privateKeyPathDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.webKeyFile" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="4" :header="t('pages.settings.externalTraffic')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.externalTrafficInformEnable') }}</template>
|
||||
<template #description>{{ t('pages.settings.externalTrafficInformEnableDesc') }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.externalTrafficInformEnable" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.externalTrafficInformURI') }}</template>
|
||||
<template #description>{{ t('pages.settings.externalTrafficInformURIDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.externalTrafficInformURI" placeholder="(http|https)://domain[:port]/path/"
|
||||
type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.restartXrayOnClientDisable') }}</template>
|
||||
<template #description>{{ t('pages.settings.restartXrayOnClientDisableDesc') }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.restartXrayOnClientDisable" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="5" :header="t('pages.settings.dateAndTime')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.timeZone') }}</template>
|
||||
<template #description>{{ t('pages.settings.timeZoneDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.timeLocation" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.settings.datepicker') }}</template>
|
||||
<template #description>{{ t('pages.settings.datepickerDescription') }}</template>
|
||||
<template #control>
|
||||
<a-select v-model:value="datepicker" :style="{ width: '100%' }">
|
||||
<a-select-option v-for="item in datepickerList" :key="item.value" :value="item.value">
|
||||
{{ item.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="6" header="LDAP">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Enable LDAP sync</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapEnable" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>LDAP host</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapHost" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>LDAP port</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapPort" :min="1" :max="65535" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Use TLS (LDAPS)</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapUseTLS" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Bind DN</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapBindDN" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('password') }}</template>
|
||||
<template #control>
|
||||
<a-input-password v-model:value="allSetting.ldapPassword" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Base DN</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapBaseDN" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>User filter</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapUserFilter" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>User attribute (username/email)</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapUserAttr" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>VLESS flag attribute</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapVlessField" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Generic flag attribute (optional)</template>
|
||||
<template #description>If set, overrides VLESS flag — e.g. shadowInactive.</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapFlagField" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Truthy values</template>
|
||||
<template #description>Comma-separated; default: true,1,yes,on</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapTruthyValues" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Invert flag</template>
|
||||
<template #description>Enable when the attribute means disabled (e.g. shadowInactive).</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapInvertFlag" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Sync schedule</template>
|
||||
<template #description>Cron-like string, e.g. @every 1m</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="allSetting.ldapSyncCron" type="text" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Inbound tags</template>
|
||||
<template #description>Inbounds that LDAP sync may auto-create or auto-delete clients on.</template>
|
||||
<template #control>
|
||||
<a-select v-model:value="ldapInboundTagList" mode="multiple" :style="{ width: '100%' }">
|
||||
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div v-if="inboundOptions.length === 0" class="ldap-no-inbounds">
|
||||
No inbounds found. Create one in Inbounds first.
|
||||
</div>
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Auto create clients</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapAutoCreate" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Auto delete clients</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="allSetting.ldapAutoDelete" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Default total (GB)</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapDefaultTotalGB" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Default expiry (days)</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapDefaultExpiryDays" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Default IP limit</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="allSetting.ldapDefaultLimitIP" :min="0" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ldap-no-inbounds {
|
||||
margin-top: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user