diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4588320
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,66 @@
+name: CI
+
+# 运行时机:
+# - PR 提交到 main / next / beta / develop 时验证
+# - push 到这些分支时也跑(配合 release.yml 互补,release.yml 跑发布,这个跑校验)
+on:
+ pull_request:
+ branches: [main, next, beta, develop]
+ paths-ignore:
+ - 'docs/**'
+ - '**.md'
+ push:
+ branches: [main, next, beta, develop]
+ paths-ignore:
+ - 'docs/**'
+ - '**.md'
+
+# 同一 PR/branch 多次 push 时,取消旧 run,只跑最新那次
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build & Type-check
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache Bun dependencies
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.bun/install/cache
+ node_modules
+ apps/*/node_modules
+ packages/*/node_modules
+ key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build (turbo build — both admin + user apps)
+ run: bun run build
+
+ - name: Upload build artifacts
+ if: success()
+ uses: actions/upload-artifact@v4
+ with:
+ name: dist-${{ github.sha }}
+ path: |
+ apps/admin/dist
+ apps/user/dist
+ retention-days: 7
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index 4133bab..2f2b31d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,6 @@ npm-debug.log*
# Misc
.DS_Store
*.pem
+
+# Auto-generated icon bundle (regenerated on every dev/build)
+packages/ui/src/composed/icons-bundle.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94360bb..e7badf1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,67 @@ This document records all notable changes to ShadCN Admin.
---
+## [4.3.0](https://github.com/perfect-panel/frontend/compare/v1.4.2...v4.3.0) (2026-04-26)
+
+### 🔥 Breaking Changes / 重大变更
+
+* **billing model / 计费模型:** Migrate from IP-based concurrent limit to device-slot model. Each user subscription now manages N device slots; each device gets its own subscribe URL (token + UUID). 从「IP 并发限制」迁移到「设备槽位」模型。每个订阅管理 N 个设备槽,每台设备拥有独立的订阅 URL(token + UUID)
+* **i18n keys / 翻译键:** Renamed `connectedDevices` semantics from "concurrent IP" to "devices"; Chinese text changes from「同时连接 IP 数 / 并发设备」to「设备数」/ EN: "Connected Devices" → "Devices"
+
+### ✨ Features / 新功能
+
+* **dashboard / 仪表盘:** Per-device cards with online indicator, smart device-type icons (mobile/laptop/tablet/router), today traffic, last seen, last IP / 设备卡:在线指示灯、按设备名智能图标、今日流量、最近上线、上次 IP
+* **dashboard / 仪表盘:** Multi-line subscribe URL support with line selector (主线 + 备用 + CDN 多线路) / 多订阅域名支持,卡片内可切换线路
+* **dashboard / 仪表盘:** Plan-level "Import to Client" section — 11 supported clients (v2rayN / Clash / Hiddify / Surge / Stash / FlClash / Shadowrocket / etc.) with per-client tutorial sheet
+* **dashboard / 仪表盘:** Add Device / Add Traffic / Reset All / Renew action dialogs with prorated pricing
+* **dashboard / 仪表盘:** Addon device support — user-purchased extra slots, deletable, separate pricing; base devices remain locked / 加购设备:用户购买的额外槽位可删除,套餐基础设备不可删
+* **subscribe / 订阅:** Auto-update interval — admin sets hours, backend smart-injects per UA: Profile-Update-Interval header (Clash family / Hiddify) or #!MANAGED-CONFIG directive (Surge / Stash) / 自动更新间隔:按 UA 智能下发
+* **glass UI / 玻璃化:** Full glassmorphism redesign — gradient background, backdrop-blur cards, frosted sidebar/header/dialogs, dark mode adapted / 玻璃感全站升级
+* **sidebar / 侧边栏:** Colored nav icons + new label scheme (首页 / 账号 / 我的服务 / 账单与钱包 / 帮助中心)
+* **right sidebar / 右侧栏:** Hero balance card + small gift/commission cards + invite-earn CTA / 余额 hero 卡 + 辅助小卡 + 邀请返利
+* **admin / 管理后台:** Per-client tutorial editor linked to `site_content` CMS; supports per-language fallback
+* **admin / 管理后台:** Subscription detail view with device 类型 column (base / addon)
+
+### ⚡️ Performance / 性能
+
+* **bundle / 包体积:** Removed all CDN dependencies (jsdelivr, jsdmirror, monaco CDN, iconify API) — fully offline
+* **bundle / 包体积:** Replaced mathjs (1.5MB) with native + Function evaluator
+* **bundle / 包体积:** Monaco editor lazy-loaded via dynamic import; Vite `optimizeDeps.exclude`
+* **bundle / 包体积:** date-fns subpath import (`date-fns/locale/zh-CN`) saves ~988KB
+* **bundle / 包体积:** Lottie animation lazy-loaded
+* **icon / 图标:** Pre-bundled ~134 used icons into 62KB JSON, replaces 12MB iconify API runtime fetch
+
+### 🎨 Style / 样式
+
+* **toast:** Large size (340-420px) + glass + type-tinted backgrounds (success/error/warning/info)
+* **tabs:** Selected state with primary border + glow + lift animation
+* **cards:** Hover micro-interactions (lift + shadow + glow)
+* **buttons:** Outline buttons enhanced contrast on glass surface; primary buttons with brand-color glow
+* **selected device card:** Primary tint background + ring + badge / 选中设备卡:主色背景 + ring + 徽章
+
+### 🐛 Bug Fixes / 问题修复
+
+* **mobile / 移动端:** Header logo wrap, action buttons compress on iPhone, horizontal overflow (grid-cols-1 missing) / 头部 logo 换行、操作按钮压缩、grid-cols-1 缺失导致横向溢出
+* **rename UX / 重命名:** Device name no longer triggers rename on accidental click — separate pencil button / 设备名误触改名修复 — 独立铅笔按钮
+* **iOS icon / iOS 图标:** Replaced `simple-icons:ios` (renders as text "iOS") with `mdi:cellphone-iphone`
+* **refresh / 刷新:** Visual feedback on refresh — spinning icon + disabled state + success toast
+* **purchase race / 支付竞态:** Fixed OrderStatusError toast spam during webhook race
+* **balance copy / 复制提示:** Custom server messages now flow through (was masked by generic "Param Error")
+* **subscribe URL / 订阅链接:** Fixed `BuildSubscribeURL` stub — now uses configured `SubscribeDomain` + `SubscribePath`
+* **DeepCopy fields:** Added missing V4.3 fields (`DeviceCount`, `TrafficAddon`, `IsAddon`, etc.) to admin response types
+
+### ♻️ Refactoring / 重构
+
+* **i18n / 国际化:** Full bilingual coverage — 0 missing keys across `dashboard.json`, `subscribe.json`, `layout.json`, `components.json`, `auth.json`, `order.json`, `user.json`, `system.json`, `servers.json`, `nodes.json`, `tool.json`, `document.json`, `menu.json`, `log.json`
+* **i18n / 国际化:** Migrated all hardcoded English placeholders (auth forms, profile, area-code, editors) to `t()` calls
+* **shared components / 共享组件:** MarkdownEditor / MonacoEditor / HTMLEditor / JSONEditor / GoTemplateEditor / Combobox / ColumnFilter / AreaCodeSelect / Pagination — all i18n-enabled via `components` namespace
+* **client / 客户端区:** Client section moved from per-device duplication to plan-level (single instance, selected device drives URL) / 客户端区从设备级移到套餐级,只渲染一次
+
+### 🔧 Chores / 杂项
+
+* **terminology / 术语:** Hysteria 2 naming consistent across 3 projects (was mixed `Hysteria` / `Hy2` / `hysteria2`)
+
+
## [1.4.2](https://github.com/perfect-panel/frontend/compare/v1.4.1...v1.4.2) (2026-04-06)
### 🐛 Bug Fixes / 问题修复
diff --git a/apps/admin/.env.example b/apps/admin/.env.example
index 250f86e..7cfa0b1 100644
--- a/apps/admin/.env.example
+++ b/apps/admin/.env.example
@@ -3,11 +3,12 @@ VITE_API_BASE_URL=
# API prefix path
VITE_API_PREFIX=
-# CDN URL for static assets
-VITE_CDN_URL=https://cdn.jsdmirror.com
+# CDN URL — leave EMPTY to disable all CDN-backed features (sponsor card, remote tutorial).
+# Only set this if you self-host a mirror of perfect-panel/ppanel-assets.
+VITE_CDN_URL=
-# Enable tutorial document feature (true/false)
-VITE_TUTORIAL_DOCUMENT=true
+# Enable tutorial document feature (true/false). Requires VITE_CDN_URL to be set.
+VITE_TUTORIAL_DOCUMENT=false
# Default login credentials (for development only)
VITE_USER_EMAIL=
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 0e4115b..c92c96f 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -3,7 +3,9 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vite --port 3001",
+ "predev": "bun --cwd ../../packages/ui run icons:bundle",
+ "dev": "vite --host --port 3001",
+ "prebuild": "bun --cwd ../../packages/ui run icons:bundle",
"build": "vite build && tsc",
"serve": "vite preview",
"lint": "biome lint",
diff --git a/apps/admin/public/assets/locales/en-US/components.json b/apps/admin/public/assets/locales/en-US/components.json
index 6c26754..daa3bcc 100644
--- a/apps/admin/public/assets/locales/en-US/components.json
+++ b/apps/admin/public/assets/locales/en-US/components.json
@@ -52,10 +52,33 @@
"90015": "This account has reached the limit of sending times today, please try again tomorrow.",
"unknown": "An error occurred in the system, please try again later."
},
+ "combobox": {
+ "search": "Search...",
+ "select": "Select..."
+ },
+ "editor": {
+ "markdownDescription": "Support markdown and html syntax",
+ "markdownTitle": "Markdown Editor",
+ "startTyping": "Start typing...",
+ "title": "Editor",
+ "goTemplateDescriptionSprig": "Go text/template syntax with Sprig functions",
+ "goTemplateDescription": "Go text/template syntax",
+ "goTemplateTitle": "Go Template Editor",
+ "goTemplatePlaceholder": "Enter your Go template here...",
+ "htmlDescription": "Support HTML",
+ "htmlTitle": "HTML Editor",
+ "htmlPreviewTitle": "HTML Preview",
+ "jsonTitle": "Edit JSON"
+ },
"language": "Language",
"pagination": {
"pageInfo": "Page {{page}} of {{total}}",
- "rowsPerPage": "Rows per page"
+ "rowsPerPage": "Rows per page",
+ "firstPage": "Go to first page",
+ "previousPage": "Go to previous page",
+ "selectPage": "Select page number",
+ "nextPage": "Go to next page",
+ "lastPage": "Go to last page"
},
"theme": {
"dark": "Dark",
@@ -63,5 +86,16 @@
"system": "System",
"toggle": "Toggle theme"
},
- "unlimited": "Unlimited"
+ "timezone": {
+ "all": "All",
+ "current": "Current",
+ "recommended": "Recommended",
+ "search": "Search timezone...",
+ "server": "Server"
+ },
+ "unlimited": "Unlimited",
+ "areaCode": {
+ "select": "Select Area Code",
+ "search": "Search area code..."
+ }
}
diff --git a/apps/admin/public/assets/locales/en-US/document.json b/apps/admin/public/assets/locales/en-US/document.json
index 8ade5d8..42a6820 100644
--- a/apps/admin/public/assets/locales/en-US/document.json
+++ b/apps/admin/public/assets/locales/en-US/document.json
@@ -24,5 +24,7 @@
"tags": "Tags",
"title": "Title",
"updatedAt": "Updated At",
- "updateSuccess": "Updated successfully"
+ "updateSuccess": "Updated successfully",
+ "tabDocuments": "Documents",
+ "tabSiteContent": "Site Content (Terms / Tutorials)"
}
diff --git a/apps/admin/public/assets/locales/en-US/log.json b/apps/admin/public/assets/locales/en-US/log.json
index 57d6b3d..b69f8f6 100644
--- a/apps/admin/public/assets/locales/en-US/log.json
+++ b/apps/admin/public/assets/locales/en-US/log.json
@@ -24,7 +24,11 @@
"upload": "Upload",
"user": "User",
"userAgent": "User Agent",
- "userId": "User ID"
+ "userId": "User ID",
+ "actor": "Actor",
+ "action": "Action",
+ "target": "Target",
+ "detail": "Detail"
},
"detail": "Detail",
"failed": "Failed",
@@ -42,7 +46,8 @@
"serverTraffic": "Server Traffic Log",
"subscribe": "Subscribe Log",
"subscribeTraffic": "Subscribe Traffic Log",
- "trafficDetails": "Traffic Details"
+ "trafficDetails": "Traffic Details",
+ "audit": "Audit Log"
},
"type": {
"231": "Auto Reset",
diff --git a/apps/admin/public/assets/locales/en-US/menu.json b/apps/admin/public/assets/locales/en-US/menu.json
index 3ffc716..edab5b7 100644
--- a/apps/admin/public/assets/locales/en-US/menu.json
+++ b/apps/admin/public/assets/locales/en-US/menu.json
@@ -31,5 +31,6 @@
"Ticket Management": "Ticket Management",
"Traffic Details": "Traffic Details",
"User Management": "User Management",
- "Users & Support": "Users & Support"
+ "Users & Support": "Users & Support",
+ "Audit": "Audit"
}
diff --git a/apps/admin/public/assets/locales/en-US/nodes.json b/apps/admin/public/assets/locales/en-US/nodes.json
index 05ed877..7e7f283 100644
--- a/apps/admin/public/assets/locales/en-US/nodes.json
+++ b/apps/admin/public/assets/locales/en-US/nodes.json
@@ -27,5 +27,12 @@
"tags": "Tags",
"tags_description": "Permission grouping tag (incl. plan binding and delivery policies).",
"tags_placeholder": "Use Enter or comma (,) to add multiple tags",
- "updated": "Updated"
+ "updated": "Updated",
+ "errors": {
+ "nameRequired": "Please enter a name",
+ "serverRequired": "Please select a server",
+ "protocolRequired": "Please select a protocol",
+ "serverAddrRequired": "Please enter an entry address",
+ "portRange": "Port must be between 1 and 65535"
+ }
}
diff --git a/apps/admin/public/assets/locales/en-US/product.json b/apps/admin/public/assets/locales/en-US/product.json
index f590049..1ec0a59 100644
--- a/apps/admin/public/assets/locales/en-US/product.json
+++ b/apps/admin/public/assets/locales/en-US/product.json
@@ -10,7 +10,7 @@
"delete": "Delete",
"deleteSuccess": "Delete Successful",
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
- "deviceLimit": "IP Limit",
+ "deviceLimit": "Device Limit",
"edit": "Edit",
"editSubscribe": "Edit Subscription",
"sortSuccess": "Sort completed successfully",
@@ -23,7 +23,7 @@
"deductionRatio": "Automatic/Manual Deduction Configuration",
"deductionRatioDescription": "Used for deduction. By default, the system adopts an automatic calculation algorithm. When a manual ratio is provided, the system calculates proportions based on the time and traffic ratio, ensuring the total equals 100%.",
"description": "Description",
- "deviceLimit": "IP Limit",
+ "deviceLimit": "Device Limit",
"discount": "Discount",
"discount_price": "Discount Price",
"discountDescription": "Set discount based on unit price",
diff --git a/apps/admin/public/assets/locales/en-US/servers.json b/apps/admin/public/assets/locales/en-US/servers.json
index 57f59e0..f20c673 100644
--- a/apps/admin/public/assets/locales/en-US/servers.json
+++ b/apps/admin/public/assets/locales/en-US/servers.json
@@ -152,5 +152,18 @@
"unlimited": "Unlimited",
"up_mbps": "Upload Bandwidth",
"updated": "Updated",
- "user": "User"
+ "user": "User",
+ "saved": "Saved",
+ "directList": "Direct List",
+ "directListTitle": "Direct Allowlist",
+ "directListDesc": "Domains the client connects directly (not via proxy). One per line. Includes panel/payment domains so users can recharge while throttled.",
+ "directListField": "Direct domains",
+ "entries": "entries",
+ "directListHint": "Suggested: panel domain, subscribe domain, payment gateways (Stripe / PayPal / Alipay / WeChat Pay).",
+ "save": "Save",
+ "validation": {
+ "requiredNumberField": "{{field}} is required and must be between {{min}} and {{max}}",
+ "requiredSelectField": "{{field}} is required",
+ "requiredField": "{{field}} is required"
+ }
}
diff --git a/apps/admin/public/assets/locales/en-US/subscribe.json b/apps/admin/public/assets/locales/en-US/subscribe.json
index 78c5797..7369c99 100644
--- a/apps/admin/public/assets/locales/en-US/subscribe.json
+++ b/apps/admin/public/assets/locales/en-US/subscribe.json
@@ -18,7 +18,9 @@
"save": "Save",
"saveFailed": "Save failed",
"update": "Update",
- "updateSuccess": "Updated successfully"
+ "updateSuccess": "Updated successfully",
+ "batchDeleteSuccess": "Successfully deleted {count} clients",
+ "batchDeleteWarning": "Are you sure you want to delete the selected {count} clients?"
},
"config": {
"description": "Manage subscription system settings",
@@ -39,7 +41,9 @@
"userAgentListDescription": "Allowed {{userAgent}} for subscription access, one per line. Configured application {{userAgent}} will be automatically included",
"userAgentListPlaceholder": "Enter allowed {{userAgent}}, one per line",
"wildcardResolution": "Wildcard Resolution",
- "wildcardResolutionDescription": "Enable wildcard domain resolution for subscriptions"
+ "wildcardResolutionDescription": "Enable wildcard domain resolution for subscriptions",
+ "updateInterval": "Subscription Auto-Update Interval (hours)",
+ "updateIntervalDescription": "After import, clients will auto-update subscription on this schedule. 0 = disabled. Smart UA detection: Clash family / Hiddify / Mihomo Party use Profile-Update-Interval header; Surge / Stash use #!MANAGED-CONFIG injection; v2rayN / Shadowrocket / QuanX / Loon do not support — user must set manually."
},
"form": {
"addTitle": "Add Client",
@@ -85,7 +89,12 @@
"tabs": {
"basic": "Basic Info",
"download": "Downloads",
- "template": "Templates"
+ "template": "Templates",
+ "tutorial": "Tutorial"
+ },
+ "validation": {
+ "nameRequired": "Client name is required",
+ "userAgentRequiredSuffix": "is required"
}
},
"outputFormats": {
@@ -104,7 +113,8 @@
"description": "Description",
"name": "Client Name",
"outputFormat": "Output Format",
- "supportedPlatforms": "Supported Platforms"
+ "supportedPlatforms": "Supported Platforms",
+ "enabled": "Enabled"
}
},
"templatePreview": {
@@ -117,5 +127,20 @@
"loading": "Loading...",
"preview": "Preview",
"title": "Template Preview"
+ },
+ "tutorial": {
+ "keyRequired": "Please set a tutorial key first",
+ "saved": "Tutorial saved",
+ "keyLabel": "Tutorial Key",
+ "keyDescription": "site_content row key. Same key shared across languages — switch the language at the top-right to edit a different translation.",
+ "titleLabel": "Title",
+ "titlePlaceholder": "e.g. 导入教程",
+ "bodyLabel": "Content",
+ "bodyPlaceholder": "Markdown / HTML content. Variables: {{.SubscribeUrl}}, {{.AppScheme}}, {{.AppName}}, {{.SiteName}}",
+ "variableHint": "Template variables auto-replaced when shown to users: {{.SubscribeUrl}} / {{.AppScheme}} / {{.AppName}} / {{.SiteName}}",
+ "loading": "Loading…",
+ "editingLang": "Editing: {{lang}}",
+ "saving": "Saving…",
+ "save": "Save Tutorial"
}
}
diff --git a/apps/admin/public/assets/locales/en-US/system.json b/apps/admin/public/assets/locales/en-US/system.json
index 1bcd0fe..c3ac5e7 100644
--- a/apps/admin/public/assets/locales/en-US/system.json
+++ b/apps/admin/public/assets/locales/en-US/system.json
@@ -132,5 +132,22 @@
"seconds": "seconds",
"times": "time(s)",
"title": "Verification Code Settings"
- }
+ },
+ "siteContent": {
+ "key": {
+ "terms_of_use": "Terms of Use"
+ },
+ "bodyRequired": "Body cannot be empty",
+ "title": "Site Content (CMS)",
+ "subtitle": "Edit the user agreement and 11 client tutorials, with per-language fallback.",
+ "empty": "No content rows yet.",
+ "edit": "Edit",
+ "editor": "Edit Site Content",
+ "titleField": "Title",
+ "body": "Body (HTML / Markdown)",
+ "bodyPlaceholder": "Paste markdown or HTML here. Renders on user-facing pages.",
+ "version": "Version (bump to force users re-accept terms)"
+ },
+ "saved": "Saved",
+ "save": "Save"
}
diff --git a/apps/admin/public/assets/locales/en-US/tool.json b/apps/admin/public/assets/locales/en-US/tool.json
index 0bf5b5d..0d4a187 100644
--- a/apps/admin/public/assets/locales/en-US/tool.json
+++ b/apps/admin/public/assets/locales/en-US/tool.json
@@ -17,5 +17,6 @@
"updateSuccess": "Update completed successfully",
"updateWebDescription": "Are you sure you want to update the web version from {{current}} to {{latest}}?",
"userUpdateSuccess": "User updated successfully",
- "webVersion": "Web Version"
+ "webVersion": "Web Version",
+ "updateDescription": "Are you sure you want to update?"
}
diff --git a/apps/admin/public/assets/locales/en-US/user.json b/apps/admin/public/assets/locales/en-US/user.json
index 71a2bbe..59e68c9 100644
--- a/apps/admin/public/assets/locales/en-US/user.json
+++ b/apps/admin/public/assets/locales/en-US/user.json
@@ -116,6 +116,7 @@
"token": "token",
"totalTraffic": "Total Traffic",
"tradeNotifications": "Trade Notifications",
+ "selectSubscription": "Select Subscription",
"trafficDetails": "Traffic Details",
"trafficLimit": "Traffic Limit",
"trafficStats": "Traffic Stats",
@@ -134,5 +135,41 @@
"userList": "User List",
"userName": "Username",
"userProfile": "User Profile",
- "verified": "Verified"
+ "verified": "Verified",
+ "tags": "Tags",
+ "subscriptionCount": "Subscriptions",
+ "registeredAt": "Registered At",
+ "tagsPlaceholder": "Press Enter to add a tag",
+ "usedTraffic": "Used Traffic",
+ "trafficAddon": "Traffic Addon",
+ "useDevice": "Use Device",
+ "addonDevice": "Addon Device",
+ "noReset": "No Reset",
+ "device": {
+ "resetDesc": "Generate new token + UUID. Old subscribe URL stops working immediately.",
+ "resetSuccess": "Device reset",
+ "resetTitle": "Reset this device?",
+ "reset": "Reset",
+ "disableDesc": "Disabled devices stop receiving traffic; slot is preserved.",
+ "disabled": "Device disabled",
+ "disableTitle": "Disable this device?",
+ "disable": "Disable",
+ "enabled": "Device enabled",
+ "enable": "Enable",
+ "column": {
+ "id": "Device ID",
+ "name": "Name",
+ "type": "Type",
+ "uuid": "UUID",
+ "ip": "Last IP",
+ "lastSeen": "Last Seen",
+ "todayTraffic": "Today",
+ "status": "Status",
+ "count": "Devices"
+ },
+ "typeAddon": "Addon",
+ "typeBase": "Base",
+ "statusEnabled": "Enabled",
+ "statusDisabled": "Disabled"
+ }
}
diff --git a/apps/admin/public/assets/locales/zh-CN/components.json b/apps/admin/public/assets/locales/zh-CN/components.json
index b56ad5a..dfcedad 100644
--- a/apps/admin/public/assets/locales/zh-CN/components.json
+++ b/apps/admin/public/assets/locales/zh-CN/components.json
@@ -52,10 +52,33 @@
"90015": "该账户今日已达到发送次数限制,请明天再试。",
"unknown": "系统发生错误,请稍后再试。"
},
+ "combobox": {
+ "search": "搜索...",
+ "select": "请选择..."
+ },
+ "editor": {
+ "markdownDescription": "支持 Markdown 和 HTML 语法",
+ "markdownTitle": "Markdown 编辑器",
+ "startTyping": "开始输入...",
+ "title": "编辑器",
+ "goTemplateDescriptionSprig": "Go text/template 语法(带 Sprig 函数)",
+ "goTemplateDescription": "Go text/template 语法",
+ "goTemplateTitle": "Go 模板编辑器",
+ "goTemplatePlaceholder": "在此输入 Go 模板...",
+ "htmlDescription": "支持 HTML",
+ "htmlTitle": "HTML 编辑器",
+ "htmlPreviewTitle": "HTML 预览",
+ "jsonTitle": "编辑 JSON"
+ },
"language": "语言",
"pagination": {
"pageInfo": "第 {{page}} 页,共 {{total}} 页",
- "rowsPerPage": "每页行数"
+ "rowsPerPage": "每页行数",
+ "firstPage": "跳到首页",
+ "previousPage": "上一页",
+ "selectPage": "选择页码",
+ "nextPage": "下一页",
+ "lastPage": "跳到末页"
},
"theme": {
"dark": "深色",
@@ -63,5 +86,16 @@
"system": "系统",
"toggle": "切换主题"
},
- "unlimited": "无限制"
+ "timezone": {
+ "all": "全部",
+ "current": "当前",
+ "recommended": "推荐",
+ "search": "搜索时区...",
+ "server": "服务器"
+ },
+ "unlimited": "无限制",
+ "areaCode": {
+ "select": "选择区号",
+ "search": "搜索区号..."
+ }
}
diff --git a/apps/admin/public/assets/locales/zh-CN/document.json b/apps/admin/public/assets/locales/zh-CN/document.json
index 23d7582..5540de0 100644
--- a/apps/admin/public/assets/locales/zh-CN/document.json
+++ b/apps/admin/public/assets/locales/zh-CN/document.json
@@ -24,5 +24,7 @@
"tags": "标签",
"title": "标题",
"updatedAt": "更新时间",
- "updateSuccess": "更新成功"
+ "updateSuccess": "更新成功",
+ "tabDocuments": "文档",
+ "tabSiteContent": "站内内容(用户协议 / 客户端教程)"
}
diff --git a/apps/admin/public/assets/locales/zh-CN/log.json b/apps/admin/public/assets/locales/zh-CN/log.json
index 2124368..9f7e8f8 100644
--- a/apps/admin/public/assets/locales/zh-CN/log.json
+++ b/apps/admin/public/assets/locales/zh-CN/log.json
@@ -1,9 +1,12 @@
{
"column": {
+ "actor": "操作者",
+ "action": "操作",
"amount": "金额",
"balance": "余额",
"content": "内容",
"date": "日期",
+ "detail": "详情",
"download": "下载",
"identifier": "标识符",
"ip": "IP",
@@ -17,6 +20,7 @@
"subscribe": "订阅",
"subscribeId": "订阅 ID",
"success": "成功",
+ "target": "目标",
"time": "时间",
"to": "收件人",
"total": "总计",
@@ -31,6 +35,7 @@
"sent": "已发送",
"success": "成功",
"title": {
+ "audit": "操作审计",
"balance": "余额日志",
"commission": "佣金日志",
"email": "邮件日志",
diff --git a/apps/admin/public/assets/locales/zh-CN/menu.json b/apps/admin/public/assets/locales/zh-CN/menu.json
index d24feb6..c17bd35 100644
--- a/apps/admin/public/assets/locales/zh-CN/menu.json
+++ b/apps/admin/public/assets/locales/zh-CN/menu.json
@@ -1,6 +1,7 @@
{
"ADS Config": "广告配置",
"Announcement Management": "公告管理",
+ "Audit": "操作审计",
"Auth Control": "认证控制",
"Balance": "余额",
"Commerce": "商业",
diff --git a/apps/admin/public/assets/locales/zh-CN/nodes.json b/apps/admin/public/assets/locales/zh-CN/nodes.json
index a2634e9..8ed3b43 100644
--- a/apps/admin/public/assets/locales/zh-CN/nodes.json
+++ b/apps/admin/public/assets/locales/zh-CN/nodes.json
@@ -27,5 +27,12 @@
"tags": "标签",
"tags_description": "权限分组标签(包含计划绑定和投递策略)。",
"tags_placeholder": "使用回车或逗号 (,) 添加多个标签",
- "updated": "已更新"
+ "updated": "已更新",
+ "errors": {
+ "nameRequired": "请输入名称",
+ "portRange": "端口必须在 1 到 65535 之间",
+ "protocolRequired": "请选择协议",
+ "serverAddrRequired": "请输入入口地址",
+ "serverRequired": "请选择服务器"
+ }
}
diff --git a/apps/admin/public/assets/locales/zh-CN/product.json b/apps/admin/public/assets/locales/zh-CN/product.json
index d8a6e28..558fde0 100644
--- a/apps/admin/public/assets/locales/zh-CN/product.json
+++ b/apps/admin/public/assets/locales/zh-CN/product.json
@@ -10,7 +10,15 @@
"delete": "删除",
"deleteSuccess": "删除成功",
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
- "deviceLimit": "IP限制",
+ "deviceLimit": "设备数",
+ "deviceCount": "设备数",
+ "useDevice": "使用设备",
+ "addonDevice": "加购设备",
+ "addonDeviceLimit": "加购设备上限",
+ "maxDeviceCount": "最大设备数",
+ "addonDevicePrice": "加购单价",
+ "trafficAddon": "加购流量包",
+ "orderCount": "订单数",
"edit": "编辑",
"editSubscribe": "编辑订阅",
"sortSuccess": "排序成功",
@@ -23,7 +31,10 @@
"deductionRatio": "自动/手动扣减配置",
"deductionRatioDescription": "用于扣减。默认情况下,系统采用自动计算算法。当提供手动比例时,系统根据时间和流量比例计算比例,确保总和为 100%。",
"description": "描述",
- "deviceLimit": "IP限制",
+ "deviceCount": "设备数",
+ "deviceCountPlaceholder": "套餐默认含几台",
+ "maxDeviceCount": "最大设备数限制",
+ "maxDeviceCountPlaceholder": "用户最多可加到几台",
"discount": "折扣",
"discount_price": "折扣价格",
"discountDescription": "根据单价设置折扣",
@@ -50,7 +61,6 @@
"quota": "购买限制",
"renewalReset": "续费重置",
"renewalResetDescription": "续费时重置周期",
- "replacement": "重置价格(每次)",
"resetCycle": "重置周期",
"resetOn1st": "每月1日重置",
"selectResetCycle": "请选择重置周期",
@@ -59,19 +69,26 @@
"showOriginalPriceDescription": "开启后,在订阅卡片上将会显示原价和折后价,帮助用户了解优惠幅度",
"speedLimit": "速度限制",
"traffic": "流量",
- "unitPrice": "单价",
+ "unitPrice": "套餐价",
+ "unitPriceDescription": "用户购买时一次性收取的套餐固定价",
+ "addonDevicePrice": "加购设备单价",
+ "addonDevicePriceDescription": "用户超出设备槽数后加购单台的价格",
"unitTime": "时间单位",
- "Year": "年"
+ "Year": "年",
+ "commissionRate": "邀请佣金比例",
+ "commissionRateDescription": "邀请人佣金百分比,默认 10%",
+ "trafficAddonPrice": "加购流量包单价",
+ "trafficAddonSize": "加购流量包步长",
+ "trafficAddonSizeDescription": "用户每次最少加购的流量大小,默认 1 GB"
},
"inventory": "订阅库存",
"language": "语言",
"name": "名称",
- "quota": "购买限制/次",
- "replacement": "重置价格/次",
+ "quota": "购买限制",
"sell": "销售",
"show": "显示",
"sold": "订阅数量",
"traffic": "流量",
- "unitPrice": "单价",
+ "unitPrice": "套餐价",
"updateSuccess": "更新成功"
}
diff --git a/apps/admin/public/assets/locales/zh-CN/servers.json b/apps/admin/public/assets/locales/zh-CN/servers.json
index eac5b5e..b7a7ca4 100644
--- a/apps/admin/public/assets/locales/zh-CN/servers.json
+++ b/apps/admin/public/assets/locales/zh-CN/servers.json
@@ -152,5 +152,18 @@
"unlimited": "无限制",
"up_mbps": "上传带宽",
"updated": "已更新",
- "user": "用户"
+ "user": "用户",
+ "save": "保存",
+ "saved": "已保存",
+ "directList": "直连白名单",
+ "directListTitle": "直连域名白名单",
+ "directListDesc": "客户端直连(不走代理)的域名清单,每行一个。包含面板与支付域名,确保限速时仍能续费。",
+ "directListField": "直连域名",
+ "directListHint": "建议:面板域名、订阅域名、支付网关(Stripe / PayPal / 支付宝 / 微信支付)",
+ "entries": "条",
+ "validation": {
+ "requiredField": "{{field}} 不能为空",
+ "requiredNumberField": "{{field}} 不能为空,且必须在 {{min}} 到 {{max}} 之间",
+ "requiredSelectField": "请选择 {{field}}"
+ }
}
diff --git a/apps/admin/public/assets/locales/zh-CN/subscribe.json b/apps/admin/public/assets/locales/zh-CN/subscribe.json
index ea33f0f..c17e6fa 100644
--- a/apps/admin/public/assets/locales/zh-CN/subscribe.json
+++ b/apps/admin/public/assets/locales/zh-CN/subscribe.json
@@ -16,7 +16,9 @@
"save": "保存",
"saveFailed": "保存失败",
"update": "更新",
- "updateSuccess": "更新成功"
+ "updateSuccess": "更新成功",
+ "batchDeleteSuccess": "Successfully deleted {count} clients",
+ "batchDeleteWarning": "Are you sure you want to delete the selected {count} clients?"
},
"config": {
"description": "管理订阅系统设置",
@@ -37,7 +39,9 @@
"userAgentListDescription": "允许访问订阅的 {{userAgent}},每行一个。已配置的应用程序 {{userAgent}} 将自动包含",
"userAgentListPlaceholder": "输入允许的 {{userAgent}},每行一个",
"wildcardResolution": "泛域名解析",
- "wildcardResolutionDescription": "为订阅启用泛域名解析"
+ "wildcardResolutionDescription": "为订阅启用泛域名解析",
+ "updateInterval": "订阅自动更新间隔(小时)",
+ "updateIntervalDescription": "导入后客户端按此周期自动更新订阅。0 = 关闭。智能识别客户端:Clash 家族 / Hiddify / Mihomo Party 走 Profile-Update-Interval header;Surge / Stash 走 #!MANAGED-CONFIG 注入;v2rayN / Shadowrocket / QuanX / Loon 不支持,需用户手动设置。"
},
"form": {
"addTitle": "添加客户端",
@@ -83,7 +87,12 @@
"tabs": {
"basic": "基本信息",
"download": "下载",
- "template": "模板"
+ "template": "模板",
+ "tutorial": "Tutorial"
+ },
+ "validation": {
+ "nameRequired": "Client name is required",
+ "userAgentRequiredSuffix": "is required"
}
},
"outputFormats": {
@@ -102,7 +111,8 @@
"description": "描述",
"name": "客户端名称",
"outputFormat": "输出格式",
- "supportedPlatforms": "支持的平台"
+ "supportedPlatforms": "支持的平台",
+ "enabled": "Enabled"
}
},
"templatePreview": {
@@ -115,5 +125,20 @@
"loading": "加载中...",
"preview": "预览",
"title": "模板预览"
+ },
+ "tutorial": {
+ "keyRequired": "Please set a tutorial key first",
+ "saved": "Tutorial saved",
+ "keyLabel": "Tutorial Key",
+ "keyDescription": "site_content row key. Same key shared across languages — switch the language at the top-right to edit a different translation.",
+ "titleLabel": "Title",
+ "titlePlaceholder": "e.g. 导入教程",
+ "bodyLabel": "Content",
+ "bodyPlaceholder": "Markdown / HTML content. Variables: {{.SubscribeUrl}}, {{.AppScheme}}, {{.AppName}}, {{.SiteName}}",
+ "variableHint": "Template variables auto-replaced when shown to users: {{.SubscribeUrl}} / {{.AppScheme}} / {{.AppName}} / {{.SiteName}}",
+ "loading": "Loading…",
+ "editingLang": "Editing: {{lang}}",
+ "saving": "Saving…",
+ "save": "Save Tutorial"
}
}
diff --git a/apps/admin/public/assets/locales/zh-CN/system.json b/apps/admin/public/assets/locales/zh-CN/system.json
index 0a9eaa0..cd7d737 100644
--- a/apps/admin/public/assets/locales/zh-CN/system.json
+++ b/apps/admin/public/assets/locales/zh-CN/system.json
@@ -132,5 +132,22 @@
"seconds": "秒",
"times": "次",
"title": "验证码设置"
+ },
+ "saved": "已保存",
+ "save": "保存",
+ "siteContent": {
+ "title": "站内内容",
+ "subtitle": "维护用户协议与 11 个客户端导入教程的中英文文案",
+ "edit": "编辑",
+ "editor": "编辑站内内容",
+ "titleField": "标题",
+ "body": "正文(HTML / Markdown)",
+ "bodyPlaceholder": "粘贴 Markdown 或 HTML 文本,将在用户端页面渲染",
+ "bodyRequired": "正文不能为空",
+ "version": "版本号(递增可强制用户重新接受协议)",
+ "empty": "暂无站内内容",
+ "key": {
+ "terms_of_use": "用户协议"
+ }
}
}
diff --git a/apps/admin/public/assets/locales/zh-CN/tool.json b/apps/admin/public/assets/locales/zh-CN/tool.json
index 32a6b2a..dad5985 100644
--- a/apps/admin/public/assets/locales/zh-CN/tool.json
+++ b/apps/admin/public/assets/locales/zh-CN/tool.json
@@ -17,5 +17,6 @@
"updateSuccess": "更新成功",
"updateWebDescription": "确定要将前端版本从 {{current}} 更新到 {{latest}} 吗?",
"userUpdateSuccess": "用户端更新成功",
- "webVersion": "前端版本"
+ "webVersion": "前端版本",
+ "updateDescription": "确定要执行更新吗?"
}
diff --git a/apps/admin/public/assets/locales/zh-CN/user.json b/apps/admin/public/assets/locales/zh-CN/user.json
index 7d4fb06..f49a827 100644
--- a/apps/admin/public/assets/locales/zh-CN/user.json
+++ b/apps/admin/public/assets/locales/zh-CN/user.json
@@ -30,14 +30,14 @@
"deleteSubscriptionDescription": "此操作无法撤销。",
"deleteSuccess": "删除成功",
"isDeleted": "状态",
- "deviceLimit": "IP限制",
+ "deviceLimit": "设备数",
"download": "下载",
"downloadTraffic": "下载流量",
"edit": "编辑",
"editSubscription": "编辑订阅",
"enable": "启用",
- "expiredAt": "过期时间",
- "expireTime": "过期时间",
+ "expiredAt": "到期时间",
+ "expireTime": "到期时间",
"giftAmount": "赠送金额",
"giftAmountPlaceholder": "输入赠送金额",
"giftLogs": "赠送日志",
@@ -81,7 +81,8 @@
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
"toggleSubscriptionStatus": "切换状态",
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
- "resetTime": "重置时间",
+ "resetTime": "下次重置",
+ "noReset": "不重置",
"resetToken": "重置订阅地址",
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
"resetTokenSuccess": "订阅地址重置成功",
@@ -116,10 +117,15 @@
"token": "令牌",
"totalTraffic": "总流量",
"tradeNotifications": "交易通知",
+ "selectSubscription": "选择订阅",
"trafficDetails": "流量详情",
"trafficLimit": "流量限制",
"trafficStats": "流量统计",
"trafficUsage": "流量使用",
+ "usedTraffic": "已用流量",
+ "trafficAddon": "加购流量包",
+ "useDevice": "使用设备",
+ "addonDevice": "加购设备",
"unlimited": "无限制",
"unverified": "未验证",
"update": "更新",
@@ -134,5 +140,49 @@
"userList": "用户列表",
"userName": "用户名",
"userProfile": "用户资料",
- "verified": "已验证"
+ "tags": "标签",
+ "tagsPlaceholder": "输入标签后按回车,例如 VIP / 试用 / 长期客户",
+ "subscriptionCount": "订阅",
+ "registeredAt": "注册时间",
+ "authType": {
+ "email": "邮箱",
+ "mobile": "手机",
+ "device": "设备",
+ "google": "谷歌",
+ "apple": "苹果",
+ "facebook": "脸书",
+ "github": "GitHub",
+ "telegram": "Telegram",
+ "discord": "Discord"
+ },
+ "verified": "已验证",
+ "device": {
+ "tabSlots": "设备槽",
+ "tabOnline": "在线设备",
+ "reset": "重置",
+ "resetTitle": "确认重置该设备?",
+ "resetDesc": "将生成新的 token 与 UUID,旧订阅链接立即失效。",
+ "resetSuccess": "设备已重置",
+ "disable": "停用",
+ "disableTitle": "确认停用该设备?",
+ "disableDesc": "停用后该设备不再下发流量;槽位保留",
+ "disabled": "已停用",
+ "enable": "启用",
+ "enabled": "已启用",
+ "statusEnabled": "已启用",
+ "statusDisabled": "已停用",
+ "column": {
+ "id": "设备 ID",
+ "name": "设备名",
+ "uuid": "UUID",
+ "ip": "最近 IP",
+ "lastSeen": "最近上线",
+ "todayTraffic": "今日流量",
+ "status": "状态",
+ "type": "类型",
+ "count": "设备数"
+ },
+ "typeAddon": "加购",
+ "typeBase": "套餐基础"
+ }
}
diff --git a/apps/admin/src/components/date-cell.tsx b/apps/admin/src/components/date-cell.tsx
new file mode 100644
index 0000000..51e6d79
--- /dev/null
+++ b/apps/admin/src/components/date-cell.tsx
@@ -0,0 +1,14 @@
+import { formatDate } from "@/utils/common";
+
+// 把 "2026/5/26 00:00:00" 拆成两行:日期 / 时间(灰色小字)
+export function DateCell({ ts }: { ts?: number | null }) {
+ if (!ts || ts <= 0) return null;
+ const full = formatDate(ts) || "";
+ const [date, time] = full.split(" ");
+ return (
+
+ {date}
+ {time && {time}}
+
+ );
+}
diff --git a/apps/admin/src/config/index.ts b/apps/admin/src/config/index.ts
index 09a90dd..f699e4d 100644
--- a/apps/admin/src/config/index.ts
+++ b/apps/admin/src/config/index.ts
@@ -1,10 +1,11 @@
export const fallbackLng = "en-US";
export const supportedLngs = ["en-US", "zh-CN"];
-export const CDN_URL =
- import.meta.env.VITE_CDN_URL || "https://cdn.jsdmirror.com";
+// CDN_URL: leave empty to fully disable CDN-backed features (sponsor card,
+// remote tutorial). To self-host, set VITE_CDN_URL to your own mirror.
+export const CDN_URL = import.meta.env.VITE_CDN_URL || "";
export const TUTORIAL_DOCUMENT =
- import.meta.env.VITE_TUTORIAL_DOCUMENT || "true";
+ import.meta.env.VITE_TUTORIAL_DOCUMENT || "false";
export const USER_EMAIL = import.meta.env.VITE_USER_EMAIL;
export const USER_PASSWORD = import.meta.env.VITE_USER_PASSWORD;
diff --git a/apps/admin/src/layout/navs.ts b/apps/admin/src/layout/navs.ts
index e9be2a8..72cd27e 100644
--- a/apps/admin/src/layout/navs.ts
+++ b/apps/admin/src/layout/navs.ts
@@ -189,6 +189,11 @@ export function useNavs() {
url: "/dashboard/log/gift",
icon: "flat-color-icons:donate",
},
+ {
+ title: t("Audit", "Audit"),
+ url: "/dashboard/audit",
+ icon: "flat-color-icons:rules",
+ },
],
},
],
diff --git a/apps/admin/src/layout/timezone-switch.tsx b/apps/admin/src/layout/timezone-switch.tsx
index 82f40b1..0b654b6 100644
--- a/apps/admin/src/layout/timezone-switch.tsx
+++ b/apps/admin/src/layout/timezone-switch.tsx
@@ -160,7 +160,7 @@ function getTimezoneOffset(timezone: string): string {
}
export default function TimezoneSwitch() {
- const { i18n } = useTranslation();
+ const { i18n, t } = useTranslation("components");
const locale = i18n.language;
const [timezone, setTimezone] = useState("UTC");
const [open, setOpen] = useState(false);
@@ -208,9 +208,9 @@ export default function TimezoneSwitch() {
-
+
-
+
{timezoneOptions
.filter((option) => option.value === timezone)
.map((option) => (
@@ -233,7 +233,7 @@ export default function TimezoneSwitch() {
))}
{serverTimezones.length > 0 && (
-
+
{serverTimezones.map((option) => (
)}
-
+
{timezoneOptions
.filter(
(option) =>
@@ -280,7 +280,7 @@ export default function TimezoneSwitch() {
))}
-
+
{timezoneOptions
.filter(
(option) =>
diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts
index 4f6dcff..545c032 100644
--- a/apps/admin/src/routeTree.gen.ts
+++ b/apps/admin/src/routeTree.gen.ts
@@ -17,6 +17,7 @@ const IndexLazyRouteImport = createFileRoute('/')()
const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')()
const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')()
const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')()
+const DashboardAuditLazyRouteImport = createFileRoute('/dashboard/audit')()
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
const DashboardTicketIndexLazyRouteImport =
createFileRoute('/dashboard/ticket/')()
@@ -116,6 +117,13 @@ const DashboardNodesLazyRoute = DashboardNodesLazyRouteImport.update({
} as any).lazy(() =>
import('./routes/dashboard/nodes.lazy').then((d) => d.Route),
)
+const DashboardAuditLazyRoute = DashboardAuditLazyRouteImport.update({
+ id: '/audit',
+ path: '/audit',
+ getParentRoute: () => DashboardRouteLazyRoute,
+} as any).lazy(() =>
+ import('./routes/dashboard/audit.lazy').then((d) => d.Route),
+)
const DashboardUserIndexLazyRoute = DashboardUserIndexLazyRouteImport.update({
id: '/user/',
path: '/user/',
@@ -314,6 +322,7 @@ const DashboardLogBalanceLazyRoute = DashboardLogBalanceLazyRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
+ '/dashboard/audit': typeof DashboardAuditLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
@@ -345,6 +354,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
+ '/dashboard/audit': typeof DashboardAuditLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute
@@ -378,6 +388,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
+ '/dashboard/audit': typeof DashboardAuditLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
@@ -412,6 +423,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/dashboard'
+ | '/dashboard/audit'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/'
@@ -443,6 +455,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/dashboard/audit'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard'
@@ -475,6 +488,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/dashboard'
+ | '/dashboard/audit'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/'
@@ -547,6 +561,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardNodesLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
+ '/dashboard/audit': {
+ id: '/dashboard/audit'
+ path: '/audit'
+ fullPath: '/dashboard/audit'
+ preLoaderRoute: typeof DashboardAuditLazyRouteImport
+ parentRoute: typeof DashboardRouteLazyRoute
+ }
'/dashboard/user/': {
id: '/dashboard/user/'
path: '/user'
@@ -726,6 +747,7 @@ declare module '@tanstack/react-router' {
}
interface DashboardRouteLazyRouteChildren {
+ DashboardAuditLazyRoute: typeof DashboardAuditLazyRoute
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
@@ -757,6 +779,7 @@ interface DashboardRouteLazyRouteChildren {
}
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
+ DashboardAuditLazyRoute: DashboardAuditLazyRoute,
DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardIndexLazyRoute: DashboardIndexLazyRoute,
diff --git a/apps/admin/src/routes/dashboard/audit.lazy.tsx b/apps/admin/src/routes/dashboard/audit.lazy.tsx
new file mode 100644
index 0000000..4655da4
--- /dev/null
+++ b/apps/admin/src/routes/dashboard/audit.lazy.tsx
@@ -0,0 +1,6 @@
+import { createLazyFileRoute } from "@tanstack/react-router";
+import AuditLogPage from "@/sections/audit";
+
+export const Route = createLazyFileRoute("/dashboard/audit")({
+ component: AuditLogPage,
+});
diff --git a/apps/admin/src/sections/audit/index.tsx b/apps/admin/src/sections/audit/index.tsx
new file mode 100644
index 0000000..b5b8588
--- /dev/null
+++ b/apps/admin/src/sections/audit/index.tsx
@@ -0,0 +1,361 @@
+"use client";
+
+import { useSearch } from "@tanstack/react-router";
+import { Badge } from "@workspace/ui/components/badge";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@workspace/ui/components/tooltip";
+import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
+import type { AuditLogItem } from "@workspace/ui/services/admin/audit";
+import { queryAuditLog } from "@workspace/ui/services/admin/audit";
+import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
+import { IpLink } from "@/components/ip-link";
+import { UserDetail } from "@/sections/user/user-detail";
+
+type Filter = {
+ user_id?: number;
+ actor?: string;
+ action?: string;
+};
+
+// 操作行为中文映射
+const ACTION_LABELS: Record = {
+ purchase: "购买套餐",
+ renew: "续费套餐",
+ add_device: "加购设备",
+ addon_traffic: "加购流量",
+ reset_device: "重置设备",
+ reset_all_devices: "全部重置设备",
+ disable_device: "停用设备",
+ enable_device: "启用设备",
+ rename_device: "重命名设备",
+ throttle_start: "开始限速",
+ throttle_cut_off: "断网处理",
+ admin_login_remote: "管理员异地登录",
+ update_direct_list: "更新直连白名单",
+ cms_upsert: "编辑站内内容",
+ notify_traffic_90: "流量预警(90%)",
+ notify_throttle_12h: "限速12小时提醒",
+};
+
+// 操作行为颜色(按类别区分)
+const ACTION_VARIANT: Record<
+ string,
+ "default" | "destructive" | "outline" | "secondary"
+> = {
+ purchase: "default",
+ renew: "default",
+ add_device: "default",
+ addon_traffic: "default",
+ reset_device: "secondary",
+ reset_all_devices: "secondary",
+ rename_device: "secondary",
+ enable_device: "secondary",
+ disable_device: "destructive",
+ throttle_start: "destructive",
+ throttle_cut_off: "destructive",
+ admin_login_remote: "destructive",
+ update_direct_list: "outline",
+ cms_upsert: "outline",
+ notify_traffic_90: "outline",
+ notify_throttle_12h: "outline",
+};
+
+// 目标对象前缀中文映射
+const TARGET_PREFIX_LABELS: Record = {
+ device: "设备",
+ user_subscribe: "用户订阅",
+ user: "用户",
+ subscribe: "套餐",
+ site_content: "站内内容",
+ server: "节点",
+ order: "订单",
+ admin: "管理员",
+};
+
+function formatTarget(target: string): string {
+ if (!target) return "—";
+ const parts = target.split(":");
+ if (parts.length < 2) return target;
+ const [prefix, ...rest] = parts;
+ const label = TARGET_PREFIX_LABELS[prefix!] || prefix!;
+ // site_content:KEY:LANG => 站内内容 / KEY / LANG
+ if (rest.length > 1) {
+ return `${label} / ${rest.join(" / ")}`;
+ }
+ // device:1 => 设备 #1
+ const id = rest[0]!;
+ return /^\d+$/.test(id) ? `${label} #${id}` : `${label} / ${id}`;
+}
+
+// detail 字段中文键名映射(覆盖后端所有审计 detail 键)
+const DETAIL_KEY_LABELS: Record = {
+ // 通用
+ name: "名称",
+ reason: "原因",
+ ip: "IP地址",
+ by_admin: "管理员操作",
+ count: "数量",
+ // 用户/订阅
+ user_id: "用户ID",
+ subscribe_id: "套餐ID",
+ user_subscribe_id: "用户订阅ID",
+ // 设备
+ device_id: "设备ID",
+ device_name: "设备名称",
+ device_count: "设备数",
+ old_name: "原名称",
+ new_name: "新名称",
+ reset_count: "重置设备数",
+ reset_count_hour: "1小时内重置次数",
+ reset_count_day: "1天内重置次数",
+ // 加购/订单
+ amount: "金额",
+ unit_price: "单价",
+ ratio_bp: "折算比例",
+ addon_bytes: "加购流量",
+ addon_order_id: "加购订单ID",
+ throttled_reset: "已解除限速",
+ // 流量/配额
+ traffic: "流量",
+ traffic_addon: "加购流量",
+ used: "已用流量",
+ quota: "配额",
+ total: "总流量",
+ online: "在线设备数",
+ percent: "百分比",
+ // 时间
+ expire_time: "到期时间",
+ start_time: "开始时间",
+ end_time: "结束时间",
+ // 站内内容
+ title: "标题",
+ version: "版本",
+ // 节点
+ hosts_count: "主机数量",
+};
+
+// 字节量字段:自动转 KB/MB/GB/TB
+const BYTE_KEYS = new Set([
+ "addon_bytes",
+ "used",
+ "quota",
+ "total",
+ "traffic",
+ "traffic_addon",
+]);
+// 金额字段:分 → 元
+const MONEY_KEYS = new Set(["amount", "unit_price"]);
+// 万分比字段:bp → %
+const BP_KEYS = new Set(["ratio_bp"]);
+// 时间戳字段:毫秒/秒
+const TIME_KEYS = new Set(["expire_time", "start_time", "end_time"]);
+
+function formatBytes(n: number): string {
+ if (!Number.isFinite(n) || n <= 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"];
+ let i = 0;
+ let v = n;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v.toFixed(v >= 100 || i === 0 ? 0 : 2)} ${units[i]}`;
+}
+
+function formatTimestamp(n: number): string {
+ if (!Number.isFinite(n) || n <= 0) return "-";
+ // 自动判定 秒/毫秒
+ const ms = n > 1e12 ? n : n * 1000;
+ const d = new Date(ms);
+ if (Number.isNaN(d.getTime())) return String(n);
+ const pad = (x: number) => String(x).padStart(2, "0");
+ return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+function formatDetailValue(key: string, v: unknown): string {
+ if (v === null || v === undefined) return "—";
+ if (typeof v === "boolean") return v ? "是" : "否";
+ if (typeof v === "number") {
+ if (BYTE_KEYS.has(key)) return formatBytes(v);
+ if (MONEY_KEYS.has(key)) return `¥${(v / 100).toFixed(2)}`;
+ if (BP_KEYS.has(key)) return `${(v / 100).toFixed(2)}%`;
+ if (TIME_KEYS.has(key)) return formatTimestamp(v);
+ return String(v);
+ }
+ if (typeof v === "object") return JSON.stringify(v);
+ return String(v);
+}
+
+function formatDetail(detail: string): {
+ short: string;
+ full: string;
+} {
+ if (!detail) return { short: "—", full: "" };
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(detail);
+ } catch {
+ return { short: detail, full: detail };
+ }
+ if (parsed === null || typeof parsed !== "object") {
+ return { short: String(parsed), full: String(parsed) };
+ }
+ const entries = Object.entries(parsed as Record);
+ const lines = entries.map(([k, v]) => {
+ const label = DETAIL_KEY_LABELS[k] || k;
+ return `${label}: ${formatDetailValue(k, v)}`;
+ });
+ const short = lines.slice(0, 3).join(" · ");
+ const full = lines.join("\n");
+ return { short, full };
+}
+
+export default function AuditLogPage() {
+ const { t } = useTranslation("log");
+ const sp = useSearch({ strict: false }) as Record;
+
+ const initialFilters: Filter = {
+ user_id: sp.user_id ? Number(sp.user_id) : undefined,
+ actor: sp.actor,
+ action: sp.action,
+ };
+
+ return (
+
+ columns={[
+ {
+ accessorKey: "actor",
+ header: t("column.actor", "操作者"),
+ cell: ({ row }) => {
+ const actor = row.original.actor || "system";
+ const actorLabel =
+ actor === "system"
+ ? "系统"
+ : actor === "admin"
+ ? "管理员"
+ : actor === "user"
+ ? "用户"
+ : actor;
+ return (
+
+ {actorLabel}
+
+ );
+ },
+ },
+ {
+ accessorKey: "user_id",
+ header: t("column.user", "关联用户"),
+ cell: ({ row }) =>
+ row.original.user_id > 0 ? (
+
+ ) : (
+ —
+ ),
+ },
+ {
+ accessorKey: "action",
+ header: t("column.action", "操作行为"),
+ cell: ({ row }) => {
+ const code = row.original.action;
+ const label = ACTION_LABELS[code] || code;
+ const variant = ACTION_VARIANT[code] || "outline";
+ return (
+
+
+ {label}
+
+
+ {code}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "target",
+ header: t("column.target", "操作对象"),
+ cell: ({ row }) => {
+ const raw = row.original.target || "";
+ if (!raw) {
+ return —;
+ }
+ return (
+
+
+ {formatTarget(raw)}
+
+
+ {raw}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "detail",
+ header: t("column.detail", "详细信息"),
+ cell: ({ row }) => {
+ const detail = row.original.detail || "";
+ if (!detail) {
+ return —;
+ }
+ const { short, full } = formatDetail(detail);
+ return (
+
+
+
+ {short}
+
+
+
+
+ {full}
+
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "client_ip",
+ header: t("column.ip", "IP地址"),
+ cell: ({ row }) =>
+ row.original.client_ip ? (
+
+ ) : (
+ —
+ ),
+ },
+ {
+ accessorKey: "created_at",
+ header: t("column.time", "操作时间"),
+ cell: ({ row }) => ,
+ },
+ ]}
+ header={{ title: t("title.audit", "操作日志") }}
+ initialFilters={initialFilters}
+ params={[
+ { key: "user_id", placeholder: t("column.userId", "用户ID") },
+ { key: "actor", placeholder: t("column.actor", "操作者") },
+ { key: "action", placeholder: t("column.action", "操作行为") },
+ ]}
+ request={async (pagination, filter) => {
+ const { data } = await queryAuditLog({
+ page: pagination.page,
+ size: pagination.size,
+ user_id: (filter as Filter)?.user_id,
+ actor: (filter as Filter)?.actor,
+ action: (filter as Filter)?.action,
+ });
+ const list = (data?.data?.list || []) as AuditLogItem[];
+ const total = Number(data?.data?.total || list.length);
+ return { list, total };
+ }}
+ />
+ );
+}
diff --git a/apps/admin/src/sections/auth/index.tsx b/apps/admin/src/sections/auth/index.tsx
index bd47dc9..e0ae275 100644
--- a/apps/admin/src/sections/auth/index.tsx
+++ b/apps/admin/src/sections/auth/index.tsx
@@ -1,13 +1,20 @@
"use client";
-import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { Link, useNavigate } from "@tanstack/react-router";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
-import { useEffect } from "react";
+import { lazy, Suspense, useEffect } from "react";
import { useGlobalStore } from "@/stores/global";
import EmailAuthForm from "./email/auth-form";
+// Lottie player is ~700 KB. The animation only renders on `lg:` and is
+// purely decorative, so split it out of the initial login bundle.
+const DotLottieReact = lazy(() =>
+ import("@lottiefiles/dotlottie-react").then((m) => ({
+ default: m.DotLottieReact,
+ }))
+);
+
export default function Auth() {
const { common, user } = useGlobalStore();
const { site } = common;
@@ -33,12 +40,14 @@ export default function Auth() {
/>
{site.site_name}
-
+
+
+
{site.site_desc}
diff --git a/apps/admin/src/sections/dashboard/components/billing.tsx b/apps/admin/src/sections/dashboard/components/billing.tsx
index c052446..f0046a1 100644
--- a/apps/admin/src/sections/dashboard/components/billing.tsx
+++ b/apps/admin/src/sections/dashboard/components/billing.tsx
@@ -11,6 +11,7 @@ import {
CardTitle,
} from "@workspace/ui/components/card";
import { useTranslation } from "react-i18next";
+import { CDN_URL } from "@/config";
interface BillingProps {
type: "dashboard" | "payment";
@@ -24,30 +25,34 @@ interface ItemType {
href: string;
}
-async function getBillingURL() {
+// Sponsor card data lives in perfect-panel/ppanel-assets. To enable, set
+// VITE_CDN_URL to a mirror of jsDelivr (e.g. https://cdn.jsdmirror.com or
+// your own CDN). When VITE_CDN_URL is empty the card is hidden and no
+// network call is made.
+async function getBillingURL(cdnBase: string) {
+ const fallback = `${cdnBase}/gh/perfect-panel/ppanel-assets/billing/index.json`;
try {
const response = await fetch(
"https://api.github.com/repos/perfect-panel/ppanel-assets/commits"
);
const json = await response.json();
const version = json[0]?.sha || "latest";
- const url = new URL(
- "https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets"
- );
+ const url = new URL(`${cdnBase}/gh/perfect-panel/ppanel-assets`);
url.pathname += `@${version}/billing/index.json`;
return url.toString();
} catch (_error) {
- return "https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets/billing/index.json";
+ return fallback;
}
}
export default function Billing({ type }: BillingProps) {
const { t } = useTranslation("dashboard");
+ const cdnEnabled = Boolean(CDN_URL);
const { data: list } = useQuery({
- queryKey: ["billing", type],
+ queryKey: ["billing", type, CDN_URL],
queryFn: async () => {
- const url = await getBillingURL();
+ const url = await getBillingURL(CDN_URL);
const response = await fetch(url, {
headers: {
Accept: "application/json",
@@ -64,6 +69,7 @@ export default function Billing({ type }: BillingProps) {
: [];
},
initialData: [],
+ enabled: cdnEnabled,
});
if (!list?.length) return null;
diff --git a/apps/admin/src/sections/document/index.tsx b/apps/admin/src/sections/document/index.tsx
index 519bb14..052b7fe 100644
--- a/apps/admin/src/sections/document/index.tsx
+++ b/apps/admin/src/sections/document/index.tsx
@@ -1,5 +1,11 @@
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@workspace/ui/components/tabs";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
@@ -15,14 +21,15 @@ import {
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
-import { formatDate } from "@/utils/common";
+import { DateCell } from "@/components/date-cell";
+import SiteContent from "@/sections/site-content";
import DocumentForm from "./document-form";
-export default function Page() {
+function CustomDocumentList() {
const { t } = useTranslation("document");
const [loading, setLoading] = useState(false);
-
const ref = useRef(null);
+
return (
action={ref}
@@ -128,7 +135,9 @@ export default function Page() {
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
- cell: ({ row }) => formatDate(row.getValue("updated_at")),
+ cell: ({ row }) => (
+
+ ),
},
]}
header={{
@@ -177,3 +186,25 @@ export default function Page() {
/>
);
}
+
+export default function Page() {
+ const { t } = useTranslation("document");
+ return (
+
+
+
+ {t("tabDocuments", "Documents")}
+
+
+ {t("tabSiteContent", "Site Content (Terms / Tutorials)")}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/sections/log/balance/index.tsx b/apps/admin/src/sections/log/balance/index.tsx
index bbfce9c..1bfb24e 100644
--- a/apps/admin/src/sections/log/balance/index.tsx
+++ b/apps/admin/src/sections/log/balance/index.tsx
@@ -5,10 +5,10 @@ import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterBalanceLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function BalanceLogPage() {
const { t } = useTranslation("log");
@@ -83,7 +83,7 @@ export default function BalanceLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.balance", "Balance Log") }}
diff --git a/apps/admin/src/sections/log/commission/index.tsx b/apps/admin/src/sections/log/commission/index.tsx
index 975dce5..947792f 100644
--- a/apps/admin/src/sections/log/commission/index.tsx
+++ b/apps/admin/src/sections/log/commission/index.tsx
@@ -5,10 +5,10 @@ import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterCommissionLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function CommissionLogPage() {
const { t } = useTranslation("log");
@@ -58,7 +58,7 @@ export default function CommissionLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.commission", "Commission Log") }}
diff --git a/apps/admin/src/sections/log/email/index.tsx b/apps/admin/src/sections/log/email/index.tsx
index b4068dc..75900d6 100644
--- a/apps/admin/src/sections/log/email/index.tsx
+++ b/apps/admin/src/sections/log/email/index.tsx
@@ -5,7 +5,7 @@ import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterEmailLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
-import { formatDate } from "@/utils/common";
+import { DateCell } from "@/components/date-cell";
export default function EmailLogPage() {
const { t } = useTranslation("log");
@@ -67,7 +67,7 @@ export default function EmailLogPage() {
{
accessorKey: "created_at",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.created_at),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.email", "Email Log") }}
diff --git a/apps/admin/src/sections/log/gift/index.tsx b/apps/admin/src/sections/log/gift/index.tsx
index 962a83c..695c34d 100644
--- a/apps/admin/src/sections/log/gift/index.tsx
+++ b/apps/admin/src/sections/log/gift/index.tsx
@@ -5,10 +5,10 @@ import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterGiftLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function GiftLogPage() {
const { t } = useTranslation("log");
@@ -77,7 +77,7 @@ export default function GiftLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.gift", "Gift Log") }}
diff --git a/apps/admin/src/sections/log/login/index.tsx b/apps/admin/src/sections/log/login/index.tsx
index 1166224..53bf6c7 100644
--- a/apps/admin/src/sections/log/login/index.tsx
+++ b/apps/admin/src/sections/log/login/index.tsx
@@ -11,9 +11,9 @@ import {
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterLoginLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function LoginLogPage() {
const { t } = useTranslation("log");
@@ -81,7 +81,7 @@ export default function LoginLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.login", "Login Log") }}
diff --git a/apps/admin/src/sections/log/mobile/index.tsx b/apps/admin/src/sections/log/mobile/index.tsx
index 334c051..9b7cee7 100644
--- a/apps/admin/src/sections/log/mobile/index.tsx
+++ b/apps/admin/src/sections/log/mobile/index.tsx
@@ -5,7 +5,7 @@ import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterMobileLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
-import { formatDate } from "@/utils/common";
+import { DateCell } from "@/components/date-cell";
export default function MobileLogPage() {
const { t } = useTranslation("log");
@@ -67,7 +67,7 @@ export default function MobileLogPage() {
{
accessorKey: "created_at",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.created_at),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.mobile", "SMS Log") }}
diff --git a/apps/admin/src/sections/log/register/index.tsx b/apps/admin/src/sections/log/register/index.tsx
index bc14f65..c350630 100644
--- a/apps/admin/src/sections/log/register/index.tsx
+++ b/apps/admin/src/sections/log/register/index.tsx
@@ -11,9 +11,9 @@ import {
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterRegisterLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function RegisterLogPage() {
const { t } = useTranslation("log");
@@ -74,7 +74,7 @@ export default function RegisterLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.register", "Register Log") }}
diff --git a/apps/admin/src/sections/log/reset-subscribe/index.tsx b/apps/admin/src/sections/log/reset-subscribe/index.tsx
index 1dfae0c..db21510 100644
--- a/apps/admin/src/sections/log/reset-subscribe/index.tsx
+++ b/apps/admin/src/sections/log/reset-subscribe/index.tsx
@@ -5,9 +5,9 @@ import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterResetSubscribeLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { OrderLink } from "@/components/order-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function ResetSubscribeLogPage() {
const { t } = useTranslation("log");
@@ -66,7 +66,7 @@ export default function ResetSubscribeLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.resetSubscribe", "Reset Subscribe Log") }}
diff --git a/apps/admin/src/sections/log/subscribe/index.tsx b/apps/admin/src/sections/log/subscribe/index.tsx
index 3a739a8..4ba5c57 100644
--- a/apps/admin/src/sections/log/subscribe/index.tsx
+++ b/apps/admin/src/sections/log/subscribe/index.tsx
@@ -10,9 +10,9 @@ import {
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterSubscribeLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { IpLink } from "@/components/ip-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
-import { formatDate } from "@/utils/common";
export default function SubscribeLogPage() {
const { t } = useTranslation("log");
@@ -77,7 +77,7 @@ export default function SubscribeLogPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.subscribe", "Subscribe Log") }}
diff --git a/apps/admin/src/sections/log/traffic-details/index.tsx b/apps/admin/src/sections/log/traffic-details/index.tsx
index 8bb85d8..847c30f 100644
--- a/apps/admin/src/sections/log/traffic-details/index.tsx
+++ b/apps/admin/src/sections/log/traffic-details/index.tsx
@@ -5,9 +5,9 @@ import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterTrafficLogDetails } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { useServer } from "@/stores/server";
-import { formatDate } from "@/utils/common";
export default function TrafficDetailsPage() {
const { t } = useTranslation("log");
@@ -63,7 +63,7 @@ export default function TrafficDetailsPage() {
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
- cell: ({ row }) => formatDate(row.original.timestamp),
+ cell: ({ row }) => ,
},
]}
header={{ title: t("title.trafficDetails", "Traffic Details") }}
diff --git a/apps/admin/src/sections/marketing/email/task-manager.tsx b/apps/admin/src/sections/marketing/email/task-manager.tsx
index e83194b..89eed5e 100644
--- a/apps/admin/src/sections/marketing/email/task-manager.tsx
+++ b/apps/admin/src/sections/marketing/email/task-manager.tsx
@@ -27,6 +27,7 @@ import {
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
+import { DateCell } from "@/components/date-cell";
import { formatDate } from "@/utils/common";
export default function EmailTaskManager() {
@@ -256,7 +257,7 @@ export default function EmailTaskManager() {
header: t("createdAt", "Created At"),
cell: ({ row }) => {
const createdAt = row.getValue("created_at") as number;
- return formatDate(createdAt);
+ return ;
},
},
]}
diff --git a/apps/admin/src/sections/marketing/quota/task-manager.tsx b/apps/admin/src/sections/marketing/quota/task-manager.tsx
index ff07956..f381976 100644
--- a/apps/admin/src/sections/marketing/quota/task-manager.tsx
+++ b/apps/admin/src/sections/marketing/quota/task-manager.tsx
@@ -12,6 +12,7 @@ import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { queryQuotaTaskList } from "@workspace/ui/services/admin/marketing";
import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
@@ -220,7 +221,7 @@ export default function QuotaTaskManager() {
size: 150,
cell: ({ row }) => {
const createdAt = row.getValue("created_at") as number;
- return formatDate(createdAt);
+ return ;
},
},
]}
diff --git a/apps/admin/src/sections/order/index.tsx b/apps/admin/src/sections/order/index.tsx
index fce99ab..db662ce 100644
--- a/apps/admin/src/sections/order/index.tsx
+++ b/apps/admin/src/sections/order/index.tsx
@@ -18,9 +18,9 @@ import {
} from "@workspace/ui/services/admin/order";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
+import { DateCell } from "@/components/date-cell";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
-import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
export default function Order() {
@@ -193,7 +193,7 @@ export default function Order() {
header: t("updateTime", "Update Time"),
cell: ({ row }) => {
const order = row.original as API.Order;
- return formatDate(order.updated_at);
+ return ;
},
},
{
diff --git a/apps/admin/src/sections/product/subscribe-form.tsx b/apps/admin/src/sections/product/subscribe-form.tsx
index fefa2e8..12a6212 100644
--- a/apps/admin/src/sections/product/subscribe-form.tsx
+++ b/apps/admin/src/sections/product/subscribe-form.tsx
@@ -79,6 +79,14 @@ const defaultValues = {
renewal_reset: false,
show_original_price: false,
deduction_mode: "auto",
+ unit_price: 0,
+ // device-billing defaults
+ max_device_count: 0,
+ unit_price_per_device: 0,
+ traffic_addon_unit_price: 0,
+ traffic_addon_unit_size: 1_073_741_824, // 1 GB
+ // commission_rate=0 → 走全局"系统设置 → 邀请设置 → 推荐奖励百分比"
+ commission_rate: 0,
};
export default function SubscribeForm>({
@@ -98,7 +106,7 @@ export default function SubscribeForm>({
const formSchema = z.object({
name: z.string(),
description: z.string().optional(),
- unit_price: z.number(),
+ unit_price: z.number().optional(),
unit_time: z.string(),
replacement: z.number().optional(),
discount: z
@@ -122,6 +130,12 @@ export default function SubscribeForm>({
reset_cycle: z.number().optional(),
renewal_reset: z.boolean().optional(),
show_original_price: z.boolean().optional(),
+ // device-billing fields
+ max_device_count: z.number().optional(),
+ unit_price_per_device: z.number().optional(),
+ traffic_addon_unit_price: z.number().optional(),
+ traffic_addon_unit_size: z.number().optional(),
+ commission_rate: z.number().optional(),
});
const form = useForm>({
@@ -145,7 +159,9 @@ export default function SubscribeForm>({
updateTimeoutRef.current = setTimeout(() => {
const { unit_price } = form.getValues();
- if (!(unit_price && values?.length)) return;
+ // 折扣按"套餐价"计算(unit_price),与 quantity 时间倍数挂钩。
+ const basePrice = unit_price || 0;
+ if (!(basePrice && values?.length)) return;
let hasChanges = false;
const calculatedValues = values.map((item: any, index: number) => {
@@ -164,7 +180,7 @@ export default function SubscribeForm>({
case "discount":
if (quantity > 0 && discount > 0) {
const newPrice = evaluateWithPrecision(
- `${unit_price} * ${quantity} * ${discount} / 100`
+ `${basePrice} * ${quantity} * ${discount} / 100`
);
if (Math.abs(newPrice - price) > 0.01) {
result.price = newPrice;
@@ -176,7 +192,7 @@ export default function SubscribeForm>({
case "price":
if (quantity > 0 && price > 0) {
const newDiscount = evaluateWithPrecision(
- `${price} / ${quantity} / ${unit_price} * 100`
+ `${price} / ${quantity} / ${basePrice} * 100`
);
if (Math.abs(newDiscount - discount) > 0.01) {
result.discount = Math.min(100, Math.max(0, newDiscount));
@@ -184,7 +200,7 @@ export default function SubscribeForm>({
}
} else if (discount > 0 && price > 0) {
const newQuantity = evaluateWithPrecision(
- `${price} / ${unit_price} / ${discount} * 100`
+ `${price} / ${basePrice} / ${discount} * 100`
);
if (
Math.abs(newQuantity - quantity) > 0.01 &&
@@ -199,18 +215,18 @@ export default function SubscribeForm>({
default:
if (quantity > 0 && discount > 0 && price === 0) {
result.price = evaluateWithPrecision(
- `${unit_price} * ${quantity} * ${discount} / 100`
+ `${basePrice} * ${quantity} * ${discount} / 100`
);
hasChanges = true;
} else if (quantity > 0 && price > 0 && discount === 0) {
const newDiscount = evaluateWithPrecision(
- `${price} / ${quantity} / ${unit_price} * 100`
+ `${price} / ${quantity} / ${basePrice} * 100`
);
result.discount = Math.min(100, Math.max(0, newDiscount));
hasChanges = true;
} else if (discount > 0 && price > 0 && quantity === 0) {
const newQuantity = evaluateWithPrecision(
- `${price} / ${unit_price} / ${discount} * 100`
+ `${price} / ${basePrice} / ${discount} * 100`
);
if (newQuantity > 0) {
result.quantity = Math.max(1, Math.round(newQuantity));
@@ -357,7 +373,7 @@ export default function SubscribeForm>({
/>
-
+
>({
name="device_limit"
render={({ field }) => (
- {t("form.deviceLimit")}
+ {t("form.deviceCount")}
{
@@ -435,9 +452,7 @@ export default function SubscribeForm>({
)}
/>
-
-
>({
)}
/>
+
+ (
+
+ {t("form.maxDeviceCount")}
+
+ {
+ form.setValue(field.name, value);
+ }}
+ />
+
+
+
+ )}
+ />
>({
-
+
>({
(
+
+ {t("form.resetCycle")}
+
+
+ placeholder={t("form.selectResetCycle")}
+ {...field}
+ onChange={(value) => {
+ if (typeof value === "number") {
+ form.setValue(field.name, value);
+ }
+ }}
+ options={[
+ { label: t("form.noReset"), value: 0 },
+ { label: t("form.resetOn1st"), value: 1 },
+ { label: t("form.monthlyReset"), value: 2 },
+ { label: t("form.annualReset"), value: 3 },
+ ]}
+ />
+
+
+
+ )}
+ />
+
+
+
(
- {t("form.replacement")}
+ {t("form.addonDevicePrice")}
@@ -653,27 +723,56 @@ export default function SubscribeForm>({
)}
/>
+
(
- {t("form.resetCycle")}
+ {t("form.trafficAddonPrice")}
-
- placeholder={t("form.selectResetCycle")}
+ {
- if (typeof value === "number") {
- form.setValue(field.name, value);
- }
+ formatInput={(value) =>
+ unitConversion("centsToDollars", value)
+ }
+ formatOutput={(value) =>
+ unitConversion("dollarsToCents", value)
+ }
+ min={0}
+ onValueChange={(value) => {
+ form.setValue(field.name, value);
+ }}
+ />
+
+
+
+ )}
+ />
+
+ (
+
+ {t("form.trafficAddonSize")}
+
+
+ unitConversion("bytesToGb", value)
+ }
+ formatOutput={(value) =>
+ unitConversion("gbToBytes", value)
+ }
+ onValueChange={(value) => {
+ form.setValue(field.name, value);
}}
- options={[
- { label: t("form.noReset"), value: 0 },
- { label: t("form.resetOn1st"), value: 1 },
- { label: t("form.monthlyReset"), value: 2 },
- { label: t("form.annualReset"), value: 3 },
- ]}
/>
@@ -900,33 +999,6 @@ export default function SubscribeForm>({
)}
/>
- (
-
-
-
-
- {t("form.showOriginalPrice")}
-
-
- {t("form.showOriginalPriceDescription")}
-
-
-
- {
- form.setValue(field.name, value);
- }}
- />
-
-
-
-
- )}
- />
diff --git a/apps/admin/src/sections/product/subscribe-table.tsx b/apps/admin/src/sections/product/subscribe-table.tsx
index de846e8..e019a05 100644
--- a/apps/admin/src/sections/product/subscribe-table.tsx
+++ b/apps/admin/src/sections/product/subscribe-table.tsx
@@ -180,13 +180,6 @@ export default function SubscribeTable() {
>
),
},
- {
- accessorKey: "replacement",
- header: t("replacement"),
- cell: ({ row }) => (
-
- ),
- },
{
accessorKey: "traffic",
header: t("traffic"),
@@ -196,13 +189,55 @@ export default function SubscribeTable() {
},
{
accessorKey: "device_limit",
- header: t("deviceLimit"),
+ header: t("useDevice"),
+ cell: ({ row }) => {
+ const v = row.getValue("device_limit") as number;
+ return v && v > 0 ? `${v} 台` : "--";
+ },
+ },
+ {
+ accessorKey: "unit_price_per_device",
+ header: t("addonDevice"),
+ cell: ({ row }) => {
+ const v = row.getValue("unit_price_per_device") as number;
+ return v && v > 0 ? (
+ <>
+
+ /台
+ >
+ ) : (
+ "--"
+ );
+ },
+ },
+ {
+ accessorKey: "max_device_count",
+ header: t("addonDeviceLimit"),
+ cell: ({ row }) => {
+ const v = row.getValue("max_device_count") as number;
+ return v && v > 0 ? `${v} 台` : "--";
+ },
+ },
+ {
+ accessorKey: "traffic_addon_unit_price",
+ header: t("trafficAddon"),
+ cell: ({ row }) => {
+ const price = row.getValue("traffic_addon_unit_price") as number;
+ const size = row.original.traffic_addon_unit_size as number;
+ if (!(price && price > 0)) return "--";
+ const sizeGB = size ? size / 1024 / 1024 / 1024 : 1;
+ return (
+ <>
+
/{sizeGB}GB
+ >
+ );
+ },
+ },
+ {
+ accessorKey: "quota",
+ header: t("quota"),
cell: ({ row }) => (
-
+
),
},
{
@@ -217,13 +252,6 @@ export default function SubscribeTable() {
);
},
},
- {
- accessorKey: "quota",
- header: t("quota"),
- cell: ({ row }) => (
-
- ),
- },
{
accessorKey: "language",
header: t("language"),
@@ -238,7 +266,7 @@ export default function SubscribeTable() {
},
{
accessorKey: "sold",
- header: t("sold"),
+ header: t("orderCount"),
cell: ({ row }) => (
{row.getValue("sold")}
),
diff --git a/apps/admin/src/sections/servers/direct-list.tsx b/apps/admin/src/sections/servers/direct-list.tsx
new file mode 100644
index 0000000..64bd261
--- /dev/null
+++ b/apps/admin/src/sections/servers/direct-list.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { Badge } from "@workspace/ui/components/badge";
+import { Button } from "@workspace/ui/components/button";
+import { Label } from "@workspace/ui/components/label";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@workspace/ui/components/sheet";
+import { Textarea } from "@workspace/ui/components/textarea";
+import {
+ getServerDirectList,
+ updateServerDirectList,
+} from "@workspace/ui/services/admin/server";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+const DEFAULT_HINT = [
+ "panel.example.com",
+ "sub.example.com",
+ "stripe.com",
+ "paypal.com",
+ "alipay.com",
+ "qpay.tenpay.com",
+];
+
+export default function DirectListEditor({ serverId }: { serverId: number }) {
+ const { t } = useTranslation("servers");
+ const [open, setOpen] = useState(false);
+ const [text, setText] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const { data } = await getServerDirectList(serverId);
+ if (cancelled) return;
+ const list = data?.data?.direct_list || [];
+ setText(list.join("\n"));
+ } finally {
+ setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, serverId]);
+
+ const handleSave = async () => {
+ setLoading(true);
+ try {
+ const list = text
+ .split(/\r?\n/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ await updateServerDirectList(serverId, { direct_list: list });
+ toast.success(t("saved", "Saved"));
+ setOpen(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const lines = text
+ .split(/\r?\n/)
+ .map((s) => s.trim())
+ .filter(Boolean).length;
+
+ return (
+
+
+
+
+
+
+ {t("directListTitle", "Direct Allowlist")}
+
+ {t(
+ "directListDesc",
+ "Domains the client connects directly (not via proxy). One per line. Includes panel/payment domains so users can recharge while throttled."
+ )}
+
+
+
+
+
+
+
+ {lines} {t("entries", "entries")}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/sections/servers/form-schema/constants.ts b/apps/admin/src/sections/servers/form-schema/constants.ts
index 1645f9e..07c8d87 100644
--- a/apps/admin/src/sections/servers/form-schema/constants.ts
+++ b/apps/admin/src/sections/servers/form-schema/constants.ts
@@ -14,6 +14,18 @@ export const protocols = [
// Global label map for display; fallback to raw value if missing
export const LABELS = {
+ // protocols (display names — "hysteria" is actually Hysteria 2)
+ shadowsocks: "Shadowsocks",
+ vmess: "VMess",
+ vless: "VLESS",
+ trojan: "Trojan",
+ hysteria: "Hysteria 2",
+ tuic: "TUIC",
+ anytls: "AnyTLS",
+ socks: "SOCKS",
+ naive: "Naive",
+ http: "HTTP",
+ mieru: "Mieru",
// transport
tcp: "TCP",
udp: "UDP",
diff --git a/apps/admin/src/sections/servers/index.tsx b/apps/admin/src/sections/servers/index.tsx
index 788457a..3abfa52 100644
--- a/apps/admin/src/sections/servers/index.tsx
+++ b/apps/admin/src/sections/servers/index.tsx
@@ -20,7 +20,9 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
+import DirectListEditor from "./direct-list";
import DynamicMultiplier from "./dynamic-multiplier";
+import { getLabel } from "./form-schema";
import OnlineUsersCell from "./online-users-cell";
import ServerConfig from "./server-config";
import ServerForm from "./server-form";
@@ -125,6 +127,7 @@ export default function Servers() {
trigger={t("edit", "Edit")}
/>,
,
+
,
{ratio.toFixed(2)}x
- {p.type}
+ {getLabel(p.type)}
{p.port}
);
diff --git a/apps/admin/src/sections/servers/server-config.tsx b/apps/admin/src/sections/servers/server-config.tsx
index b56b8bb..10dfdc6 100644
--- a/apps/admin/src/sections/servers/server-config.tsx
+++ b/apps/admin/src/sections/servers/server-config.tsx
@@ -515,7 +515,7 @@ export default function ServerConfig() {
{ label: "VLESS", value: "vless" },
{ label: "Trojan", value: "trojan" },
{ label: "WireGuard", value: "wireguard" },
- { label: "Hysteria", value: "hysteria" },
+ { label: "Hysteria 2", value: "hysteria" },
{ label: "TUIC", value: "tuic" },
{ label: "AnyTLS", value: "anytls" },
{ label: "Naive", value: "naive" },
diff --git a/apps/admin/src/sections/servers/server-form.tsx b/apps/admin/src/sections/servers/server-form.tsx
index 3332b1a..f7c1bac 100644
--- a/apps/admin/src/sections/servers/server-form.tsx
+++ b/apps/admin/src/sections/servers/server-form.tsx
@@ -669,8 +669,8 @@ export default function ServerForm(props: {
-
- {type}
+
+ {getLabel(type)}
{current.transport && (
diff --git a/apps/admin/src/sections/site-content/index.tsx b/apps/admin/src/sections/site-content/index.tsx
new file mode 100644
index 0000000..b2c88b9
--- /dev/null
+++ b/apps/admin/src/sections/site-content/index.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Badge } from "@workspace/ui/components/badge";
+import { Button } from "@workspace/ui/components/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@workspace/ui/components/card";
+import { Input } from "@workspace/ui/components/input";
+import { Label } from "@workspace/ui/components/label";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@workspace/ui/components/sheet";
+import { Textarea } from "@workspace/ui/components/textarea";
+import { Icon } from "@workspace/ui/composed/icon";
+import type { SiteContentItem } from "@workspace/ui/services/admin/siteContent";
+import {
+ getSiteContent,
+ upsertSiteContent,
+} from "@workspace/ui/services/admin/siteContent";
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+const LANGS = ["zh-CN", "en-US"] as const;
+type Lang = (typeof LANGS)[number];
+
+// Stable display order — terms first, then tutorials by official client list.
+const KEY_ORDER = [
+ "terms_of_use",
+ "client_tutorial_v2rayn",
+ "client_tutorial_clash",
+ "client_tutorial_clashmeta",
+ "client_tutorial_stash",
+ "client_tutorial_shadowrocket",
+ "client_tutorial_hiddify",
+ "client_tutorial_quantumult",
+ "client_tutorial_loon",
+ "client_tutorial_flclash",
+ "client_tutorial_surge",
+ "client_tutorial_surge_mac",
+];
+
+function keyLabel(key: string, t: (k: any, d?: any) => string) {
+ const map: Record = {
+ terms_of_use: t("siteContent.key.terms_of_use", "Terms of Use"),
+ client_tutorial_v2rayn: "v2rayN",
+ client_tutorial_clash: "Clash",
+ client_tutorial_clashmeta: "Clash Meta",
+ client_tutorial_stash: "Stash",
+ client_tutorial_shadowrocket: "Shadowrocket",
+ client_tutorial_hiddify: "Hiddify",
+ client_tutorial_quantumult: "Quantumult X",
+ client_tutorial_loon: "Loon",
+ client_tutorial_flclash: "FlClash",
+ client_tutorial_surge: "Surge",
+ client_tutorial_surge_mac: "Surge for Mac",
+ };
+ return map[key] || key;
+}
+
+type Editor = {
+ contentKey: string;
+ contentLang: Lang;
+ title: string;
+ body: string;
+ version: string;
+};
+
+export default function SiteContentPage() {
+ const { t } = useTranslation("system");
+ const [open, setOpen] = useState(false);
+ const [editor, setEditor] = useState(null);
+
+ const { data, refetch } = useQuery({
+ queryKey: ["site_content_all"],
+ queryFn: async () => {
+ const { data } = await getSiteContent({});
+ return data?.data?.list || [];
+ },
+ });
+
+ const grouped = useMemo(() => {
+ const map = new Map>();
+ (data || []).forEach((row) => {
+ if (!map.has(row.content_key)) {
+ map.set(row.content_key, new Map());
+ }
+ map.get(row.content_key)!.set(row.content_lang as Lang, row);
+ });
+ // Discover keys not in static order (defensive against backend additions)
+ const knownKeys = new Set(KEY_ORDER);
+ const extra: string[] = [];
+ map.forEach((_, k) => {
+ if (!knownKeys.has(k)) extra.push(k);
+ });
+ return {
+ map,
+ orderedKeys: [...KEY_ORDER.filter((k) => map.has(k)), ...extra.sort()],
+ };
+ }, [data]);
+
+ const openEditor = (key: string, lang: Lang) => {
+ const existing = grouped.map.get(key)?.get(lang);
+ setEditor({
+ contentKey: key,
+ contentLang: lang,
+ title: existing?.title || keyLabel(key, t),
+ body: existing?.body || "",
+ version: existing?.version || "1",
+ });
+ setOpen(true);
+ };
+
+ const handleSave = async () => {
+ if (!editor) return;
+ if (!editor.body.trim()) {
+ toast.error(t("siteContent.bodyRequired", "Body cannot be empty"));
+ return;
+ }
+ try {
+ await upsertSiteContent({
+ content_key: editor.contentKey,
+ content_lang: editor.contentLang,
+ title: editor.title,
+ body: editor.body,
+ version: editor.version,
+ });
+ toast.success(t("saved", "Saved"));
+ setOpen(false);
+ await refetch();
+ } catch {
+ // request layer already toasts
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t("siteContent.title", "Site Content (CMS)")}
+
+
+ {t(
+ "siteContent.subtitle",
+ "Edit the user agreement and 11 client tutorials, with per-language fallback."
+ )}
+
+
+
+ {grouped.orderedKeys.length === 0 && (
+
+ {t("siteContent.empty", "No content rows yet.")}
+
+ )}
+ {grouped.orderedKeys.map((key) => {
+ const langs =
+ grouped.map.get(key) || new Map();
+ return (
+
+
+
+ {keyLabel(key, t)}
+ {key}
+
+
+ {LANGS.map((lang) => {
+ const exists = langs.has(lang);
+ return (
+
+ {lang} {exists ? "✓" : "✗"}
+
+ );
+ })}
+ {LANGS.map((lang) => (
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {t("siteContent.editor", "Edit Site Content")}
+
+
+ {editor && (
+ <>
+ {editor.contentKey}
+ {" · "}
+ {editor.contentLang}
+ >
+ )}
+
+
+
+ {editor && (
+
+
+
+
+ setEditor({ ...editor, title: e.target.value })
+ }
+ value={editor.title}
+ />
+
+
+
+
+
+
+
+ setEditor({ ...editor, version: e.target.value })
+ }
+ placeholder="1"
+ value={editor.version}
+ />
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/sections/subscribe/config-form.tsx b/apps/admin/src/sections/subscribe/config-form.tsx
index 120d5d3..2482790 100644
--- a/apps/admin/src/sections/subscribe/config-form.tsx
+++ b/apps/admin/src/sections/subscribe/config-form.tsx
@@ -42,6 +42,8 @@ const subscribeConfigSchema = z.object({
subscribe_domain: z.string().optional(),
user_agent_limit: z.boolean().optional(),
user_agent_list: z.string().optional(),
+ // V4.3:导入后客户端自动更新订阅的间隔(小时)。0 = 关闭。
+ update_interval_hours: z.number().int().min(0).max(168).optional(),
});
type SubscribeConfigFormData = z.infer;
@@ -69,6 +71,7 @@ export default function ConfigForm() {
subscribe_domain: "",
user_agent_limit: false,
user_agent_list: "",
+ update_interval_hours: 24,
},
});
@@ -238,6 +241,35 @@ export default function ConfigForm() {
)}
/>
+ (
+
+
+ {t("config.updateInterval", "订阅自动更新间隔(小时)")}
+
+
+ field.onChange(Number(v) || 0)}
+ placeholder="24"
+ type="number"
+ value={field.value ?? 24}
+ />
+
+
+ {t(
+ "config.updateIntervalDescription",
+ "导入后客户端按此周期自动更新订阅。0 = 关闭。智能识别客户端:Clash 家族 / Hiddify / Mihomo Party 走 Profile-Update-Interval header;Surge / Stash 走 #!MANAGED-CONFIG 注入;v2rayN / Shadowrocket / QuanX / Loon 不支持,需用户手动设置。"
+ )}
+
+
+
+ )}
+ />
+
z.object({
@@ -90,6 +91,8 @@ const createClientFormSchema = (t: any) =>
android: z.string().optional(),
harmony: z.string().optional(),
}),
+ // V4.3 决策 25:关联到 site_content.content_key 的教程
+ tutorial_key: z.string().optional(),
});
type ClientFormData = z.infer>;
@@ -122,6 +125,7 @@ export function ProtocolForm() {
android: "",
harmony: "",
},
+ tutorial_key: "",
},
});
@@ -142,15 +146,17 @@ export function ProtocolForm() {
const columns: ColumnDef[] = [
{
- accessorKey: "is_default",
- header: t("table.columns.default", "Default"),
+ // V4.3:列名「默认」改为「启用」,Switch 控制 enabled 字段。
+ // 关闭后该客户端不会出现在用户端的客户端列表里(管理端 + UA 命中仍可用)。
+ accessorKey: "enabled",
+ header: t("table.columns.enabled", "Enabled"),
cell: ({ row }) => (
{
await updateSubscribeApplication({
...row.original,
- is_default: checked,
+ enabled: checked,
});
tableRef.current?.refresh();
}}
@@ -259,6 +265,7 @@ export function ProtocolForm() {
android: "",
harmony: "",
},
+ tutorial_key: "",
});
setOpen(true);
};
@@ -275,6 +282,7 @@ export function ProtocolForm() {
android: "",
harmony: "",
},
+ tutorial_key: client.tutorial_key || "",
});
setOpen(true);
};
@@ -322,6 +330,10 @@ export function ProtocolForm() {
await updateSubscribeApplication({
...data,
is_default: editingClient.is_default,
+ // Preserve current enabled state — it's flipped via the row Switch,
+ // not via the Sheet form, so don't let an undefined form value
+ // accidentally overwrite it.
+ enabled: editingClient.enabled !== false,
id: editingClient.id,
});
toast.success(t("actions.updateSuccess", "Updated successfully"));
@@ -329,6 +341,8 @@ export function ProtocolForm() {
await createSubscribeApplication({
...data,
is_default: false,
+ // New clients default to enabled (visible to users).
+ enabled: true,
});
toast.success(t("actions.createSuccess", "Created successfully"));
}
@@ -447,13 +461,16 @@ export function ProtocolForm() {