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")} + +
+