commit c2498911abe94e820dc1dcd8cd189f009b16facf
Author: qwer-xyz <50532012+qwer-xyz@users.noreply.github.com>
Date: Sat Jun 20 14:22:31 2026 +0800
first commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..125e57a
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,56 @@
+# Dependencies (will be installed in container)
+node_modules/
+**/node_modules/
+
+# Build outputs (will be built in container)
+dist/
+**/dist/
+!agent/dist/
+!agent/dist/incudal-agent-linux-amd64
+!agent/dist/incudal-agent-linux-amd64.gz
+!agent/dist/incudal-agent-linux-arm64
+!agent/dist/incudal-agent-linux-arm64.gz
+!agent/dist/manifest.json
+
+# Git
+.git/
+.gitignore
+
+# IDE
+.vscode/
+.idea/
+.cursor/
+.kiro/
+
+# Environment files
+.env
+.env.*
+server/.env
+
+# Certificates
+server/certs/*.key
+server/certs/*.crt
+server/certs/*.pem
+
+# Docs
+docs/
+*.md
+!README.md
+
+# Tests
+coverage/
+*.test.ts
+*.spec.ts
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Temporary
+tmp/
+temp/
+.cache/
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..bdd2288
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,185 @@
+
+根据分析 `docker-compose.yml`、`.env.example` 和代码中使用的环境变量,以下是完整的环境变量清单:
+
+---
+
+## Docker Compose 部署环境变量
+
+### 必需变量(必须配置)
+
+| 变量名 | 说明 | 示例值 | 生成方式 |
+|--------|------|--------|----------|
+| `POSTGRES_PASSWORD` | PostgreSQL 数据库密码 | `BJ4hbYQTLsz97zzJUGg8RFiq` | `openssl rand -base64 24` |
+| `JWT_SECRET` | JWT 签名密钥(≥32字符,至少 3 类字符) | `A1!b7e9c4d0...` | `printf 'A1!%s\n' "$(openssl rand -hex 48)" \| cut -c1-64` |
+| `ENCRYPTION_KEY` | 敏感数据加密密钥(≥32字符,需稳定保存) | `XpPc3tcVSmy4/...` | `openssl rand -base64 32` |
+
+---
+
+### 可选变量(有默认值)
+
+| 变量名 | 说明 | 默认值 |
+|--------|------|--------|
+| `POSTGRES_USER` | 数据库用户名 | `incudal` |
+| `POSTGRES_DB` | 数据库名称 | `incudal` |
+| `REDIS_PASSWORD` | Redis 密码 | 空(无密码) |
+| `APP_PORT` | 外部暴露端口 | `3000` |
+| `ADMIN_PASSWORD` | 首次初始化管理员密码 | `admin123` |
+| `LOG_LEVEL` | 日志级别 | `info` |
+| `DISABLE_REQUEST_LOG` | 禁用请求日志 | `true` |
+| `COOKIE_SECRET` | Cookie 签名密钥 | 空 |
+| `FRONTEND_URL` | 前端 URL(CORS 白名单 + 页面跳转) | 空 |
+| `PAYMENT_CALLBACK_BASE_URL` | 支付回调使用的后端公网 URL | 空(默认回退到 `FRONTEND_URL`) |
+| `PAYMENT_CALLBACK_IP_WHITELIST` | 支付回调来源 IP 白名单(逗号分隔,留空时 Heleket 默认收紧到官方 IP) | 空 |
+| `PAYMENT_CALLBACK_SKIP_IP_WHITELIST` | 跳过支付回调 IP 白名单校验(仅开发调试) | `false` |
+| `ALERT_WEBHOOK_URL` | 系统告警 Webhook URL | 空 |
+| `DB_POOL_MAX` | PostgreSQL 连接池最大连接数 | `20` |
+| `DB_POOL_MIN` | PostgreSQL 连接池最小连接数 | `5` |
+| `DB_CONNECTION_TIMEOUT` | 获取数据库连接超时(毫秒) | `5000` |
+| `DB_IDLE_TIMEOUT` | 数据库空闲连接超时(毫秒) | `30000` |
+| `DB_STATEMENT_TIMEOUT` | PostgreSQL 语句超时(毫秒) | `30000` |
+| `DB_QUERY_TIMEOUT` | PostgreSQL 查询超时(毫秒) | `30000` |
+| `TRAFFIC_CONCURRENCY_PER_HOST` | 每宿主机流量采集并发数 | `10` |
+| `TRAFFIC_HOST_CONCURRENCY` | 同时处理宿主机数量 | `3` |
+| `DB_WORKER_BACKOFF_MS` | worker 遇到数据库异常后的退避时间(毫秒) | `15000` |
+
+---
+
+### 最小 .env 配置示例
+
+可以直接自动生成或补齐缺失项:
+
+```bash
+bash scripts/init-env.sh
+```
+
+脚本只补齐缺失或空值的变量,不覆盖已有非空配置。
+
+```bash
+# ============ 必需配置 ============
+# 数据库密码 - 使用 openssl rand -base64 24 生成
+POSTGRES_PASSWORD=your_strong_password_here
+
+# JWT 密钥 - 使用下面命令生成:
+# printf 'A1!%s\n' "$(openssl rand -hex 48)" | cut -c1-64
+JWT_SECRET=your_mixed_64_char_secret_here
+
+# 敏感数据加密密钥 - 使用 openssl rand -base64 32 生成
+# 注意:上线后必须稳定保存,不能随意更换
+ENCRYPTION_KEY=your_base64_32_byte_encryption_key_here
+
+# ============ 建议配置 ============
+# 管理员初始密码(首次启动后建议立即修改)
+ADMIN_PASSWORD=your_admin_password
+
+# Redis 密码(可选但推荐)
+REDIS_PASSWORD=your_redis_password
+```
+
+---
+
+### 完整 .env 配置示例(生产环境推荐)
+
+```bash
+# ============ 数据库配置 ============
+POSTGRES_USER=incudal
+POSTGRES_PASSWORD=BJ4hbYQTLsz97zzJUGg8RFiq
+POSTGRES_DB=incudal
+
+# ============ Redis 配置 ============
+REDIS_PASSWORD=redis_password_here
+
+# ============ 安全配置(必须修改!)============
+# JWT 密钥 - 生成方式:
+# printf 'A1!%s\n' "$(openssl rand -hex 48)" | cut -c1-64
+JWT_SECRET=A1!b7e9c4d0f2a8e6b9d1c3f5a7b8d0e2c4f6a9b1d3e5c7a8f0b2d4e6c8
+
+# Cookie 签名密钥(可选,增强安全性)
+COOKIE_SECRET=another_random_string_here
+
+# 敏感数据加密密钥(2FA 等)- 生成方式: openssl rand -base64 32
+ENCRYPTION_KEY=XpPc3tcVSmy4/pxpLL40jVfMDJyayR+bE1ibamEWlys=
+
+# ============ 应用配置 ============
+APP_PORT=3000
+ADMIN_PASSWORD=QpXEZrBkDosvu7TFMcAJjbv7
+LOG_LEVEL=info
+DISABLE_REQUEST_LOG=true
+
+# ============ CORS 配置 + 页面跳转(必配!)============
+FRONTEND_URL=https://your-domain.com
+
+# ============ 支付回调(前后端分域时必配)============
+# 第三方支付平台回调到后端时使用的公网地址
+# 例如前端为 https://panel.example.com,后端为 https://api.example.com
+# 则这里应配置为 https://api.example.com
+PAYMENT_CALLBACK_BASE_URL=https://api.your-domain.com
+
+# 支付回调来源 IP 白名单(逗号分隔)
+# 留空时 Heleket 默认仅允许官方文档中的 webhook IP:31.133.220.8
+# PAYMENT_CALLBACK_IP_WHITELIST=31.133.220.8
+
+# 仅开发调试时可临时跳过支付回调 IP 白名单校验
+# PAYMENT_CALLBACK_SKIP_IP_WHITELIST=false
+
+# ============ 监控告警(可选)============
+# 系统异常时发送 Webhook 告警
+# ALERT_WEBHOOK_URL=https://your-webhook-url
+
+# ============ 数据库稳定性调优(可选)============
+DB_POOL_MAX=30
+DB_POOL_MIN=2
+DB_CONNECTION_TIMEOUT=10000
+DB_IDLE_TIMEOUT=15000
+DB_STATEMENT_TIMEOUT=30000
+DB_QUERY_TIMEOUT=30000
+TRAFFIC_CONCURRENCY_PER_HOST=10
+TRAFFIC_HOST_CONCURRENCY=3
+DB_WORKER_BACKOFF_MS=15000
+```
+
+---
+
+### 部署命令
+
+```bash
+# 1. 创建 .env 文件
+cp .env.example .env
+
+# 2. 编辑 .env,配置上述变量
+nano .env
+
+# 3. 启动服务
+docker-compose up -d
+
+# 4. 查看日志
+docker-compose logs -f app
+```
+
+---
+
+### 变量来源说明
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 环境变量流向图 │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ .env 文件 │
+│ │ │
+│ ├─── POSTGRES_* ──────► db 容器 (PostgreSQL) │
+│ │ │
+│ ├─── REDIS_PASSWORD ──► redis 容器 │
+│ │ │
+│ └─── 其他变量 ────────► app 容器 (Incudal) │
+│ │ │
+│ ├── DATABASE_URL (自动组装) │
+│ ├── REDIS_URL (自动组装) │
+│ ├── JWT_SECRET │
+│ ├── ENCRYPTION_KEY │
+│ ├── ADMIN_PASSWORD │
+│ └── LOG_LEVEL 等 │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**重点**:`DATABASE_URL` 和 `REDIS_URL` 由 docker-compose.yml 自动组装,无需在 .env 中配置。
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..96407ee
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+# Auto detect text files and perform LF normalization
+* text=auto
+
+# Shell scripts must always use LF
+*.sh text eol=lf
+
+# Docker files must use LF
+Dockerfile text eol=lf
+docker-compose*.yml text eol=lf
+
+# Keep Windows batch files with CRLF
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml
new file mode 100644
index 0000000..2f0ac4e
--- /dev/null
+++ b/.github/workflows/agent-release.yml
@@ -0,0 +1,190 @@
+name: Agent Build & Release
+
+on:
+ push:
+ branches:
+ - main
+ - dev
+ paths:
+ - 'agent/**'
+ - 'server/templates/agent-install.sh'
+ - 'server/src/routes/agent.ts'
+ - 'server/src/lib/agent-auth.ts'
+ - 'server/src/lib/host-agent-credentials.ts'
+ - 'server/src/services/agent-instance-report.ts'
+ - '.github/workflows/agent-release.yml'
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'agent/**'
+ - 'server/templates/agent-install.sh'
+ - 'server/src/routes/agent.ts'
+ - 'server/src/lib/agent-auth.ts'
+ - 'server/src/lib/host-agent-credentials.ts'
+ - 'server/src/services/agent-instance-report.ts'
+ - '.github/workflows/agent-release.yml'
+ workflow_dispatch:
+ inputs:
+ publish_release:
+ description: 'Publish an Agent GitHub Release using the version in agent/VERSION.'
+ required: false
+ default: false
+ type: boolean
+
+env:
+ GO_VERSION: '1.22.x'
+
+jobs:
+ version:
+ name: Resolve Agent Version
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ version_changed: ${{ steps.changed.outputs.version_changed }}
+ should_release: ${{ steps.release.outputs.should_release }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Read Agent version
+ id: version
+ shell: bash
+ run: |
+ set -euo pipefail
+ VERSION="$(tr -d '[:space:]' < agent/VERSION)"
+
+ if [[ ! "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+ echo "Invalid Agent version: ${VERSION}" >&2
+ echo "Expected vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-suffix" >&2
+ exit 1
+ fi
+
+ echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
+ echo "Agent version: ${VERSION}"
+
+ - name: Detect agent/VERSION change
+ id: changed
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ VERSION_CHANGED=false
+ if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
+ BEFORE="${{ github.event.before }}"
+ AFTER="${{ github.sha }}"
+
+ if [[ -z "${BEFORE}" || "${BEFORE}" =~ ^0+$ ]]; then
+ VERSION_CHANGED=true
+ elif git diff --name-only "${BEFORE}" "${AFTER}" | grep -qx 'agent/VERSION'; then
+ VERSION_CHANGED=true
+ fi
+ fi
+
+ echo "version_changed=${VERSION_CHANGED}" >> "${GITHUB_OUTPUT}"
+ echo "agent/VERSION changed: ${VERSION_CHANGED}"
+
+ - name: Decide release publishing
+ id: release
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ SHOULD_RELEASE=false
+ if [[ "${GITHUB_EVENT_NAME}" == "push" && "${{ steps.changed.outputs.version_changed }}" == "true" ]]; then
+ SHOULD_RELEASE=true
+ elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${{ inputs.publish_release }}" == "true" ]]; then
+ SHOULD_RELEASE=true
+ fi
+
+ echo "should_release=${SHOULD_RELEASE}" >> "${GITHUB_OUTPUT}"
+ echo "should release: ${SHOULD_RELEASE}"
+
+ build-agent:
+ name: Build Agent
+ runs-on: ubuntu-latest
+ needs:
+ - version
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ cache-dependency-path: agent/go.mod
+
+ - name: Test Agent
+ working-directory: agent
+ run: go test ./...
+
+ - name: Build Agent release files
+ run: VERSION="${{ needs.version.outputs.version }}" bash agent/scripts/build-release.sh
+
+ - name: Prepare release assets
+ shell: bash
+ run: |
+ set -euo pipefail
+ VERSION="${{ needs.version.outputs.version }}"
+ mkdir -p agent/release
+
+ cp agent/dist/incudal-agent-linux-amd64 "agent/release/incudal-agent-x86_64-${VERSION}"
+ cp agent/dist/incudal-agent-linux-arm64 "agent/release/incudal-agent-aarch64-${VERSION}"
+
+ - name: Verify release assets
+ shell: bash
+ run: |
+ set -euo pipefail
+ VERSION="${{ needs.version.outputs.version }}"
+ test -s "agent/release/incudal-agent-x86_64-${VERSION}"
+ test -s "agent/release/incudal-agent-aarch64-${VERSION}"
+
+ - name: Upload Agent artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: incudal-agent-${{ needs.version.outputs.version }}
+ path: |
+ agent/release/incudal-agent-x86_64-${{ needs.version.outputs.version }}
+ agent/release/incudal-agent-aarch64-${{ needs.version.outputs.version }}
+ retention-days: 30
+
+ release-agent:
+ name: Publish Agent Release
+ runs-on: ubuntu-latest
+ needs:
+ - version
+ - build-agent
+ if: needs.version.outputs.should_release == 'true'
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download Agent artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: incudal-agent-${{ needs.version.outputs.version }}
+ path: agent-release
+
+ - name: Publish GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: agent-${{ needs.version.outputs.version }}
+ target_commitish: ${{ github.sha }}
+ name: Incudal Agent ${{ needs.version.outputs.version }}
+ files: |
+ agent-release/incudal-agent-x86_64-${{ needs.version.outputs.version }}
+ agent-release/incudal-agent-aarch64-${{ needs.version.outputs.version }}
+ generate_release_notes: true
+ draft: false
+ prerelease: ${{ contains(needs.version.outputs.version, '-') }}
+ fail_on_unmatched_files: true
+ overwrite_files: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1d0ae42
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,81 @@
+name: CI
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint-and-typecheck:
+ name: Lint & Type Check
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: Generate Prisma Client
+ run: pnpm --filter server exec prisma generate
+ env:
+ DATABASE_URL: postgresql://user:pass@localhost:5432/db
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Type check (client)
+ run: pnpm --filter client type-check
+
+ - name: Type check (server)
+ run: pnpm --filter server type-check
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.22.x'
+ cache-dependency-path: agent/go.mod
+
+ - name: Test Agent
+ working-directory: agent
+ run: go test ./...
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ needs: lint-and-typecheck
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: Generate Prisma Client
+ run: pnpm --filter server exec prisma generate
+ env:
+ DATABASE_URL: postgresql://user:pass@localhost:5432/db
+
+ - name: Build
+ run: pnpm build
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..0799b0b
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,65 @@
+name: Docker Build & Push
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Image tag (e.g., latest, v1.0.0)'
+ required: false
+ default: 'latest'
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push:
+ name: Build & Push Docker Image
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata for Docker
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ # For tags: v1.0.0 -> 1.0.0, latest
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ # For manual trigger
+ type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
+ # Always tag latest for version tags
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ platforms: linux/amd64
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c448902
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,257 @@
+# ============================================================================
+# Incudal 预构建产物包发布工作流
+# 在推送版本 tag (v*) 或手动触发时,自动构建前后端并打包为 .tar.gz 发行包
+# 支持 amd64 和 arm64 双架构
+# ============================================================================
+
+name: Build & Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: '发行版本号(例如 v1.0.0),留空则使用 git SHA 作为预览版'
+ required: false
+ default: ''
+
+env:
+ NODE_VERSION: '22'
+
+jobs:
+ # ===== 构建发行包(多架构矩阵) =====
+ build:
+ name: 构建 (${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: ubuntu-latest
+ - arch: arm64
+ runner: ubuntu-24.04-arm
+ permissions:
+ contents: write
+
+ steps:
+ - name: 检出代码
+ uses: actions/checkout@v4
+
+ - name: 安装 pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: 安装 Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'pnpm'
+
+ # ===== 安装依赖 =====
+ - name: 安装全部依赖
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: 重新构建 esbuild(Vite 构建需要)
+ run: pnpm rebuild esbuild
+
+ # ===== 生成 Prisma Client =====
+ - name: 生成 Prisma Client
+ run: pnpm --filter server exec prisma generate
+ env:
+ DATABASE_URL: postgresql://user:pass@localhost:5432/db
+
+ # ===== 构建前端 =====
+ - name: 构建前端 (Vite)
+ run: pnpm --filter client build
+
+ # ===== 构建后端 =====
+ - name: 编译后端 (TypeScript)
+ run: pnpm --filter server build
+
+ # ===== 准备发行目录 =====
+ - name: 准备发行目录结构
+ run: |
+ mkdir -p release/client/dist
+ mkdir -p release/server
+
+ # 复制前端构建产物
+ cp -r client/dist/* release/client/dist/
+
+ # 复制后端构建产物
+ cp -r server/dist release/server/
+ cp -r server/prisma release/server/
+ cp server/prisma.config.ts release/server/
+ cp server/package.json release/server/
+ cp -r server/templates release/server/
+
+ # 复制根配置文件
+ cp package.json release/
+ cp pnpm-workspace.yaml release/
+
+ # ===== 安装生产依赖 =====
+ - name: 安装后端生产依赖
+ working-directory: release/server
+ run: |
+ # 在发行目录中只安装生产依赖
+ npm install --omit=dev --ignore-scripts
+
+ # 在当前架构的 runner 上原生生成 Prisma Client
+ DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
+
+ # ===== 创建启动辅助脚本 =====
+ - name: 创建管理脚本
+ run: |
+ cat > release/incudal.sh << 'SCRIPT'
+ #!/bin/bash
+ # Incudal 快捷管理脚本
+ set -e
+ APP_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+ case "${1:-}" in
+ start)
+ echo "🚀 启动 Incudal..."
+ cd "$APP_DIR/server"
+ npx prisma migrate deploy
+ cd "$APP_DIR"
+ exec node server/dist/app.js
+ ;;
+ migrate)
+ echo "🔄 执行数据库迁移..."
+ cd "$APP_DIR/server"
+ npx prisma migrate deploy
+ echo "✅ 迁移完成"
+ ;;
+ version)
+ echo "Incudal $(node -e "console.log(require('./package.json').version)")"
+ ;;
+ *)
+ echo "用法: $0 {start|migrate|version}"
+ exit 1
+ ;;
+ esac
+ SCRIPT
+ chmod +x release/incudal.sh
+
+ # ===== 确定版本号 =====
+ - name: 确定版本号
+ id: version
+ run: |
+ if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then
+ VERSION="${{ github.ref_name }}"
+ elif [[ -n "${{ github.event.inputs.tag }}" ]]; then
+ VERSION="${{ github.event.inputs.tag }}"
+ else
+ VERSION="preview-$(echo ${{ github.sha }} | head -c 7)"
+ fi
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "📦 版本号: $VERSION"
+
+ # ===== 打包 =====
+ - name: 打包为 tar.gz
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ cd release
+ tar -czf "../incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" .
+ cd ..
+ ls -lh "incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz"
+ echo "📦 发行包大小: $(du -h "incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" | cut -f1)"
+
+ # ===== 上传构建产物 =====
+ - name: 上传构建产物
+ uses: actions/upload-artifact@v4
+ with:
+ name: incudal-${{ steps.version.outputs.version }}-linux-${{ matrix.arch }}
+ path: incudal-${{ steps.version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz
+ retention-days: 30
+
+ # ===== 发布 Release(等待所有架构构建完成) =====
+ release:
+ name: 发布 Release
+ needs:
+ - build
+ if: startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: 确定版本号
+ id: version
+ run: echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
+
+ # 下载所有架构的构建产物
+ - name: 下载 amd64 产物
+ uses: actions/download-artifact@v4
+ with:
+ name: incudal-${{ steps.version.outputs.version }}-linux-amd64
+
+ - name: 下载 arm64 产物
+ uses: actions/download-artifact@v4
+ with:
+ name: incudal-${{ steps.version.outputs.version }}-linux-arm64
+
+ - name: 列出发行文件
+ run: ls -lh incudal-*.tar.gz
+
+ # 创建 Release 并上传所有架构的产物
+ - name: 创建 GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ incudal-*.tar.gz
+ generate_release_notes: true
+ draft: false
+ prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
+
+ # ===== 构建并推送 Docker 镜像(多架构) =====
+ docker:
+ name: 构建 Docker 镜像
+ if: startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: 检出代码
+ uses: actions/checkout@v4
+
+ # 设置 QEMU(用于跨架构构建 arm64)
+ - name: 设置 QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # 设置 Docker Buildx(多架构构建引擎)
+ - name: 设置 Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # 登录 GitHub Container Registry
+ - name: 登录 GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # 提取版本号和元数据
+ - name: 提取 Docker 元数据
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest
+
+ # 构建并推送多架构镜像
+ - name: 构建并推送 Docker 镜像
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..348dd9e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,93 @@
+# =====================================================
+# Incudal 项目 .gitignore
+# =====================================================
+
+# ==================== 依赖 ====================
+node_modules/
+.pnpm-store/
+
+# ==================== 构建产物 ====================
+dist/
+build/
+client/dist/
+server/dist/
+!agent/dist/
+!agent/dist/incudal-agent-linux-amd64
+!agent/dist/incudal-agent-linux-amd64.gz
+!agent/dist/incudal-agent-linux-arm64
+!agent/dist/incudal-agent-linux-arm64.gz
+
+# ==================== 环境变量 ====================
+.env
+.env.local
+.env.*.local
+server/.env
+
+# ==================== Prisma ====================
+server/src/generated/
+**/generated/prisma/
+
+# ==================== 日志 ====================
+logs/
+*.log
+npm-debug.log*
+pnpm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# ==================== IDE/编辑器 ====================
+.vscode/
+.idea/
+*.swp
+*.swo
+*.sublime-workspace
+*.sublime-project
+
+# ==================== 操作系统 ====================
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+Desktop.ini
+
+# ==================== 证书 (敏感) ====================
+server/certs/*.key
+server/certs/*.crt
+server/certs/*.pem
+!server/certs/.gitkeep
+
+# ==================== 数据库 ====================
+*.db
+*.sqlite
+*.sqlite3
+
+# ==================== Docker ====================
+.docker/
+
+# ==================== 临时文件 ====================
+tmp/
+temp/
+.cache/
+*.tmp
+*.temp
+*.pid
+
+# ==================== Vite ====================
+client/node_modules/.vite/
+client/node_modules/.vite-temp/
+
+# ==================== TypeScript ====================
+*.tsbuildinfo
+
+# ==================== 测试覆盖率 ====================
+coverage/
+.nyc_output/
+
+# ==================== 文档 (内部) ====================
+docs/
+SDK/
+SDKNEW/
+namecrane_modules/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..941ccbb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,85 @@
+# =====================================================
+# Incudal 多阶段构建 Dockerfile
+# =====================================================
+
+# Stage 1: 依赖安装
+FROM node:22-alpine AS deps
+RUN corepack enable && corepack prepare pnpm@9 --activate
+WORKDIR /app
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY client/package.json ./client/
+COPY server/package.json ./server/
+
+RUN pnpm install --frozen-lockfile --ignore-scripts
+
+# esbuild 依赖安装脚本不能被跳过,否则 Vite 在构建阶段无法启动二进制
+RUN pnpm rebuild esbuild
+
+# Stage 2: 构建前端
+FROM node:22-alpine AS builder-client
+RUN corepack enable && corepack prepare pnpm@9 --activate
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/client/node_modules ./client/node_modules
+COPY client ./client
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+
+RUN pnpm --filter client build
+
+# Stage 3: 构建后端
+FROM node:22-alpine AS builder-server
+RUN corepack enable && corepack prepare pnpm@9 --activate
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/server/node_modules ./server/node_modules
+COPY server ./server
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+
+# 生成 Prisma Client 并构建
+WORKDIR /app/server
+RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
+WORKDIR /app
+RUN pnpm --filter server build
+
+# Stage 4: 生产镜像
+FROM node:22-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+
+# 创建非 root 用户
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 incudal
+
+# 复制依赖 (包含生成的 Prisma Client)
+COPY --from=builder-server /app/node_modules ./node_modules
+COPY --from=builder-server /app/server/node_modules ./server/node_modules
+
+# 复制构建产物
+COPY --from=builder-server /app/server/dist ./server/dist
+COPY --from=builder-client /app/client/dist ./client/dist
+
+# 复制必要配置文件
+COPY server/package.json ./server/
+COPY server/prisma.config.ts ./server/
+COPY server/prisma ./server/prisma
+COPY server/templates ./server/templates
+COPY server/scripts ./server/scripts
+COPY server/src ./server/src
+COPY package.json pnpm-workspace.yaml ./
+
+# 复制启动脚本
+COPY server/docker-entrypoint.sh ./server/
+RUN chmod +x ./server/docker-entrypoint.sh
+
+# 创建证书目录
+RUN mkdir -p server/certs && chown -R incudal:nodejs server/certs
+
+USER incudal
+
+EXPOSE 3000
+
+ENTRYPOINT ["./server/docker-entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..68fc42b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, qwer-xyz
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1fed7b7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,194 @@
+
Incudal
+
+基于 Incus 的 LXC / KVM NAT VPS 销售、交付与管理面板。
+
+## 项目简介
+
+Incudal 基于 Incus 的 NAT VPS 销售与管理面板。
+项目支持 LXC / KVM 实例、套餐与镜像管理、账务计费、节点托管、用户后台、管理员后台以及宿主机 Agent。
+
+> 演示站:https://demo.incudal.com
+> 仅供学习与参考,本项目存在诸多不完善之处。有任何问题建议 Fork 后使用 AI 解决。
+
+## 主要功能
+
+- 实例交付:基于 Incus 创建和管理 LXC / KVM 实例,支持 NAT 网络、系统镜像、套餐资源和节点绑定。
+- 平台运营:提供用户端与管理员后台,覆盖用户、套餐、镜像、节点、工单、公告、日志和系统配置等管理流程。
+- 计费与权益:支持余额、充值、消费记录、托管收益、支付渠道、积分、VIP 等级和会员福利。
+- 节点与扩展:通过宿主机 Agent 上报资源与状态,并支持托管节点、反代建站、邮箱服务等扩展能力。
+
+## 目录结构
+
+```text
+client/ Vue 3 + Vite 前端
+server/ Fastify + Prisma 后端
+agent/ Go 宿主机 Agent
+server/prisma/ 数据库 schema 与 migrations
+server/templates/ 安装脚本、邮件等模板
+.github/workflows/ CI、Docker、Agent Release 工作流
+scripts/ 本地开发和检查脚本
+```
+
+## 搭建教程
+
+当前无论使用哪种部署方式,都需要在 `server/certs` 目录下生成或放置证书与密钥,并确保 Docker 容器具有读取权限。
+
+### 方式一:Docker Compose 开发环境
+
+适合本地试跑和开发预览。该方式会自动启动 Node、PostgreSQL 和 Redis,并自动安装依赖、执行数据库迁移。
+
+```bash
+git clone https://github.com/qwer-xyz/incudal.git
+cd incudal
+docker compose -f docker-compose.dev.yml up
+```
+
+启动后访问:
+
+```text
+前端:http://127.0.0.1:43173
+后端:http://127.0.0.1:8888
+```
+
+停止服务:
+
+```bash
+docker compose -f docker-compose.dev.yml down
+```
+
+如需同时删除本地开发数据库卷:
+
+```bash
+docker compose -f docker-compose.dev.yml down -v
+```
+
+### 方式二:生产镜像部署
+
+生产环境建议准备独立 PostgreSQL 和 Redis,然后构建并运行镜像。
+
+```bash
+docker build -t incudal:local .
+docker run -d --name incudal \
+ -p 3000:3000 \
+ --env-file .env \
+ incudal:local
+```
+
+生产环境至少需要配置:
+
+```env
+NODE_ENV=production
+PORT=3000
+DATABASE_URL=postgresql://user:password@postgres:5432/incudal
+REDIS_URL=redis://redis:6379
+JWT_SECRET=please-change-to-a-long-random-secret
+FRONTEND_URL=https://your-domain.example
+```
+
+容器启动时会执行 `prisma migrate deploy`,然后启动后端服务。生产部署前请确保数据库可连接、`JWT_SECRET` 足够随机、反向代理和 HTTPS 已配置好。
+
+Agent 正式二进制不存放在面板仓库内,面板运行时会从 GitHub Release 查询和代理下载。如部署在私有仓库或 fork,可按需配置:
+
+```env
+INCUDAL_AGENT_RELEASE_REPOSITORY=qwer-xyz/incudal
+INCUDAL_AGENT_RELEASE_TOKEN=github_pat_xxx
+```
+
+## 开发教程
+
+### 本地依赖
+
+- Node.js 20 或更高版本
+- pnpm 9.14.2
+- PostgreSQL
+- Redis
+- Go 1.22 或更高版本,仅开发 Agent 时需要
+
+启用 pnpm:
+
+```bash
+corepack enable
+corepack prepare pnpm@9.14.2 --activate
+pnpm install
+```
+
+配置本地环境变量后执行数据库迁移:
+
+```bash
+pnpm --filter server exec prisma migrate deploy
+```
+
+启动前后端开发服务:
+
+```bash
+pnpm dev
+```
+
+默认开发端口:
+
+```text
+client: http://127.0.0.1:5173
+server: http://127.0.0.1:8888
+```
+
+### 常用命令
+
+```bash
+pnpm --filter client type-check
+pnpm --filter server type-check
+pnpm build
+pnpm lint
+```
+
+本地检查:
+
+```shell
+# Windows
+.\scripts\local-ci.ps1
+# macOS
+.\scripts\local-ci-macos.sh
+```
+
+### 数据库开发
+
+Prisma schema 位于 `server/prisma/schema.prisma`,迁移文件位于 `server/prisma/migrations/`。修改数据库结构后,应生成迁移并确认前后端类型检查通过。
+
+常用命令:
+
+```bash
+pnpm --filter server exec prisma generate
+pnpm --filter server exec prisma migrate dev
+pnpm --filter server exec prisma migrate deploy
+```
+
+### Agent 开发与发布
+
+Agent 位于 `agent/`,版本号统一由 `agent/VERSION` 控制。
+
+本地测试:
+
+```bash
+cd agent
+go test ./...
+go run ./cmd/incudal-agent -config ./config.example.yaml -once
+```
+
+本地构建双架构产物:
+
+```bash
+bash agent/scripts/build-release.sh
+```
+
+`agent/dist` 只是本地临时构建目录,不提交到 Git。正式发布由 GitHub Actions `Agent Build & Release` 完成:当 `agent/VERSION` 变动并推送后,会发布 GitHub Release,并生成:
+
+```text
+incudal-agent-x86_64-v0.0.1
+incudal-agent-aarch64-v0.0.1
+```
+
+## 开发约定
+
+- 前端新增文案需要同步维护 `client/src/locales/` 下的多语言键。
+- 后端新增管理接口应使用登录鉴权和管理员鉴权,并补充必要的字段校验和速率限制。
+- 数据库变更需要提交 Prisma migration,不直接修改生产库结构。
+- 不提交构建产物、临时文件、密钥、数据库 dump 或本地 `.env`。
diff --git a/TransparentProxy.md b/TransparentProxy.md
new file mode 100644
index 0000000..2fac5ca
--- /dev/null
+++ b/TransparentProxy.md
@@ -0,0 +1,104 @@
+针对 Incus/LXC 容器环境的 dae 透明代理。
+
+这套方案实现了:宿主机自身流量直连、接管容器出站流量走出口节点、兼容容器入站端口映射、规避节点不支持 UDP 导致的断网。
+
+---
+
+### 部署流程 (适用于 Ubuntu 24.04 + Incus)
+
+#### 第一步
+
+默认情况下,Incus 容器网桥内的流量走的是二层转发,会绕过大多的网络层探针。为了让 `dae` 能抓到容器发出的 TCP 握手包,必须开启网桥的三层过滤。
+
+在宿主机执行以下命令:
+
+```bash
+# 1. 确保 Incus 网桥保持默认的 NAT 开启状态 (保证入站映射正常)
+incus network set incusbr0 ipv4.nat=true
+
+# 2. 加载网桥过滤模块
+sudo modprobe br_netfilter
+
+# 3. 写入内核参数,强制网桥流量经过 iptables (进而被 dae 的 eBPF 探针捕获)
+echo "net.bridge.bridge-nf-call-iptables = 1" | sudo tee /etc/sysctl.d/99-bridge-nf.conf
+echo "net.bridge.bridge-nf-call-ip6tables = 1" | sudo tee -a /etc/sysctl.d/99-bridge-nf.conf
+echo "net.bridge.bridge-nf-call-arptables = 1" | sudo tee -a /etc/sysctl.d/99-bridge-nf.conf
+
+# 4. 生效配置
+sudo sysctl -p /etc/sysctl.d/99-bridge-nf.conf
+```
+
+#### 第二步:安装 dae
+
+```bash
+sudo apt update && sudo apt install curl -y
+sudo bash -c "$(curl -sL https://github.com/daeuniverse/dae-installer/raw/main/installer.sh)"
+```
+
+#### 第三步
+
+清空并编辑 `/usr/local/etc/dae/config.dae`,将以下内容粘贴进去。
+
+```dae
+global {
+ tproxy_port: 8321
+ tproxy_port_protect: true
+ wan_interface: auto
+
+ # 绑定 Incus 网桥
+ lan_interface: incusbr0
+
+ auto_config_kernel_parameter: true
+}
+
+node {
+ # 落地节点
+ node1: ''
+}
+
+group {
+ my_upstream {
+ filter: name(node1)
+ policy: fixed(0)
+ }
+}
+
+dns {
+ upstream {
+ # 强制使用 tcp 协议查 DNS,完美解决代理节点不支持 UDP 导致的 DNS 解析超时黑洞
+ cloudflare: 'tcp://1.1.1.1:53'
+ }
+ routing {
+ request {
+ fallback: cloudflare
+ }
+ }
+}
+
+routing {
+ # 1. 内网互通与组播放行
+ dip(224.0.0.0/3, 'ff00::/8') -> direct
+ dip(geoip:private) -> direct
+
+ # 2. 宿主机防失联 (SSH)
+ dport(22, 2551) -> must_direct
+ sport(22, 2551) -> must_direct
+
+ # 3. 兼容 NAT 端口映射的回程放行
+ sport(80, 443, 3306) -> must_direct
+
+ # 4. 劫持容器网段
+ sip(10.10.0.0/22) -> my_upstream
+
+ # 5. 宿主机自身流量及未匹配流量兜底直连
+ fallback: direct
+}
+```
+
+#### 第四步:启动
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl restart dae
+sudo systemctl enable dae
+```
diff --git a/agent/.gitignore b/agent/.gitignore
new file mode 100644
index 0000000..bb8a5e0
--- /dev/null
+++ b/agent/.gitignore
@@ -0,0 +1,4 @@
+/incudal-agent
+/dist/*
+*.test
+coverage.out
diff --git a/agent/README.md b/agent/README.md
new file mode 100644
index 0000000..228c915
--- /dev/null
+++ b/agent/README.md
@@ -0,0 +1,174 @@
+# Incudal Host Agent
+
+宿主机 Agent 客户端实现。
+
+当前阶段负责读取配置、向面板上报 HMAC 签名心跳,并按面板心跳响应执行自动升级。
+
+## 配置
+
+默认配置路径:
+
+```bash
+/etc/incudal-agent/config.yaml
+```
+
+示例:
+
+```yaml
+panel_url: "https://idev.bitpd.com"
+agent_id: "agt_xxx"
+agent_secret: "ias_xxx"
+heartbeat_interval_seconds: 30
+request_timeout_seconds: 10
+```
+
+也可以使用环境变量覆盖:
+
+```bash
+INCUDAL_PANEL_URL=
+INCUDAL_AGENT_ID=
+INCUDAL_AGENT_SECRET=
+INCUDAL_HEARTBEAT_INTERVAL_SECONDS=
+INCUDAL_REQUEST_TIMEOUT_SECONDS=
+```
+
+## 运行
+
+单次心跳测试:
+
+```bash
+go run ./cmd/incudal-agent -config ./config.example.yaml -once
+```
+
+循环心跳:
+
+```bash
+go run ./cmd/incudal-agent -config /etc/incudal-agent/config.yaml
+```
+
+## 当前能力
+
+- 读取 Agent 配置
+- 采集 CPU 数量和内存总量
+- 探测常见 Incus/LXD Unix socket
+- 生成 canonical JSON body hash
+- 生成 HMAC-SHA256 签名
+- 调用 `POST /api/agent/heartbeat`
+- 读取心跳响应中的 `upgrade` 指令并自动升级自身
+
+当前不执行实例创建、销毁、启停等下发任务。
+
+## 自动升级
+
+面板会在 Agent 心跳响应中返回升级指令:
+
+```json
+{
+ "upgrade": {
+ "available": true,
+ "version": "v1.0.1",
+ "url": "https:///api/agent/binary/incudal-agent-linux-amd64?v=v1.0.1",
+ "sha256": "",
+ "gzip": false
+ }
+}
+```
+
+Agent 只接受当前 `panel_url` 同源下载地址。下载后先校验 SHA-256,再解包、写入临时文件、备份旧二进制、原子替换并执行 `systemctl restart incudal-agent`。
+
+`-once` 单次心跳测试模式不会执行自动升级,避免安装前置检测阶段替换正在测试的二进制。
+
+旧版本 Agent 不包含升级执行器,首次启用自动升级时仍需要通过面板安装命令或重新安装按钮部署一次新版 Agent;之后才会按心跳响应自动升级。
+
+## Release 构建与发布
+
+本地构建双架构二进制:
+
+```bash
+cd ..
+bash agent/scripts/build-release.sh
+```
+
+Agent 版本统一从 `agent/VERSION` 读取,格式固定为 `vMAJOR.MINOR.PATCH`。
+需要发布新版 Agent 时,先递增 `agent/VERSION`,再构建 release 产物。
+
+产物:
+
+```text
+agent/dist/incudal-agent-linux-amd64
+agent/dist/incudal-agent-linux-arm64
+agent/dist/manifest.json
+```
+
+`agent/dist` 是本地临时构建目录,不再纳入 Git。正式发布由 GitHub Actions `Agent Build & Release` 完成。
+
+推送中只要 `agent/VERSION` 发生变化,Actions 会读取该版本号,构建并发布 GitHub Release:
+
+```text
+tag: agent-v0.0.1
+assets:
+ incudal-agent-x86_64-v0.0.1
+ incudal-agent-aarch64-v0.0.1
+```
+
+面板运行时不会读取本地 `agent/dist`。它会从 GitHub Release 查询最新 Agent 版本,动态生成 `/api/agent/manifest.json`,并通过 `/api/agent/binary/*` 代理下载对应 Release 资产。
+
+默认 GitHub Release 仓库为 `qwer-xyz/incudal_classic`。如果部署到 fork 或私有仓库,可设置:
+
+```bash
+INCUDAL_AGENT_RELEASE_REPOSITORY="owner/repo"
+INCUDAL_AGENT_RELEASE_TOKEN="github_pat_xxx" # 私有仓库需要
+```
+
+## 安装脚本
+
+面板提供通用安装脚本:
+
+```bash
+curl -fsSL "$PANEL_URL/api/agent/install.sh" | sudo env \
+ INCUDAL_PANEL_URL="$PANEL_URL" \
+ INCUDAL_AGENT_INSTALL_TOKEN="$AGENT_INSTALL_TOKEN" \
+ INCUDAL_AGENT_BINARY_URL="$BINARY_URL" \
+ bash
+```
+
+`INCUDAL_AGENT_INSTALL_TOKEN` 由面板生成,30 分钟内有效且只能使用一次。
+安装脚本会调用 `/api/agent/install-config/:token` 拉取 `agent_id` 和 `agent_secret`。
+旧的 `INCUDAL_AGENT_ID` / `INCUDAL_AGENT_SECRET` 直传方式仍保留兼容。
+
+安装脚本会:
+
+- 下载 `incudal-agent` 二进制
+- 写入 `/etc/incudal-agent/config.yaml`
+- 写入 systemd service
+- 执行一次心跳测试
+- 启动或重启 `incudal-agent.service`
+
+如果没有传入 `INCUDAL_AGENT_BINARY_URL`,默认从当前面板下载:
+
+```text
+https:///api/agent/binary/incudal-agent-linux-amd64
+https:///api/agent/binary/incudal-agent-linux-arm64
+```
+
+默认下载会先读取面板的 manifest:
+
+```text
+https:///api/agent/manifest.json
+```
+
+安装脚本会按当前 OS/ARCH 取出文件名和 SHA-256,下载后先校验摘要,再解包安装。
+
+如果手动传入 `INCUDAL_AGENT_BINARY_URL`,可同时传入 `INCUDAL_AGENT_BINARY_SHA256` 开启校验;未传 SHA-256 时仍保留兼容安装,但会输出 warning。
+
+安装脚本会先下载到临时文件,再原子替换 `/usr/local/bin/incudal-agent`。
+重复安装或升级时,会执行 `systemctl restart incudal-agent` 确保立即切换到最新二进制。
+
+dry-run 验证:
+
+```bash
+INCUDAL_AGENT_DRY_RUN=1 \
+INCUDAL_PANEL_URL="http://127.0.0.1:8888" \
+INCUDAL_AGENT_INSTALL_TOKEN="ait_testtoken_abcdefghijklmnopqrstuvwxyz123456" \
+bash server/templates/agent-install.sh
+```
diff --git a/agent/VERSION b/agent/VERSION
new file mode 100644
index 0000000..45c7a58
--- /dev/null
+++ b/agent/VERSION
@@ -0,0 +1 @@
+v0.0.1
diff --git a/agent/cmd/incudal-agent/main.go b/agent/cmd/incudal-agent/main.go
new file mode 100644
index 0000000..5c326fe
--- /dev/null
+++ b/agent/cmd/incudal-agent/main.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "os"
+ "os/signal"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "incudal-agent/internal/config"
+ "incudal-agent/internal/panel"
+ "incudal-agent/internal/report"
+ "incudal-agent/internal/upgrade"
+)
+
+var version = "dev"
+
+func main() {
+ configPath := flag.String("config", "/etc/incudal-agent/config.yaml", "agent config file")
+ once := flag.Bool("once", false, "send one heartbeat and exit")
+ flag.Parse()
+
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ log.Fatalf("load config: %v", err)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ client := panel.New(cfg)
+ if *once {
+ if _, err := sendHeartbeat(ctx, client, cfg.HeartbeatIntervalSeconds); err != nil {
+ log.Fatalf("heartbeat: %v", err)
+ }
+ return
+ }
+
+ log.Printf("incudal-agent started: panel=%s interval=%s", cfg.PanelURL, cfg.HeartbeatInterval)
+ upgradeRunner := upgrade.DefaultRunner(cfg)
+ var upgradeInProgress atomic.Bool
+ if result, err := sendHeartbeat(ctx, client, cfg.HeartbeatIntervalSeconds); err != nil {
+ log.Printf("heartbeat failed: %v", err)
+ } else {
+ scheduleAgentUpgrade(ctx, upgradeRunner, result, &upgradeInProgress)
+ }
+
+ ticker := time.NewTicker(cfg.HeartbeatInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("incudal-agent stopped")
+ return
+ case <-ticker.C:
+ if result, err := sendHeartbeat(ctx, client, cfg.HeartbeatIntervalSeconds); err != nil {
+ log.Printf("heartbeat failed: %v", err)
+ } else {
+ scheduleAgentUpgrade(ctx, upgradeRunner, result, &upgradeInProgress)
+ }
+ }
+ }
+}
+
+func sendHeartbeat(ctx context.Context, client *panel.Client, heartbeatIntervalSeconds int) (panel.HeartbeatResult, error) {
+ result, err := client.Heartbeat(ctx, report.HeartbeatPayload(version, heartbeatIntervalSeconds))
+ if err != nil {
+ return result, err
+ }
+ upgradeAvailable := result.Upgrade != nil && result.Upgrade.Available
+ log.Printf("heartbeat ok: status=%d latencyMs=%d upgrade=%t", result.StatusCode, result.LatencyMs, upgradeAvailable)
+ return result, nil
+}
+
+func scheduleAgentUpgrade(ctx context.Context, runner *upgrade.Runner, result panel.HeartbeatResult, upgradeInProgress *atomic.Bool) {
+ if result.Upgrade == nil || !result.Upgrade.Available {
+ return
+ }
+ if !upgradeInProgress.CompareAndSwap(false, true) {
+ log.Printf("agent upgrade already scheduled: version=%s", result.Upgrade.Version)
+ return
+ }
+
+ instruction := *result.Upgrade
+ log.Printf("agent upgrade scheduled: version=%s", instruction.Version)
+ go func() {
+ defer upgradeInProgress.Store(false)
+
+ if delay := upgrade.RandomJitter(5 * time.Minute); delay > 0 {
+ timer := time.NewTimer(delay)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return
+ case <-timer.C:
+ }
+ }
+
+ upgradeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+ if err := runner.Apply(upgradeCtx, instruction, version); err != nil {
+ log.Printf("agent upgrade failed: version=%s error=%v", instruction.Version, err)
+ return
+ }
+ log.Printf("agent upgrade applied: version=%s", instruction.Version)
+ }()
+}
diff --git a/agent/config.example.yaml b/agent/config.example.yaml
new file mode 100644
index 0000000..8113d57
--- /dev/null
+++ b/agent/config.example.yaml
@@ -0,0 +1,8 @@
+# Incudal Host Agent minimal config.
+# 首版仅支持简单 key: value 格式,不支持嵌套 YAML。
+
+panel_url: "https://idev.bitpd.com"
+agent_id: "agt_replace_me"
+agent_secret: "ias_replace_me"
+heartbeat_interval_seconds: 30
+request_timeout_seconds: 10
diff --git a/agent/go.mod b/agent/go.mod
new file mode 100644
index 0000000..87932fb
--- /dev/null
+++ b/agent/go.mod
@@ -0,0 +1,3 @@
+module incudal-agent
+
+go 1.19
diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go
new file mode 100644
index 0000000..1e32d4a
--- /dev/null
+++ b/agent/internal/config/config.go
@@ -0,0 +1,158 @@
+package config
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ DefaultHeartbeatIntervalSeconds = 30
+ MinHeartbeatIntervalSeconds = 5
+ MaxHeartbeatIntervalSeconds = 3600
+)
+
+type Config struct {
+ PanelURL string
+ AgentID string
+ AgentSecret string
+ HeartbeatInterval time.Duration
+ RequestTimeout time.Duration
+ HeartbeatIntervalSeconds int
+ RequestTimeoutSeconds int
+}
+
+func Load(path string) (Config, error) {
+ values := map[string]string{}
+ if path != "" {
+ fileValues, err := readKeyValueFile(path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return Config{}, err
+ }
+ for key, value := range fileValues {
+ values[key] = value
+ }
+ }
+
+ overlayEnv(values, "panel_url", "INCUDAL_PANEL_URL")
+ overlayEnv(values, "agent_id", "INCUDAL_AGENT_ID")
+ overlayEnv(values, "agent_secret", "INCUDAL_AGENT_SECRET")
+ overlayEnv(values, "heartbeat_interval_seconds", "INCUDAL_HEARTBEAT_INTERVAL_SECONDS")
+ overlayEnv(values, "request_timeout_seconds", "INCUDAL_REQUEST_TIMEOUT_SECONDS")
+
+ heartbeatSeconds := clampInt(
+ parsePositiveInt(values["heartbeat_interval_seconds"], DefaultHeartbeatIntervalSeconds),
+ MinHeartbeatIntervalSeconds,
+ MaxHeartbeatIntervalSeconds,
+ )
+ timeoutSeconds := parsePositiveInt(values["request_timeout_seconds"], 10)
+ cfg := Config{
+ PanelURL: strings.TrimRight(values["panel_url"], "/"),
+ AgentID: values["agent_id"],
+ AgentSecret: values["agent_secret"],
+ HeartbeatIntervalSeconds: heartbeatSeconds,
+ RequestTimeoutSeconds: timeoutSeconds,
+ HeartbeatInterval: time.Duration(heartbeatSeconds) * time.Second,
+ RequestTimeout: time.Duration(timeoutSeconds) * time.Second,
+ }
+
+ if err := cfg.Validate(); err != nil {
+ return Config{}, err
+ }
+ return cfg, nil
+}
+
+func (cfg Config) Validate() error {
+ if cfg.PanelURL == "" {
+ return errors.New("panel_url is required")
+ }
+ parsed, err := url.Parse(cfg.PanelURL)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return fmt.Errorf("panel_url is invalid: %s", cfg.PanelURL)
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return fmt.Errorf("panel_url scheme must be http or https: %s", parsed.Scheme)
+ }
+ if cfg.AgentID == "" {
+ return errors.New("agent_id is required")
+ }
+ if cfg.AgentSecret == "" {
+ return errors.New("agent_secret is required")
+ }
+ if cfg.HeartbeatInterval < time.Duration(MinHeartbeatIntervalSeconds)*time.Second {
+ return fmt.Errorf("heartbeat interval must be at least %d seconds", MinHeartbeatIntervalSeconds)
+ }
+ if cfg.RequestTimeout < time.Second {
+ return errors.New("request timeout must be at least 1 second")
+ }
+ return nil
+}
+
+func readKeyValueFile(path string) (map[string]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ values := map[string]string{}
+ scanner := bufio.NewScanner(file)
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, value, ok := strings.Cut(line, ":")
+ if !ok {
+ return nil, fmt.Errorf("invalid config line %d: expected key: value", lineNumber)
+ }
+ key = strings.TrimSpace(key)
+ value = trimConfigValue(value)
+ if key == "" {
+ return nil, fmt.Errorf("invalid config line %d: empty key", lineNumber)
+ }
+ values[key] = value
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return values, nil
+}
+
+func trimConfigValue(value string) string {
+ trimmed := strings.TrimSpace(value)
+ trimmed = strings.Trim(trimmed, `"`)
+ trimmed = strings.Trim(trimmed, `'`)
+ return trimmed
+}
+
+func overlayEnv(values map[string]string, key string, envName string) {
+ if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
+ values[key] = value
+ }
+}
+
+func parsePositiveInt(value string, fallback int) int {
+ parsed, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil || parsed <= 0 {
+ return fallback
+ }
+ return parsed
+}
+
+func clampInt(value int, min int, max int) int {
+ if value < min {
+ return min
+ }
+ if value > max {
+ return max
+ }
+ return value
+}
diff --git a/agent/internal/config/config_test.go b/agent/internal/config/config_test.go
new file mode 100644
index 0000000..53e4b08
--- /dev/null
+++ b/agent/internal/config/config_test.go
@@ -0,0 +1,51 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestLoadClampsHeartbeatInterval(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ expected int
+ }{
+ {name: "too low", value: "1", expected: MinHeartbeatIntervalSeconds},
+ {name: "too high", value: "7200", expected: MaxHeartbeatIntervalSeconds},
+ {name: "valid", value: "60", expected: 60},
+ {name: "invalid", value: "invalid", expected: DefaultHeartbeatIntervalSeconds},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath := writeTestConfig(t, tt.value)
+ cfg, err := Load(configPath)
+ if err != nil {
+ t.Fatalf("load config: %v", err)
+ }
+ if cfg.HeartbeatIntervalSeconds != tt.expected {
+ t.Fatalf("heartbeat seconds mismatch: got=%d want=%d", cfg.HeartbeatIntervalSeconds, tt.expected)
+ }
+ if cfg.HeartbeatInterval != time.Duration(tt.expected)*time.Second {
+ t.Fatalf("heartbeat interval mismatch: got=%s want=%s", cfg.HeartbeatInterval, time.Duration(tt.expected)*time.Second)
+ }
+ })
+ }
+}
+
+func writeTestConfig(t *testing.T, heartbeatInterval string) string {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ content := "panel_url: \"https://panel.example\"\n" +
+ "agent_id: \"agt_test\"\n" +
+ "agent_secret: \"ias_test\"\n" +
+ "heartbeat_interval_seconds: " + heartbeatInterval + "\n"
+ if err := os.WriteFile(path, []byte(content), 0600); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ return path
+}
diff --git a/agent/internal/panel/client.go b/agent/internal/panel/client.go
new file mode 100644
index 0000000..99f9840
--- /dev/null
+++ b/agent/internal/panel/client.go
@@ -0,0 +1,119 @@
+package panel
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "incudal-agent/internal/config"
+ "incudal-agent/internal/protocol"
+)
+
+const heartbeatPath = "/api/agent/heartbeat"
+
+type Client struct {
+ panelURL string
+ agentID string
+ agentSecret string
+ httpClient *http.Client
+}
+
+type HeartbeatResult struct {
+ StatusCode int
+ Body string
+ OK bool
+ Upgrade *UpgradeInstruction
+ LatencyMs int64
+}
+
+type UpgradeInstruction struct {
+ Available bool `json:"available"`
+ Version string `json:"version"`
+ URL string `json:"url"`
+ SHA256 string `json:"sha256"`
+ Gzip bool `json:"gzip"`
+ Size int64 `json:"size"`
+}
+
+type heartbeatResponse struct {
+ Upgrade *UpgradeInstruction `json:"upgrade"`
+}
+
+func New(cfg config.Config) *Client {
+ return &Client{
+ panelURL: strings.TrimRight(cfg.PanelURL, "/"),
+ agentID: cfg.AgentID,
+ agentSecret: cfg.AgentSecret,
+ httpClient: &http.Client{
+ Timeout: cfg.RequestTimeout,
+ },
+ }
+}
+
+func (client *Client) Heartbeat(ctx context.Context, payload map[string]any) (HeartbeatResult, error) {
+ body, err := protocol.CanonicalJSON(payload)
+ if err != nil {
+ return HeartbeatResult{}, err
+ }
+
+ timestamp := protocol.NewTimestamp()
+ nonce, err := protocol.NewNonce()
+ if err != nil {
+ return HeartbeatResult{}, err
+ }
+ bodyHash := protocol.BodySHA256(body)
+ signingPayload := protocol.SigningPayload(http.MethodPost, heartbeatPath, timestamp, nonce, bodyHash)
+ signature := protocol.Signature(client.agentSecret, signingPayload)
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.panelURL+heartbeatPath, bytes.NewReader(body))
+ if err != nil {
+ return HeartbeatResult{}, err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("x-incudal-agent-id", client.agentID)
+ request.Header.Set("x-incudal-timestamp", timestamp)
+ request.Header.Set("x-incudal-nonce", nonce)
+ request.Header.Set("x-incudal-body-sha256", bodyHash)
+ request.Header.Set("x-incudal-signature", signature)
+
+ startedAt := time.Now()
+ response, err := client.httpClient.Do(request)
+ if err != nil {
+ return HeartbeatResult{}, err
+ }
+ defer response.Body.Close()
+
+ responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024))
+ if err != nil {
+ return HeartbeatResult{}, err
+ }
+
+ result := HeartbeatResult{
+ StatusCode: response.StatusCode,
+ Body: string(responseBody),
+ OK: response.StatusCode >= 200 && response.StatusCode < 300,
+ LatencyMs: time.Since(startedAt).Milliseconds(),
+ }
+ if !result.OK {
+ return result, fmt.Errorf("heartbeat failed: status=%d body=%s", response.StatusCode, result.Body)
+ }
+
+ var parsedResponse heartbeatResponse
+ if err := json.Unmarshal(responseBody, &parsedResponse); err == nil {
+ result.Upgrade = parsedResponse.Upgrade
+ }
+
+ var parsed map[string]any
+ if err := json.Unmarshal(responseBody, &parsed); err == nil {
+ parsed["latencyMs"] = result.LatencyMs
+ if compact, err := json.Marshal(parsed); err == nil {
+ result.Body = string(compact)
+ }
+ }
+ return result, nil
+}
diff --git a/agent/internal/protocol/signing.go b/agent/internal/protocol/signing.go
new file mode 100644
index 0000000..3b85345
--- /dev/null
+++ b/agent/internal/protocol/signing.go
@@ -0,0 +1,52 @@
+package protocol
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// CanonicalJSON 使用 Go 标准库的 JSON 编码。
+// map key 会按字典序输出,必须与面板端 stableStringify 规则保持一致。
+func CanonicalJSON(value any) ([]byte, error) {
+ return json.Marshal(value)
+}
+
+func BodySHA256(body []byte) string {
+ sum := sha256.Sum256(body)
+ return hex.EncodeToString(sum[:])
+}
+
+func SigningPayload(method string, path string, timestamp string, nonce string, bodyHash string) string {
+ return strings.Join([]string{
+ strings.ToUpper(method),
+ path,
+ timestamp,
+ nonce,
+ strings.ToLower(bodyHash),
+ }, "\n")
+}
+
+func Signature(secret string, payload string) string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(payload))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func NewTimestamp() string {
+ return fmt.Sprintf("%d", time.Now().UnixMilli())
+}
+
+func NewNonce() (string, error) {
+ var raw [18]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(raw[:]), nil
+}
diff --git a/agent/internal/protocol/signing_test.go b/agent/internal/protocol/signing_test.go
new file mode 100644
index 0000000..2f7d4f5
--- /dev/null
+++ b/agent/internal/protocol/signing_test.go
@@ -0,0 +1,49 @@
+package protocol
+
+import "testing"
+
+func TestCanonicalJSONIsStableForMapOrder(t *testing.T) {
+ bodyA := map[string]any{
+ "version": "0.1.0",
+ "resources": map[string]any{
+ "memory": 1024,
+ "cpu": 8,
+ },
+ "capabilities": []any{"heartbeat", "report"},
+ }
+ bodyB := map[string]any{
+ "capabilities": []any{"heartbeat", "report"},
+ "resources": map[string]any{
+ "cpu": 8,
+ "memory": 1024,
+ },
+ "version": "0.1.0",
+ }
+
+ jsonA, err := CanonicalJSON(bodyA)
+ if err != nil {
+ t.Fatalf("canonical json A: %v", err)
+ }
+ jsonB, err := CanonicalJSON(bodyB)
+ if err != nil {
+ t.Fatalf("canonical json B: %v", err)
+ }
+
+ if string(jsonA) != string(jsonB) {
+ t.Fatalf("canonical json mismatch:\nA=%s\nB=%s", jsonA, jsonB)
+ }
+ if BodySHA256(jsonA) != BodySHA256(jsonB) {
+ t.Fatalf("body hash mismatch")
+ }
+}
+
+func TestSignatureChangesWithPath(t *testing.T) {
+ secret := "ias_test_secret"
+ bodyHash := BodySHA256([]byte(`{"ok":true}`))
+ payloadA := SigningPayload("POST", "/api/agent/heartbeat", "1777380000000", "nonce-123456", bodyHash)
+ payloadB := SigningPayload("POST", "/api/agent/report", "1777380000000", "nonce-123456", bodyHash)
+
+ if Signature(secret, payloadA) == Signature(secret, payloadB) {
+ t.Fatalf("signature should change when request path changes")
+ }
+}
diff --git a/agent/internal/report/incus.go b/agent/internal/report/incus.go
new file mode 100644
index 0000000..79a1853
--- /dev/null
+++ b/agent/internal/report/incus.go
@@ -0,0 +1,362 @@
+package report
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ maxReportedIncusInstances = 1000
+ incusStateConcurrency = 8
+)
+
+var externalGuestInterfacePattern = regexp.MustCompile(`^(eth[0-9]+|en(?:o|p|s|x)[a-z0-9]+)$`)
+
+type incusAPIResponse struct {
+ Type string `json:"type"`
+ Status string `json:"status"`
+ Error string `json:"error"`
+ Metadata json.RawMessage `json:"metadata"`
+}
+
+type incusInstanceSummary struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ StatusCode int `json:"status_code"`
+ Type string `json:"type"`
+}
+
+type incusInstanceState struct {
+ Status string `json:"status"`
+ Network map[string]incusNetworkDevice `json:"network"`
+}
+
+type incusNetworkDevice struct {
+ Addresses []incusNetworkAddress `json:"addresses"`
+ Hwaddr string `json:"hwaddr"`
+ Counters incusNetworkCounters `json:"counters"`
+ State string `json:"state"`
+}
+
+type incusNetworkAddress struct {
+ Family string `json:"family"`
+ Address string `json:"address"`
+}
+
+type incusNetworkCounters struct {
+ BytesReceived json.Number `json:"bytes_received"`
+ BytesSent json.Number `json:"bytes_sent"`
+}
+
+type trafficCounters struct {
+ rx uint64
+ tx uint64
+}
+
+func collectIncusInstanceReport() map[string]any {
+ reportedAt := time.Now().UTC().Format(time.RFC3339)
+ socketPath, ok := detectIncusSocket()
+ if !ok {
+ return map[string]any{
+ "available": false,
+ "reportedAt": reportedAt,
+ "total": 0,
+ "items": []any{},
+ }
+ }
+
+ // 只通过本机 Unix socket 做只读采集,不要求宿主机开放额外 Agent 端口。
+ client := newIncusUnixHTTPClient(socketPath)
+ instances, err := listIncusInstances(client)
+ if err != nil {
+ return map[string]any{
+ "available": false,
+ "reportedAt": reportedAt,
+ "total": 0,
+ "items": []any{},
+ "error": truncateReportError(err),
+ }
+ }
+
+ limitedInstances := instances
+ if len(limitedInstances) > maxReportedIncusInstances {
+ limitedInstances = limitedInstances[:maxReportedIncusInstances]
+ }
+
+ items := make([]map[string]any, len(limitedInstances))
+ var wg sync.WaitGroup
+ semaphore := make(chan struct{}, incusStateConcurrency)
+
+ for index, instance := range limitedInstances {
+ wg.Add(1)
+ go func(index int, instance incusInstanceSummary) {
+ defer wg.Done()
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+
+ items[index] = buildIncusInstanceReportItem(client, instance)
+ }(index, instance)
+ }
+ wg.Wait()
+
+ normalizedItems := make([]any, 0, len(items))
+ for _, item := range items {
+ if item != nil {
+ normalizedItems = append(normalizedItems, item)
+ }
+ }
+
+ return map[string]any{
+ "available": true,
+ "reportedAt": reportedAt,
+ "total": len(instances),
+ "truncated": len(instances) > len(limitedInstances),
+ "items": normalizedItems,
+ }
+}
+
+func newIncusUnixHTTPClient(socketPath string) *http.Client {
+ transport := &http.Transport{
+ DialContext: func(ctx context.Context, _network string, _addr string) (net.Conn, error) {
+ var dialer net.Dialer
+ return dialer.DialContext(ctx, "unix", socketPath)
+ },
+ DisableCompression: true,
+ DisableKeepAlives: true,
+ }
+
+ return &http.Client{
+ Transport: transport,
+ Timeout: 8 * time.Second,
+ }
+}
+
+func listIncusInstances(client *http.Client) ([]incusInstanceSummary, error) {
+ return incusRequest[[]incusInstanceSummary](client, "/1.0/instances?recursion=1")
+}
+
+func getIncusInstanceState(client *http.Client, name string) (incusInstanceState, error) {
+ return incusRequest[incusInstanceState](client, "/1.0/instances/"+url.PathEscape(name)+"/state")
+}
+
+func incusRequest[T any](client *http.Client, path string) (T, error) {
+ var zero T
+ request, err := http.NewRequest(http.MethodGet, "http://incus"+path, nil)
+ if err != nil {
+ return zero, err
+ }
+
+ response, err := client.Do(request)
+ if err != nil {
+ return zero, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return zero, fmt.Errorf("incus request failed: path=%s status=%d", path, response.StatusCode)
+ }
+
+ var envelope incusAPIResponse
+ decoder := json.NewDecoder(response.Body)
+ decoder.UseNumber()
+ if err := decoder.Decode(&envelope); err != nil {
+ return zero, err
+ }
+ if envelope.Type == "error" {
+ if envelope.Error != "" {
+ return zero, fmt.Errorf("incus error: %s", envelope.Error)
+ }
+ return zero, fmt.Errorf("incus error: status=%s", envelope.Status)
+ }
+
+ metadataDecoder := json.NewDecoder(bytes.NewReader(envelope.Metadata))
+ metadataDecoder.UseNumber()
+ if err := metadataDecoder.Decode(&zero); err != nil {
+ return zero, err
+ }
+ return zero, nil
+}
+
+func buildIncusInstanceReportItem(client *http.Client, instance incusInstanceSummary) map[string]any {
+ item := map[string]any{
+ "name": instance.Name,
+ "status": instance.Status,
+ "statusCode": instance.StatusCode,
+ "type": instance.Type,
+ }
+
+ if instance.Name == "" {
+ return item
+ }
+
+ state, err := getIncusInstanceState(client, instance.Name)
+ if err != nil {
+ item["error"] = truncateReportError(err)
+ return item
+ }
+ if state.Status != "" {
+ item["status"] = state.Status
+ }
+
+ counters := getTrafficCountersFromIncusState(instance.Name, state)
+ item["traffic"] = map[string]any{
+ "rxBytes": strconv.FormatUint(counters.rx, 10),
+ "txBytes": strconv.FormatUint(counters.tx, 10),
+ }
+
+ if ipv4, ipv6 := firstRoutableAddresses(state.Network); ipv4 != "" || ipv6 != "" {
+ network := map[string]any{}
+ if ipv4 != "" {
+ network["ipv4"] = ipv4
+ }
+ if ipv6 != "" {
+ network["ipv6"] = ipv6
+ }
+ item["network"] = network
+ }
+
+ return item
+}
+
+func getTrafficCountersFromIncusState(instanceName string, state incusInstanceState) trafficCounters {
+ billableVmMacs := generateBillableVmMacs(instanceName)
+ totals := trafficCounters{}
+ fallbackInterfaces := make([]incusNetworkDevice, 0)
+ hasStrictBillableInterface := false
+
+ // 与面板旧采集口径保持一致,避免 guest 内部 bridge/veth 被重复计费。
+ for ifName, ifData := range state.Network {
+ if isBillableNetworkInterface(ifName, ifData, billableVmMacs) {
+ hasStrictBillableInterface = true
+ addNetworkCounters(&totals, ifData.Counters)
+ continue
+ }
+
+ if isLikelyExternalGuestInterface(ifName) {
+ fallbackInterfaces = append(fallbackInterfaces, ifData)
+ }
+ }
+
+ if !hasStrictBillableInterface {
+ for _, ifData := range fallbackInterfaces {
+ addNetworkCounters(&totals, ifData.Counters)
+ }
+ }
+
+ return totals
+}
+
+func isBillableNetworkInterface(ifName string, ifData incusNetworkDevice, billableVmMacs map[string]struct{}) bool {
+ if ifName == "lo" {
+ return false
+ }
+ if ifName == "eth0" || ifName == "eth1" {
+ return true
+ }
+
+ hwaddr := strings.ToLower(strings.TrimSpace(ifData.Hwaddr))
+ if hwaddr == "" {
+ return false
+ }
+ _, ok := billableVmMacs[hwaddr]
+ return ok
+}
+
+func isLikelyExternalGuestInterface(ifName string) bool {
+ return externalGuestInterfacePattern.MatchString(strings.ToLower(ifName))
+}
+
+func addNetworkCounters(totals *trafficCounters, counters incusNetworkCounters) {
+ totals.rx += jsonNumberToUint64(counters.BytesReceived)
+ totals.tx += jsonNumberToUint64(counters.BytesSent)
+}
+
+func jsonNumberToUint64(value json.Number) uint64 {
+ raw := strings.TrimSpace(value.String())
+ if raw == "" {
+ return 0
+ }
+ parsed, err := strconv.ParseUint(raw, 10, 64)
+ if err == nil {
+ return parsed
+ }
+ return 0
+}
+
+func generateBillableVmMacs(seed string) map[string]struct{} {
+ return map[string]struct{}{
+ generateVmNicMac(seed, "eth0"): {},
+ generateVmNicMac(seed, "eth1"): {},
+ }
+}
+
+func generateVmNicMac(seed string, nicLabel string) string {
+ hash := sha256.Sum256([]byte("incudal-vm-nic:" + seed + ":" + nicLabel))
+ bytes := []byte{0x02, hash[0], hash[1], hash[2], hash[3], hash[4]}
+ encoded := hex.EncodeToString(bytes)
+ return strings.Join([]string{
+ encoded[0:2],
+ encoded[2:4],
+ encoded[4:6],
+ encoded[6:8],
+ encoded[8:10],
+ encoded[10:12],
+ }, ":")
+}
+
+func firstRoutableAddresses(network map[string]incusNetworkDevice) (string, string) {
+ var ipv4 string
+ var ipv6 string
+
+ for _, ifData := range network {
+ for _, address := range ifData.Addresses {
+ if address.Address == "" {
+ continue
+ }
+ ip := net.ParseIP(address.Address)
+ if ip == nil || !isRoutableGuestIP(ip) {
+ continue
+ }
+ if ipv4 == "" && ip.To4() != nil && strings.EqualFold(address.Family, "inet") {
+ ipv4 = address.Address
+ continue
+ }
+ if ipv6 == "" && ip.To4() == nil && strings.EqualFold(address.Family, "inet6") {
+ ipv6 = address.Address
+ }
+ }
+ }
+
+ return ipv4, ipv6
+}
+
+func isRoutableGuestIP(ip net.IP) bool {
+ return !ip.IsLoopback() &&
+ !ip.IsUnspecified() &&
+ !ip.IsLinkLocalUnicast() &&
+ !ip.IsLinkLocalMulticast() &&
+ !ip.IsMulticast()
+}
+
+func truncateReportError(err error) string {
+ if err == nil {
+ return ""
+ }
+ message := err.Error()
+ if len(message) > 200 {
+ return message[:200]
+ }
+ return message
+}
diff --git a/agent/internal/report/report.go b/agent/internal/report/report.go
new file mode 100644
index 0000000..d99d735
--- /dev/null
+++ b/agent/internal/report/report.go
@@ -0,0 +1,298 @@
+package report
+
+import (
+ "os"
+ "runtime"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+var incusSocketCandidates = []string{
+ "/var/lib/incus/unix.socket",
+ "/var/snap/incus/common/lxd/unix.socket",
+ "/var/lib/lxd/unix.socket",
+}
+
+func HeartbeatPayload(version string, heartbeatIntervalSeconds int) map[string]any {
+ return map[string]any{
+ "version": version,
+ "capabilities": []any{"heartbeat", "report", "host-metrics", "instance-status", "traffic-counters"},
+ "runtime": map[string]any{
+ "goos": runtime.GOOS,
+ "goarch": runtime.GOARCH,
+ },
+ "incus": detectIncus(),
+ "instances": collectIncusInstanceReport(),
+ "resources": collectResources(),
+ "metrics": collectMetrics(heartbeatIntervalSeconds),
+ }
+}
+
+func collectResources() map[string]any {
+ resources := map[string]any{
+ "cpuTotal": runtime.NumCPU(),
+ }
+ if cpuUsagePercent := readCPUUsagePercent(); cpuUsagePercent >= 0 {
+ resources["cpuUsagePercent"] = cpuUsagePercent
+ }
+ for key, value := range readMemoryStats() {
+ resources[key] = value
+ }
+ for key, value := range readDiskStats("/") {
+ resources[key] = value
+ }
+ if processCount := readProcessCount(); processCount >= 0 {
+ resources["processCount"] = processCount
+ }
+ return resources
+}
+
+func collectMetrics(heartbeatIntervalSeconds int) map[string]any {
+ switch {
+ case heartbeatIntervalSeconds <= 0:
+ heartbeatIntervalSeconds = 30
+ case heartbeatIntervalSeconds < 5:
+ heartbeatIntervalSeconds = 5
+ case heartbeatIntervalSeconds > 3600:
+ heartbeatIntervalSeconds = 3600
+ }
+
+ metrics := map[string]any{
+ "reportedAt": time.Now().UTC().Format(time.RFC3339),
+ "heartbeatIntervalSeconds": heartbeatIntervalSeconds,
+ }
+ if uptimeSeconds := readUptimeSeconds(); uptimeSeconds > 0 {
+ metrics["uptimeSeconds"] = uptimeSeconds
+ }
+ for key, value := range readLoadAverage() {
+ metrics[key] = value
+ }
+ return metrics
+}
+
+func detectIncus() map[string]any {
+ socketPath, ok := detectIncusSocket()
+ if ok {
+ return map[string]any{
+ "available": true,
+ "socket": socketPath,
+ }
+ }
+ return map[string]any{
+ "available": false,
+ "socket": "",
+ }
+}
+
+func detectIncusSocket() (string, bool) {
+ for _, socketPath := range incusSocketCandidates {
+ if info, err := os.Stat(socketPath); err == nil && !info.IsDir() {
+ return socketPath, true
+ }
+ }
+ return "", false
+}
+
+type cpuStat struct {
+ idle uint64
+ total uint64
+}
+
+func readCPUUsagePercent() float64 {
+ before, ok := readCPUStat()
+ if !ok {
+ return -1
+ }
+ time.Sleep(200 * time.Millisecond)
+ after, ok := readCPUStat()
+ if !ok {
+ return -1
+ }
+
+ totalDelta := after.total - before.total
+ idleDelta := after.idle - before.idle
+ if totalDelta == 0 || idleDelta > totalDelta {
+ return -1
+ }
+
+ return roundPercent(float64(totalDelta-idleDelta) / float64(totalDelta) * 100)
+}
+
+func readCPUStat() (cpuStat, bool) {
+ content, err := os.ReadFile("/proc/stat")
+ if err != nil {
+ return cpuStat{}, false
+ }
+ for _, line := range strings.Split(string(content), "\n") {
+ if !strings.HasPrefix(line, "cpu ") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 5 {
+ return cpuStat{}, false
+ }
+
+ var values []uint64
+ for _, field := range fields[1:] {
+ value, err := strconv.ParseUint(field, 10, 64)
+ if err != nil {
+ return cpuStat{}, false
+ }
+ values = append(values, value)
+ }
+
+ var total uint64
+ for _, value := range values {
+ total += value
+ }
+ idle := values[3]
+ if len(values) > 4 {
+ idle += values[4]
+ }
+ return cpuStat{idle: idle, total: total}, true
+ }
+ return cpuStat{}, false
+}
+
+func readMemoryStats() map[string]any {
+ meminfo := readMeminfoKB()
+ stats := map[string]any{}
+ memTotal := meminfo["MemTotal"]
+ memAvailable := meminfo["MemAvailable"]
+ if memTotal > 0 {
+ memUsed := memTotal - memAvailable
+ if memUsed < 0 {
+ memUsed = 0
+ }
+ stats["memoryTotalMb"] = memTotal / 1024
+ stats["memoryAvailableMb"] = memAvailable / 1024
+ stats["memoryUsedMb"] = memUsed / 1024
+ stats["memoryUsagePercent"] = roundPercent(float64(memUsed) / float64(memTotal) * 100)
+ }
+
+ swapTotal := meminfo["SwapTotal"]
+ swapFree := meminfo["SwapFree"]
+ if swapTotal > 0 {
+ swapUsed := swapTotal - swapFree
+ if swapUsed < 0 {
+ swapUsed = 0
+ }
+ stats["swapTotalMb"] = swapTotal / 1024
+ stats["swapUsedMb"] = swapUsed / 1024
+ stats["swapUsagePercent"] = roundPercent(float64(swapUsed) / float64(swapTotal) * 100)
+ } else {
+ stats["swapTotalMb"] = int64(0)
+ stats["swapUsedMb"] = int64(0)
+ stats["swapUsagePercent"] = float64(0)
+ }
+
+ return stats
+}
+
+func readMeminfoKB() map[string]int64 {
+ content, err := os.ReadFile("/proc/meminfo")
+ if err != nil {
+ return map[string]int64{}
+ }
+
+ values := map[string]int64{}
+ for _, line := range strings.Split(string(content), "\n") {
+ key, rest, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ fields := strings.Fields(rest)
+ if len(fields) == 0 {
+ continue
+ }
+ kb, err := strconv.ParseInt(fields[0], 10, 64)
+ if err != nil {
+ continue
+ }
+ values[key] = kb
+ }
+ return values
+}
+
+func readDiskStats(path string) map[string]any {
+ var stat syscall.Statfs_t
+ if err := syscall.Statfs(path, &stat); err != nil {
+ return map[string]any{}
+ }
+
+ blockSize := uint64(stat.Bsize)
+ total := stat.Blocks * blockSize
+ free := stat.Bfree * blockSize
+ if total == 0 || free > total {
+ return map[string]any{}
+ }
+
+ used := total - free
+ return map[string]any{
+ "diskMountpoint": path,
+ "diskTotalBytes": total,
+ "diskUsedBytes": used,
+ "diskAvailableBytes": stat.Bavail * blockSize,
+ "diskUsagePercent": roundPercent(float64(used) / float64(total) * 100),
+ }
+}
+
+func readLoadAverage() map[string]any {
+ content, err := os.ReadFile("/proc/loadavg")
+ if err != nil {
+ return map[string]any{}
+ }
+ fields := strings.Fields(string(content))
+ if len(fields) < 3 {
+ return map[string]any{}
+ }
+
+ loads := map[string]any{}
+ keys := []string{"load1", "load5", "load15"}
+ for index, key := range keys {
+ value, err := strconv.ParseFloat(fields[index], 64)
+ if err == nil {
+ loads[key] = value
+ }
+ }
+ return loads
+}
+
+func readProcessCount() int {
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return -1
+ }
+ count := 0
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ if _, err := strconv.Atoi(entry.Name()); err == nil {
+ count++
+ }
+ }
+ return count
+}
+
+func readUptimeSeconds() int64 {
+ content, err := os.ReadFile("/proc/uptime")
+ if err != nil {
+ return 0
+ }
+ fields := strings.Fields(string(content))
+ if len(fields) == 0 {
+ return 0
+ }
+ value, err := strconv.ParseFloat(fields[0], 64)
+ if err != nil {
+ return 0
+ }
+ return int64(value)
+}
+
+func roundPercent(value float64) float64 {
+ return float64(int(value*10+0.5)) / 10
+}
diff --git a/agent/internal/report/report_test.go b/agent/internal/report/report_test.go
new file mode 100644
index 0000000..ce02d2e
--- /dev/null
+++ b/agent/internal/report/report_test.go
@@ -0,0 +1,166 @@
+package report
+
+import "testing"
+
+func TestHeartbeatPayloadIncludesHostMetrics(t *testing.T) {
+ payload := HeartbeatPayload("test-version", 30)
+
+ capabilities, ok := payload["capabilities"].([]any)
+ if !ok {
+ t.Fatalf("capabilities missing or invalid: %#v", payload["capabilities"])
+ }
+ if !containsCapability(capabilities, "host-metrics") {
+ t.Fatalf("host-metrics capability missing: %#v", capabilities)
+ }
+ if !containsCapability(capabilities, "instance-status") {
+ t.Fatalf("instance-status capability missing: %#v", capabilities)
+ }
+ if !containsCapability(capabilities, "traffic-counters") {
+ t.Fatalf("traffic-counters capability missing: %#v", capabilities)
+ }
+
+ instances, ok := payload["instances"].(map[string]any)
+ if !ok {
+ t.Fatalf("instances report missing or invalid: %#v", payload["instances"])
+ }
+ for _, key := range []string{"available", "reportedAt", "total", "items"} {
+ if _, ok := instances[key]; !ok {
+ t.Fatalf("instances key %s missing: %#v", key, instances)
+ }
+ }
+
+ runtimeInfo, ok := payload["runtime"].(map[string]any)
+ if !ok {
+ t.Fatalf("runtime missing or invalid: %#v", payload["runtime"])
+ }
+ for _, key := range []string{"goos", "goarch"} {
+ if _, ok := runtimeInfo[key]; !ok {
+ t.Fatalf("runtime key %s missing: %#v", key, runtimeInfo)
+ }
+ }
+
+ resources, ok := payload["resources"].(map[string]any)
+ if !ok {
+ t.Fatalf("resources missing or invalid: %#v", payload["resources"])
+ }
+ for _, key := range []string{
+ "cpuTotal",
+ "cpuUsagePercent",
+ "memoryTotalMb",
+ "memoryUsedMb",
+ "memoryUsagePercent",
+ "swapTotalMb",
+ "swapUsedMb",
+ "swapUsagePercent",
+ "diskTotalBytes",
+ "diskUsedBytes",
+ "diskUsagePercent",
+ "processCount",
+ } {
+ if _, ok := resources[key]; !ok {
+ t.Fatalf("resource key %s missing: %#v", key, resources)
+ }
+ }
+
+ metrics, ok := payload["metrics"].(map[string]any)
+ if !ok {
+ t.Fatalf("metrics missing or invalid: %#v", payload["metrics"])
+ }
+ for _, key := range []string{"reportedAt", "heartbeatIntervalSeconds", "uptimeSeconds", "load1", "load5", "load15"} {
+ if _, ok := metrics[key]; !ok {
+ t.Fatalf("metric key %s missing: %#v", key, metrics)
+ }
+ }
+ if metrics["heartbeatIntervalSeconds"] != 30 {
+ t.Fatalf("heartbeat interval mismatch: %#v", metrics["heartbeatIntervalSeconds"])
+ }
+}
+
+func TestHeartbeatPayloadClampsHeartbeatInterval(t *testing.T) {
+ tests := []struct {
+ name string
+ input int
+ expected int
+ }{
+ {name: "zero falls back", input: 0, expected: 30},
+ {name: "too low clamps to min", input: 1, expected: 5},
+ {name: "too high clamps to max", input: 7200, expected: 3600},
+ {name: "valid stays unchanged", input: 60, expected: 60},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ payload := HeartbeatPayload("test-version", tt.input)
+ metrics, ok := payload["metrics"].(map[string]any)
+ if !ok {
+ t.Fatalf("metrics missing or invalid: %#v", payload["metrics"])
+ }
+ if metrics["heartbeatIntervalSeconds"] != tt.expected {
+ t.Fatalf("heartbeat interval mismatch: got=%#v want=%d", metrics["heartbeatIntervalSeconds"], tt.expected)
+ }
+ })
+ }
+}
+
+func TestTrafficCountersFromIncusStateUsesBillableInterfaces(t *testing.T) {
+ state := incusInstanceState{
+ Network: map[string]incusNetworkDevice{
+ "lo": {
+ Counters: incusNetworkCounters{
+ BytesReceived: "999",
+ BytesSent: "999",
+ },
+ },
+ "eth0": {
+ Counters: incusNetworkCounters{
+ BytesReceived: "100",
+ BytesSent: "200",
+ },
+ },
+ "docker0": {
+ Counters: incusNetworkCounters{
+ BytesReceived: "300",
+ BytesSent: "400",
+ },
+ },
+ },
+ }
+
+ counters := getTrafficCountersFromIncusState("vm-test", state)
+ if counters.rx != 100 || counters.tx != 200 {
+ t.Fatalf("traffic counters mismatch: got=%+v", counters)
+ }
+}
+
+func TestTrafficCountersFromIncusStateFallsBackToExternalInterfaces(t *testing.T) {
+ state := incusInstanceState{
+ Network: map[string]incusNetworkDevice{
+ "lo": {
+ Counters: incusNetworkCounters{
+ BytesReceived: "999",
+ BytesSent: "999",
+ },
+ },
+ "enp5s0": {
+ Counters: incusNetworkCounters{
+ BytesReceived: "123",
+ BytesSent: "456",
+ },
+ },
+ },
+ }
+
+ counters := getTrafficCountersFromIncusState("vm-test", state)
+ if counters.rx != 123 || counters.tx != 456 {
+ t.Fatalf("traffic counters mismatch: got=%+v", counters)
+ }
+}
+
+func containsCapability(capabilities []any, expected string) bool {
+ for _, capability := range capabilities {
+ if capability == expected {
+ return true
+ }
+ }
+ return false
+}
diff --git a/agent/internal/upgrade/upgrade.go b/agent/internal/upgrade/upgrade.go
new file mode 100644
index 0000000..1a091ba
--- /dev/null
+++ b/agent/internal/upgrade/upgrade.go
@@ -0,0 +1,400 @@
+package upgrade
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "incudal-agent/internal/config"
+ "incudal-agent/internal/panel"
+)
+
+const (
+ defaultServiceName = "incudal-agent"
+ defaultMaxDownloadBytes = 64 * 1024 * 1024
+)
+
+var ErrUpgradeInProgress = errors.New("agent upgrade already in progress")
+var systemdServiceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@-]+$`)
+
+type RestartFunc func(ctx context.Context, serviceName string) error
+
+type Runner struct {
+ BinaryPath string
+ BackupPath string
+ LockPath string
+ ServiceName string
+ AllowedBaseURL string
+ HTTPClient *http.Client
+ Restart RestartFunc
+ MaxDownloadBytes int64
+}
+
+func DefaultRunner(cfg config.Config) *Runner {
+ binaryPath, err := os.Executable()
+ if err != nil || binaryPath == "" {
+ binaryPath = "/usr/local/bin/incudal-agent"
+ }
+
+ return &Runner{
+ BinaryPath: binaryPath,
+ BackupPath: binaryPath + ".bak",
+ LockPath: defaultLockPath(),
+ ServiceName: defaultServiceName,
+ AllowedBaseURL: cfg.PanelURL,
+ HTTPClient: &http.Client{Timeout: cfg.RequestTimeout},
+ Restart: restartSystemdService,
+ MaxDownloadBytes: defaultMaxDownloadBytes,
+ }
+}
+
+func RandomJitter(max time.Duration) time.Duration {
+ if max <= 0 {
+ return 0
+ }
+ limit := big.NewInt(int64(max))
+ value, err := rand.Int(rand.Reader, limit)
+ if err != nil {
+ return 0
+ }
+ return time.Duration(value.Int64())
+}
+
+func (runner *Runner) Apply(ctx context.Context, instruction panel.UpgradeInstruction, currentVersion string) error {
+ if !instruction.Available {
+ return nil
+ }
+ if instruction.Version == "" {
+ return errors.New("upgrade version is required")
+ }
+ if instruction.Version == currentVersion {
+ return nil
+ }
+ if instruction.URL == "" {
+ return errors.New("upgrade URL is required")
+ }
+ if instruction.SHA256 == "" {
+ return errors.New("upgrade sha256 is required")
+ }
+ if err := runner.validateUpgradeURL(instruction.URL); err != nil {
+ return err
+ }
+
+ unlock, err := acquireLock(runner.lockPath())
+ if err != nil {
+ return err
+ }
+ defer unlock()
+
+ packageBytes, err := runner.download(ctx, instruction.URL)
+ if err != nil {
+ return err
+ }
+ if err := verifySHA256(packageBytes, instruction.SHA256); err != nil {
+ return err
+ }
+
+ binaryBytes := packageBytes
+ if instruction.Gzip {
+ binaryBytes, err = gunzip(packageBytes)
+ if err != nil {
+ return err
+ }
+ }
+
+ tempPath, err := runner.writeTempBinary(binaryBytes)
+ if err != nil {
+ return err
+ }
+ if err := runner.replaceBinary(tempPath); err != nil {
+ _ = os.Remove(tempPath)
+ return err
+ }
+
+ if err := runner.restart(ctx); err != nil {
+ return fmt.Errorf("restart agent after upgrade: %w", err)
+ }
+ return nil
+}
+
+func (runner *Runner) validateUpgradeURL(rawURL string) error {
+ upgradeURL, err := url.Parse(rawURL)
+ if err != nil || upgradeURL.Scheme == "" || upgradeURL.Host == "" {
+ return fmt.Errorf("upgrade URL is invalid: %s", rawURL)
+ }
+ if upgradeURL.Scheme != "http" && upgradeURL.Scheme != "https" {
+ return fmt.Errorf("upgrade URL scheme is not allowed: %s", upgradeURL.Scheme)
+ }
+
+ baseURL, err := url.Parse(strings.TrimRight(runner.AllowedBaseURL, "/"))
+ if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
+ return fmt.Errorf("panel URL is invalid: %s", runner.AllowedBaseURL)
+ }
+ if !strings.EqualFold(upgradeURL.Scheme, baseURL.Scheme) || !strings.EqualFold(upgradeURL.Host, baseURL.Host) {
+ return errors.New("upgrade URL is outside panel origin")
+ }
+ return nil
+}
+
+func (runner *Runner) download(ctx context.Context, rawURL string) ([]byte, error) {
+ client := runner.HTTPClient
+ if client == nil {
+ client = &http.Client{Timeout: 30 * time.Second}
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
+ return nil, fmt.Errorf("download upgrade failed: status=%d body=%s", response.StatusCode, string(body))
+ }
+
+ limit := runner.MaxDownloadBytes
+ if limit <= 0 {
+ limit = defaultMaxDownloadBytes
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(body)) > limit {
+ return nil, fmt.Errorf("upgrade package exceeds %d bytes", limit)
+ }
+ return body, nil
+}
+
+func (runner *Runner) writeTempBinary(binaryBytes []byte) (string, error) {
+ binaryPath := runner.binaryPath()
+ tempFile, err := os.CreateTemp(filepath.Dir(binaryPath), ".incudal-agent-upgrade-*")
+ if err != nil {
+ return "", err
+ }
+ tempPath := tempFile.Name()
+ defer tempFile.Close()
+
+ if _, err := tempFile.Write(binaryBytes); err != nil {
+ _ = os.Remove(tempPath)
+ return "", err
+ }
+ if err := tempFile.Chmod(0755); err != nil {
+ _ = os.Remove(tempPath)
+ return "", err
+ }
+ return tempPath, nil
+}
+
+func (runner *Runner) replaceBinary(tempPath string) error {
+ binaryPath := runner.binaryPath()
+ backupPath := runner.backupPath()
+
+ if _, err := os.Stat(binaryPath); err == nil {
+ _ = os.Remove(backupPath)
+ if err := copyFile(binaryPath, backupPath); err != nil {
+ return fmt.Errorf("backup current agent: %w", err)
+ }
+ }
+
+ if err := os.Rename(tempPath, binaryPath); err != nil {
+ return fmt.Errorf("replace agent binary: %w", err)
+ }
+ return nil
+}
+
+func (runner *Runner) rollback() error {
+ backupPath := runner.backupPath()
+ if _, err := os.Stat(backupPath); err != nil {
+ return err
+ }
+ return os.Rename(backupPath, runner.binaryPath())
+}
+
+func (runner *Runner) restart(ctx context.Context) error {
+ if runner.Restart == nil {
+ return nil
+ }
+ serviceName := runner.ServiceName
+ if serviceName == "" {
+ serviceName = defaultServiceName
+ }
+ return runner.Restart(ctx, serviceName)
+}
+
+func (runner *Runner) binaryPath() string {
+ if runner.BinaryPath != "" {
+ return runner.BinaryPath
+ }
+ return "/usr/local/bin/incudal-agent"
+}
+
+func (runner *Runner) backupPath() string {
+ if runner.BackupPath != "" {
+ return runner.BackupPath
+ }
+ return runner.binaryPath() + ".bak"
+}
+
+func (runner *Runner) lockPath() string {
+ if runner.LockPath != "" {
+ return runner.LockPath
+ }
+ return defaultLockPath()
+}
+
+func defaultLockPath() string {
+ if info, err := os.Stat("/run"); err == nil && info.IsDir() {
+ return "/run/incudal-agent-upgrade.lock"
+ }
+ return filepath.Join(os.TempDir(), "incudal-agent-upgrade.lock")
+}
+
+func acquireLock(lockPath string) (func(), error) {
+ file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ if errors.Is(err, os.ErrExist) {
+ return nil, ErrUpgradeInProgress
+ }
+ return nil, err
+ }
+ _, _ = fmt.Fprintf(file, "%d\n", os.Getpid())
+ _ = file.Close()
+
+ return func() {
+ _ = os.Remove(lockPath)
+ }, nil
+}
+
+func verifySHA256(payload []byte, expected string) error {
+ sum := sha256.Sum256(payload)
+ actual := hex.EncodeToString(sum[:])
+ if !strings.EqualFold(actual, expected) {
+ return fmt.Errorf("upgrade sha256 mismatch: expected=%s actual=%s", expected, actual)
+ }
+ return nil
+}
+
+func gunzip(payload []byte) ([]byte, error) {
+ reader, err := gzip.NewReader(bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ return io.ReadAll(reader)
+}
+
+func copyFile(source string, target string) error {
+ sourceFile, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+
+ info, err := sourceFile.Stat()
+ if err != nil {
+ return err
+ }
+ mode := info.Mode().Perm()
+ if mode == 0 {
+ mode = 0755
+ }
+
+ targetFile, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
+ if err != nil {
+ return err
+ }
+ defer targetFile.Close()
+
+ if _, err := io.Copy(targetFile, sourceFile); err != nil {
+ return err
+ }
+ return targetFile.Chmod(mode)
+}
+
+func restartSystemdService(ctx context.Context, serviceName string) error {
+ if !systemdServiceNamePattern.MatchString(serviceName) {
+ return fmt.Errorf("invalid systemd service name: %s", serviceName)
+ }
+
+ if err := scheduleSystemdRestart(ctx, serviceName); err == nil {
+ return nil
+ }
+
+ systemctlPath, err := exec.LookPath("systemctl")
+ if err != nil {
+ return err
+ }
+
+ // 不等待 systemctl 完成。Agent 正在重启自身,等待子进程会在服务停止时
+ // 收到 SIGTERM,旧逻辑会误判失败并回滚已替换的新二进制。
+ command := exec.CommandContext(ctx, systemctlPath, "restart", serviceName)
+ return command.Start()
+}
+
+func scheduleSystemdRestart(ctx context.Context, serviceName string) error {
+ systemdRunPath, err := exec.LookPath("systemd-run")
+ if err != nil {
+ return err
+ }
+ systemctlPath, err := exec.LookPath("systemctl")
+ if err != nil {
+ return err
+ }
+
+ unitName := fmt.Sprintf("incudal-agent-restart-%d", os.Getpid())
+ args := []string{
+ "--unit", unitName,
+ "--description", "Restart Incudal Agent after self-upgrade",
+ "--on-active=2s",
+ "--collect",
+ systemctlPath, "restart", serviceName,
+ }
+
+ command := exec.CommandContext(ctx, systemdRunPath, args...)
+ output, err := command.CombinedOutput()
+ if err == nil {
+ return nil
+ }
+
+ // 老版本 systemd 可能不支持 --collect,降级重试一次。
+ if strings.Contains(string(output), "unrecognized option '--collect'") ||
+ strings.Contains(string(output), "Unknown option --collect") {
+ args = []string{
+ "--unit", unitName,
+ "--description", "Restart Incudal Agent after self-upgrade",
+ "--on-active=2s",
+ systemctlPath, "restart", serviceName,
+ }
+ command = exec.CommandContext(ctx, systemdRunPath, args...)
+ output, err = command.CombinedOutput()
+ }
+ if err != nil {
+ trimmedOutput := strings.TrimSpace(string(output))
+ if trimmedOutput == "" {
+ return err
+ }
+ return fmt.Errorf("%w: %s", err, trimmedOutput)
+ }
+ return nil
+}
diff --git a/agent/internal/upgrade/upgrade_test.go b/agent/internal/upgrade/upgrade_test.go
new file mode 100644
index 0000000..f889111
--- /dev/null
+++ b/agent/internal/upgrade/upgrade_test.go
@@ -0,0 +1,230 @@
+package upgrade
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "incudal-agent/internal/panel"
+)
+
+func TestApplyUpgradeReplacesBinaryAndRestarts(t *testing.T) {
+ tempDir := t.TempDir()
+ binaryPath := filepath.Join(tempDir, "incudal-agent")
+ if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil {
+ t.Fatalf("write current binary: %v", err)
+ }
+
+ nextBinary := []byte("new-binary")
+ packageBytes := gzipBytes(t, nextBinary)
+ sha := sha256Hex(packageBytes)
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ response.WriteHeader(http.StatusOK)
+ _, _ = response.Write(packageBytes)
+ }))
+ defer server.Close()
+
+ restarted := false
+ runner := Runner{
+ BinaryPath: binaryPath,
+ BackupPath: binaryPath + ".bak",
+ LockPath: filepath.Join(tempDir, "upgrade.lock"),
+ ServiceName: "incudal-agent",
+ AllowedBaseURL: server.URL,
+ HTTPClient: server.Client(),
+ MaxDownloadBytes: 1024 * 1024,
+ Restart: func(_ context.Context, serviceName string) error {
+ if serviceName != "incudal-agent" {
+ t.Fatalf("unexpected service name: %s", serviceName)
+ }
+ restarted = true
+ return nil
+ },
+ }
+
+ err := runner.Apply(context.Background(), panel.UpgradeInstruction{
+ Available: true,
+ Version: "v2",
+ URL: server.URL + "/incudal-agent-linux-amd64.gz",
+ SHA256: sha,
+ Gzip: true,
+ }, "v1")
+ if err != nil {
+ t.Fatalf("apply upgrade: %v", err)
+ }
+ if !restarted {
+ t.Fatalf("restart was not called")
+ }
+
+ actual, err := os.ReadFile(binaryPath)
+ if err != nil {
+ t.Fatalf("read replaced binary: %v", err)
+ }
+ if string(actual) != string(nextBinary) {
+ t.Fatalf("binary mismatch: %q", string(actual))
+ }
+
+ backup, err := os.ReadFile(binaryPath + ".bak")
+ if err != nil {
+ t.Fatalf("read backup binary: %v", err)
+ }
+ if string(backup) != "old-binary" {
+ t.Fatalf("backup mismatch: %q", string(backup))
+ }
+}
+
+func TestApplyUpgradeRejectsBadSHA(t *testing.T) {
+ tempDir := t.TempDir()
+ binaryPath := filepath.Join(tempDir, "incudal-agent")
+ if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil {
+ t.Fatalf("write current binary: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ response.WriteHeader(http.StatusOK)
+ _, _ = response.Write([]byte("payload"))
+ }))
+ defer server.Close()
+
+ restarted := false
+ runner := Runner{
+ BinaryPath: binaryPath,
+ BackupPath: binaryPath + ".bak",
+ LockPath: filepath.Join(tempDir, "upgrade.lock"),
+ AllowedBaseURL: server.URL,
+ HTTPClient: server.Client(),
+ MaxDownloadBytes: 1024 * 1024,
+ Restart: func(context.Context, string) error {
+ restarted = true
+ return nil
+ },
+ }
+
+ err := runner.Apply(context.Background(), panel.UpgradeInstruction{
+ Available: true,
+ Version: "v2",
+ URL: server.URL + "/incudal-agent-linux-amd64.gz",
+ SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
+ Gzip: true,
+ }, "v1")
+ if err == nil {
+ t.Fatalf("expected sha mismatch")
+ }
+ if restarted {
+ t.Fatalf("restart should not be called")
+ }
+
+ current, err := os.ReadFile(binaryPath)
+ if err != nil {
+ t.Fatalf("read current binary: %v", err)
+ }
+ if string(current) != "old-binary" {
+ t.Fatalf("current binary should stay unchanged: %q", string(current))
+ }
+}
+
+func TestApplyUpgradeDoesNotRollbackWhenSelfRestartIsInterrupted(t *testing.T) {
+ tempDir := t.TempDir()
+ binaryPath := filepath.Join(tempDir, "incudal-agent")
+ if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil {
+ t.Fatalf("write current binary: %v", err)
+ }
+
+ nextBinary := []byte("new-binary")
+ packageBytes := gzipBytes(t, nextBinary)
+ sha := sha256Hex(packageBytes)
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ response.WriteHeader(http.StatusOK)
+ _, _ = response.Write(packageBytes)
+ }))
+ defer server.Close()
+
+ runner := Runner{
+ BinaryPath: binaryPath,
+ BackupPath: binaryPath + ".bak",
+ LockPath: filepath.Join(tempDir, "upgrade.lock"),
+ ServiceName: "incudal-agent",
+ AllowedBaseURL: server.URL,
+ HTTPClient: server.Client(),
+ MaxDownloadBytes: 1024 * 1024,
+ Restart: func(context.Context, string) error {
+ return errors.New("signal: terminated")
+ },
+ }
+
+ err := runner.Apply(context.Background(), panel.UpgradeInstruction{
+ Available: true,
+ Version: "v2",
+ URL: server.URL + "/incudal-agent-linux-amd64.gz",
+ SHA256: sha,
+ Gzip: true,
+ }, "v1")
+ if err == nil {
+ t.Fatalf("expected restart error")
+ }
+
+ actual, err := os.ReadFile(binaryPath)
+ if err != nil {
+ t.Fatalf("read replaced binary: %v", err)
+ }
+ if string(actual) != string(nextBinary) {
+ t.Fatalf("binary should stay replaced after restart interruption: %q", string(actual))
+ }
+}
+
+func TestApplyUpgradeRejectsDifferentOrigin(t *testing.T) {
+ tempDir := t.TempDir()
+ binaryPath := filepath.Join(tempDir, "incudal-agent")
+ if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil {
+ t.Fatalf("write current binary: %v", err)
+ }
+
+ runner := Runner{
+ BinaryPath: binaryPath,
+ BackupPath: binaryPath + ".bak",
+ LockPath: filepath.Join(tempDir, "upgrade.lock"),
+ AllowedBaseURL: "https://panel.example",
+ Restart: func(context.Context, string) error {
+ t.Fatalf("restart should not be called")
+ return nil
+ },
+ }
+
+ err := runner.Apply(context.Background(), panel.UpgradeInstruction{
+ Available: true,
+ Version: "v2",
+ URL: "https://evil.example/incudal-agent-linux-amd64.gz",
+ SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
+ Gzip: true,
+ }, "v1")
+ if err == nil {
+ t.Fatalf("expected origin validation error")
+ }
+}
+
+func gzipBytes(t *testing.T, payload []byte) []byte {
+ t.Helper()
+
+ var buffer bytes.Buffer
+ writer := gzip.NewWriter(&buffer)
+ if _, err := writer.Write(payload); err != nil {
+ t.Fatalf("gzip write: %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("gzip close: %v", err)
+ }
+ return buffer.Bytes()
+}
+
+func sha256Hex(payload []byte) string {
+ sum := sha256.Sum256(payload)
+ return hex.EncodeToString(sum[:])
+}
diff --git a/agent/scripts/build-release.sh b/agent/scripts/build-release.sh
new file mode 100644
index 0000000..36f4dc5
--- /dev/null
+++ b/agent/scripts/build-release.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DIST_DIR="${DIST_DIR:-${ROOT_DIR}/dist}"
+VERSION_FILE="${VERSION_FILE:-${ROOT_DIR}/VERSION}"
+VERSION_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
+
+default_version() {
+ if [ ! -f "${VERSION_FILE}" ]; then
+ echo "Agent version file not found: ${VERSION_FILE}" >&2
+ exit 1
+ fi
+
+ tr -d '[:space:]' < "${VERSION_FILE}"
+}
+
+VERSION="${VERSION:-$(default_version)}"
+if [[ ! "${VERSION}" =~ ${VERSION_PATTERN} ]]; then
+ echo "Invalid Agent version: ${VERSION}" >&2
+ echo "Expected format: vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-suffix" >&2
+ exit 1
+fi
+
+mkdir -p "${DIST_DIR}"
+
+build_one() {
+ local goarch="$1"
+ local output="${DIST_DIR}/incudal-agent-linux-${goarch}"
+
+ echo "Building ${output} (version=${VERSION})"
+ (
+ cd "${ROOT_DIR}"
+ CGO_ENABLED=0 GOOS=linux GOARCH="${goarch}" \
+ go build \
+ -trimpath \
+ -buildvcs=false \
+ -gcflags "all=-l" \
+ -ldflags "-s -w -X main.version=${VERSION}" \
+ -o "${output}" \
+ ./cmd/incudal-agent
+ )
+ chmod +x "${output}"
+ gzip -9 -c "${output}" > "${output}.gz"
+}
+
+build_one amd64
+build_one arm64
+
+sha256_file() {
+ sha256sum "$1" | awk '{print $1}'
+}
+
+size_file() {
+ wc -c < "$1" | tr -d ' '
+}
+
+GENERATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+AMD64_GZ="${DIST_DIR}/incudal-agent-linux-amd64.gz"
+ARM64_GZ="${DIST_DIR}/incudal-agent-linux-arm64.gz"
+cat > "${DIST_DIR}/manifest.json" <
+ }
+ },
+ plugins: {
+ '@typescript-eslint': tseslint
+ },
+ rules: {
+ // Vue 特定规则
+ 'vue/multi-word-component-names': 'off',
+ 'vue/no-v-html': 'off', // 项目中使用了 markdown 渲染,需要 v-html
+ 'vue/require-default-prop': 'off',
+ 'vue/require-explicit-emits': 'warn',
+ 'vue/html-self-closing': 'off',
+ 'vue/max-attributes-per-line': 'off',
+ 'vue/singleline-html-element-content-newline': 'off',
+
+ // TypeScript 规则(放宽限制)
+ '@typescript-eslint/no-unused-vars': ['warn', {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_'
+ }],
+ '@typescript-eslint/no-explicit-any': 'off', // 允许使用 any(API 响应等场景需要)
+
+ // 通用规则
+ 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
+ 'no-undef': 'off', // TypeScript 会处理
+ 'no-unused-vars': 'off', // 使用 TypeScript 版本
+ 'no-useless-escape': 'error' // 保留这个错误检查
+ }
+ },
+
+ // JavaScript 文件配置
+ {
+ files: ['**/*.{js,jsx}'],
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module'
+ },
+ rules: {
+ 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn'
+ }
+ }
+]
+
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..fca0a8d
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 基于 Incus 的低价 NAT VPS 平台
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..186826d
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "client",
+ "version": "1.0.0",
+ "description": "Incudal 前端客户端",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint src --fix",
+ "type-check": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@vueuse/core": "^12.0.0",
+ "@xterm/addon-clipboard": "^0.2.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-image": "^0.9.0",
+ "@xterm/addon-search": "^0.16.0",
+ "@xterm/addon-serialize": "^0.14.0",
+ "@xterm/addon-unicode11": "^0.9.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/addon-webgl": "^0.19.0",
+ "@xterm/xterm": "^6.0.0",
+ "axios": "^1.7.9",
+ "flag-icons": "^7.5.0",
+ "marked": "^17.0.1",
+ "pinia": "^2.2.8",
+ "simple-icons": "^16.1.0",
+ "vue": "^3.5.13",
+ "vue-i18n": "^11.2.2",
+ "vue-router": "^4.5.0",
+ "vue-turnstile": "^1.0.11"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.16.0",
+ "@tailwindcss/forms": "^0.5.9",
+ "@types/node": "^24.10.1",
+ "@typescript-eslint/eslint-plugin": "^8.48.1",
+ "@typescript-eslint/parser": "^8.48.1",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.16.0",
+ "eslint-plugin-vue": "^9.32.0",
+ "javascript-obfuscator": "^5.0.1",
+ "postcss": "^8.4.49",
+ "rollup-plugin-obfuscator": "^1.1.0",
+ "tailwindcss": "^3.4.17",
+ "terser": "^5.44.1",
+ "typescript": "^5.9.3",
+ "vite": "^6.0.3",
+ "vue-eslint-parser": "^9.4.2",
+ "vue-tsc": "^3.1.6"
+ }
+}
diff --git a/client/postcss.config.js b/client/postcss.config.js
new file mode 100644
index 0000000..75af576
--- /dev/null
+++ b/client/postcss.config.js
@@ -0,0 +1,7 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+}
+
diff --git a/client/public/badges/dark/abyss.svg b/client/public/badges/dark/abyss.svg
new file mode 100644
index 0000000..d1b74d7
--- /dev/null
+++ b/client/public/badges/dark/abyss.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/aether.svg b/client/public/badges/dark/aether.svg
new file mode 100644
index 0000000..7d48d84
--- /dev/null
+++ b/client/public/badges/dark/aether.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/apex.svg b/client/public/badges/dark/apex.svg
new file mode 100644
index 0000000..8c589b6
--- /dev/null
+++ b/client/public/badges/dark/apex.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/aquarius.svg b/client/public/badges/dark/aquarius.svg
new file mode 100644
index 0000000..abb30f5
--- /dev/null
+++ b/client/public/badges/dark/aquarius.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/ares.svg b/client/public/badges/dark/ares.svg
new file mode 100644
index 0000000..f5764d0
--- /dev/null
+++ b/client/public/badges/dark/ares.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/aries.svg b/client/public/badges/dark/aries.svg
new file mode 100644
index 0000000..dc633c6
--- /dev/null
+++ b/client/public/badges/dark/aries.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/astral.svg b/client/public/badges/dark/astral.svg
new file mode 100644
index 0000000..e1f1492
--- /dev/null
+++ b/client/public/badges/dark/astral.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/barrage.svg b/client/public/badges/dark/barrage.svg
new file mode 100644
index 0000000..4d439ae
--- /dev/null
+++ b/client/public/badges/dark/barrage.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/barrier.svg b/client/public/badges/dark/barrier.svg
new file mode 100644
index 0000000..35ce681
--- /dev/null
+++ b/client/public/badges/dark/barrier.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/bastion.svg b/client/public/badges/dark/bastion.svg
new file mode 100644
index 0000000..28afc4b
--- /dev/null
+++ b/client/public/badges/dark/bastion.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/blackhole.svg b/client/public/badges/dark/blackhole.svg
new file mode 100644
index 0000000..05f2d91
--- /dev/null
+++ b/client/public/badges/dark/blackhole.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/cancer.svg b/client/public/badges/dark/cancer.svg
new file mode 100644
index 0000000..ccd67d8
--- /dev/null
+++ b/client/public/badges/dark/cancer.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/capricorn.svg b/client/public/badges/dark/capricorn.svg
new file mode 100644
index 0000000..9f10d6c
--- /dev/null
+++ b/client/public/badges/dark/capricorn.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/citadel.svg b/client/public/badges/dark/citadel.svg
new file mode 100644
index 0000000..6411f37
--- /dev/null
+++ b/client/public/badges/dark/citadel.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/core.svg b/client/public/badges/dark/core.svg
new file mode 100644
index 0000000..05d7b6f
--- /dev/null
+++ b/client/public/badges/dark/core.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/cronus.svg b/client/public/badges/dark/cronus.svg
new file mode 100644
index 0000000..e4ba305
--- /dev/null
+++ b/client/public/badges/dark/cronus.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/crosshair.svg b/client/public/badges/dark/crosshair.svg
new file mode 100644
index 0000000..8db88be
--- /dev/null
+++ b/client/public/badges/dark/crosshair.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/defender.svg b/client/public/badges/dark/defender.svg
new file mode 100644
index 0000000..ac48dfc
--- /dev/null
+++ b/client/public/badges/dark/defender.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/dog.svg b/client/public/badges/dark/dog.svg
new file mode 100644
index 0000000..08c1975
--- /dev/null
+++ b/client/public/badges/dark/dog.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/dragon.svg b/client/public/badges/dark/dragon.svg
new file mode 100644
index 0000000..bff6ee8
--- /dev/null
+++ b/client/public/badges/dark/dragon.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/dynamo.svg b/client/public/badges/dark/dynamo.svg
new file mode 100644
index 0000000..9aa9e45
--- /dev/null
+++ b/client/public/badges/dark/dynamo.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/earth.svg b/client/public/badges/dark/earth.svg
new file mode 100644
index 0000000..986d93f
--- /dev/null
+++ b/client/public/badges/dark/earth.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/elite.svg b/client/public/badges/dark/elite.svg
new file mode 100644
index 0000000..62821ff
--- /dev/null
+++ b/client/public/badges/dark/elite.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/enigma.svg b/client/public/badges/dark/enigma.svg
new file mode 100644
index 0000000..75b5725
--- /dev/null
+++ b/client/public/badges/dark/enigma.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/fission.svg b/client/public/badges/dark/fission.svg
new file mode 100644
index 0000000..318bcea
--- /dev/null
+++ b/client/public/badges/dark/fission.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/fortress.svg b/client/public/badges/dark/fortress.svg
new file mode 100644
index 0000000..f8d8d51
--- /dev/null
+++ b/client/public/badges/dark/fortress.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/fusion.svg b/client/public/badges/dark/fusion.svg
new file mode 100644
index 0000000..611dc52
--- /dev/null
+++ b/client/public/badges/dark/fusion.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/galaxy.svg b/client/public/badges/dark/galaxy.svg
new file mode 100644
index 0000000..c4f3b05
--- /dev/null
+++ b/client/public/badges/dark/galaxy.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/gemini.svg b/client/public/badges/dark/gemini.svg
new file mode 100644
index 0000000..df0193b
--- /dev/null
+++ b/client/public/badges/dark/gemini.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/genesis.svg b/client/public/badges/dark/genesis.svg
new file mode 100644
index 0000000..44626be
--- /dev/null
+++ b/client/public/badges/dark/genesis.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/glyph.svg b/client/public/badges/dark/glyph.svg
new file mode 100644
index 0000000..44eed37
--- /dev/null
+++ b/client/public/badges/dark/glyph.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/goat.svg b/client/public/badges/dark/goat.svg
new file mode 100644
index 0000000..952ad8a
--- /dev/null
+++ b/client/public/badges/dark/goat.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/guardian.svg b/client/public/badges/dark/guardian.svg
new file mode 100644
index 0000000..b0a0a81
--- /dev/null
+++ b/client/public/badges/dark/guardian.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/horse.svg b/client/public/badges/dark/horse.svg
new file mode 100644
index 0000000..cfd18da
--- /dev/null
+++ b/client/public/badges/dark/horse.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/ignition.svg b/client/public/badges/dark/ignition.svg
new file mode 100644
index 0000000..572c66f
--- /dev/null
+++ b/client/public/badges/dark/ignition.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/intercept.svg b/client/public/badges/dark/intercept.svg
new file mode 100644
index 0000000..4e384ee
--- /dev/null
+++ b/client/public/badges/dark/intercept.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/jupiter.svg b/client/public/badges/dark/jupiter.svg
new file mode 100644
index 0000000..f4c69bc
--- /dev/null
+++ b/client/public/badges/dark/jupiter.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/leo.svg b/client/public/badges/dark/leo.svg
new file mode 100644
index 0000000..2bd9877
--- /dev/null
+++ b/client/public/badges/dark/leo.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/libra.svg b/client/public/badges/dark/libra.svg
new file mode 100644
index 0000000..f00cc32
--- /dev/null
+++ b/client/public/badges/dark/libra.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/lockon.svg b/client/public/badges/dark/lockon.svg
new file mode 100644
index 0000000..2665868
--- /dev/null
+++ b/client/public/badges/dark/lockon.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/matrix.svg b/client/public/badges/dark/matrix.svg
new file mode 100644
index 0000000..eed6082
--- /dev/null
+++ b/client/public/badges/dark/matrix.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/mercury.svg b/client/public/badges/dark/mercury.svg
new file mode 100644
index 0000000..39ff9f1
--- /dev/null
+++ b/client/public/badges/dark/mercury.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/monkey.svg b/client/public/badges/dark/monkey.svg
new file mode 100644
index 0000000..0faa226
--- /dev/null
+++ b/client/public/badges/dark/monkey.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/moon.svg b/client/public/badges/dark/moon.svg
new file mode 100644
index 0000000..4e0d1a6
--- /dev/null
+++ b/client/public/badges/dark/moon.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/mystic.svg b/client/public/badges/dark/mystic.svg
new file mode 100644
index 0000000..4d67beb
--- /dev/null
+++ b/client/public/badges/dark/mystic.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/nebula.svg b/client/public/badges/dark/nebula.svg
new file mode 100644
index 0000000..382f634
--- /dev/null
+++ b/client/public/badges/dark/nebula.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/neptune.svg b/client/public/badges/dark/neptune.svg
new file mode 100644
index 0000000..d3e5e8f
--- /dev/null
+++ b/client/public/badges/dark/neptune.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/nexus.svg b/client/public/badges/dark/nexus.svg
new file mode 100644
index 0000000..23c4ac3
--- /dev/null
+++ b/client/public/badges/dark/nexus.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/omega.svg b/client/public/badges/dark/omega.svg
new file mode 100644
index 0000000..54a64d2
--- /dev/null
+++ b/client/public/badges/dark/omega.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/oracle.svg b/client/public/badges/dark/oracle.svg
new file mode 100644
index 0000000..7675e81
--- /dev/null
+++ b/client/public/badges/dark/oracle.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/overload.svg b/client/public/badges/dark/overload.svg
new file mode 100644
index 0000000..5fa0f3e
--- /dev/null
+++ b/client/public/badges/dark/overload.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/overwatch.svg b/client/public/badges/dark/overwatch.svg
new file mode 100644
index 0000000..b959dab
--- /dev/null
+++ b/client/public/badges/dark/overwatch.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/ox.svg b/client/public/badges/dark/ox.svg
new file mode 100644
index 0000000..5e7422e
--- /dev/null
+++ b/client/public/badges/dark/ox.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/paladin.svg b/client/public/badges/dark/paladin.svg
new file mode 100644
index 0000000..4b7fe7c
--- /dev/null
+++ b/client/public/badges/dark/paladin.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/paradox.svg b/client/public/badges/dark/paradox.svg
new file mode 100644
index 0000000..823dc10
--- /dev/null
+++ b/client/public/badges/dark/paradox.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/phalanx.svg b/client/public/badges/dark/phalanx.svg
new file mode 100644
index 0000000..0ff6485
--- /dev/null
+++ b/client/public/badges/dark/phalanx.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/pig.svg b/client/public/badges/dark/pig.svg
new file mode 100644
index 0000000..189e268
--- /dev/null
+++ b/client/public/badges/dark/pig.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/pisces.svg b/client/public/badges/dark/pisces.svg
new file mode 100644
index 0000000..fdc8ee9
--- /dev/null
+++ b/client/public/badges/dark/pisces.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/plasma.svg b/client/public/badges/dark/plasma.svg
new file mode 100644
index 0000000..9408ead
--- /dev/null
+++ b/client/public/badges/dark/plasma.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/pluto.svg b/client/public/badges/dark/pluto.svg
new file mode 100644
index 0000000..3765e29
--- /dev/null
+++ b/client/public/badges/dark/pluto.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/pulsar.svg b/client/public/badges/dark/pulsar.svg
new file mode 100644
index 0000000..3fbd696
--- /dev/null
+++ b/client/public/badges/dark/pulsar.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/pulse.svg b/client/public/badges/dark/pulse.svg
new file mode 100644
index 0000000..6a3b3e7
--- /dev/null
+++ b/client/public/badges/dark/pulse.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/quantum.svg b/client/public/badges/dark/quantum.svg
new file mode 100644
index 0000000..d567b27
--- /dev/null
+++ b/client/public/badges/dark/quantum.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/rabbit.svg b/client/public/badges/dark/rabbit.svg
new file mode 100644
index 0000000..3316794
--- /dev/null
+++ b/client/public/badges/dark/rabbit.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/radar.svg b/client/public/badges/dark/radar.svg
new file mode 100644
index 0000000..1d0096a
--- /dev/null
+++ b/client/public/badges/dark/radar.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/radiance.svg b/client/public/badges/dark/radiance.svg
new file mode 100644
index 0000000..1df6f76
--- /dev/null
+++ b/client/public/badges/dark/radiance.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/rat.svg b/client/public/badges/dark/rat.svg
new file mode 100644
index 0000000..2b0581c
--- /dev/null
+++ b/client/public/badges/dark/rat.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/recon.svg b/client/public/badges/dark/recon.svg
new file mode 100644
index 0000000..87a3c96
--- /dev/null
+++ b/client/public/badges/dark/recon.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/rooster.svg b/client/public/badges/dark/rooster.svg
new file mode 100644
index 0000000..64d4d96
--- /dev/null
+++ b/client/public/badges/dark/rooster.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/rune.svg b/client/public/badges/dark/rune.svg
new file mode 100644
index 0000000..12f1bd2
--- /dev/null
+++ b/client/public/badges/dark/rune.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/sagittarius.svg b/client/public/badges/dark/sagittarius.svg
new file mode 100644
index 0000000..6da6ff2
--- /dev/null
+++ b/client/public/badges/dark/sagittarius.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/scorpio.svg b/client/public/badges/dark/scorpio.svg
new file mode 100644
index 0000000..a653484
--- /dev/null
+++ b/client/public/badges/dark/scorpio.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/sentinel.svg b/client/public/badges/dark/sentinel.svg
new file mode 100644
index 0000000..292f4c3
--- /dev/null
+++ b/client/public/badges/dark/sentinel.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/sigil.svg b/client/public/badges/dark/sigil.svg
new file mode 100644
index 0000000..f9fe656
--- /dev/null
+++ b/client/public/badges/dark/sigil.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/snake.svg b/client/public/badges/dark/snake.svg
new file mode 100644
index 0000000..1b21ec3
--- /dev/null
+++ b/client/public/badges/dark/snake.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/sniper.svg b/client/public/badges/dark/sniper.svg
new file mode 100644
index 0000000..1468881
--- /dev/null
+++ b/client/public/badges/dark/sniper.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/solaris.svg b/client/public/badges/dark/solaris.svg
new file mode 100644
index 0000000..4ff31ed
--- /dev/null
+++ b/client/public/badges/dark/solaris.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/strike.svg b/client/public/badges/dark/strike.svg
new file mode 100644
index 0000000..e8c863e
--- /dev/null
+++ b/client/public/badges/dark/strike.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/taurus.svg b/client/public/badges/dark/taurus.svg
new file mode 100644
index 0000000..37d35b4
--- /dev/null
+++ b/client/public/badges/dark/taurus.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/tiger.svg b/client/public/badges/dark/tiger.svg
new file mode 100644
index 0000000..6d904ab
--- /dev/null
+++ b/client/public/badges/dark/tiger.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/titan.svg b/client/public/badges/dark/titan.svg
new file mode 100644
index 0000000..9a88787
--- /dev/null
+++ b/client/public/badges/dark/titan.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/tracer.svg b/client/public/badges/dark/tracer.svg
new file mode 100644
index 0000000..612e886
--- /dev/null
+++ b/client/public/badges/dark/tracer.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/ultra.svg b/client/public/badges/dark/ultra.svg
new file mode 100644
index 0000000..4c32809
--- /dev/null
+++ b/client/public/badges/dark/ultra.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/uranus.svg b/client/public/badges/dark/uranus.svg
new file mode 100644
index 0000000..6d2ce12
--- /dev/null
+++ b/client/public/badges/dark/uranus.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/vanguard.svg b/client/public/badges/dark/vanguard.svg
new file mode 100644
index 0000000..2ea4dd9
--- /dev/null
+++ b/client/public/badges/dark/vanguard.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/virgo.svg b/client/public/badges/dark/virgo.svg
new file mode 100644
index 0000000..a30a8c3
--- /dev/null
+++ b/client/public/badges/dark/virgo.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/dark/vortex.svg b/client/public/badges/dark/vortex.svg
new file mode 100644
index 0000000..0c83859
--- /dev/null
+++ b/client/public/badges/dark/vortex.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/ward.svg b/client/public/badges/dark/ward.svg
new file mode 100644
index 0000000..f3c8a3a
--- /dev/null
+++ b/client/public/badges/dark/ward.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/dark/wormhole.svg b/client/public/badges/dark/wormhole.svg
new file mode 100644
index 0000000..ab3a053
--- /dev/null
+++ b/client/public/badges/dark/wormhole.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/abyss.svg b/client/public/badges/light/abyss.svg
new file mode 100644
index 0000000..660bac3
--- /dev/null
+++ b/client/public/badges/light/abyss.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/aether.svg b/client/public/badges/light/aether.svg
new file mode 100644
index 0000000..8b262db
--- /dev/null
+++ b/client/public/badges/light/aether.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/apex.svg b/client/public/badges/light/apex.svg
new file mode 100644
index 0000000..2077836
--- /dev/null
+++ b/client/public/badges/light/apex.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/aquarius.svg b/client/public/badges/light/aquarius.svg
new file mode 100644
index 0000000..e0d17d6
--- /dev/null
+++ b/client/public/badges/light/aquarius.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/ares.svg b/client/public/badges/light/ares.svg
new file mode 100644
index 0000000..6293dc7
--- /dev/null
+++ b/client/public/badges/light/ares.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/aries.svg b/client/public/badges/light/aries.svg
new file mode 100644
index 0000000..13df493
--- /dev/null
+++ b/client/public/badges/light/aries.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/astral.svg b/client/public/badges/light/astral.svg
new file mode 100644
index 0000000..411abf6
--- /dev/null
+++ b/client/public/badges/light/astral.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/barrage.svg b/client/public/badges/light/barrage.svg
new file mode 100644
index 0000000..d2d8381
--- /dev/null
+++ b/client/public/badges/light/barrage.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/barrier.svg b/client/public/badges/light/barrier.svg
new file mode 100644
index 0000000..39bb419
--- /dev/null
+++ b/client/public/badges/light/barrier.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/bastion.svg b/client/public/badges/light/bastion.svg
new file mode 100644
index 0000000..2641e0e
--- /dev/null
+++ b/client/public/badges/light/bastion.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/blackhole.svg b/client/public/badges/light/blackhole.svg
new file mode 100644
index 0000000..7235bd8
--- /dev/null
+++ b/client/public/badges/light/blackhole.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/cancer.svg b/client/public/badges/light/cancer.svg
new file mode 100644
index 0000000..ac70a73
--- /dev/null
+++ b/client/public/badges/light/cancer.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/capricorn.svg b/client/public/badges/light/capricorn.svg
new file mode 100644
index 0000000..c60ecad
--- /dev/null
+++ b/client/public/badges/light/capricorn.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/citadel.svg b/client/public/badges/light/citadel.svg
new file mode 100644
index 0000000..e4884cf
--- /dev/null
+++ b/client/public/badges/light/citadel.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/core.svg b/client/public/badges/light/core.svg
new file mode 100644
index 0000000..12bd06c
--- /dev/null
+++ b/client/public/badges/light/core.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/cronus.svg b/client/public/badges/light/cronus.svg
new file mode 100644
index 0000000..7534ae8
--- /dev/null
+++ b/client/public/badges/light/cronus.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/crosshair.svg b/client/public/badges/light/crosshair.svg
new file mode 100644
index 0000000..7c0743a
--- /dev/null
+++ b/client/public/badges/light/crosshair.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/defender.svg b/client/public/badges/light/defender.svg
new file mode 100644
index 0000000..4592fd9
--- /dev/null
+++ b/client/public/badges/light/defender.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/dog.svg b/client/public/badges/light/dog.svg
new file mode 100644
index 0000000..9472ff7
--- /dev/null
+++ b/client/public/badges/light/dog.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/dragon.svg b/client/public/badges/light/dragon.svg
new file mode 100644
index 0000000..f2e934b
--- /dev/null
+++ b/client/public/badges/light/dragon.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/dynamo.svg b/client/public/badges/light/dynamo.svg
new file mode 100644
index 0000000..7457980
--- /dev/null
+++ b/client/public/badges/light/dynamo.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/earth.svg b/client/public/badges/light/earth.svg
new file mode 100644
index 0000000..4f38d8f
--- /dev/null
+++ b/client/public/badges/light/earth.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/elite.svg b/client/public/badges/light/elite.svg
new file mode 100644
index 0000000..97d39b6
--- /dev/null
+++ b/client/public/badges/light/elite.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/enigma.svg b/client/public/badges/light/enigma.svg
new file mode 100644
index 0000000..f28d58a
--- /dev/null
+++ b/client/public/badges/light/enigma.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/fission.svg b/client/public/badges/light/fission.svg
new file mode 100644
index 0000000..75e491c
--- /dev/null
+++ b/client/public/badges/light/fission.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/fortress.svg b/client/public/badges/light/fortress.svg
new file mode 100644
index 0000000..369f406
--- /dev/null
+++ b/client/public/badges/light/fortress.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/fusion.svg b/client/public/badges/light/fusion.svg
new file mode 100644
index 0000000..73ee2f6
--- /dev/null
+++ b/client/public/badges/light/fusion.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/galaxy.svg b/client/public/badges/light/galaxy.svg
new file mode 100644
index 0000000..95c55c5
--- /dev/null
+++ b/client/public/badges/light/galaxy.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/gemini.svg b/client/public/badges/light/gemini.svg
new file mode 100644
index 0000000..01ecd9e
--- /dev/null
+++ b/client/public/badges/light/gemini.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/genesis.svg b/client/public/badges/light/genesis.svg
new file mode 100644
index 0000000..d9325ff
--- /dev/null
+++ b/client/public/badges/light/genesis.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/glyph.svg b/client/public/badges/light/glyph.svg
new file mode 100644
index 0000000..997e2bd
--- /dev/null
+++ b/client/public/badges/light/glyph.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/goat.svg b/client/public/badges/light/goat.svg
new file mode 100644
index 0000000..12a185d
--- /dev/null
+++ b/client/public/badges/light/goat.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/guardian.svg b/client/public/badges/light/guardian.svg
new file mode 100644
index 0000000..811a92c
--- /dev/null
+++ b/client/public/badges/light/guardian.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/horse.svg b/client/public/badges/light/horse.svg
new file mode 100644
index 0000000..a5bb4af
--- /dev/null
+++ b/client/public/badges/light/horse.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/ignition.svg b/client/public/badges/light/ignition.svg
new file mode 100644
index 0000000..334d575
--- /dev/null
+++ b/client/public/badges/light/ignition.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/intercept.svg b/client/public/badges/light/intercept.svg
new file mode 100644
index 0000000..f48d3a9
--- /dev/null
+++ b/client/public/badges/light/intercept.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/jupiter.svg b/client/public/badges/light/jupiter.svg
new file mode 100644
index 0000000..dece832
--- /dev/null
+++ b/client/public/badges/light/jupiter.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/leo.svg b/client/public/badges/light/leo.svg
new file mode 100644
index 0000000..7a02ecc
--- /dev/null
+++ b/client/public/badges/light/leo.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/libra.svg b/client/public/badges/light/libra.svg
new file mode 100644
index 0000000..7a2dc0a
--- /dev/null
+++ b/client/public/badges/light/libra.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/lockon.svg b/client/public/badges/light/lockon.svg
new file mode 100644
index 0000000..9b77107
--- /dev/null
+++ b/client/public/badges/light/lockon.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/matrix.svg b/client/public/badges/light/matrix.svg
new file mode 100644
index 0000000..7e47fdf
--- /dev/null
+++ b/client/public/badges/light/matrix.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/mercury.svg b/client/public/badges/light/mercury.svg
new file mode 100644
index 0000000..9ff324b
--- /dev/null
+++ b/client/public/badges/light/mercury.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/monkey.svg b/client/public/badges/light/monkey.svg
new file mode 100644
index 0000000..234dcc7
--- /dev/null
+++ b/client/public/badges/light/monkey.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/moon.svg b/client/public/badges/light/moon.svg
new file mode 100644
index 0000000..59e96bf
--- /dev/null
+++ b/client/public/badges/light/moon.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/mystic.svg b/client/public/badges/light/mystic.svg
new file mode 100644
index 0000000..1f6d1c6
--- /dev/null
+++ b/client/public/badges/light/mystic.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/nebula.svg b/client/public/badges/light/nebula.svg
new file mode 100644
index 0000000..a4856b7
--- /dev/null
+++ b/client/public/badges/light/nebula.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/neptune.svg b/client/public/badges/light/neptune.svg
new file mode 100644
index 0000000..049eacb
--- /dev/null
+++ b/client/public/badges/light/neptune.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/nexus.svg b/client/public/badges/light/nexus.svg
new file mode 100644
index 0000000..d8e9ccc
--- /dev/null
+++ b/client/public/badges/light/nexus.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/omega.svg b/client/public/badges/light/omega.svg
new file mode 100644
index 0000000..482a8f1
--- /dev/null
+++ b/client/public/badges/light/omega.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/oracle.svg b/client/public/badges/light/oracle.svg
new file mode 100644
index 0000000..1a929d6
--- /dev/null
+++ b/client/public/badges/light/oracle.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/overload.svg b/client/public/badges/light/overload.svg
new file mode 100644
index 0000000..d75d239
--- /dev/null
+++ b/client/public/badges/light/overload.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/overwatch.svg b/client/public/badges/light/overwatch.svg
new file mode 100644
index 0000000..99f2e91
--- /dev/null
+++ b/client/public/badges/light/overwatch.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/ox.svg b/client/public/badges/light/ox.svg
new file mode 100644
index 0000000..e186d81
--- /dev/null
+++ b/client/public/badges/light/ox.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/paladin.svg b/client/public/badges/light/paladin.svg
new file mode 100644
index 0000000..a85b0db
--- /dev/null
+++ b/client/public/badges/light/paladin.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/paradox.svg b/client/public/badges/light/paradox.svg
new file mode 100644
index 0000000..5812165
--- /dev/null
+++ b/client/public/badges/light/paradox.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/phalanx.svg b/client/public/badges/light/phalanx.svg
new file mode 100644
index 0000000..652fe4b
--- /dev/null
+++ b/client/public/badges/light/phalanx.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/pig.svg b/client/public/badges/light/pig.svg
new file mode 100644
index 0000000..40ba238
--- /dev/null
+++ b/client/public/badges/light/pig.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/pisces.svg b/client/public/badges/light/pisces.svg
new file mode 100644
index 0000000..f75290b
--- /dev/null
+++ b/client/public/badges/light/pisces.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/plasma.svg b/client/public/badges/light/plasma.svg
new file mode 100644
index 0000000..c9e67db
--- /dev/null
+++ b/client/public/badges/light/plasma.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/pluto.svg b/client/public/badges/light/pluto.svg
new file mode 100644
index 0000000..1ac80f3
--- /dev/null
+++ b/client/public/badges/light/pluto.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/pulsar.svg b/client/public/badges/light/pulsar.svg
new file mode 100644
index 0000000..1a3a30a
--- /dev/null
+++ b/client/public/badges/light/pulsar.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/pulse.svg b/client/public/badges/light/pulse.svg
new file mode 100644
index 0000000..0913e30
--- /dev/null
+++ b/client/public/badges/light/pulse.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/quantum.svg b/client/public/badges/light/quantum.svg
new file mode 100644
index 0000000..3832d1e
--- /dev/null
+++ b/client/public/badges/light/quantum.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/rabbit.svg b/client/public/badges/light/rabbit.svg
new file mode 100644
index 0000000..9a77916
--- /dev/null
+++ b/client/public/badges/light/rabbit.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/radar.svg b/client/public/badges/light/radar.svg
new file mode 100644
index 0000000..4ea319f
--- /dev/null
+++ b/client/public/badges/light/radar.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/radiance.svg b/client/public/badges/light/radiance.svg
new file mode 100644
index 0000000..176787f
--- /dev/null
+++ b/client/public/badges/light/radiance.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/rat.svg b/client/public/badges/light/rat.svg
new file mode 100644
index 0000000..1ba7b70
--- /dev/null
+++ b/client/public/badges/light/rat.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/recon.svg b/client/public/badges/light/recon.svg
new file mode 100644
index 0000000..af9f57b
--- /dev/null
+++ b/client/public/badges/light/recon.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/rooster.svg b/client/public/badges/light/rooster.svg
new file mode 100644
index 0000000..a186e56
--- /dev/null
+++ b/client/public/badges/light/rooster.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/rune.svg b/client/public/badges/light/rune.svg
new file mode 100644
index 0000000..d7f99dd
--- /dev/null
+++ b/client/public/badges/light/rune.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/sagittarius.svg b/client/public/badges/light/sagittarius.svg
new file mode 100644
index 0000000..cb09c78
--- /dev/null
+++ b/client/public/badges/light/sagittarius.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/scorpio.svg b/client/public/badges/light/scorpio.svg
new file mode 100644
index 0000000..ff94d62
--- /dev/null
+++ b/client/public/badges/light/scorpio.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/sentinel.svg b/client/public/badges/light/sentinel.svg
new file mode 100644
index 0000000..3aeab18
--- /dev/null
+++ b/client/public/badges/light/sentinel.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/sigil.svg b/client/public/badges/light/sigil.svg
new file mode 100644
index 0000000..1aaadb3
--- /dev/null
+++ b/client/public/badges/light/sigil.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/snake.svg b/client/public/badges/light/snake.svg
new file mode 100644
index 0000000..2c1437e
--- /dev/null
+++ b/client/public/badges/light/snake.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/sniper.svg b/client/public/badges/light/sniper.svg
new file mode 100644
index 0000000..a2ecc99
--- /dev/null
+++ b/client/public/badges/light/sniper.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/solaris.svg b/client/public/badges/light/solaris.svg
new file mode 100644
index 0000000..bfaab05
--- /dev/null
+++ b/client/public/badges/light/solaris.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/strike.svg b/client/public/badges/light/strike.svg
new file mode 100644
index 0000000..a22afc7
--- /dev/null
+++ b/client/public/badges/light/strike.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/taurus.svg b/client/public/badges/light/taurus.svg
new file mode 100644
index 0000000..a6d5699
--- /dev/null
+++ b/client/public/badges/light/taurus.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/tiger.svg b/client/public/badges/light/tiger.svg
new file mode 100644
index 0000000..ff22606
--- /dev/null
+++ b/client/public/badges/light/tiger.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/titan.svg b/client/public/badges/light/titan.svg
new file mode 100644
index 0000000..0e961da
--- /dev/null
+++ b/client/public/badges/light/titan.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/tracer.svg b/client/public/badges/light/tracer.svg
new file mode 100644
index 0000000..ec1c7d2
--- /dev/null
+++ b/client/public/badges/light/tracer.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/ultra.svg b/client/public/badges/light/ultra.svg
new file mode 100644
index 0000000..122c4bd
--- /dev/null
+++ b/client/public/badges/light/ultra.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/uranus.svg b/client/public/badges/light/uranus.svg
new file mode 100644
index 0000000..3f00d15
--- /dev/null
+++ b/client/public/badges/light/uranus.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/vanguard.svg b/client/public/badges/light/vanguard.svg
new file mode 100644
index 0000000..ff3727c
--- /dev/null
+++ b/client/public/badges/light/vanguard.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/virgo.svg b/client/public/badges/light/virgo.svg
new file mode 100644
index 0000000..e19e9f8
--- /dev/null
+++ b/client/public/badges/light/virgo.svg
@@ -0,0 +1,80 @@
+
diff --git a/client/public/badges/light/vortex.svg b/client/public/badges/light/vortex.svg
new file mode 100644
index 0000000..d38b3c8
--- /dev/null
+++ b/client/public/badges/light/vortex.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/ward.svg b/client/public/badges/light/ward.svg
new file mode 100644
index 0000000..b0eccaa
--- /dev/null
+++ b/client/public/badges/light/ward.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/badges/light/wormhole.svg b/client/public/badges/light/wormhole.svg
new file mode 100644
index 0000000..90b5574
--- /dev/null
+++ b/client/public/badges/light/wormhole.svg
@@ -0,0 +1,102 @@
+
diff --git a/client/public/icons/peer.svg b/client/public/icons/peer.svg
new file mode 100644
index 0000000..824e52b
--- /dev/null
+++ b/client/public/icons/peer.svg
@@ -0,0 +1,115 @@
+
\ No newline at end of file
diff --git a/client/public/icons/prime.svg b/client/public/icons/prime.svg
new file mode 100644
index 0000000..8e0ecca
--- /dev/null
+++ b/client/public/icons/prime.svg
@@ -0,0 +1,144 @@
+
diff --git a/client/public/icons/pro.svg b/client/public/icons/pro.svg
new file mode 100644
index 0000000..50fb10e
--- /dev/null
+++ b/client/public/icons/pro.svg
@@ -0,0 +1,114 @@
+
diff --git a/client/public/incudal_logo.webp b/client/public/incudal_logo.webp
new file mode 100644
index 0000000..1bd5fc3
Binary files /dev/null and b/client/public/incudal_logo.webp differ
diff --git a/client/public/sw.js b/client/public/sw.js
new file mode 100644
index 0000000..803c104
--- /dev/null
+++ b/client/public/sw.js
@@ -0,0 +1,138 @@
+/**
+ * Service Worker - 静态资源缓存
+ * 提升二次加载速度,支持离线访问静态资源
+ */
+
+const CACHE_NAME = 'incudal-cache-v2'
+
+// 需要缓存的静态资源类型
+const CACHEABLE_EXTENSIONS = ['.js', '.css', '.woff', '.woff2', '.ttf', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico']
+
+// 需要始终从网络获取的路径(不缓存)
+const NETWORK_ONLY_PATTERNS = [
+ '/api/', // API 请求
+ '/auth/', // 认证请求
+ '/oauth/', // OAuth 请求
+ '/__vite_ping', // Vite HMR
+ '/sw.js' // Service Worker 自身
+]
+
+// 安装事件
+self.addEventListener('install', (event) => {
+ console.log('[SW] Installing service worker...')
+ // 跳过等待,立即激活
+ event.waitUntil(self.skipWaiting())
+})
+
+// 激活事件
+self.addEventListener('activate', (event) => {
+ console.log('[SW] Activating service worker...')
+ event.waitUntil(
+ Promise.all([
+ // 清理旧版本缓存
+ caches.keys().then(cacheNames => {
+ return Promise.all(
+ cacheNames
+ .filter(cacheName => cacheName !== CACHE_NAME)
+ .map(cacheName => {
+ console.log('[SW] Deleting old cache:', cacheName)
+ return caches.delete(cacheName)
+ })
+ )
+ }),
+ // 立即接管所有客户端
+ self.clients.claim()
+ ])
+ )
+})
+
+// 判断是否应该缓存该请求
+function shouldCache(url) {
+ // 检查是否是网络优先的路径
+ for (const pattern of NETWORK_ONLY_PATTERNS) {
+ if (url.pathname.includes(pattern)) {
+ return false
+ }
+ }
+
+ // 只缓存同源请求
+ if (url.origin !== self.location.origin) {
+ return false
+ }
+
+ // 检查文件扩展名
+ const pathname = url.pathname.toLowerCase()
+ return CACHEABLE_EXTENSIONS.some(ext => pathname.endsWith(ext))
+}
+
+// 拦截请求
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url)
+
+ // 只处理 GET 请求
+ if (event.request.method !== 'GET') {
+ return
+ }
+
+ // 页面导航请求不做离线接管:
+ // 当前策略不缓存 HTML,如果这里兜底到缓存首页,会把瞬时网络抖动放大成“离线状态”。
+ if (event.request.mode === 'navigate') {
+ return
+ }
+
+ // 可缓存的静态资源 - 缓存优先策略(Stale While Revalidate)
+ if (shouldCache(url)) {
+ event.respondWith(
+ caches.match(event.request).then(cachedResponse => {
+ if (cachedResponse) {
+ // 返回缓存,同时在后台更新
+ event.waitUntil(
+ fetch(event.request)
+ .then(networkResponse => {
+ if (networkResponse && networkResponse.status === 200) {
+ return caches.open(CACHE_NAME).then(cache => {
+ cache.put(event.request, networkResponse.clone())
+ })
+ }
+ })
+ .catch(() => {
+ // 后台更新失败,忽略
+ })
+ )
+ return cachedResponse
+ }
+
+ // 没有缓存,从网络获取并缓存
+ return fetch(event.request).then(networkResponse => {
+ if (networkResponse && networkResponse.status === 200) {
+ const responseToCache = networkResponse.clone()
+ event.waitUntil(
+ caches.open(CACHE_NAME).then(cache => {
+ cache.put(event.request, responseToCache)
+ })
+ )
+ }
+ return networkResponse
+ })
+ })
+ )
+ return
+ }
+
+ // 其他请求直接走网络
+})
+
+// 监听消息(用于手动清除缓存等操作)
+self.addEventListener('message', (event) => {
+ if (event.data && event.data.type === 'CLEAR_CACHE') {
+ event.waitUntil(
+ caches.delete(CACHE_NAME).then(() => {
+ console.log('[SW] Cache cleared')
+ // 通知客户端
+ if (event.source && event.source.postMessage) {
+ event.source.postMessage({ type: 'CACHE_CLEARED' })
+ }
+ })
+ )
+ }
+})
diff --git a/client/public/tos/en.md b/client/public/tos/en.md
new file mode 100644
index 0000000..9d178e7
--- /dev/null
+++ b/client/public/tos/en.md
@@ -0,0 +1,3 @@
+# Terms of Service
+
+By using this platform, you agree to all terms of this agreement.
diff --git a/client/public/tos/zh.md b/client/public/tos/zh.md
new file mode 100644
index 0000000..584e3e0
--- /dev/null
+++ b/client/public/tos/zh.md
@@ -0,0 +1,3 @@
+# 服务协议
+
+使用本平台即表示您同意本协议全部条款。
diff --git a/client/src/App.vue b/client/src/App.vue
new file mode 100644
index 0000000..c64e1d7
--- /dev/null
+++ b/client/src/App.vue
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/api/index.ts b/client/src/api/index.ts
new file mode 100644
index 0000000..1da60da
--- /dev/null
+++ b/client/src/api/index.ts
@@ -0,0 +1,5250 @@
+import axios, { type AxiosInstance } from 'axios'
+import { useAuthStore } from '@/stores/auth'
+import type {
+ LoginRequest,
+ LoginResponse,
+ RegisterRequest,
+ RegisterResponse,
+ UpdateUserResponse,
+ GenerateInviteRequest,
+ InviteListResponse,
+ User,
+ BadgeOverview,
+ BadgeMultiDrawResponse,
+ BadgeOwnership,
+ BadgeCatalogItem,
+ BadgeSeriesItem,
+ UserQuota,
+ UpdateUserRequest,
+ Instance,
+ InstanceWithDetails,
+ InstanceStats,
+ CreateInstanceRequest,
+ UpdateInstanceRequest,
+ ChangeHostOptionsResponse,
+ PortMapping,
+ CreatePortMappingRequest,
+ IpAddress,
+ Ipv6Subnet,
+ HostAgentStatusResponse,
+ HostAgentInstallCommandResponse,
+ HostAgentUpgradeRequestResponse,
+ Snapshot,
+ Backup,
+ CreateSnapshotRequest,
+ CreateBackupRequest,
+ SnapshotPolicy,
+ BackupPolicy,
+ UpdateSnapshotPolicyRequest,
+ UpdateBackupPolicyRequest,
+ Host,
+ HostWithDetails,
+ AvailableHost,
+ CreateHostRequest,
+ UpdateHostRequest,
+ Package,
+ CreatePackageRequest,
+ UpdatePackageRequest,
+ SshKey,
+ CreateSshKeyRequest,
+ NotificationChannel,
+ CreateNotificationChannelRequest,
+ UpdateNotificationChannelRequest,
+ OAuthConfig,
+ UserOAuthBinding,
+ UpdateOAuthConfigRequest,
+ HelpArticle,
+ CreateHelpArticleRequest,
+ UpdateHelpArticleRequest,
+ HostImagePolicy,
+ SystemImage,
+ CreateSystemImageRequest,
+ UpdateSystemImageRequest,
+ Log,
+ PaginatedResponse,
+ Ticket,
+ TicketMessage,
+ TicketStatus,
+ CreateTicketRequest,
+ PaginatedTickets,
+ PaginatedTicketMessages,
+ TerminalSavedCommand,
+ CreateTerminalSavedCommandRequest,
+ UpdateTerminalSavedCommandRequest,
+ TelegramBindingStatus,
+ TelegramBindTokenResponse,
+ TelegramAdminBindingsResponse,
+ TelegramWebhookDeleteResponse,
+ TelegramWebhookInfoResponse,
+ TelegramWebhookSetupResponse,
+ UserInvite,
+ UserInviteSummary
+} from '@/types/api.js'
+
+export type VipRuleType = 'user' | 'hosting'
+export type VipConditionMode = 'any' | 'all'
+export type UserVipMetric = 'totalRecharge' | 'totalConsume'
+
+export interface VipBadgeStyle {
+ backgroundColor: string
+ textColor: string
+}
+
+export interface VipBenefitHallConfig {
+ balance?: {
+ enabled?: boolean
+ amount?: number
+ }
+ points?: {
+ enabled?: boolean
+ amount?: number
+ }
+ instance?: {
+ enabled?: boolean
+ packageId?: number | null
+ packageName?: string | null
+ planId?: number | null
+ planName?: string | null
+ days?: number | null
+ quantity?: number | null
+ }
+}
+
+export interface VipLevelRule {
+ id?: number
+ type: VipRuleType
+ level: number
+ enabled: boolean
+ conditionMode: VipConditionMode
+ userMetric?: UserVipMetric
+ minRecharge: number | null
+ minConsume: number | null
+ minHostingIncome: number | null
+ minHostingInstances: number | null
+ benefits?: Record & {
+ badgeStyle?: VipBadgeStyle
+ benefitHall?: VipBenefitHallConfig
+ }
+ badgeStyle?: VipBadgeStyle
+}
+
+export interface VipLevelRulesResponse {
+ type: VipRuleType
+ maxLevel: number
+ userMetric?: UserVipMetric
+ rules: VipLevelRule[]
+}
+
+export type VipProgressMetric = 'totalRecharge' | 'totalConsume' | 'totalHostingIncome' | 'instanceCount'
+
+export interface VipProgressCondition {
+ metric: VipProgressMetric
+ current: number
+ target: number
+ remaining: number
+ matched: boolean
+ progress: number
+}
+
+export interface VipLevelProgress {
+ currentLevel: number
+ nextLevel: number | null
+ conditionMode: VipConditionMode | null
+ userMetric?: UserVipMetric | null
+ progress: number
+ isMaxLevel: boolean
+ conditions: VipProgressCondition[]
+}
+
+export interface VipOverviewResponse {
+ userVipLevel: number
+ hostingVipLevel: number
+ userVipBadgeStyle?: VipBadgeStyle | null
+ hostingVipBadgeStyle?: VipBadgeStyle | null
+ userVipMetric?: UserVipMetric
+ userStats: {
+ totalRecharge: number
+ totalConsume: number
+ }
+ hostingStats: {
+ totalHostingIncome: number
+ instanceCount: number
+ }
+ userVipProgress?: VipLevelProgress
+ hostingVipProgress?: VipLevelProgress
+}
+
+export type VipBenefitRewardType = 'balance' | 'points' | 'instance'
+export type VipBenefitClaimStatus = 'delivered' | 'pending'
+export type VipBenefitRewardState = 'claimable' | 'claimed' | 'locked' | 'blocked'
+
+export interface VipBenefitRewardConfig {
+ amount?: number
+ packageId?: number | null
+ packageName?: string | null
+ planId?: number | null
+ planName?: string | null
+ days?: number
+ quantity?: number
+}
+
+export interface VipBenefitClaim {
+ id: number
+ rewardId: number
+ level: number
+ status: VipBenefitClaimStatus
+ claimNo: number
+ snapshot: Record
+ deliveredAt: string | null
+ createdAt: string
+}
+
+export interface VipBenefitReward {
+ id: number
+ level: number
+ type: VipBenefitRewardType
+ title: string
+ description: string | null
+ claimLimit: number
+ sortOrder: number
+ enabled: boolean
+ config: VipBenefitRewardConfig
+ createdAt?: string
+ updatedAt?: string
+ claimedCount?: number
+ remainingClaims?: number
+ state?: VipBenefitRewardState
+ blockedByLevel?: number | null
+ claims?: VipBenefitClaim[]
+}
+
+export interface VipBenefitRewardInput {
+ level: number
+ type: VipBenefitRewardType
+ title: string
+ description?: string | null
+ claimLimit?: number
+ sortOrder?: number
+ enabled?: boolean
+ config: VipBenefitRewardConfig
+}
+
+export interface VipBenefitAmountSummary {
+ balanceAmount: number
+ pointsAmount: number
+ instanceQuantity: number
+}
+
+export interface VipBenefitOverviewResponse {
+ currentLevel: number
+ userVipMetric: UserVipMetric
+ userStats: {
+ totalRecharge: number
+ totalConsume: number
+ }
+ userVipBadgeStyle?: VipBadgeStyle | null
+ rewards: VipBenefitReward[]
+ summary: {
+ totalRewards: number
+ unlockedRewards: number
+ claimableRewards: number
+ claimedRewards: number
+ lockedRewards: number
+ blockedRewards: number
+ pendingRewards: number
+ entitlement: VipBenefitAmountSummary
+ remaining: VipBenefitAmountSummary
+ }
+}
+
+// API 超时配置(毫秒)
+const TIMEOUT = {
+ DEFAULT: 30000, // 30秒 - 普通请求
+ MEDIUM: 60000, // 60秒 - 中等耗时操作
+ LONG: 120000, // 120秒 - 较长操作(启动/停止/重启,需等待IP获取)
+ SNAPSHOT: 180000, // 3分钟 - 快照/备份操作
+ REBUILD: 300000, // 5分钟 - 重装系统
+ CLONE: 600000, // 10分钟 - 复制实例
+ BATCH: 900000, // 15分钟 - 批量操作
+}
+
+function buildTicketFormData(
+ data: CreateTicketRequest | { content: string; attachments?: File[] }
+): FormData {
+ const formData = new FormData()
+
+ if ('instanceId' in data && data.instanceId !== undefined && data.instanceId !== null) {
+ formData.append('instanceId', String(data.instanceId))
+ }
+ if ('subject' in data) {
+ formData.append('subject', data.subject)
+ }
+ if ('category' in data && data.category) {
+ formData.append('category', data.category)
+ }
+ if ('priority' in data && data.priority) {
+ formData.append('priority', data.priority)
+ }
+
+ formData.append('content', data.content ?? '')
+
+ for (const file of data.attachments || []) {
+ formData.append('images', file)
+ }
+
+ return formData
+}
+
+// 创建 axios 实例
+const http: AxiosInstance = axios.create({
+ baseURL: '/api',
+ timeout: TIMEOUT.DEFAULT,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+})
+
+// 刷新 token 的锁,防止多个请求同时触发刷新
+let isRefreshing = false
+let failedQueue: Array<{
+ resolve: (value?: unknown) => void
+ reject: (reason?: unknown) => void
+}> = []
+// 记录最后一次刷新尝试的时间,避免过于频繁的刷新
+let lastRefreshAttempt = 0
+const MIN_REFRESH_INTERVAL = 5000 // 5秒内最多刷新一次
+// 记录认证失败的时间,防止在短时间内重复清除状态
+let lastAuthFailureTime = 0
+const AUTH_FAILURE_COOLDOWN = 10000 // 10秒内最多清除一次认证状态
+
+// 处理队列中的请求
+const processQueue = (error: any, token: string | null = null) => {
+ failedQueue.forEach(prom => {
+ if (error) {
+ prom.reject(error)
+ } else {
+ prom.resolve(token)
+ }
+ })
+ failedQueue = []
+}
+
+/**
+ * 解析 JWT token,获取过期时间
+ */
+function parseJWT(token: string): { exp?: number; iat?: number } | null {
+ try {
+ const base64Url = token.split('.')[1]
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
+ const jsonPayload = decodeURIComponent(
+ atob(base64)
+ .split('')
+ .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
+ .join('')
+ )
+ return JSON.parse(jsonPayload)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * 检查 token 是否即将过期(简化版:剩余时间少于 1 天)
+ */
+function isTokenExpiringSoon(token: string): boolean {
+ const decoded = parseJWT(token)
+ if (!decoded || !decoded.exp) {
+ return false
+ }
+ const exp = decoded.exp * 1000 // 转换为毫秒
+ const now = Date.now()
+ const timeUntilExpiry = exp - now
+ // 简化版:如果剩余时间少于 1 天,则认为即将过期
+ return timeUntilExpiry < 24 * 60 * 60 * 1000
+}
+
+/**
+ * 主动刷新 token(在 token 即将过期前)
+ */
+async function proactiveRefreshToken(): Promise {
+ if (isRefreshing) {
+ // 如果正在刷新,等待刷新完成
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject })
+ }).then(token => token as string | null).catch(() => null)
+ }
+
+ // 检查刷新频率,避免过于频繁
+ const now = Date.now()
+ if (now - lastRefreshAttempt < MIN_REFRESH_INTERVAL) {
+ // 最近刚刷新过,直接返回 null,使用当前 token
+ return null
+ }
+
+ isRefreshing = true
+ lastRefreshAttempt = now
+
+ try {
+ const refreshResponse = await fetch('/api/auth/refresh', {
+ method: 'POST',
+ credentials: 'include', // 重要:发送 Cookie(包含 refreshToken)
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+
+ if (!refreshResponse.ok) {
+ // 如果是 401 或 400,说明 refreshToken 也失效了,需要重新登录
+ // 400 通常表示 refresh token 无效、过期或格式错误
+ if (refreshResponse.status === 401 || refreshResponse.status === 400) {
+ throw new Error('REFRESH_TOKEN_INVALID')
+ }
+ throw new Error(`Refresh token failed: ${refreshResponse.status}`)
+ }
+
+ const refreshData = await refreshResponse.json()
+ const newToken = refreshData?.token
+ if (newToken) {
+ localStorage.setItem('token', newToken)
+ // 同步更新 auth store 中的 token
+ try {
+ const authStore = useAuthStore()
+ authStore.syncToken()
+ } catch {
+ // 如果 store 未初始化,忽略
+ }
+ processQueue(null, newToken)
+ return newToken
+ } else {
+ throw new Error('No token in refresh response')
+ }
+ } catch (refreshError: any) {
+ processQueue(refreshError, null)
+ // 如果是 refreshToken 失效,需要清除并跳转登录
+ if (refreshError?.message === 'REFRESH_TOKEN_INVALID') {
+ localStorage.removeItem('token')
+ if (!window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register')) {
+ window.location.href = '/login'
+ }
+ return null
+ }
+ // 其他错误(如网络错误)不立即跳转登录页,让响应拦截器处理
+ // 这样可以避免因为临时网络问题导致用户被退出
+ console.warn('Proactive token refresh failed:', refreshError)
+ return null
+ } finally {
+ isRefreshing = false
+ }
+}
+
+// 请求拦截器 - 添加 token 并检查是否需要刷新
+http.interceptors.request.use(
+ async (config) => {
+ let token = localStorage.getItem('token')
+ if (token) {
+ // 检查 token 是否即将过期,如果是则先刷新
+ if (isTokenExpiringSoon(token)) {
+ const newToken = await proactiveRefreshToken()
+ if (newToken) {
+ token = newToken
+ }
+ // 如果刷新失败,继续使用旧 token,让响应拦截器处理 401
+ }
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+ },
+ (error) => Promise.reject(error)
+)
+
+// 响应拦截器 - 处理错误和自动刷新 token
+http.interceptors.response.use(
+ (response) => response.data,
+ async (error) => {
+ const responseData = error.response?.data
+ const requestUrl = error.config?.url || ''
+ const originalRequest = error.config
+
+ // 401 未授权,尝试刷新 token
+ if (error.response?.status === 401) {
+ // 排除登录、注册、刷新和check-2fa接口本身
+ const isAuthEndpoint = requestUrl.startsWith('/auth/login') ||
+ requestUrl.startsWith('/auth/register') ||
+ requestUrl.startsWith('/auth/refresh') ||
+ requestUrl.startsWith('/auth/check-2fa')
+
+ // 如果是认证相关接口的 401,不尝试刷新,直接返回错误
+ if (isAuthEndpoint) {
+ // 创建带有错误码的错误对象
+ const apiError = {
+ message: responseData?.error || 'Unauthorized',
+ code: responseData?.code || null,
+ details: responseData?.details || null
+ }
+ return Promise.reject(apiError)
+ }
+
+ // 如果已经重试过,说明刷新也失败了,清除所有认证状态并跳转登录页
+ if (originalRequest?._retry) {
+ const now = Date.now()
+ // 防止在短时间内重复清除状态和跳转
+ if (now - lastAuthFailureTime > AUTH_FAILURE_COOLDOWN) {
+ lastAuthFailureTime = now
+ // 清除 auth store 状态
+ try {
+ const authStore = useAuthStore()
+ authStore.clearLocalAuth()
+ } catch {
+ // 如果 store 未初始化,只清除 localStorage
+ localStorage.removeItem('token')
+ }
+ if (!window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register')) {
+ window.location.href = '/login'
+ }
+ }
+ return Promise.reject(error)
+ }
+
+ // 如果正在刷新,将请求加入队列
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject })
+ }).then(token => {
+ if (token) {
+ originalRequest.headers.Authorization = `Bearer ${token}`
+ }
+ return http(originalRequest)
+ }).catch(err => {
+ return Promise.reject(err)
+ })
+ }
+
+ // 检查刷新频率,避免过于频繁
+ const now = Date.now()
+ if (now - lastRefreshAttempt < MIN_REFRESH_INTERVAL && lastRefreshAttempt > 0) {
+ // 最近刚刷新过,可能是网络问题,等待一下再重试原请求
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ if (originalRequest) {
+ return http(originalRequest)
+ }
+ return Promise.reject(error)
+ }
+
+ // 标记正在刷新,防止重复刷新
+ if (originalRequest) {
+ originalRequest._retry = true
+ }
+ isRefreshing = true
+ lastRefreshAttempt = now
+
+ try {
+ // 尝试刷新 token(使用 fetch API,避免触发拦截器循环)
+ const refreshResponse = await fetch('/api/auth/refresh', {
+ method: 'POST',
+ credentials: 'include', // 重要:发送 Cookie(包含 refreshToken)
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+
+ if (!refreshResponse.ok) {
+ // 如果是 401 或 400,说明 refreshToken 也失效了,需要重新登录
+ // 400 通常表示 refresh token 无效、过期或格式错误
+ if (refreshResponse.status === 401 || refreshResponse.status === 400) {
+ throw new Error('REFRESH_TOKEN_INVALID')
+ }
+ throw new Error(`Refresh token failed: ${refreshResponse.status}`)
+ }
+
+ const refreshData = await refreshResponse.json()
+ const newToken = refreshData?.token
+ if (newToken) {
+ // 更新 localStorage 中的 token
+ localStorage.setItem('token', newToken)
+ // 同步更新 auth store 中的 token
+ try {
+ const authStore = useAuthStore()
+ authStore.syncToken()
+ } catch {
+ // 如果 store 未初始化,忽略
+ }
+ // 处理队列中的请求
+ processQueue(null, newToken)
+ // 更新请求头并重试原请求
+ if (originalRequest) {
+ originalRequest.headers.Authorization = `Bearer ${newToken}`
+ return http(originalRequest)
+ }
+ return Promise.reject(error)
+ } else {
+ throw new Error('No token in refresh response')
+ }
+ } catch (refreshError: any) {
+ // 刷新失败,根据错误类型决定是否跳转登录
+ processQueue(refreshError, null)
+
+ // 如果是 refreshToken 失效(401 或 400),清除所有认证状态并跳转到登录页
+ // 400 通常表示 refresh token 无效、过期或格式错误
+ if (refreshError?.message === 'REFRESH_TOKEN_INVALID' ||
+ (refreshError?.response?.status === 401) ||
+ (refreshError?.response?.status === 400)) {
+ const now = Date.now()
+ // 防止在短时间内重复清除状态和跳转
+ if (now - lastAuthFailureTime > AUTH_FAILURE_COOLDOWN) {
+ lastAuthFailureTime = now
+ // 清除 auth store 状态
+ try {
+ const authStore = useAuthStore()
+ authStore.clearLocalAuth()
+ } catch {
+ // 如果 store 未初始化,只清除 localStorage
+ localStorage.removeItem('token')
+ }
+ if (!window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register')) {
+ window.location.href = '/login'
+ }
+ }
+ } else {
+ // 其他错误(如网络错误),不立即跳转,记录日志
+ console.warn('Token refresh failed, but not invalidating session:', refreshError)
+ }
+ return Promise.reject(refreshError)
+ } finally {
+ isRefreshing = false
+ }
+ }
+
+ // 创建带有错误码的错误对象,用于前端翻译
+ // 保留原始响应数据中的额外字段(如 conflicts, availableCount 等)
+ const apiError = {
+ message: responseData?.error || responseData?.message || 'Request failed',
+ code: responseData?.code || null,
+ details: responseData?.details || null,
+ ...responseData // 保留原始响应中的所有字段
+ }
+
+ return Promise.reject(apiError)
+ }
+)
+
+// API 模块
+const api = {
+ // 认证
+ auth: {
+ check2FA: (username: string): Promise<{ requires2FA: boolean }> =>
+ http.post('/auth/check-2fa', { username }),
+ login: (username: string, password: string, totpCode?: string, recoveryCode?: string, turnstileToken?: string): Promise =>
+ http.post('/auth/login', { username, password, totpCode, recoveryCode, turnstileToken } as LoginRequest & { totpCode?: string; recoveryCode?: string; turnstileToken?: string }),
+ register: (data: RegisterRequest & { emailCode?: string }): Promise =>
+ http.post('/auth/register', data),
+ sendVerificationCode: (email: string, turnstileToken?: string): Promise<{ message: string; expiresAt: string }> =>
+ http.post('/auth/send-verification-code', { email, turnstileToken }),
+ sendForgotPasswordCode: (email: string, turnstileToken?: string): Promise<{ message: string; expiresAt: string }> =>
+ http.post('/auth/forgot-password/send-code', { email, turnstileToken }),
+ resetPassword: (email: string, code: string, turnstileToken?: string): Promise<{ message: string; twoFactorDisabled: boolean }> =>
+ http.post('/auth/forgot-password/reset', { email, code, turnstileToken }),
+ me: (): Promise<{ user: User }> => http.get('/auth/me'),
+ logout: (): Promise => http.post('/auth/logout'),
+ generateInvite: (data: GenerateInviteRequest = {}): Promise<{ code: string; codes?: string[]; count?: number; expiresAt?: string; url?: string }> =>
+ http.post('/auth/invite', data),
+ getInvites: (params: { page?: number; pageSize?: number; status?: 'used' | 'unused' } = {}): Promise =>
+ http.get('/auth/invites', { params }),
+ deleteInvite: (id: number): Promise => http.delete(`/auth/invites/${id}`),
+ // 双因素认证
+ get2FAStatus: (): Promise<{ enabled: boolean }> => http.get('/auth/2fa/status'),
+ setup2FA: (): Promise<{ secret: string; qrCode: string; recoveryCodes: string[] }> =>
+ http.post('/auth/2fa/setup'),
+ enable2FA: (code: string): Promise<{ message: string }> =>
+ http.post('/auth/2fa/enable', { code }),
+ disable2FA: (password: string, code: string): Promise<{ message: string }> =>
+ http.post('/auth/2fa/disable', { password, code }),
+ getRecoveryCodes: (): Promise<{ total: number; remaining: number; used: number }> =>
+ http.get('/auth/2fa/recovery-codes'),
+ regenerateRecoveryCodes: (password: string, code: string): Promise<{ recoveryCodes: string[] }> =>
+ http.post('/auth/2fa/regenerate-recovery-codes', { password, code }),
+ // 登录历史
+ getLoginHistory: (params?: { page?: number; pageSize?: number }): Promise<{
+ records: Array<{
+ id: number
+ ip: string
+ country: string | null
+ region: string | null
+ city: string | null
+ isp: string | null
+ timezone: string | null
+ userAgent: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/auth/login-history', { params })
+ },
+
+ userInvites: {
+ summary: (): Promise => http.get('/user-invites/summary'),
+ list: (params: { page?: number; pageSize?: number } = {}): Promise<{
+ invites: UserInvite[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/user-invites', { params }),
+ generate: (data: { costResource: string; count?: number }): Promise<{
+ invites: UserInvite[]
+ count: number
+ }> => http.post('/user-invites', data)
+ },
+
+ // 用户管理
+ users: {
+ list: (params: Record = {}): Promise> =>
+ http.get('/users', { params }),
+ get: (id: number): Promise => http.get(`/users/${id}`),
+ update: (id: number, data: UpdateUserRequest): Promise =>
+ http.patch(`/users/${id}`, data),
+ sendChangeEmailCode: (id: number, email: string): Promise<{ message: string; expiresAt: string }> =>
+ http.post(`/users/${id}/change-email/send-code`, { email }),
+ increaseQuota: (quota: { hostLimit?: number; friendLimit?: number }): Promise<{ message: string }> =>
+ http.post('/users/me/quota/increase', quota),
+ updateRole: (id: number, role: 'admin' | 'user'): Promise<{ message: string; role: 'admin' | 'user'; revokedSessions?: number }> =>
+ http.patch(`/users/${id}/role`, { role }),
+ updateStatus: (id: number, status: 'active' | 'banned', reason?: string): Promise =>
+ http.patch(`/users/${id}/status`, { status, reason }),
+ recalculateQuota: (id: number): Promise =>
+ http.post(`/users/${id}/quota/recalculate`),
+ delete: (id: number): Promise => http.delete(`/users/${id}`),
+ // 管理员重置用户密码(自动生成)
+ resetPassword: (id: number): Promise<{ message: string; newPassword: string; username: string }> =>
+ http.post(`/users/${id}/reset-password`),
+ // 管理员取消用户 2FA
+ disable2FA: (id: number): Promise<{ message: string }> =>
+ http.post(`/users/${id}/disable-2fa`),
+ // 管理员解绑用户 OAuth
+ unbindOAuth: (userId: number, provider: 'github' | 'google'): Promise<{ message: string }> =>
+ http.delete(`/users/${userId}/oauth/${provider}`),
+ // 管理员获取用户登录记录
+ getLoginRecords: (userId: number, params: { page?: number; pageSize?: number } = {}): Promise<{
+ records: Array<{
+ id: number
+ ip: string
+ country: string | null
+ region: string | null
+ city: string | null
+ isp: string | null
+ timezone: string | null
+ userAgent: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get(`/users/${userId}/login-records`, { params }),
+ // 管理员检测关联账号
+ detectLinkedAccounts: (days: number = 90): Promise<{
+ detectedAt: string
+ durationMs: number
+ days: number
+ summary: {
+ ipGroups: number
+ emailGroups: number
+ usernameGroups: number
+ }
+ ipGroups: Array<{
+ ip: string
+ userCount: number
+ totalLogins: number
+ users: Array<{
+ id: number
+ username: string
+ email: string | null
+ status: string
+ loginCount: number
+ lastLogin: string
+ }>
+ }>
+ emailGroups: Array<{
+ pattern: string
+ userCount: number
+ users: Array<{
+ id: number
+ username: string
+ email: string
+ status: string
+ createdAt: string
+ }>
+ }>
+ usernameGroups: Array<{
+ pattern: string
+ userCount: number
+ users: Array<{
+ id: number
+ username: string
+ email: string | null
+ status: string
+ createdAt: string
+ }>
+ }>
+ }> => http.get('/users/detect-linked-accounts', { params: { days } })
+ },
+
+ // 终端快捷命令
+ terminalSavedCommands: {
+ list: (): Promise<{ commands: TerminalSavedCommand[] }> =>
+ http.get('/terminal-saved-commands'),
+ create: (data: CreateTerminalSavedCommandRequest): Promise<{ message: string; command: TerminalSavedCommand }> =>
+ http.post('/terminal-saved-commands', data),
+ update: (id: number, data: UpdateTerminalSavedCommandRequest): Promise<{ message: string; command: TerminalSavedCommand }> =>
+ http.put(`/terminal-saved-commands/${id}`, data),
+ delete: (id: number): Promise<{ message: string }> =>
+ http.delete(`/terminal-saved-commands/${id}`)
+ },
+
+ // 实例管理
+ instances: {
+ list: (params: Record = {}): Promise & { availableCountries?: string[] }> =>
+ http.get('/instances', { params }),
+ get: (id: number): Promise => http.get(`/instances/${id}`),
+ getPassword: (id: number): Promise<{ rootPassword: string | null }> => http.get(`/instances/${id}/password`),
+ getStats: (id: number): Promise => http.get(`/instances/${id}/stats`),
+ create: (data: CreateInstanceRequest): Promise => http.post('/instances', data),
+ getAvailableHosts: (params: Record = {}): Promise =>
+ http.get('/instances/available-hosts', { params }),
+ getChangeHostOptions: (id: number): Promise =>
+ http.get(`/instances/${id}/change-host-options`),
+ changeHost: (id: number, data: { targetHostId: number; sshKeyId: number }): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/change-host`, data),
+ createTerminalTicket: (id: number): Promise<{ ticket: string; expiresIn: number }> =>
+ http.post(`/ws/instances/${id}/terminal-ticket`, {}),
+ updateOrder: (id: number, action: 'top' | 'up' | 'down' | 'bottom'): Promise<{ message: string; updated: number }> =>
+ http.patch(`/instances/${id}/order`, { action }),
+ delete: (id: number, reason?: string): Promise<{ message: string; refundAmount?: number }> =>
+ http.delete(`/instances/${id}`, { data: reason ? { reason } : {}, timeout: TIMEOUT.LONG }),
+ // 实例操作任务(异步模式)
+ start: (id: number): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/start`, {}),
+ stop: (id: number): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/stop`, {}),
+ restart: (id: number): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/restart`, {}),
+ // 获取实例的活跃任务
+ getActiveTask: (id: number): Promise<{
+ task: {
+ id: number
+ taskType: string
+ status: string
+ progress?: string | null
+ error?: string | null
+ queuePosition: number
+ createdAt: string
+ startedAt?: string | null
+ finishedAt?: string | null
+ } | null
+ }> => http.get(`/instances/${id}/task`),
+ // 获取任务详情
+ getTaskById: (taskId: number): Promise<{
+ task: {
+ id: number
+ instanceId: number
+ instanceName?: string | null
+ taskType: string
+ status: string
+ progress?: string | null
+ error?: string | null
+ queuePosition: number
+ createdAt: string
+ startedAt?: string | null
+ finishedAt?: string | null
+ newInstanceId?: number | null
+ }
+ }> => http.get(`/instances/tasks/${taskId}`),
+ rebuild: (id: number, data: { image: string; sshKeyId?: number; customInitCommandIds?: number[] }): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/rebuild`, data),
+ recreate: (id: number, data: { image: string; sshKeyId?: number; customInitCommandIds?: number[] }): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/recreate`, data),
+ addPort: (id: number, data: CreatePortMappingRequest): Promise =>
+ http.post(`/instances/${id}/ports`, data),
+ addPortBatch: (id: number, data: {
+ protocol: 'tcp' | 'udp' | 'both'
+ privatePortStart: number
+ privatePortEnd: number
+ publicPortStart?: number
+ publicPortEnd?: number
+ remark?: string
+ portMappings?: Array<{ privatePort: number; publicPort: number }>
+ }): Promise<{
+ message: string
+ mappings: Array<{ id: number; protocol: string; publicPort: number; privatePort: number }>
+ count: number
+ } | {
+ error: string
+ message: string
+ conflicts: Array<{ publicPort: number; suggestedPort: number | null }>
+ availableCount: number
+ }> => http.post(`/instances/${id}/ports/batch`, data),
+ deletePort: (id: number, portId: number): Promise =>
+ http.delete(`/instances/${id}/ports/${portId}`),
+ // 快照
+ getSnapshots: (id: number): Promise => http.get(`/instances/${id}/snapshots`),
+ createSnapshot: (id: number, data: CreateSnapshotRequest): Promise =>
+ http.post(`/instances/${id}/snapshots`, data, { timeout: TIMEOUT.SNAPSHOT }),
+ deleteSnapshot: (id: number, snapshotId: number): Promise =>
+ http.delete(`/instances/${id}/snapshots/${snapshotId}`, { timeout: TIMEOUT.MEDIUM }),
+ restoreSnapshot: (id: number, snapshotId: number): Promise =>
+ http.post(`/instances/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: TIMEOUT.SNAPSHOT }),
+ getSnapshotPolicy: (id: number): Promise =>
+ http.get(`/instances/${id}/snapshot-policy`),
+ updateSnapshotPolicy: (id: number, data: UpdateSnapshotPolicyRequest): Promise =>
+ http.put(`/instances/${id}/snapshot-policy`, data),
+ // 备份
+ getBackups: (id: number): Promise => http.get(`/instances/${id}/backups`),
+ createBackup: (id: number, data: CreateBackupRequest): Promise =>
+ http.post(`/instances/${id}/backups`, data, { timeout: TIMEOUT.SNAPSHOT }),
+ deleteBackup: (id: number, backupId: number): Promise =>
+ http.delete(`/instances/${id}/backups/${backupId}`, { timeout: TIMEOUT.MEDIUM }),
+ getBackupPolicy: (id: number): Promise =>
+ http.get(`/instances/${id}/backup-policy`),
+ updateBackupPolicy: (id: number, data: UpdateBackupPolicyRequest): Promise =>
+ http.put(`/instances/${id}/backup-policy`, data),
+ // 备份导出
+ exportBackup: (id: number, backupId: number): Promise<{ taskId: string; status: string; expiresAt: string; downloadUrl: string }> =>
+ http.post(`/instances/${id}/backups/${backupId}/export`, {}),
+ getExportStatus: (id: number, taskId: string): Promise<{ taskId: string; status: string; error?: string; expiresAt: string }> =>
+ http.get(`/instances/${id}/backups/export/${taskId}/status`),
+ // 获取一次性下载 token(安全改进:使用短期 token 替代 JWT URL 参数)
+ getDownloadToken: (id: number, taskId: string): Promise<{ downloadUrl: string; expiresIn: number }> =>
+ http.post(`/instances/${id}/backups/export/${taskId}/download-token`, {}),
+ // 已废弃:直接获取下载 URL(不安全,保留用于向后兼容)
+ // @deprecated 使用 getDownloadToken 替代
+ getExportDownloadUrl: (id: number, taskId: string): string =>
+ `/api/instances/${id}/backups/export/${taskId}/download`,
+ // 备份恢复
+ restoreBackup: (id: number, backupId: number): Promise<{ taskId: string; status: string; message: string }> =>
+ http.post(`/instances/${id}/restore/${backupId}`, {}, { timeout: TIMEOUT.SNAPSHOT }),
+ getRestoreStatus: (id: number, taskId: string): Promise<{
+ taskId: string
+ status: string
+ error?: string
+ queuePosition: number
+ duration: number | null
+ createdAt: string
+ startedAt: string | null
+ finishedAt: string | null
+ }> =>
+ http.get(`/instances/${id}/restore/${taskId}`),
+ rollbackRestore: (id: number, taskId: string): Promise<{ success: boolean; message: string }> =>
+ http.post(`/instances/${id}/restore/${taskId}/rollback`, {}, { timeout: TIMEOUT.SNAPSHOT }),
+ // 备份上传到远程存储
+ uploadBackupRemote: (id: number, backupId: number, storageConfigId?: number): Promise<{
+ taskId: number
+ status: string
+ queuePosition: number
+ storageName: string
+ message: string
+ }> => http.post(`/instances/${id}/backups/${backupId}/upload-remote`, { storageConfigId }),
+ getUploadTaskStatus: (id: number, taskId: number): Promise<{
+ taskId: number
+ status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'
+ error?: string
+ remoteFileName?: string
+ fileSize?: string
+ storageName?: string
+ storageType?: string
+ queuePosition: number
+ duration: number | null
+ createdAt: string
+ startedAt: string | null
+ finishedAt: string | null
+ }> => http.get(`/instances/${id}/upload-tasks/${taskId}`),
+ cancelUploadTask: (id: number, taskId: number): Promise<{ success: boolean; message: string }> =>
+ http.delete(`/instances/${id}/upload-tasks/${taskId}`),
+ getActiveUploadTask: (id: number): Promise<{
+ task: {
+ taskId: number
+ backupId: number
+ status: 'PENDING' | 'PROCESSING'
+ storageName?: string
+ storageType?: string
+ queuePosition: number
+ duration: number | null
+ createdAt: string
+ startedAt: string | null
+ finishedAt: string | null
+ } | null
+ }> => http.get(`/instances/${id}/upload-tasks/active`),
+ // 配额
+ updateQuota: (id: number, data: UpdateInstanceRequest): Promise =>
+ http.patch(`/instances/${id}/quota`, data),
+ // 重命名
+ rename: (id: number, name: string): Promise<{ message: string; name: string }> =>
+ http.patch(`/instances/${id}/rename`, { name }),
+ // 更新配置(CPU、内存、磁盘)
+ updateConfig: (id: number, data: { cpu?: number; memory?: number; disk?: number; monthlyTrafficLimit?: string | null }): Promise<{
+ message: string
+ instance: { id: number; cpu: number; memory: number; disk: number }
+ }> => http.patch(`/instances/${id}/config`, data),
+ // 复制实例
+ clone: (id: number): Promise<{ message: string; taskId: number; status: string }> =>
+ http.post(`/instances/${id}/clone`, {}),
+ // 实例配置
+ getConfig: (id: number): Promise<{
+ config: {
+ limits_read: string
+ limits_write: string
+ limits_read_iops: number
+ limits_write_iops: number
+ limits_ingress: string
+ limits_egress: string
+ limits_processes: number
+ limits_cpu_priority: number
+ boot_autostart: boolean
+ boot_autostart_priority: number
+ boot_autostart_delay: number
+ boot_host_shutdown_timeout: number
+ }
+ overrides: Record
+ packageDefaults: {
+ limits_read: string
+ limits_write: string
+ limits_read_iops: number
+ limits_write_iops: number
+ limits_ingress: string
+ limits_egress: string
+ limits_processes: number
+ limits_cpu_priority: number
+ boot_autostart: boolean
+ boot_autostart_priority: number
+ boot_autostart_delay: number
+ boot_host_shutdown_timeout: number
+ }
+ ioLimitMode: 'throughput' | 'iops'
+ swap: {
+ available: boolean
+ enabled: boolean
+ sizeMb: number
+ kind: 'container' | 'vm'
+ requiresRunning: boolean
+ }
+ }> => http.get(`/instances/${id}/config`),
+ enableSwap: (id: number): Promise<{ message: string; swapEnabled: boolean; swapSize: number }> =>
+ http.post(`/instances/${id}/swap/enable`, {}),
+ disableSwap: (id: number): Promise<{ message: string; swapEnabled: boolean; swapSize: number }> =>
+ http.post(`/instances/${id}/swap/disable`, {}),
+ updateInstanceConfig: (id: number, data: {
+ limitsRead?: string | null
+ limitsWrite?: string | null
+ limitsReadIops?: number | null
+ limitsWriteIops?: number | null
+ limitsIngress?: string | null
+ limitsEgress?: string | null
+ limitsProcesses?: number | null
+ limitsCpuPriority?: number | null
+ bootAutostart?: boolean | null
+ bootAutostartPriority?: number | null
+ bootAutostartDelay?: number | null
+ bootHostShutdownTimeout?: number | null
+ }): Promise<{ message: string }> => http.patch(`/instances/${id}/advanced-config`, data),
+ // 提升进程数限制
+ boostProcesses: (id: number): Promise<{ message: string; newLimit: number }> =>
+ http.post(`/instances/${id}/boost-processes`, {}),
+ // IP 地址管理
+ getIpAddresses: (id: number): Promise<{ ipAddresses: IpAddress[] }> =>
+ http.get(`/instances/${id}/ips`),
+ addIpAddress: (id: number, customAddress?: string): Promise<{ success: boolean; ipAddress: IpAddress }> =>
+ http.post(`/instances/${id}/ips`, customAddress ? { customAddress } : {}, { timeout: TIMEOUT.MEDIUM }),
+ deleteIpAddress: (id: number, ipId: number): Promise<{ success: boolean }> =>
+ http.delete(`/instances/${id}/ips/${ipId}`, { timeout: TIMEOUT.MEDIUM }),
+ // IPv6 地址管理 (新双网卡架构)
+ setCustomIpv6: (id: number, ipId: number, address: string): Promise<{ success: boolean; ipAddress: IpAddress }> =>
+ http.put(`/instances/${id}/ips/${ipId}/custom`, { address }, { timeout: TIMEOUT.MEDIUM }),
+ // IPv6 网段管理
+ getIpv6Subnets: (id: number): Promise<{ subnets: Ipv6Subnet[] }> =>
+ http.get(`/instances/${id}/subnets`),
+ allocateIpv6Subnet: (id: number, prefix: 112 | 120 | 124): Promise<{ success: boolean; subnet: Ipv6Subnet }> =>
+ http.post(`/instances/${id}/subnet`, { prefix }, { timeout: TIMEOUT.MEDIUM }),
+ deleteIpv6Subnet: (id: number, subnetId: number): Promise<{ success: boolean }> =>
+ http.delete(`/instances/${id}/subnet/${subnetId}`, { timeout: TIMEOUT.MEDIUM }),
+ // 站点管理
+ getSites: (id: number): Promise<{
+ sites: Array<{
+ id: number
+ domain: string
+ targetPort: number
+ httpsEnabled: boolean
+ remark: string | null
+ status: 'pending' | 'active' | 'error'
+ enabled: boolean
+ error: string | null
+ createdAt: string
+ }>
+ caddyEnabled: boolean
+ dnsRecordType: 'A' | 'AAAA' | 'CNAME' | null
+ dnsRecordValue: string | null
+ siteQuota: {
+ used: number
+ limit: number // 0 = 不限制
+ }
+ canManageSites?: boolean
+ }> => http.get(`/instances/${id}/sites`),
+ addSite: (id: number, data: { domain: string; targetPort: number; httpsEnabled?: boolean; remark?: string }): Promise<{
+ success: boolean
+ site: { id: number; domain: string; targetPort: number; httpsEnabled: boolean; status: string }
+ dnsHint: {
+ type: 'A' | 'AAAA' | 'CNAME'
+ host: string
+ value: string
+ }
+ }> => http.post(`/instances/${id}/sites`, data),
+ deleteSite: (id: number, siteId: number): Promise<{ success: boolean }> =>
+ http.delete(`/instances/${id}/sites/${siteId}`),
+ updateSite: (id: number, siteId: number, data: { targetPort?: number; httpsEnabled?: boolean; remark?: string }): Promise<{
+ message: string
+ site: { id: number; domain: string; targetPort: number; httpsEnabled: boolean; remark: string | null; status: string }
+ }> => http.patch(`/instances/${id}/sites/${siteId}`, data),
+ refreshSite: (id: number, siteId: number): Promise<{ success: boolean; status: string }> =>
+ http.post(`/instances/${id}/sites/${siteId}/refresh`),
+ checkDns: (id: number, siteId: number): Promise<{
+ dnsResolved: boolean
+ ipMatches?: boolean
+ expectedIp: string
+ resolvedIps: string[]
+ activated?: boolean
+ status: string
+ error?: string
+ message?: string
+ }> => http.post(`/instances/${id}/sites/${siteId}/check-dns`),
+ toggleSite: (id: number, siteId: number): Promise<{ message: string; enabled: boolean; status: string }> =>
+ http.post(`/instances/${id}/sites/${siteId}/toggle`),
+ getCertificateStatus: (id: number, siteId: number): Promise<{
+ httpsEnabled: boolean
+ status: 'disabled' | 'valid' | 'pending' | 'dns_error' | 'connection_refused' | 'timeout' | 'cert_pending' | 'error'
+ message?: string
+ error?: string
+ hint?: string
+ certificate?: {
+ valid: boolean
+ issuer: string
+ subject: string
+ validFrom: string
+ validTo: string
+ daysRemaining: number
+ }
+ }> => http.get(`/instances/${id}/sites/${siteId}/certificate`),
+ // Cloud-init 状态检查
+ checkCloudInitStatus: (id: number): Promise =>
+ http.get(`/instances/${id}/cloud-init-status`),
+ manualCompleteCloudInit: (id: number): Promise =>
+ http.post(`/instances/${id}/cloud-init-status/manual-complete`, {}),
+ // 封停/解封实例(仅宿主机所有者和管理员)
+ suspend: (id: number, reason?: string): Promise<{ message: string }> =>
+ http.post(`/instances/${id}/suspend`, { reason }),
+ unsuspend: (id: number): Promise<{ message: string }> =>
+ http.post(`/instances/${id}/unsuspend`, {}),
+ // 同步实例状态(从 Incus 获取实际状态并更新数据库)
+ syncStatus: (id: number): Promise<{
+ success: boolean
+ statusChanged: boolean
+ from?: string
+ to?: string
+ currentStatus: string
+ ipv4Changed?: boolean
+ oldIpv4?: string
+ newIpv4?: string
+ proxySitesUpdated?: number
+ }> => http.post(`/instances/${id}/sync-status`, {}),
+ // 重新分配 IPv6 地址(仅 nat_ipv6 模式)
+ reassignIpv6: (id: number): Promise<{ message: string; oldIpv6: string | null; newIpv6: string }> =>
+ http.post(`/instances/${id}/reassign-ipv6`, {})
+ },
+
+ // SSH 密钥
+ sshKeys: {
+ list: (): Promise<{ keys: SshKey[] }> => http.get('/ssh-keys'),
+ create: (data: CreateSshKeyRequest): Promise<{ message: string; key: SshKey }> => http.post('/ssh-keys', data),
+ delete: (id: number): Promise<{ message: string }> => http.delete(`/ssh-keys/${id}`),
+ generate: (): Promise<{ message: string; privateKey: string; key: { id: number; name: string; fingerprint: string; publicKeyPreview: string } }> =>
+ http.post('/ssh-keys/generate')
+ },
+
+ // 通知管理
+ notifications: {
+ list: (): Promise<{ channels: NotificationChannel[] }> => http.get('/notifications'),
+ get: (id: number): Promise => http.get(`/notifications/${id}`),
+ create: (data: CreateNotificationChannelRequest): Promise =>
+ http.post('/notifications', data),
+ update: (id: number, data: UpdateNotificationChannelRequest): Promise =>
+ http.patch(`/notifications/${id}`, data),
+ delete: (id: number): Promise => http.delete(`/notifications/${id}`),
+ test: (id: number): Promise => http.post(`/notifications/${id}/test`),
+ toggle: (id: number): Promise => http.post(`/notifications/${id}/toggle`),
+ // 通知历史
+ getLogs: (params: { page?: number; pageSize?: number; status?: 'pending' | 'sent' | 'failed' } = {}): Promise<{
+ logs: Array<{
+ id: number
+ channelId: number
+ channelName: string
+ channelType: string
+ eventType: string
+ message: string
+ status: string
+ error: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/notifications/logs', { params }),
+ getStats: (): Promise<{
+ stats: {
+ total: number
+ sent: number
+ failed: number
+ pending: number
+ }
+ }> => http.get('/notifications/stats'),
+ // 获取管理员创建的全局通知渠道(供托管用户绑定套餐使用)
+ getGlobalChannels: (): Promise<{
+ channels: Array<{ id: number; name: string; type: string; configPreview: string }>
+ }> => http.get('/notifications/global-channels')
+ },
+
+ // Telegram 专用机器人绑定
+ telegram: {
+ getBinding: (): Promise => http.get('/telegram/binding'),
+ createBindToken: (): Promise => http.post('/telegram/bind-token'),
+ unlink: (): Promise<{ message: string }> => http.delete('/telegram/binding'),
+ getWebhookInfo: (): Promise => http.get('/telegram/admin/webhook/info'),
+ setupWebhook: (data: { baseUrl?: string } = {}): Promise =>
+ http.post('/telegram/admin/webhook/setup', data),
+ deleteWebhook: (): Promise =>
+ http.post('/telegram/admin/webhook/delete', {}),
+ listBindings: (params?: { page?: number; pageSize?: number; search?: string }): Promise =>
+ http.get('/telegram/admin/bindings', { params }),
+ unlinkAdminBinding: (id: number): Promise<{ message: string }> =>
+ http.delete(`/telegram/admin/bindings/${id}`)
+ },
+
+ // 管理员全局通知渠道管理
+ adminNotificationChannels: {
+ list: (): Promise<{
+ channels: Array<{ id: number; name: string; type: string; enabled: boolean; boundPackages: number; configPreview: string; createdAt: string }>
+ }> => http.get('/admin/notification-channels'),
+ get: (id: number): Promise<{
+ channel: { id: number; name: string; type: string; enabled: boolean; config: Record; createdAt: string }
+ }> => http.get(`/admin/notification-channels/${id}`),
+ create: (data: { name: string; botToken: string; chatId: string; enabled?: boolean }): Promise<{ message: string; channel: { id: number; name: string } }> =>
+ http.post('/admin/notification-channels', data),
+ update: (id: number, data: { name?: string; botToken?: string; chatId?: string; enabled?: boolean }): Promise<{ message: string }> =>
+ http.patch(`/admin/notification-channels/${id}`, data),
+ delete: (id: number): Promise<{ message: string }> =>
+ http.delete(`/admin/notification-channels/${id}`),
+ test: (id: number): Promise<{ message: string }> =>
+ http.post(`/admin/notification-channels/${id}/test`)
+ },
+
+ // 主机管理
+ hosts: {
+ list: (params: Record = {}): Promise<{ hosts: HostWithDetails[]; total: number; page: number; pageSize: number; totalPages: number }> =>
+ http.get('/hosts', { params }),
+ // 管理员专用:获取托管节点列表
+ listHosted: (params: { userId?: number; page?: number; pageSize?: number; search?: string } = {}): Promise<{ hosts: HostWithDetails[]; total: number; page: number; pageSize: number; totalPages: number }> =>
+ http.get('/hosts', { params: { ...params, scope: 'hosted' } }),
+ get: (id: number): Promise => http.get(`/hosts/${id}`),
+ lookupGiftTargetUser: (hostId: number, username: string): Promise<{
+ user: {
+ id: number
+ username: string
+ status: string
+ hasSshKey: boolean
+ }
+ }> => http.get(`/hosts/${hostId}/users/lookup`, { params: { username } }),
+ getImagePolicy: (id: number): Promise => http.get(`/hosts/${id}/images`),
+ updateImagePolicy: (id: number, data: { useDefault: boolean; imageIds?: number[] }): Promise =>
+ http.put(`/hosts/${id}/images`, data),
+ create: (data: CreateHostRequest): Promise => http.post('/hosts', data),
+ update: (id: number, data: UpdateHostRequest): Promise =>
+ http.patch(`/hosts/${id}`, data),
+ delete: (id: number): Promise => http.delete(`/hosts/${id}`),
+ test: (id: number): Promise<{ success: boolean; message?: string }> =>
+ http.post(`/hosts/${id}/test`, {}, { timeout: TIMEOUT.MEDIUM }),
+ takeoverOfficial: (id: number): Promise<{
+ success: boolean
+ summary: {
+ hostId: number
+ previousOwnerId: number
+ previousOwnerUsername: string
+ instanceCount: number
+ transferredPackageCount: number
+ detachedPackageCount: number
+ transferredPackageNames: string[]
+ detachedPackageNames: string[]
+ hostRenamed: boolean
+ oldHostName: string
+ newHostName: string
+ }
+ }> => http.post(`/hosts/${id}/takeover-official`, {}),
+ verify: (id: number): Promise<{ success: boolean; message?: string; resources?: { cpuTotal: number; memoryTotalGB: number; diskTotalGB: number } }> =>
+ http.post(`/hosts/${id}/verify`, {}, { timeout: TIMEOUT.MEDIUM }),
+ regenerateInstall: (id: number): Promise<{ success: boolean; installCommand: string; installToken?: string; message: string }> =>
+ http.post(`/hosts/${id}/regenerate-install`, {}),
+ getAgentStatus: (id: number): Promise =>
+ http.get(`/agent/hosts/${id}/status`),
+ generateAgentInstallCommand: (id: number): Promise =>
+ http.post(`/agent/hosts/${id}/install-command`, {}),
+ requestAgentUpgrade: (id: number): Promise =>
+ http.post(`/agent/hosts/${id}/upgrade`, {}),
+ sync: (id: number): Promise => http.post(`/hosts/${id}/sync`, {}, { timeout: TIMEOUT.LONG }),
+ setMaintenance: (id: number, enabled: boolean): Promise =>
+ http.post(`/hosts/${id}/maintenance`, { enabled }),
+ // 存储池管理
+ getStoragePools: (id: number): Promise<{ pools: Array<{ name: string; driver: string; description: string; status: string; purpose?: 'instance_data' | 'instance_storage' | null; config: Record; usedBy: number; space?: { used: number; total: number } | null }> }> =>
+ http.get(`/hosts/${id}/storage-pools`),
+ createStoragePool: (id: number, data: {
+ name: string
+ driver?: 'zfs' | 'lvm' | 'btrfs' | 'dir' // 使用已有池时可不传
+ source?: string
+ size?: string
+ zfsPoolName?: string
+ lvmVgName?: string
+ lvmUseThinpool?: boolean
+ description?: string
+ purpose?: 'instance_data' | 'instance_storage'
+ useExisting?: boolean // 使用/导入已有存储池模式
+ existingSource?: string // 底层已存在的存储源名称(如 ZFS 池名、LVM VG 名)
+ }): Promise<{ success: boolean; message: string; imported?: boolean }> =>
+ http.post(`/hosts/${id}/storage-pools`, data),
+ deleteStoragePool: (id: number, poolName: string): Promise<{ success: boolean; message: string }> =>
+ http.delete(`/hosts/${id}/storage-pools/${poolName}`),
+ updateStoragePool: (id: number, poolName: string, data: {
+ size?: string
+ description?: string
+ purpose?: 'instance_data' | 'instance_storage'
+ }): Promise<{ success: boolean; message: string }> =>
+ http.patch(`/hosts/${id}/storage-pools/${poolName}`, data),
+ // Caddy 管理
+ getCaddy: (id: number): Promise<{
+ enabled: boolean
+ username: string | null
+ port: number
+ hasPassword: boolean
+ natPublicIp: string | null
+ sitesCount: number
+ }> => http.get(`/hosts/${id}/caddy`),
+ generateCaddyCommand: (id: number): Promise<{
+ installCommand: string
+ username: string
+ password: string
+ port: number
+ isNewCredentials: boolean
+ }> => http.post(`/hosts/${id}/caddy/generate`),
+ resetCaddyCredentials: (id: number): Promise<{
+ installCommand: string
+ username: string
+ password: string
+ port: number
+ }> => http.post(`/hosts/${id}/caddy/reset`),
+ confirmCaddyInstalled: (id: number): Promise<{ message: string }> =>
+ http.post(`/hosts/${id}/caddy/confirm`),
+ testCaddyConnection: (id: number): Promise<{
+ connected: boolean
+ sitesCount: number
+ dnsRecordType: 'A' | 'AAAA' | 'CNAME'
+ dnsRecordValue: string
+ }> => http.post(`/hosts/${id}/caddy/test`, {}, { timeout: TIMEOUT.MEDIUM }),
+ getCaddySites: (id: number, params: { page?: number; pageSize?: number } = {}): Promise<{
+ sites: Array<{
+ id: number
+ domain: string
+ targetPort: number
+ httpsEnabled: boolean
+ status: 'pending' | 'active' | 'error'
+ enabled: boolean
+ error: string | null
+ createdAt: string
+ instance: {
+ id: number
+ name: string
+ ipv4: string | null
+ status: string
+ } | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get(`/hosts/${id}/caddy/sites`, { params }),
+ // 批量删除实例预览(计算退款信息)
+ batchDeleteInstancesPreview: (hostId: number, instanceIds: number[]): Promise<{
+ instances: Array<{
+ id: number
+ name: string
+ username: string
+ userId: number
+ isOwnInstance: boolean
+ isPaid: boolean
+ remainingDays: number
+ refundAmount: number
+ }>
+ totalRefundAmount: number
+ }> => http.post(`/hosts/${hostId}/instances/batch-delete-preview`, { instanceIds }),
+ // 批量删除实例
+ batchDeleteInstances: (hostId: number, instanceIds: number[], reason?: string, databaseOnly?: boolean): Promise<{
+ message: string
+ results: Array<{ id: number; name: string; success: boolean; error?: string; refundAmount?: number }>
+ successCount: number
+ failedCount: number
+ totalRefundAmount: number
+ }> => http.delete(`/hosts/${hostId}/instances/batch`, { data: { instanceIds, reason, databaseOnly }, timeout: TIMEOUT.BATCH }),
+ // 批量赠送时长
+ giftDays: (hostId: number, instanceIds: number[], days: number): Promise<{
+ message: string
+ results: Array<{ instanceId: number; instanceName: string; success: boolean; error?: string; newExpiresAt?: string }>
+ successCount: number
+ failedCount: number
+ skippedCount: number
+ }> => http.post(`/hosts/${hostId}/instances/gift-days`, { instanceIds, days }),
+ createInstanceForUser: (hostId: number, data: {
+ username: string
+ name: string
+ packageId: number
+ image: string
+ cpu?: number
+ memory?: number
+ disk?: number
+ planId?: number
+ giftDays?: number
+ }): Promise<{
+ message: string
+ instance: {
+ id: number
+ name: string
+ incusId: string
+ host: string
+ status: string
+ user: { id: number; username: string }
+ isPaid: boolean
+ planName: string | null
+ charged: boolean
+ amount: number
+ expiresAt: string | null
+ giftDays?: number | null
+ }
+ }> => http.post(`/hosts/${hostId}/instances/create-for-user`, data),
+ // 修改付费实例的续费价格(托管节点所有者专用)
+ updateInstanceRenewalPrice: (hostId: number, instanceId: number, newPrice: number): Promise<{
+ message: string
+ instanceId: number
+ instanceName: string
+ oldPrice: number
+ newPrice: number
+ }> => http.patch(`/hosts/${hostId}/instances/${instanceId}/renewal-price`, { newPrice }),
+ // 批量同步实例状态
+ batchSyncInstanceStatus: (hostId: number, instanceIds: number[]): Promise<{
+ message: string
+ results: Array<{ id: number; name: string; success: boolean; from?: string; to?: string; ipv4Changed?: boolean; oldIpv4?: string; newIpv4?: string; proxySitesUpdated?: number; error?: string }>
+ syncedCount: number
+ changedCount: number
+ ipv4ChangedCount: number
+ failedCount: number
+ }> => http.post(`/hosts/${hostId}/instances/sync-status`, { instanceIds }, { timeout: TIMEOUT.MEDIUM }),
+ // 资源校对:重新计算宿主机资源使用量,并将配额对齐到已用配额
+ recalculateResources: (hostId: number): Promise<{
+ message: string
+ hasChanges: boolean
+ before: { cpuUsed: number; memoryUsed: number; diskUsed: number; natPortsUsedCount: number; cpuAllowanceMax: number; memoryMax: number }
+ after: { cpuUsed: number; memoryUsed: number; diskUsed: number; natPortsUsedCount: number; cpuAllowanceMax: number; memoryMax: number }
+ diff: { cpuUsed: number; memoryUsed: number; diskUsed: number; natPortsUsedCount: number; cpuAllowanceMax: number; memoryMax: number }
+ }> => http.post(`/hosts/${hostId}/recalculate-resources`),
+ // 批量封停实例
+ batchSuspendInstances: (hostId: number, instanceIds: number[], reason?: string): Promise<{
+ message: string
+ results: Array<{ id: number; name: string; success: boolean; error?: string }>
+ successCount: number
+ failedCount: number
+ }> => http.post(`/hosts/${hostId}/instances/suspend`, { instanceIds, reason }, { timeout: TIMEOUT.BATCH }),
+ // 批量解封实例
+ batchUnsuspendInstances: (hostId: number, instanceIds: number[]): Promise<{
+ message: string
+ results: Array<{ id: number; name: string; success: boolean; error?: string }>
+ successCount: number
+ failedCount: number
+ }> => http.post(`/hosts/${hostId}/instances/unsuspend`, { instanceIds }, { timeout: TIMEOUT.BATCH }),
+ // 批量为节点下所有付费实例免费延期(仅管理员)
+ batchExtendAll: (hostId: number, days: number): Promise<{
+ success: boolean
+ message: string
+ extendedCount: number
+ }> => http.post(`/hosts/${hostId}/extend-all`, { days }),
+ // 批量迁移实例到其他节点(仅管理员)
+ migrateInstances: (hostId: number, instanceIds: number[], targetHostId: number, targetImage: string, targetPlanId?: number): Promise<{
+ message: string
+ results: Array<{ id: number; name: string; success: boolean; error?: string; newInstanceId?: number }>
+ successCount: number
+ failedCount: number
+ }> => http.post(`/hosts/${hostId}/instances/migrate`, { instanceIds, targetHostId, targetImage, targetPlanId }, { timeout: TIMEOUT.BATCH }),
+ // 获取节点绑定的套餐方案(用于改节点)
+ getHostPlans: (hostId: number): Promise<{
+ plans: Array<{
+ id: number
+ name: string
+ packageId: number
+ packageName: string
+ price: number
+ billingCycle: number
+ }>
+ }> => http.get(`/hosts/${hostId}/plans`),
+ // 批量修改实例配置
+ batchUpdateConfig: (hostId: number, data: {
+ instanceIds: number[]
+ config: Record
+ }): Promise<{
+ success: boolean
+ totalCount: number
+ successCount: number
+ failedCount: number
+ failedItems: Array<{
+ instanceId: number
+ incusId: string
+ name: string
+ error?: string
+ }>
+ retryPayload?: {
+ instanceIds: number[]
+ config: Record
+ }
+ }> => http.post(`/hosts/${hostId}/instances/batch-config`, data, { timeout: TIMEOUT.BATCH }),
+ // 兑换码管理
+ getRedeemCodes: (hostId: number, params: { limit?: number; offset?: number; enabled?: boolean } = {}): Promise<{
+ codes: Array<{
+ id: number
+ code: string
+ codeType: 'c' | 'r' | 'd' | 't'
+ codeValue: number
+ maxUses: number
+ usedCount: number
+ expiresAt: string | null
+ enabled: boolean
+ remark: string | null
+ batchId: string | null
+ createdAt: string
+ }>
+ total: number
+ }> => http.get(`/hosts/${hostId}/redeem-codes`, { params }),
+ createRedeemCode: (hostId: number, data: {
+ codeType: 'c' | 'r' | 'd' | 't'
+ codeValue: number
+ maxUses?: number
+ expiresAt?: string | null
+ remark?: string
+ batchCount?: number
+ }): Promise<{ message: string; code?: string; id?: number; codes?: string[]; batchId?: string; count?: number }> =>
+ http.post(`/hosts/${hostId}/redeem-codes`, data),
+ updateRedeemCode: (hostId: number, codeId: number, data: {
+ enabled?: boolean
+ remark?: string
+ maxUses?: number
+ expiresAt?: string | null
+ }): Promise<{ message: string }> =>
+ http.patch(`/hosts/${hostId}/redeem-codes/${codeId}`, data),
+ deleteRedeemCode: (hostId: number, codeId: number): Promise<{ message: string }> =>
+ http.delete(`/hosts/${hostId}/redeem-codes/${codeId}`),
+ batchDeleteRedeemCodes: (hostId: number, ids: number[]): Promise<{ message: string; count: number }> =>
+ http.post(`/hosts/${hostId}/redeem-codes/batch-delete`, { ids }),
+ getRedeemCodeUsages: (hostId: number, codeId: number, params: { limit?: number; offset?: number } = {}): Promise<{
+ usages: Array<{
+ id: number
+ user: { id: number; username: string }
+ instance: { id: number; name: string }
+ usedAt: string
+ }>
+ total: number
+ }> => http.get(`/hosts/${hostId}/redeem-codes/${codeId}/usages`, { params }),
+ getRedeemCodeOptions: (): Promise<{
+ types: Array<{ value: string; label: string; unit: string }>
+ ranges: Record
+ }> => http.get('/redeem-code-options'),
+ opsDiscover: (hostId: number): Promise<{
+ managed: Array<{ incusName: string; incusType: string; incusStatus: string; dbId: number; dbStatus: string; userId: number }>
+ orphaned: Array<{ incusName: string; incusType: string; incusStatus: string }>
+ missing: Array<{ dbId: number; dbName: string; incusId: string; dbStatus: string }>
+ summary: {
+ totalIncus: number
+ totalDb: number
+ managedCount: number
+ orphanedCount: number
+ missingCount: number
+ }
+ }> => http.post(`/hosts/${hostId}/ops/discover`),
+ opsBaselineSync: (hostId: number): Promise<{
+ message: string
+ resources: {
+ cpuUsed: number
+ memoryUsed: number
+ diskUsed: number
+ }
+ instanceSync: {
+ total: number
+ synced: number
+ ipChanged: number
+ }
+ }> => http.post(`/hosts/${hostId}/ops/baseline-sync`),
+ opsNetworkRepair: (hostId: number): Promise<{
+ message: string
+ results: Array<{
+ id: number
+ name: string
+ success: boolean
+ statusChanged?: boolean
+ oldStatus?: string
+ newStatus?: string
+ ipv4Changed?: boolean
+ oldIpv4?: string | null
+ newIpv4?: string | null
+ ipv6Changed?: boolean
+ oldIpv6?: string | null
+ newIpv6?: string | null
+ error?: string
+ }>
+ summary: {
+ total: number
+ success: number
+ failed: number
+ changed: number
+ }
+ }> => http.post(`/hosts/${hostId}/ops/network-repair`),
+ opsInstanceSshKeys: (hostId: number, instanceId: number): Promise<{
+ keys: SshKey[]
+ }> => http.get(`/hosts/${hostId}/ops/instances/${instanceId}/ssh-keys`),
+ opsInstanceInitCommands: (hostId: number, instanceId: number, distro: string): Promise<{
+ commands: Array<{
+ id: number
+ name: string
+ commandLineCount: number
+ distros: string[]
+ description: string | null
+ }>
+ }> => http.get(`/hosts/${hostId}/ops/instances/${instanceId}/init-commands`, { params: { distro } }),
+ opsInstancePreview: (hostId: number, instanceId: number): Promise<{
+ instanceId: number
+ instanceName: string
+ incusId: string
+ instanceStatus: string
+ hostName: string
+ hostId: number
+ imageAlias?: string | null
+ canSync: boolean
+ canRestart: boolean
+ canForceRestart: boolean
+ canRebuild: boolean
+ canRecreate: boolean
+ activeTask?: {
+ id: number
+ taskType: string
+ status: string
+ } | null
+ risk: {
+ status: string
+ isStopped: boolean
+ hasActiveTask: boolean
+ suggestedAction: 'rebuild' | 'recreate' | 'sync' | 'restart' | 'none'
+ notes: string[]
+ }
+ }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/preview`),
+ opsInstanceSync: (hostId: number, instanceId: number): Promise<{
+ success: boolean
+ message: string
+ statusChanged?: boolean
+ from?: string
+ to?: string
+ currentStatus?: string
+ ipv4Changed?: boolean
+ oldIpv4?: string | null
+ newIpv4?: string | null
+ ipv6Changed?: boolean
+ oldIpv6?: string | null
+ newIpv6?: string | null
+ }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/sync`),
+ opsInstanceRestart: (hostId: number, instanceId: number, force: boolean): Promise<{
+ success: boolean
+ message: string
+ }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/restart`, { force }),
+ opsInstanceDangerousAction: (hostId: number, instanceId: number, data: {
+ action: 'rebuild' | 'recreate'
+ imageAlias: string
+ sshKeyId?: number
+ customInitCommandIds?: number[]
+ confirmationText: string
+ riskConfirmed: boolean
+ }): Promise<{
+ success: boolean
+ message: string
+ taskId: number
+ status: string
+ }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/dangerous-action`, data),
+ opsInstanceAuditScan: (hostId: number, instanceId: number): Promise<{
+ success: boolean
+ scanId: number
+ scannedAt: string
+ capability: string
+ instance: {
+ id: number
+ name: string
+ incusId: string
+ type: string
+ status: string
+ }
+ summary: {
+ riskLevel: 'info' | 'low' | 'medium' | 'high'
+ processCount: number
+ connectionCount: number
+ listeningCount: number
+ startupItemCount: number
+ findingCount: number
+ }
+ ignoredCount?: number
+ rules?: Array<{
+ id: string
+ source: 'builtin' | 'custom'
+ name: string
+ category: string
+ severity: 'info' | 'low' | 'medium' | 'high'
+ targetTypes: Array<'process' | 'network' | 'startup'>
+ matchType: 'contains' | 'regex' | 'exact'
+ pattern: string
+ caseSensitive: boolean
+ recommendation?: string | null
+ enabled: boolean
+ }>
+ findings: Array<{
+ id: string
+ severity: 'info' | 'low' | 'medium' | 'high'
+ category: string
+ title: string
+ detail: string
+ targetType: 'process' | 'network' | 'startup' | 'capability'
+ ruleId?: string
+ ruleName?: string
+ ruleSource?: 'builtin' | 'custom'
+ matchedText?: string
+ recommendation?: string | null
+ pid?: number
+ evidence: string
+ ignored?: boolean
+ ignoreReason?: string | null
+ }>
+ processes: Array<{
+ pid: number
+ ppid: number | null
+ user: string
+ stat: string
+ cpuPercent: number | null
+ memoryPercent: number | null
+ elapsed: string
+ command: string
+ args: string
+ raw: string
+ findings: string[]
+ }>
+ connections: Array<{
+ protocol: string
+ state: string
+ local: string
+ peer: string
+ process: string | null
+ pid: number | null
+ raw: string
+ }>
+ startupItems: Array<{
+ source: string
+ command: string
+ raw: string
+ findings: string[]
+ }>
+ stderr?: string[]
+ }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/audit/scan`),
+ opsInstanceAuditKillProcess: (hostId: number, instanceId: number, data: {
+ pid: number
+ signal?: 'TERM' | 'KILL'
+ reason: string
+ confirmationText: string
+ scanId?: number
+ expectedCommand?: string
+ }): Promise<{
+ success: boolean
+ message: string
+ pid: number
+ signal: 'TERM' | 'KILL'
+ stdout?: string
+ stderr?: string
+ }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/audit/kill-process`, data),
+ opsAuditRules: (hostId: number): Promise<{
+ builtin: Array
+ custom: Array
+ canCreateGlobal: boolean
+ }> => http.get(`/hosts/${hostId}/ops/audit/rules`),
+ opsAuditCreateRule: (hostId: number, data: Record): Promise =>
+ http.post(`/hosts/${hostId}/ops/audit/rules`, data),
+ opsAuditUpdateRule: (hostId: number, ruleId: number, data: Record): Promise =>
+ http.patch(`/hosts/${hostId}/ops/audit/rules/${ruleId}`, data),
+ opsAuditDeleteRule: (hostId: number, ruleId: number): Promise<{ success: boolean }> =>
+ http.delete(`/hosts/${hostId}/ops/audit/rules/${ruleId}`),
+ opsAuditUpdateBuiltinRule: (hostId: number, ruleId: string, data: Record): Promise =>
+ http.patch(`/hosts/${hostId}/ops/audit/builtin-rules/${encodeURIComponent(ruleId)}`, data),
+ opsAuditResetBuiltinRule: (hostId: number, ruleId: string): Promise<{ success: boolean }> =>
+ http.delete(`/hosts/${hostId}/ops/audit/builtin-rules/${encodeURIComponent(ruleId)}`),
+ opsAuditIgnores: (hostId: number, params?: { instanceId?: number }): Promise<{ ignores: Array }> =>
+ http.get(`/hosts/${hostId}/ops/audit/ignores`, { params }),
+ opsAuditCreateIgnore: (hostId: number, data: Record): Promise =>
+ http.post(`/hosts/${hostId}/ops/audit/ignores`, data),
+ opsAuditDeleteIgnore: (hostId: number, ignoreId: number): Promise<{ success: boolean }> =>
+ http.delete(`/hosts/${hostId}/ops/audit/ignores/${ignoreId}`),
+ opsAuditHistory: (hostId: number, params?: { instanceId?: number; pageSize?: number }): Promise<{
+ scans: Array
+ actions: Array
+ }> => http.get(`/hosts/${hostId}/ops/audit/history`, { params })
+ },
+
+ // 套餐管理
+ packages: {
+ // 公开 API(无需登录)
+ listPublic: (options?: { source?: 'official' | 'market' }): Promise<{ packages: Package[]; total: number }> => {
+ const params = new URLSearchParams()
+ if (options?.source) params.append('source', options.source)
+ const queryString = params.toString()
+ return http.get(queryString ? `/packages/public?${queryString}` : '/packages/public')
+ },
+ getPublicRegions: (options?: { source?: 'official' | 'market' }): Promise<{
+ regions: Array<{
+ code: string
+ name: string
+ packageIds: number[]
+ hostCount: number
+ }>
+ }> => {
+ const params = new URLSearchParams()
+ if (options?.source) params.append('source', options.source)
+ const queryString = params.toString()
+ return http.get(queryString ? `/packages/public/regions?${queryString}` : '/packages/public/regions')
+ },
+ // 需要登录的 API
+ list: (options?: { all?: boolean; source?: 'official' | 'market' | 'friends' | 'zone'; zoneId?: number; scope?: 'mine' | 'official' | 'hosted' }): Promise<{ packages: Package[]; total: number }> => {
+ const params = new URLSearchParams()
+ if (options?.all) params.append('all', 'true')
+ if (options?.source) params.append('source', options.source)
+ if (options?.zoneId) params.append('zoneId', String(options.zoneId))
+ if (options?.scope) params.append('scope', options.scope)
+ const queryString = params.toString()
+ return http.get(queryString ? `/packages?${queryString}` : '/packages')
+ },
+ getHostingZones: (): Promise<{
+ zones: Array<{
+ id: number
+ name: string
+ ownerId: number
+ ownerUsername: string
+ logoUrl: string
+ sortOrder: number
+ }>
+ }> => http.get('/packages/hosting-zones'),
+ // 管理员专用:获取托管套餐列表
+ listHosted: (params: { userId?: number } = {}): Promise<{ packages: Package[] }> =>
+ http.get('/packages', { params: { ...params, scope: 'hosted' } }),
+ get: (id: number): Promise => http.get(`/packages/${id}`),
+ create: (data: CreatePackageRequest): Promise => http.post('/packages', data),
+ update: (id: number, data: UpdatePackageRequest): Promise =>
+ http.patch(`/packages/${id}`, data),
+ delete: (id: number): Promise => http.delete(`/packages/${id}`),
+ // 套餐共享
+ share: (packageId: number, friendId: number, quotaMultiplier?: number | null, maxInstances?: number | null): Promise<{ message: string }> =>
+ http.post(`/packages/${packageId}/share`, { friendId, quotaMultiplier, maxInstances }),
+ unshare: (packageId: number, userId: number): Promise<{ message: string }> =>
+ http.delete(`/packages/${packageId}/share/${userId}`),
+ updateShareQuota: (packageId: number, shareId: number, quotaMultiplier?: number | null, maxInstances?: number | null): Promise<{ message: string }> =>
+ http.patch(`/packages/${packageId}/shares/${shareId}`, { quotaMultiplier, maxInstances }),
+ getShares: (packageId: number): Promise<{
+ shares: Array<{
+ id: number
+ packageId: number
+ packageName: string
+ ownerId: number
+ ownerUsername: string
+ sharedToId: number
+ sharedToUsername: string
+ sharedToAvatarStyle?: string | null
+ sharedToAvatarBadgeId?: string | null
+ quotaMultiplier: number | null
+ maxInstances: number | null
+ usage?: {
+ instanceCount: number
+ totalCpu: number
+ totalMemory: number
+ }
+ createdAt: string
+ }>
+ packageQuota: {
+ cpuMax: number
+ memoryMax: number
+ }
+ }> => http.get(`/packages/${packageId}/shares`),
+ getShared: (): Promise<{
+ shares: Array<{
+ id: number
+ packageId: number
+ packageName: string
+ ownerId: number
+ ownerUsername: string
+ sharedToId: number
+ sharedToUsername: string
+ sharedToAvatarStyle?: string | null
+ sharedToAvatarBadgeId?: string | null
+ quotaMultiplier: number | null
+ maxInstances: number | null
+ createdAt: string
+ }>
+ }> => http.get('/packages/my-shares'),
+
+ // 资源释放功能
+ getHostsDetail: (packageId: number): Promise<{
+ hosts: Array<{
+ id: number
+ name: string
+ countryCode: string
+ cpuAllowanceMax: number
+ memoryMax: number
+ cpuUsed: number
+ memoryUsed: number
+ }>
+ packageName: string
+ cpuMax: number
+ memoryMax: number
+ }> => http.get(`/packages/${packageId}/hosts-detail`),
+
+ releaseQuota: (packageId: number, data: {
+ hostIds: number[]
+ cpuAdd: number
+ memoryAdd: number
+ notify?: boolean
+ }): Promise<{
+ message: string
+ results: Array<{
+ hostId: number
+ hostName: string
+ countryCode: string
+ cpuAllowanceMax: number
+ memoryMax: number
+ cpuAvailable: number
+ memoryAvailable: number
+ }>
+ }> => http.post(`/packages/${packageId}/release-quota`, data),
+
+ // 获取托管套餐所有者信息
+ getOwnerInfo: (packageId: number): Promise<{
+ id: number
+ username: string
+ email: string
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ hostCount: number
+ instanceCount: number
+ registeredDays: number
+ vipLevel: number
+ vipBadgeStyle?: VipBadgeStyle | null
+ }> => http.get(`/packages/${packageId}/owner-info`),
+
+ // 套餐方案管理
+ getPlans: (packageId: number, options?: { activeOnly?: boolean }): Promise<{
+ plans: Array<{
+ id: number
+ name: string
+ description: string | null
+ cpu: number
+ memory: number
+ disk: number
+ portLimit: number
+ snapshotLimit: number
+ backupLimit: number
+ siteLimit: number
+ swapSize: number
+ trafficLimit: string
+ trafficLimitSpeed: string
+ price: number
+ billingCycle: number
+ setupFee: number
+ monthlyPrice: number
+ isActive: boolean
+ isSoldOut: boolean
+ sortOrder: number
+ slaGuarantee: number | null
+ }>
+ }> => http.get(`/packages/${packageId}/plans`, { params: options }),
+
+ createPlan: (packageId: number, data: {
+ name: string
+ description?: string
+ cpu: number
+ memory: number
+ disk: number
+ portLimit: number
+ snapshotLimit: number
+ backupLimit: number
+ siteLimit: number
+ swapSize: number
+ trafficLimit: string
+ trafficLimitSpeed?: string
+ price: number
+ billingCycle?: number
+ setupFee?: number
+ isActive?: boolean
+ isSoldOut?: boolean
+ sortOrder?: number
+ slaGuarantee?: number | null
+ }): Promise<{ id: number; name: string; message: string }> =>
+ http.post(`/packages/${packageId}/plans`, data),
+
+ updatePlan: (packageId: number, planId: number, data: {
+ name?: string
+ description?: string
+ cpu?: number
+ memory?: number
+ disk?: number
+ portLimit?: number
+ snapshotLimit?: number
+ backupLimit?: number
+ siteLimit?: number
+ swapSize?: number
+ trafficLimit?: string
+ trafficLimitSpeed?: string
+ price?: number
+ billingCycle?: number
+ setupFee?: number
+ isActive?: boolean
+ isSoldOut?: boolean
+ sortOrder?: number
+ slaGuarantee?: number | null
+ }): Promise<{ id: number; name: string; message: string }> =>
+ http.put(`/packages/${packageId}/plans/${planId}`, data),
+
+ deletePlan: (packageId: number, planId: number): Promise<{ message: string }> =>
+ http.delete(`/packages/${packageId}/plans/${planId}`),
+
+ // 获取可用地区列表(拥有付费方案的套餐所在的国家/地区)
+ getRegions: (params?: { source?: 'official' | 'market' | 'friends' | 'zone'; zoneId?: number }): Promise<{
+ regions: Array<{
+ code: string
+ name: string
+ packageCount: number
+ packageIds: number[] // 该地区包含的套餐 ID 列表
+ }>
+ }> => http.get('/packages/regions', { params }),
+ },
+
+ // OAuth 配置(管理员)
+ oauth: {
+ getConfigs: (): Promise => http.get('/oauth/configs'),
+ updateConfig: (provider: 'github' | 'google', data: UpdateOAuthConfigRequest): Promise =>
+ http.put(`/oauth/configs/${provider}`, data),
+ deleteConfig: (provider: 'github' | 'google'): Promise =>
+ http.delete(`/oauth/configs/${provider}`),
+ getProviders: (): Promise> =>
+ http.get('/oauth/providers'),
+ getBindings: (): Promise => http.get('/oauth/bindings'),
+ unbind: (provider: 'github' | 'google'): Promise =>
+ http.delete(`/oauth/bindings/${provider}`),
+ createBindTicket: (): Promise<{ ticket: string; expiresIn: number }> =>
+ http.post('/oauth/bind-ticket', {}),
+ // 交换 OAuth 登录码获取 Token(安全改进)
+ exchangeCode: (code: string): Promise<{ token: string; user: { id: number; username: string; role: string } }> =>
+ http.post('/oauth/exchange-code', { code })
+ },
+
+ // 帮助文章(公开)
+ help: {
+ list: (params: Record = {}): Promise> =>
+ http.get('/help', { params }),
+ pinned: (limit?: number): Promise<{ articles: Array> }> =>
+ http.get('/help/pinned', { params: limit ? { limit } : {} }),
+ categories: (): Promise => http.get('/help/categories'),
+ categoryConfig: (): Promise<{ categories: Array<{ id: string; name: string; color: string }> }> =>
+ http.get('/help/category-config'),
+ getBySlug: (slug: string): Promise => http.get(`/help/article/${slug}`),
+ // 管理员接口
+ adminList: (params: Record = {}): Promise> =>
+ http.get('/help/admin', { params }),
+ adminGet: (id: number): Promise => http.get(`/help/admin/${id}`),
+ create: (data: CreateHelpArticleRequest): Promise => http.post('/help/admin', data),
+ update: (id: number, data: UpdateHelpArticleRequest): Promise =>
+ http.patch(`/help/admin/${id}`, data),
+ delete: (id: number): Promise => http.delete(`/help/admin/${id}`),
+ saveCategoryConfig: (categories: Array<{ id: string; name: string; color: string }>): Promise<{ message: string }> =>
+ http.put('/help/admin/category-config', { categories })
+ },
+
+ // 镜像管理
+ images: {
+ // 获取系统预定义镜像列表(用户端)
+ // @param type - 可选,按实例类型过滤 (container/vm)
+ // @param memory - 可选,内存大小(MB),128MB 时只返回 Alpine/Debian
+ getSystemImages: (type?: 'container' | 'vm', memory?: number, hostId?: number): Promise<{ success: boolean; images: SystemImage[] }> => {
+ const params: Record = {}
+ if (type) params.type = type
+ if (memory !== undefined) params.memory = String(memory)
+ if (hostId !== undefined) params.hostId = String(hostId)
+ return http.get('/images/system', { params })
+ },
+ // 管理员:获取所有镜像列表
+ list: (): Promise<{ success: boolean; images: SystemImage[] }> =>
+ http.get('/images/admin'),
+ // 管理员:创建镜像
+ create: (data: CreateSystemImageRequest): Promise<{ success: boolean; image: SystemImage }> =>
+ http.post('/images/admin', data),
+ // 管理员:更新镜像
+ update: (id: number, data: UpdateSystemImageRequest): Promise<{ success: boolean; image: SystemImage }> =>
+ http.patch(`/images/admin/${id}`, data),
+ // 管理员:删除镜像
+ delete: (id: number): Promise<{ success: boolean }> =>
+ http.delete(`/images/admin/${id}`)
+ },
+
+ // 日志管理
+ logs: {
+ list: (params: Record = {}): Promise> =>
+ http.get('/logs', { params }),
+ getModules: (): Promise => http.get('/logs/modules')
+ },
+
+ // 健康检查
+ health: (): Promise<{ status: string; timestamp: string }> => http.get('/health'),
+
+ // 系统配置(管理员)
+ systemConfig: {
+ // 公开配置(无需登录)
+ getPublic: (): Promise<{
+ registrationEnabled: boolean
+ requireInviteCode: boolean
+ turnstileEnabled?: boolean
+ turnstileSiteKey?: string | null
+ ticketEnabled?: boolean
+ freeSiteMode?: boolean
+ mailAvailable?: boolean
+ avatarApiBase?: string
+ emailVerificationEnabled?: boolean
+ emailDomainWhitelistEnabled?: boolean
+ allowedEmailDomains?: string[] | null
+ transferFee?: number
+ footerContactEmail?: string | null
+ footerTelegramLink?: string | null
+ hostingMarketEntryEnabled?: boolean
+ hostingNotice?: string | null
+ brandName?: string | null
+ brandSubtitle?: string | null
+ brandLogoUrl?: string | null
+ popupAnnouncement?: string | null
+ popupAnnouncementUpdatedAt?: string | null
+ popupPromoImageUrl?: string | null
+ popupPromoPackage?: {
+ id: number
+ name: string
+ description: string | null
+ source: 'official' | 'market'
+ plans: Array<{
+ id: number
+ name: string
+ description: string | null
+ cpu: number
+ memory: number
+ disk: number
+ trafficLimit: string
+ price: number
+ billingCycle: number
+ isSoldOut: boolean
+ }>
+ } | null
+ popupPromoUpdatedAt?: string | null
+ }> =>
+ http.get('/system-config/public'),
+ // 测试 SMTP 连接
+ testSmtp: (): Promise<{ success: boolean; message?: string; error?: string }> =>
+ http.post('/system-config/smtp/test'),
+ // 发送测试邮件
+ sendTestEmail: (to: string): Promise<{ success: boolean; message?: string; error?: string }> =>
+ http.post('/system-config/smtp/send-test', { to }),
+ list: (): Promise<{ configs: Array<{ id: number; key: string; value: string; type: string; label: string | null; description: string | null }> }> =>
+ http.get('/system-config'),
+ getDefaultQuota: (): Promise<{ quota: { hostLimit: number; friendLimit: number; packageLimit: number } }> =>
+ http.get('/system-config/default-quota'),
+ update: (configs: Array<{ key: string; value: string }>): Promise<{ message: string }> =>
+ http.put('/system-config', { configs })
+ },
+
+ // Storage Configs (远程存储配置)
+ storageConfigs: {
+ list: (): Promise> => http.get('/storage-configs'),
+
+ create: (data: {
+ name: string
+ type: 'WEBDAV' | 'FTP' | 'SFTP' | 'S3'
+ host: string
+ port?: number
+ username?: string
+ password?: string
+ basePath?: string
+ isDefault?: boolean
+ }): Promise<{ id: number }> => http.post('/storage-configs', data),
+
+ update: (id: number, data: {
+ name?: string
+ type?: 'WEBDAV' | 'FTP' | 'SFTP' | 'S3'
+ host?: string
+ port?: number | null
+ username?: string | null
+ password?: string | null
+ basePath?: string | null
+ isDefault?: boolean
+ }): Promise<{ id: number }> => http.patch(`/storage-configs/${id}`, data),
+
+ delete: (id: number): Promise => http.delete(`/storage-configs/${id}`),
+
+ test: (id: number): Promise<{ success: boolean; message?: string }> =>
+ http.post(`/storage-configs/${id}/test`),
+
+ setDefault: (id: number): Promise<{ success: boolean }> =>
+ http.post(`/storage-configs/${id}/set-default`)
+ },
+
+ // Traffic (流量统计)
+ traffic: {
+ // 获取当前用户流量
+ getMyTraffic: (): Promise<{
+ monthlyUsed: string
+ monthlyUsedFormatted: string
+ monthlyLimit: string | null
+ monthlyLimitFormatted: string | null
+ extraQuota: string
+ trafficStatus: 'NORMAL' | 'WARNING' | 'LIMITED'
+ percentage: number
+ }> => http.get('/me/traffic'),
+
+ // 获取指定用户流量(管理员)
+ getUserTraffic: (userId: number): Promise<{
+ monthlyUsed: string
+ monthlyUsedFormatted: string
+ monthlyLimit: string | null
+ monthlyLimitFormatted: string | null
+ extraQuota: string
+ trafficStatus: 'NORMAL' | 'WARNING' | 'LIMITED'
+ percentage: number
+ }> => http.get(`/users/${userId}/traffic`),
+
+ // 更新用户流量限额(管理员)
+ updateUserTrafficLimit: (userId: number, monthlyLimit: string | null): Promise<{ success: boolean }> =>
+ http.put(`/users/${userId}/traffic/limit`, { monthlyLimit }),
+
+ // 获取实例流量
+ getInstanceTraffic: (instanceId: number): Promise<{
+ monthlyUsed: string
+ monthlyUsedFormatted: string
+ monthlyLimit: string | null
+ monthlyLimitFormatted: string | null
+ trafficStatus: 'NORMAL' | 'WARNING' | 'LIMITED'
+ percentage: number
+ trafficResetDay: number
+ periodStart: string
+ periodEnd: string
+ }> => http.get(`/instances/${instanceId}/traffic`),
+
+ // 获取实例流量历史
+ getInstanceTrafficHistory: (instanceId: number, days?: number): Promise<{
+ trafficResetDay: number
+ periodStart: string
+ periodEnd: string
+ data: Array<{
+ date: string
+ rxTotal: string
+ txTotal: string
+ rxFormatted: string
+ txFormatted: string
+ total: string
+ totalFormatted: string
+ }>
+ }> => http.get(`/instances/${instanceId}/traffic/history`, { params: { days } }),
+
+ // 更新实例流量限额(管理员)
+ updateInstanceTrafficLimit: (instanceId: number, monthlyLimit: string | null): Promise<{ success: boolean }> =>
+ http.put(`/instances/${instanceId}/traffic/limit`, { monthlyLimit }),
+
+ // 重置实例月度流量(宿主机所有者)
+ resetInstanceTraffic: (instanceId: number): Promise<{ success: boolean; message: string }> =>
+ http.post(`/instances/${instanceId}/traffic/reset`),
+
+ // 手动触发流量采集(管理员)
+ triggerCollection: (): Promise<{ success: boolean; message: string }> =>
+ http.post('/traffic/collect'),
+
+ // 同步套餐流量限额到所有实例(管理员)
+ syncPackageLimits: (): Promise<{ success: boolean; message: string; updatedCount: number }> =>
+ http.post('/traffic/sync-package-limits'),
+
+ // 获取节点流量统计(管理员)
+ getHostTrafficHistory: (hostId: number): Promise<{
+ trafficResetDay: number
+ periodStart: string
+ periodEnd: string
+ data: Array<{
+ date: string
+ rxTotal: string
+ txTotal: string
+ rxFormatted: string
+ txFormatted: string
+ total: string
+ totalFormatted: string
+ }>
+ summary: {
+ totalUsed: string
+ totalUsedFormatted: string
+ totalLimit: string
+ totalLimitFormatted: string
+ }
+ }> => http.get(`/hosts/${hostId}/traffic/history`)
+ },
+
+ // Friends (好友系统)
+ friends: {
+ // 获取好友列表
+ list: (): Promise<{
+ friends: Array<{
+ id: number // 好友的用户ID
+ friendshipId: number // friendship 记录的ID
+ friendId: number // 好友的用户ID(与 id 相同)
+ username: string
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ status: string
+ createdAt: string
+ acceptedAt: string | null
+ initiatedByMe: boolean
+ hostCount?: number
+ instanceCount?: number
+ }>
+ }> => http.get('/friends'),
+
+ // 发送好友请求
+ sendRequest: (username: string, remark?: string): Promise<{ message: string; request?: { id: number }; friendship?: any }> =>
+ http.post('/friends/request', { username, remark }),
+
+ // 获取待处理的好友请求
+ getPendingRequests: (): Promise<{
+ requests: Array<{
+ id: number
+ userId: number
+ username: string
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ remark: string | null
+ createdAt: string
+ }>
+ }> => http.get('/friends/requests'),
+
+ // 获取已发送的好友请求
+ getSentRequests: (): Promise<{
+ requests: Array<{
+ id: number
+ userId: number
+ username: string
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ remark: string | null
+ createdAt: string
+ }>
+ }> => http.get('/friends/requests/sent'),
+
+ // 获取历史记录(已处理的请求)
+ getHistory: (filter?: 'accepted' | 'rejected' | 'removed' | 'all'): Promise<{
+ history: Array<{
+ id: number
+ userId: number
+ username: string
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ remark: string | null
+ status: 'accepted' | 'rejected' | 'removed'
+ createdAt: string
+ acceptedAt: string | null
+ rejectedAt: string | null
+ initiatedByMe: boolean // 是否是我发起的请求
+ }>
+ }> => http.get('/friends/history', { params: { filter } }),
+
+ // 接受好友请求
+ accept: (requestId: number): Promise<{ message: string; friendship: any }> =>
+ http.post(`/friends/${requestId}/accept`),
+
+ // 拒绝好友请求
+ reject: (requestId: number): Promise<{ message: string }> =>
+ http.post(`/friends/${requestId}/reject`),
+
+ // 取消已发送的好友请求
+ cancelRequest: (requestId: number): Promise<{ message: string }> =>
+ http.delete(`/friends/request/${requestId}`),
+
+ // 删除好友
+ remove: (friendshipId: number): Promise<{ message: string }> =>
+ http.delete(`/friends/${friendshipId}`),
+
+ // 搜索用户(用于添加好友)
+ searchUser: (username: string): Promise<{
+ user: {
+ id: number
+ username: string
+ avatarStyle: string
+ isFriend: boolean
+ }
+ }> => http.get('/friends/search', { params: { username } }),
+
+ // 获取好友的资源统计
+ getFriendResources: (friendshipId: number): Promise<{
+ resources: {
+ hosts: number
+ images: number
+ packages: number
+ }
+ }> => http.get(`/friends/${friendshipId}/resources`)
+ },
+
+ // Transfers (实例转移)
+ transfers: {
+ // 搜索用户(用于转移时选择接收方)
+ searchUser: (username: string): Promise<{
+ user: {
+ id: number
+ username: string
+ status: string
+ }
+ }> => http.get('/transfers/users/search', { params: { username } }),
+
+ // 发起转移请求
+ create: (instanceId: number, targetUsername: string, remark?: string): Promise<{
+ message: string
+ transfer: { id: number; status: string }
+ }> => http.post(`/transfers/instances/${instanceId}/transfer`, { targetUsername, remark }),
+
+ // 获取转移列表
+ list: (type: 'sent' | 'received', params: { status?: string; page?: number; pageSize?: number; search?: string } = {}): Promise<{
+ transfers: Array<{
+ id: number
+ instanceId: number
+ instanceName: string
+ instanceStatus: string
+ instanceImage: string
+ fromUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null } | null
+ toUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null } | null
+ status: string
+ snapshot: any
+ remark: string | null
+ rejectReason: string | null
+ createdAt: string
+ acceptedAt: string | null
+ rejectedAt: string | null
+ cancelledAt: string | null
+ canPush?: boolean
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/transfers', { params: { type, ...params } }),
+
+ // 获取待接收数量
+ getPendingCount: (): Promise<{ count: number }> => http.get('/transfers/pending-count'),
+
+ // 获取转移详情
+ get: (id: number): Promise<{
+ transfer: {
+ id: number
+ instanceId: number
+ instanceName: string
+ instanceStatus: string
+ fromUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null }
+ toUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null }
+ status: string
+ snapshot: any
+ remark: string | null
+ rejectReason: string | null
+ createdAt: string
+ acceptedAt: string | null
+ rejectedAt: string | null
+ cancelledAt: string | null
+ }
+ }> => http.get(`/transfers/${id}`),
+
+ // 接受转移
+ accept: (id: number): Promise<{ message: string }> => http.post(`/transfers/${id}/accept`),
+
+ // 拒绝转移
+ reject: (id: number, reason?: string): Promise<{ message: string }> =>
+ http.post(`/transfers/${id}/reject`, { reason }),
+
+ // 取消转移
+ cancel: (id: number): Promise<{ message: string }> => http.post(`/transfers/${id}/cancel`),
+
+ // 直接推送(宿主机所有者可直接将实例推送给接收方,无需对方接受)
+ push: (id: number): Promise<{ message: string }> => http.post(`/transfers/${id}/push`)
+ },
+
+ // 敏感操作二次验证
+ verification: {
+ // 请求验证码
+ request: (operationType: string, resourceId?: number): Promise<{
+ message: string
+ success?: boolean
+ required?: boolean
+ channel: 'email' | 'telegram' | 'discord' | 'webhook'
+ target?: string
+ maskedTarget?: string
+ expiresAt?: string
+ expiresIn?: number
+ operationName?: string
+ }> => http.post('/verification/request', { operationType, resourceId }),
+
+ // 验证码校验
+ verify: (operationType: string, code: string, resourceId?: number): Promise<{
+ message: string
+ verified: boolean
+ }> => http.post('/verification/verify', { operationType, code, resourceId }),
+
+ // 检查是否已验证
+ check: (operationType: string, resourceId?: number): Promise<{
+ verified: boolean
+ expiresAt?: string
+ }> => http.get('/verification/check', { params: { operationType, resourceId } }),
+
+ // 获取支持的操作类型
+ getOperationTypes: (): Promise<{
+ operationTypes: Array<{ type: string; name: string; requiresResource: boolean }>
+ }> => http.get('/verification/operation-types')
+ },
+
+ // 站内信
+ inbox: {
+ // 获取消息列表
+ list: (params?: { page?: number; pageSize?: number; isRead?: boolean }): Promise<{
+ messages: Array<{
+ id: number
+ userId: number
+ eventType: string
+ title: string
+ content: string
+ isRead: boolean
+ data: Record | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/inbox', { params }),
+
+ // 获取未读数量
+ getUnreadCount: (): Promise<{ count: number }> => http.get('/inbox/unread-count'),
+
+ // 标记单条已读
+ markAsRead: (id: number): Promise<{ success: boolean }> => http.post(`/inbox/${id}/read`),
+
+ // 全部标记已读
+ markAllAsRead: (): Promise<{ success: boolean; count: number }> => http.post('/inbox/read-all'),
+
+ // 删除单条消息
+ delete: (id: number): Promise<{ success: boolean }> => http.delete(`/inbox/${id}`),
+
+ // 清空已读消息
+ deleteRead: (): Promise<{ success: boolean; count: number }> => http.delete('/inbox/read'),
+
+ // 管理员发送全站站内信
+ broadcast: (data: { title: string; content: string }): Promise<{ success: boolean; count: number }> =>
+ http.post('/inbox/admin/broadcast', data),
+
+ // 管理员发送站内信给特定用户
+ sendToUser: (userId: number, data: { title: string; content: string }): Promise<{ success: boolean }> =>
+ http.post(`/inbox/admin/send/${userId}`, data),
+
+ // 节点所有者通知实例用户
+ notifyHostUsers: (
+ hostId: number,
+ data: { title: string; content: string; instanceIds?: number[]; sendEmail?: boolean }
+ ): Promise<{
+ success: boolean
+ count: number
+ email?: {
+ requested: boolean
+ mode: 'none' | 'direct' | 'queued'
+ sentCount: number
+ queuedCount: number
+ skippedCount: number
+ failedCount: number
+ }
+ }> =>
+ http.post(`/inbox/hosts/${hostId}/notify`, data),
+
+ // 节点所有者发送站内信给特定实例用户
+ notifyInstanceUser: (instanceId: number, data: { title: string; content: string }): Promise<{ success: boolean }> =>
+ http.post(`/inbox/instances/${instanceId}/notify`, data)
+ },
+
+ // 公告/通知历史
+ announcements: {
+ // 获取公告历史列表(管理员)
+ list: (params?: { page?: number; pageSize?: number; type?: string }): Promise<{
+ items: Array<{
+ id: number
+ type: 'system_broadcast' | 'host_broadcast' | 'admin_message' | 'host_message'
+ title: string
+ content: string
+ recipientCount: number
+ hostId: number | null
+ targetUserId: number | null
+ instanceId: number | null
+ createdAt: string
+ sender: { id: number; username: string }
+ host: { id: number; name: string } | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/announcements', { params }),
+
+ // 获取宿主机所有者的公告历史
+ listMy: (params?: { page?: number; pageSize?: number; hostId?: number }): Promise<{
+ items: Array<{
+ id: number
+ type: 'host_broadcast' | 'host_message'
+ title: string
+ content: string
+ recipientCount: number
+ hostId: number | null
+ targetUserId: number | null
+ instanceId: number | null
+ createdAt: string
+ host: { id: number; name: string } | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/announcements/my', { params }),
+
+ // 获取公告详情
+ get: (id: number): Promise<{
+ id: number
+ type: 'system_broadcast' | 'host_broadcast' | 'admin_message' | 'host_message'
+ title: string
+ content: string
+ recipientCount: number
+ hostId: number | null
+ targetUserId: number | null
+ instanceId: number | null
+ createdAt: string
+ sender: { id: number; username: string }
+ host: { id: number; name: string } | null
+ }> => http.get(`/announcements/${id}`)
+ },
+
+ // 工单系统
+ tickets: {
+ // 创建工单
+ create: (data: CreateTicketRequest): Promise<{ message: string; ticket: { id: number; messageId: number } }> =>
+ (data.attachments && data.attachments.length > 0)
+ ? http.post('/tickets', buildTicketFormData(data), { headers: { 'Content-Type': 'multipart/form-data' }, timeout: TIMEOUT.MEDIUM })
+ : http.post('/tickets', {
+ instanceId: data.instanceId,
+ subject: data.subject,
+ category: data.category,
+ priority: data.priority,
+ content: data.content
+ }),
+
+ // 获取我的工单列表(支持 active 状态筛选和搜索)
+ list: (params?: { status?: TicketStatus | 'active'; search?: string; page?: number; pageSize?: number }): Promise =>
+ http.get('/tickets', { params }),
+
+ // 获取工单详情
+ get: (id: number): Promise<{ ticket: Ticket; isOwner: boolean; isCreator: boolean }> =>
+ http.get(`/tickets/${id}`),
+
+ // 获取工单消息列表
+ getMessages: (id: number, params?: { page?: number; pageSize?: number }): Promise =>
+ http.get(`/tickets/${id}/messages`, { params }),
+
+ // 回复工单
+ reply: (id: number, content: string, attachments?: File[]): Promise<{ message: string; data: TicketMessage }> =>
+ (attachments && attachments.length > 0)
+ ? http.post(`/tickets/${id}/messages`, buildTicketFormData({ content, attachments }), { headers: { 'Content-Type': 'multipart/form-data' }, timeout: TIMEOUT.MEDIUM })
+ : http.post(`/tickets/${id}/messages`, { content }),
+
+ // 读取工单图片内容
+ getAttachmentContent: (attachmentId: number): Promise =>
+ http.get(`/tickets/attachments/${attachmentId}/content`, { responseType: 'blob', timeout: TIMEOUT.MEDIUM }),
+
+ // 删除工单消息(仅管理员)
+ deleteMessage: (ticketId: number, messageId: number): Promise<{ message: string }> =>
+ http.delete(`/tickets/${ticketId}/messages/${messageId}`),
+
+ // 更新工单状态(宿主机所有者)
+ updateStatus: (id: number, status: TicketStatus): Promise<{ message: string; status: TicketStatus }> =>
+ http.patch(`/tickets/${id}/status`, { status }),
+
+ // 关闭工单
+ close: (id: number): Promise<{ message: string }> =>
+ http.post(`/tickets/${id}/close`),
+
+ // 获取宿主机收到的工单列表(支持 active 状态筛选和搜索)
+ getHostTickets: (hostId: number, params?: { status?: TicketStatus | 'active'; search?: string; page?: number; pageSize?: number }): Promise =>
+ http.get(`/tickets/hosts/${hostId}`, { params }),
+
+ // 获取所有宿主机的工单(汇总视图,支持 active 状态筛选和搜索)
+ getMyHostTickets: (params?: { status?: TicketStatus | 'active'; hostId?: number; sourceType?: 'all' | 'user' | 'official' | 'hosted'; search?: string; page?: number; pageSize?: number }): Promise =>
+ http.get('/tickets/my-hosts', { params }),
+
+ // 获取待处理工单数量
+ getPendingCount: (): Promise<{ userTickets: number; hostTickets: number; total: number; isHostOwner: boolean }> =>
+ http.get('/tickets/pending-count')
+ },
+
+ // 签到系统
+ checkin: {
+ // 获取签到状态
+ getStatus: (): Promise<{
+ hasCheckedIn: boolean
+ hasInstances: boolean
+ selfOnlyMode: boolean
+ consecutiveOthersUse: number
+ }> => http.get('/checkin/status'),
+
+ // 执行签到(资源直接存入资源池)
+ checkin: (): Promise<{
+ message: string
+ codeType: string
+ codeValue: number
+ toResourcePool: boolean
+ bonusPoints: number
+ }> => http.post('/checkin/checkin'),
+
+ // 兑换系统兑换码
+ // 仅支持系统码(h-前缀):instanceId 必须,直接应用到实例
+ redeem: (redeemCode: string, instanceId: number): Promise<{
+ message: string
+ codeType: string
+ codeValue: number
+ actualAdded: number
+ instanceId?: number
+ instanceName?: string
+ isSystemCode: boolean
+ toResourcePool: boolean
+ }> => http.post('/checkin/redeem', { redeemCode, instanceId }),
+
+ // 获取可用实例列表
+ getInstances: (): Promise<{
+ instances: Array<{
+ id: number
+ name: string
+ status: string
+ cpu: number
+ memory: number
+ disk: number
+ monthlyTrafficLimit: string | null
+ package: {
+ id: number
+ name: string
+ cpuMax: number
+ memoryMax: number
+ diskMax: number
+ monthlyTrafficLimit: string | null
+ } | null
+ host: {
+ id: number
+ name: string
+ location: string | null
+ countryCode: string
+ }
+ }>
+ }> => http.get('/checkin/instances'),
+
+ // 获取签到记录
+ getRecords: (params?: { limit?: number; offset?: number }): Promise<{
+ records: Array<{
+ id: number
+ redeemCode: string
+ codeType: string
+ codeValue: number
+ expiresAt: string
+ usedAt: string | null
+ usedBy: { id: number; username: string } | null
+ usedFor: { id: number; name: string } | null
+ createdAt: string
+ }>
+ total: number
+ }> => http.get('/checkin/records', { params }),
+
+ // 获取兑换记录
+ getRedeems: (params?: { limit?: number; offset?: number }): Promise<{
+ records: Array<{
+ id: number
+ redeemCode: string
+ codeType: string
+ codeValue: number
+ owner: { id: number; username: string }
+ usedFor: { id: number; name: string } | null
+ usedAt: string | null
+ isSystemCode?: boolean
+ }>
+ total: number
+ }> => http.get('/checkin/redeems', { params })
+ },
+
+ // 资源池系统
+ resourcePool: {
+ // 获取用户资源池
+ get: (): Promise<{
+ cpu: number
+ memory: number
+ disk: number
+ traffic: number
+ }> => http.get('/resource-pool'),
+
+ // 应用资源到实例
+ apply: (data: {
+ instanceId: number
+ resourceType: 'c' | 'r' | 'd' | 't'
+ amount: number
+ }): Promise<{
+ message: string
+ resourceType: string
+ amount: number
+ instanceId: number
+ instanceName: string
+ }> => http.post('/resource-pool/apply', data),
+
+ // 获取资源池变动记录
+ getLogs: (params?: {
+ action?: string
+ resourceType?: string
+ limit?: number
+ offset?: number
+ }): Promise<{
+ records: Array<{
+ id: number
+ action: string
+ resourceType: string
+ amount: number
+ instance: { id: number; name: string } | null
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ }> => http.get('/resource-pool/logs', { params }),
+
+ // 获取可应用资源的实例列表(包括免费和付费)
+ getInstances: (): Promise<{
+ instances: Array<{
+ id: number
+ name: string
+ status: string
+ cpu: number
+ memory: number
+ disk: number
+ monthlyTrafficLimit: string | null
+ isPaid: boolean
+ instanceType: 'vm' | 'container'
+ host: {
+ id: number
+ name: string
+ location: string | null
+ countryCode: string
+ }
+ }>
+ }> => http.get('/resource-pool/instances')
+ },
+
+ // 用户自定义初始化命令
+ initCommands: {
+ // 获取用户的所有初始化命令模板
+ list: (): Promise<{
+ commands: Array<{
+ id: number
+ name: string
+ commandPreview: string
+ commandLineCount: number
+ distros: string[]
+ description: string | null
+ enabled: boolean
+ createdAt: string
+ updatedAt: string
+ }>
+ }> => http.get('/init-commands'),
+
+ // 获取适配指定发行版的命令列表(创建/重装时使用)
+ getAvailable: (distro: string): Promise<{
+ commands: Array<{
+ id: number
+ name: string
+ commandLineCount: number
+ distros: string[]
+ description: string | null
+ }>
+ }> => http.get('/init-commands/available', { params: { distro } }),
+
+ // 获取单个命令详情
+ get: (id: number): Promise<{
+ command: {
+ id: number
+ name: string
+ command: string
+ distros: string[]
+ description: string | null
+ enabled: boolean
+ createdAt: string
+ updatedAt: string
+ }
+ }> => http.get(`/init-commands/${id}`),
+
+ // 创建初始化命令模板
+ create: (data: {
+ name: string
+ command: string
+ distros: string[]
+ description?: string
+ }): Promise<{ message: string; id: number }> => http.post('/init-commands', data),
+
+ // 更新初始化命令模板
+ update: (id: number, data: {
+ name?: string
+ command?: string
+ distros?: string[]
+ description?: string | null
+ enabled?: boolean
+ }): Promise<{ message: string }> => http.put(`/init-commands/${id}`, data),
+
+ // 删除初始化命令模板
+ delete: (id: number): Promise<{ message: string }> => http.delete(`/init-commands/${id}`),
+
+ // 获取可用的发行版列表(名称由前端翻译)
+ getDistros: (): Promise<{
+ distros: Array<{
+ id: string
+ icon: string
+ }>
+ }> => http.get('/init-commands/distros')
+ },
+
+ // 计费相关
+ billing: {
+ // 获取实例计费信息
+ getInstanceBilling: (instanceId: number): Promise<{
+ billing: {
+ instanceId: number
+ instanceName: string
+ planId: number | null
+ planName: string | null
+ price: number | null
+ billingCycle: number | null
+ expiresAt: string | null
+ autoRenew: boolean
+ renewPreview: Array<{ months: number; price: number; expiresAt: string }> | null
+ }
+ }> => http.get(`/instances/${instanceId}/billing`),
+
+ // 实例续费
+ renewInstance: (instanceId: number, months: number): Promise<{
+ message: string
+ amount: number
+ months: number
+ newExpiresAt: string
+ }> => http.post(`/instances/${instanceId}/renew`, { months }),
+
+ applyAffCodeToInstance: (instanceId: number, affCode: string): Promise<{
+ success: boolean
+ message: string
+ discountRate: number
+ discountPercent: number
+ }> => http.post(`/instances/${instanceId}/apply-aff`, { affCode }),
+
+ previewBatchRenew: (instanceIds: number[]): Promise<{
+ items: Array<{
+ id: number
+ name: string
+ canRenew: boolean
+ autoRenew: boolean
+ reason?: string
+ isHostedInstance: boolean
+ daysUntilExpire: number | null
+ options: Array<{
+ months: number
+ price: number
+ discountedPrice: number
+ expiresAt: string
+ }>
+ }>
+ }> => http.post('/instances/batch/renew-preview', { instanceIds }, { timeout: TIMEOUT.BATCH }),
+
+ renewInstancesBatch: (instanceIds: number[], months: number): Promise<{
+ message: string
+ successCount: number
+ skippedCount: number
+ failedCount: number
+ results: Array<{
+ id: number
+ name: string
+ success: boolean
+ skipped?: boolean
+ reason?: string
+ amount?: number
+ newExpiresAt?: string
+ }>
+ }> => http.post('/instances/batch/renew', { instanceIds, months }, { timeout: TIMEOUT.BATCH }),
+
+ // 升降级预览
+ getChangePlanPreview: (instanceId: number, newPlanId: number): Promise<{
+ preview: {
+ oldPlan: { id: number; name: string; price: number; billingCycle: number }
+ newPlan: { id: number; name: string; price: number; billingCycle: number; isActive: boolean; isSoldOut: boolean }
+ remainingDays: number
+ oldDailyPrice: number
+ newDailyPrice: number
+ remainingValue: number
+ newPlanCost: number
+ discountRate: number
+ discountAmount: number
+ priceDiff: number
+ isUpgrade: boolean
+ newExpiresAt: string
+ newConfig: { cpu: number; memory: number; disk: number }
+ resourceWarnings: string[] | null
+ canChange: boolean
+ cannotChangeReason?: string
+ }
+ }> => http.get(`/instances/${instanceId}/change-plan/preview`, { params: { newPlanId } }),
+
+ // 执行升降级
+ changePlan: (instanceId: number, newPlanId: number): Promise<{
+ message: string
+ priceDiff: number
+ newConfig: { cpu: number; memory: number; disk: number }
+ needRestart: boolean
+ restartMessage: string | null
+ }> => http.post(`/instances/${instanceId}/change-plan`, { newPlanId }),
+
+ // 获取销毁预览信息
+ getDestroyInfo: (instanceId: number): Promise<{
+ canDestroy: boolean
+ cannotDestroyReason: string
+ isFreeInstance: boolean
+ isFirstTime: boolean
+ rules: {
+ feeRate: number
+ }
+ refund: {
+ remainingDays: number
+ remainingValue: number
+ feeRate: number
+ feeAmount: number
+ refundAmount: number
+ destroyCount: number
+ }
+ instance: {
+ id: number
+ name: string
+ hostName: string
+ planName: string | null
+ }
+ }> => http.get(`/instances/${instanceId}/destroy-info`),
+
+ // 执行销毁
+ destroyInstance: (instanceId: number, options?: { feeWaiver?: string }): Promise<{
+ success: boolean
+ message: string
+ refundAmount: number
+ feeAmount: number
+ isFirstTime: boolean
+ isFreeInstance: boolean
+ }> => http.post(`/instances/${instanceId}/destroy${options?.feeWaiver ? `?feeWaiver=${options.feeWaiver}` : ''}`),
+
+ getBatchDestroyInfo: (instanceIds: number[]): Promise<{
+ items: Array<{
+ id: number
+ name: string
+ canDestroy: boolean
+ cannotDestroyReason: string
+ isFreeInstance: boolean
+ isFirstTime: boolean
+ feeWaiverEligible: boolean
+ refund: {
+ remainingDays: number
+ remainingValue: number
+ feeRate: number
+ feeAmount: number
+ refundAmount: number
+ destroyCount: number
+ maxRefundable: number
+ }
+ instance: {
+ id: number
+ name: string
+ hostName: string
+ planName: string | null
+ }
+ }>
+ }> => http.post('/instances/batch/destroy-info', { instanceIds }, { timeout: TIMEOUT.BATCH }),
+
+ destroyInstancesBatch: (instanceIds: number[]): Promise<{
+ message: string
+ successCount: number
+ skippedCount: number
+ failedCount: number
+ results: Array<{
+ id: number
+ name: string
+ success: boolean
+ skipped?: boolean
+ reason?: string
+ refundAmount?: number
+ feeAmount?: number
+ isFirstTime?: boolean
+ isFreeInstance?: boolean
+ }>
+ }> => http.post('/instances/batch/destroy', { instanceIds }, { timeout: TIMEOUT.BATCH }),
+
+ // 切换自动续费
+ setAutoRenew: (instanceId: number, autoRenew: boolean): Promise<{
+ message: string
+ autoRenew: boolean
+ }> => http.patch(`/instances/${instanceId}/auto-renew`, { autoRenew }),
+
+ setAutoRenewBatch: (instanceIds: number[], autoRenew: boolean): Promise<{
+ message: string
+ successCount: number
+ skippedCount: number
+ failedCount: number
+ results: Array<{
+ id: number
+ name: string
+ success: boolean
+ skipped?: boolean
+ reason?: string
+ autoRenew?: boolean
+ }>
+ }> => http.patch('/instances/batch/auto-renew', { instanceIds, autoRenew }, { timeout: TIMEOUT.BATCH }),
+
+ // 获取实例计费记录
+ getInstanceBillingRecords: (instanceId: number, params?: { page?: number; pageSize?: number; type?: string }): Promise<{
+ records: Array<{
+ id: number
+ type: string
+ amount: number
+ months: number | null
+ periodStart: string
+ periodEnd: string
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get(`/instances/${instanceId}/billing/records`, { params }),
+
+ // 获取用户余额
+ getUserBalance: (): Promise<{
+ balance: { balance: number; frozen: number; totalRecharge: number; totalConsume: number; destroyedValue: number }
+ }> => http.get('/balance/me'),
+
+ // 获取余额记录
+ getBalanceLogs: (params?: { page?: number; pageSize?: number; type?: string; lotteryGift?: 'exclude' | 'only' }): Promise<{
+ records: Array<{
+ id: number
+ type: string
+ amount: number
+ balanceBefore: number
+ balanceAfter: number
+ instanceId: number | null
+ instanceName: string | null
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/balance/me/logs', { params }),
+
+ // 获取可用支付渠道
+ getPaymentProviders: (): Promise<{
+ providers: Array<{
+ id: number
+ name: string
+ type: string
+ methods: string[]
+ methodFees?: Record
+ minAmount: number
+ maxAmount: number | null
+ feeRate: number
+ feeFixed: number
+ }>
+ }> => http.get('/recharge/providers'),
+
+ // 创建充值订单
+ createRechargeOrder: (providerId: number, amount: number, paymentMethod?: string): Promise<{
+ order: {
+ orderNo: string
+ amount: number
+ payableAmount: number
+ actualAmount: number
+ fee: number
+ status: string
+ expiredAt: string
+ createdAt: string
+ }
+ provider: {
+ id: number
+ name: string
+ type: string
+ methods: string[]
+ }
+ payUrl: string | null
+ }> => http.post('/recharge/orders', { providerId, amount, paymentMethod }),
+
+ // 获取充值记录列表
+ getRechargeOrders: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{
+ records: Array<{
+ id: number
+ orderNo: string
+ amount: number
+ payableAmount: number
+ actualAmount: number | null
+ fee: number
+ status: string
+ provider: { id: number; name: string; type: string } | null
+ paymentMethod: string | null
+ actualPaymentMethod: string | null
+ paymentCurrency: string | null
+ paymentNetwork: string | null
+ paymentUuid: string | null
+ paymentTxid: string | null
+ invoiceCurrency: string | null
+ gatewayStatus: string | null
+ gatewayStatusDescription: string | null
+ tradeNo: string | null
+ createdAt: string
+ expiredAt: string | null
+ completedAt: string | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/recharge/orders', { params }),
+
+ // 获取充值订单详情
+ getRechargeOrder: (orderNo: string): Promise<{
+ order: {
+ id: number
+ orderNo: string
+ amount: number
+ payableAmount: number
+ actualAmount: number | null
+ fee: number
+ status: string
+ provider: { id: number; name: string; type: string } | null
+ paymentMethod: string | null
+ actualPaymentMethod: string | null
+ paymentCurrency: string | null
+ paymentNetwork: string | null
+ paymentUuid: string | null
+ paymentTxid: string | null
+ invoiceCurrency: string | null
+ gatewayStatus: string | null
+ gatewayStatusDescription: string | null
+ tradeNo: string | null
+ failReason: string | null
+ createdAt: string
+ expiredAt: string | null
+ completedAt: string | null
+ }
+ }> => http.get(`/recharge/orders/${orderNo}`),
+
+ // 取消充值订单
+ cancelRechargeOrder: (orderNo: string): Promise<{
+ success: boolean
+ message: string
+ }> => http.post(`/recharge/orders/${orderNo}/cancel`),
+
+ // 重新支付订单
+ repayRechargeOrder: (orderNo: string, paymentMethod?: string): Promise<{
+ order: {
+ orderNo: string
+ amount: number
+ payableAmount: number
+ actualAmount: number | null
+ status: string
+ expiredAt: string | null
+ }
+ payUrl: string
+ }> => http.post(`/recharge/orders/${orderNo}/repay`, { paymentMethod }),
+
+ // 获取用户充值统计
+ getRechargeStats: (): Promise<{
+ stats: {
+ totalAmount: number
+ completedCount: number
+ pendingCount: number
+ }
+ }> => http.get('/recharge/stats'),
+
+ // 验证订单支付状态(主动查询易支付)
+ verifyRechargeOrder: (orderNo: string): Promise<{
+ success: boolean
+ verified: boolean
+ status: string
+ message: string
+ order?: {
+ id?: number
+ orderNo: string
+ amount: number
+ payableAmount?: number
+ actualAmount?: number | null
+ fee?: number
+ status: string
+ provider?: { id: number; name: string; type: string } | null
+ paymentMethod?: string | null
+ actualPaymentMethod?: string | null
+ paymentCurrency?: string | null
+ paymentNetwork?: string | null
+ paymentUuid?: string | null
+ paymentTxid?: string | null
+ invoiceCurrency?: string | null
+ gatewayStatus?: string | null
+ gatewayStatusDescription?: string | null
+ tradeNo?: string | null
+ failReason?: string | null
+ createdAt?: string
+ expiredAt?: string | null
+ completedAt?: string | null
+ }
+ }> => http.post(`/recharge/orders/${orderNo}/verify`),
+
+ // 获取套餐方案列表
+ getPackagePlans: (packageId: number): Promise<{
+ plans: Array<{
+ id: number
+ name: string
+ description: string | null
+ price: number
+ billingCycle: number
+ cpu: number
+ memory: number
+ disk: number
+ portLimit: number
+ snapshotLimit: number
+ backupLimit: number
+ siteLimit: number
+ swapSize: number
+ monthlyTrafficLimit: string | null
+ isActive: boolean
+ isSoldOut: boolean
+ slaGuarantee: number | null
+ }>
+ }> => http.get(`/packages/${packageId}/plans`)
+ },
+
+ // 管理员 API
+ admin: {
+ // ==================== 支付渠道管理 ====================
+
+ // 获取支付渠道列表
+ getPaymentProviders: (): Promise<{
+ providers: Array<{
+ id: number
+ name: string
+ type: string
+ status: string
+ config: Record
+ methods: string[]
+ methodFees?: Record
+ minAmount: number
+ maxAmount: number | null
+ feeRate: number
+ feeFixed: number
+ sortOrder: number
+ createdAt: string
+ updatedAt: string
+ }>
+ }> => http.get('/admin/payment-providers'),
+
+ // 创建支付渠道
+ createPaymentProvider: (data: {
+ name: string
+ type: string
+ config?: Record
+ methods?: string[]
+ minAmount?: number
+ maxAmount?: number | null
+ feeRate?: number
+ feeFixed?: number
+ sortOrder?: number
+ }): Promise<{
+ provider: { id: number; name: string; type: string; status: string }
+ message: string
+ }> => http.post('/admin/payment-providers', data),
+
+ // 更新支付渠道
+ updatePaymentProvider: (id: number, data: {
+ name?: string
+ config?: Record
+ methods?: string[]
+ minAmount?: number
+ maxAmount?: number | null
+ feeRate?: number
+ feeFixed?: number
+ sortOrder?: number
+ }): Promise<{
+ provider: { id: number; name: string; type: string; status: string }
+ message: string
+ }> => http.put(`/admin/payment-providers/${id}`, data),
+
+ // 更新支付渠道状态
+ updatePaymentProviderStatus: (id: number, status: string): Promise<{
+ message: string
+ }> => http.patch(`/admin/payment-providers/${id}/status`, { status }),
+
+ // 删除支付渠道
+ deletePaymentProvider: (id: number): Promise<{
+ message: string
+ }> => http.delete(`/admin/payment-providers/${id}`),
+
+ // ==================== 统计 ====================
+
+ getStatisticsOverview: (): Promise<{
+ meta: {
+ timezone: string
+ dailyDays: number
+ monthlyMonths: number
+ }
+ users: {
+ total: number
+ dailyNewUsers: Array<{ label: string; value: number }>
+ monthlyNewUsers: Array<{ label: string; value: number }>
+ }
+ instances: {
+ total: number
+ active: number
+ paid: number
+ free: number
+ dailyCreatedInstances: Array<{ label: string; value: number }>
+ monthlyCreatedInstances: Array<{ label: string; value: number }>
+ }
+ billing: {
+ totals: {
+ recharge: number
+ consume: number
+ aff: number
+ destroyFee: number
+ }
+ dailyRecharge: Array<{ label: string; value: number }>
+ monthlyRecharge: Array<{ label: string; value: number }>
+ dailyConsume: Array<{ label: string; value: number }>
+ monthlyConsume: Array<{ label: string; value: number }>
+ dailyAff: Array<{ label: string; value: number }>
+ monthlyAff: Array<{ label: string; value: number }>
+ dailyDestroyFee: Array<{ label: string; value: number }>
+ monthlyDestroyFee: Array<{ label: string; value: number }>
+ }
+ }> => http.get('/admin/statistics/overview'),
+
+ // ==================== VIP 等级规则 ====================
+
+ getVipLevelRules: (type: VipRuleType): Promise =>
+ http.get(`/admin/vip-levels/${type}`),
+
+ updateVipLevelRules: (type: VipRuleType, rules: VipLevelRule[], options?: { userMetric?: UserVipMetric }): Promise =>
+ http.put(`/admin/vip-levels/${type}`, { rules, ...options }),
+
+ getVipBenefitRewards: (): Promise<{ rewards: VipBenefitReward[] }> =>
+ http.get('/admin/vip-benefits/rewards'),
+
+ createVipBenefitReward: (data: VipBenefitRewardInput): Promise<{ reward: VipBenefitReward }> =>
+ http.post('/admin/vip-benefits/rewards', data),
+
+ updateVipBenefitReward: (id: number, data: VipBenefitRewardInput): Promise<{ reward: VipBenefitReward }> =>
+ http.put(`/admin/vip-benefits/rewards/${id}`, data),
+
+ deleteVipBenefitReward: (id: number): Promise<{ success: boolean }> =>
+ http.delete(`/admin/vip-benefits/rewards/${id}`),
+
+ // ==================== 托管管理 ====================
+
+ getHostingOwners: (params?: {
+ page?: number
+ pageSize?: number
+ search?: string
+ sortBy?: 'vipLevel' | 'hostingBalance' | 'frozenBalance' | 'totalIncome' | 'hostCount' | 'packageCount' | 'instanceCount'
+ sortOrder?: 'asc' | 'desc'
+ }): Promise<{
+ owners: Array<{
+ id: number
+ username: string
+ email: string | null
+ status: string
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ vipLevel: number
+ vipBadgeStyle?: VipBadgeStyle | null
+ hostingBalance: {
+ available: number
+ frozen: number
+ total: number
+ historicalTotal: number
+ }
+ hostCount: number
+ listedPackageCount: number
+ instanceCount: number
+ createdAt: string
+ hostingZoneId: number | null
+ }>
+ summary: {
+ totalHosts: number
+ totalListedPackages: number
+ totalInstances: number
+ totalAvailableBalance: number
+ totalFrozenBalance: number
+ totalHostingIncome: number
+ }
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get('/admin/hosting/owners', { params }),
+ getHostingZones: (): Promise<{
+ zones: Array<{
+ id: number
+ name: string
+ logoUrl: string
+ active: boolean
+ sortOrder: number
+ createdAt: string
+ updatedAt: string
+ listedPackageCount: number
+ hostCount: number
+ owner: {
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ }
+ }>
+ }> => http.get('/admin/hosting/zones'),
+ createHostingZone: (data: { name: string; ownerId: number; logoUrl: string }): Promise<{
+ zone: {
+ id: number
+ name: string
+ logoUrl: string
+ active: boolean
+ sortOrder: number
+ createdAt: string
+ updatedAt: string
+ listedPackageCount: number
+ hostCount: number
+ owner: {
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ }
+ }
+ message: string
+ }> => http.post('/admin/hosting/zones', data),
+ deleteHostingZone: (id: number): Promise<{ message: string }> =>
+ http.delete(`/admin/hosting/zones/${id}`),
+
+ // ==================== 计费管理 ====================
+
+ // 获取计费概览
+ getBillingOverview: (): Promise<{
+ overview: {
+ totalRevenue: number
+ thisMonthRevenue: number
+ lastMonthRevenue: number
+ todayRevenue: number
+ totalRefunds: number
+ netRevenue: number
+ paidInstancesCount: number
+ activePaidInstancesCount: number
+ suspendedCount: number
+ expiringCount: number
+ revenueMix: {
+ direct: {
+ totalAmount: number
+ thisMonthAmount: number
+ todayAmount: number
+ }
+ hosted: {
+ totalAmount: number
+ thisMonthAmount: number
+ todayAmount: number
+ }
+ }
+ recharge: {
+ totalAmount: number
+ totalCount: number
+ thisMonthAmount: number
+ thisMonthCount: number
+ todayAmount: number
+ todayCount: number
+ }
+ aff: {
+ totalCommission: number
+ totalOrders: number
+ thisMonthCommission: number
+ totalConverted: number
+ pendingConvertCount: number
+ }
+ }
+ }> => http.get('/admin/billing/overview'),
+
+ // 获取扣费记录
+ getBillingRecords: (params?: { page?: number; pageSize?: number; type?: string; userId?: number; instanceId?: number }): Promise<{
+ records: Array<{
+ id: number
+ type: string
+ amount: number
+ months: number | null
+ periodStart: string | null
+ periodEnd: string | null
+ remark: string | null
+ createdAt: string
+ instance: { id: number; name: string } | null
+ user: { id: number; username: string } | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/admin/billing/records', { params }),
+
+ // 获取付费实例列表
+ getBillingInstances: (params?: { page?: number; pageSize?: number; status?: string; expiring?: boolean; hostId?: number | ''; search?: string }): Promise<{
+ instances: Array<{
+ id: number
+ incusId: string
+ name: string
+ status: string
+ user: { id: number; username: string }
+ host: { id: number; name: string; instanceType?: string } | null
+ package: { id: number; name: string } | null
+ packagePlan: { id: number; name: string } | null
+ packagePlanId: number | null
+ billingPrice: number | null
+ billingCycle: number | null
+ expiresAt: string | null
+ createdAt: string
+ remainingDays: number | null
+ instanceTypeLabel: string
+ autoRenew: boolean
+ suspendedAt: string | null
+ suspendReason: string | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ hosts: Array<{ id: number; name: string }> // 有付费实例的节点列表
+ }> => http.get('/admin/billing/instances', { params }),
+
+ // 管理员封停实例
+ suspendInstance: (instanceId: number, reason?: string): Promise<{
+ success: boolean
+ message: string
+ }> => http.post(`/admin/instances/${instanceId}/suspend`, { reason }),
+
+ // 管理员解封实例
+ unsuspendInstance: (instanceId: number): Promise<{
+ success: boolean
+ message: string
+ }> => http.post(`/admin/instances/${instanceId}/unsuspend`),
+
+ // 管理员延期实例
+ extendInstance: (instanceId: number, days: number, reason?: string, freeExtend?: boolean): Promise<{
+ success: boolean
+ message: string
+ amount: number
+ newExpiresAt: string
+ }> => http.post(`/admin/instances/${instanceId}/extend`, { days, reason, freeExtend }),
+
+ // 管理员退款
+ refundInstance: (instanceId: number, amount: number, reason: string): Promise<{
+ success: boolean
+ message: string
+ amount: number
+ maxRefundable: number
+ }> => http.post(`/admin/instances/${instanceId}/refund`, { amount, reason }),
+
+ // 管理员删除并退款
+ deleteAndRefundInstance: (instanceId: number, refundType: 'remaining' | 'full', reason: string, databaseOnly: boolean = false): Promise<{
+ success: boolean
+ message: string
+ refundAmount: number
+ refundType: string
+ }> => http.post(`/admin/instances/${instanceId}/delete-and-refund`, { refundType, reason, databaseOnly }),
+
+ // 为实例应用AFF优惠码
+ applyAffCode: (instanceId: number, affCode: string): Promise<{
+ success: boolean
+ message: string
+ discountRate: number
+ }> => http.post(`/admin/instances/${instanceId}/apply-aff`, { affCode }),
+
+ // 修改实例专属价格
+ updateInstancePrice: (instanceId: number, newPrice: number, settleBalance: boolean, expectedVersion?: number): Promise<{
+ success: boolean
+ message: string
+ oldPrice: number
+ newPrice: number
+ priceDiff: number
+ remainingDays: number
+ billingCycle: number
+ }> => http.post(`/admin/instances/${instanceId}/update-price`, { newPrice, settleBalance, expectedVersion }),
+
+ previewInstancePriceUpdate: (instanceId: number, newPrice: number, settleBalance: boolean): Promise<{
+ oldPrice: number
+ newPrice: number
+ billingCycle: number
+ remainingDays: number
+ priceDiff: number
+ discountRate: number
+ userBalance: number
+ instanceVersion: number
+ }> => http.post(`/admin/instances/${instanceId}/update-price/preview`, { newPrice, settleBalance }),
+
+ previewBatchInstancePriceUpdate: (instanceIds: number[], newPrice: number, settleBalance: boolean): Promise<{
+ summary: {
+ selectedCount: number
+ validCount: number
+ changedCount: number
+ unchangedCount: number
+ failedCount: number
+ totalCharge: number
+ totalRefund: number
+ netAmount: number
+ }
+ canSubmit: boolean
+ items: Array<{
+ id: number
+ name: string | null
+ user: { id: number; username: string; balance: number } | null
+ oldPrice: number | null
+ newPrice: number
+ billingCycle: number | null
+ remainingDays: number
+ priceDiff: number
+ discountRate: number
+ status: 'ready' | 'unchanged' | 'failed'
+ error?: string
+ instanceVersion?: number
+ }>
+ userImpacts: Array<{
+ userId: number
+ username: string
+ balanceBefore: number
+ balanceAfter: number
+ totalCharge: number
+ totalRefund: number
+ netDiff: number
+ insufficientBalance: boolean
+ }>
+ }> => http.post('/admin/instances/batch-update-price/preview', { instanceIds, newPrice, settleBalance }, { timeout: TIMEOUT.MEDIUM }),
+
+ updateBatchInstancePrice: (instanceIds: number[], newPrice: number, settleBalance: boolean, expectations?: Array<{ instanceId: number; version: number }>): Promise<{
+ success: boolean
+ message: string
+ preview: {
+ summary: {
+ selectedCount: number
+ validCount: number
+ changedCount: number
+ unchangedCount: number
+ failedCount: number
+ totalCharge: number
+ totalRefund: number
+ netAmount: number
+ }
+ canSubmit: boolean
+ items: Array<{
+ id: number
+ name: string | null
+ user: { id: number; username: string; balance: number } | null
+ oldPrice: number | null
+ newPrice: number
+ billingCycle: number | null
+ remainingDays: number
+ priceDiff: number
+ discountRate: number
+ status: 'ready' | 'unchanged' | 'failed'
+ error?: string
+ instanceVersion?: number
+ }>
+ userImpacts: Array<{
+ userId: number
+ username: string
+ balanceBefore: number
+ balanceAfter: number
+ totalCharge: number
+ totalRefund: number
+ netDiff: number
+ insufficientBalance: boolean
+ }>
+ }
+ }> => http.post('/admin/instances/batch-update-price', { instanceIds, newPrice, settleBalance, expectations }, { timeout: TIMEOUT.MEDIUM }),
+
+ // 获取可升级的方案列表
+ getAvailablePlans: (instanceId: number): Promise<{
+ currentPlan: {
+ id: number
+ name: string
+ price: number
+ billingCycle: number
+ monthlyPrice: number
+ cpu: number
+ memory: number
+ disk: number
+ }
+ remainingDays: number
+ availablePlans: Array<{
+ id: number
+ name: string
+ description: string | null
+ price: number
+ billingCycle: number
+ monthlyPrice: number
+ priceDiff: number
+ cpu: number
+ memory: number
+ disk: number
+ portLimit: number
+ snapshotLimit: number
+ backupLimit: number
+ siteLimit: number
+ swapSize: number
+ trafficLimit: string
+ isSoldOut: boolean
+ slaGuarantee: number | null
+ }>
+ userBalance: number
+ }> => http.get(`/admin/instances/${instanceId}/available-plans`),
+
+ // 升级实例方案
+ upgradePlan: (instanceId: number, newPlanId: number): Promise<{
+ success: boolean
+ message: string
+ priceDifference: number
+ oldPlan: { id: number; name: string }
+ newPlan: { id: number; name: string }
+ resourcesSynced: boolean
+ }> => http.post(`/admin/instances/${instanceId}/upgrade-plan`, { newPlanId }),
+
+ // ==================== 充值记录管理 ====================
+
+ // 获取充值记录列表
+ getRechargeRecords: (params?: { page?: number; pageSize?: number; status?: string; userId?: number }): Promise<{
+ records: Array<{
+ id: number
+ orderNo: string
+ userId: number
+ user: { id: number; username: string }
+ amount: number
+ payableAmount: number
+ actualAmount: number | null
+ fee: number
+ status: string
+ payMethod: string | null
+ actualPaymentMethod: string | null
+ paymentCurrency: string | null
+ paymentNetwork: string | null
+ paymentUuid: string | null
+ paymentTxid: string | null
+ invoiceCurrency: string | null
+ gatewayStatus: string | null
+ gatewayStatusDescription: string | null
+ provider: { id: number; name: string; displayName: string; type: string } | null
+ tradeNo: string | null
+ createdAt: string
+ paidAt: string | null
+ completedAt: string | null
+ expiresAt: string | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/admin/billing/recharge-records', { params }),
+
+ // 同步充值订单状态
+ syncRechargeRecord: (id: number): Promise<{
+ success: boolean
+ synced: boolean
+ status: string
+ message: string
+ }> => http.post(`/admin/billing/recharge-records/${id}/sync`),
+
+ // 获取充值订单列表
+ getRechargeOrders: (params?: { page?: number; pageSize?: number; status?: string; userId?: number }): Promise<{
+ records: Array<{
+ id: number
+ orderNo: string
+ userId: number
+ amount: number
+ actualAmount: number | null
+ fee: number
+ status: string
+ provider: { id: number; name: string; type: string } | null
+ createdAt: string
+ completedAt: string | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/admin/recharge/orders', { params }),
+
+ // 获取充值统计
+ getRechargeStats: (): Promise<{
+ stats: {
+ todayAmount: number
+ todayCount: number
+ thisMonthAmount: number
+ thisMonthCount: number
+ totalAmount: number
+ totalCount: number
+ }
+ }> => http.get('/admin/recharge/stats'),
+
+ // 手动完成充值订单
+ completeRechargeOrder: (orderNo: string, tradeNo?: string): Promise<{
+ success: boolean
+ message: string
+ }> => http.post(`/admin/recharge/orders/${orderNo}/complete`, { tradeNo }),
+
+ // 手动失败充值订单
+ failRechargeOrder: (orderNo: string, reason: string): Promise<{
+ success: boolean
+ message: string
+ }> => http.post(`/admin/recharge/orders/${orderNo}/fail`, { reason }),
+
+ // ==================== 用户余额管理 ====================
+
+ // 获取用户余额
+ getUserBalance: (userId: number): Promise<{
+ userId: number
+ username: string
+ balance: number
+ totalRecharge: number
+ totalConsume: number
+ totalRefund: number
+ }> => http.get(`/balance/admin/${userId}`),
+
+ // 获取用户余额日志
+ getUserBalanceLogs: (userId: number, params?: { page?: number; pageSize?: number; type?: string; lotteryGift?: 'exclude' | 'only' }): Promise<{
+ logs: Array<{
+ id: number
+ type: string
+ amount: number
+ balanceBefore: number
+ balanceAfter: number
+ orderId: string | null
+ instanceId: number | null
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get(`/balance/admin/${userId}/logs`, { params }),
+
+ // 调整用户余额
+ adjustUserBalance: (userId: number, amount: number, remark: string): Promise<{
+ message: string
+ newBalance: number
+ balanceLog: {
+ id: number
+ type: string
+ amount: number
+ balanceBefore: number
+ balanceAfter: number
+ } | null
+ }> => http.post(`/balance/admin/${userId}/adjust`, { amount, remark }),
+
+ // 赠送用户余额
+ giftUserBalance: (userId: number, amount: number, remark?: string): Promise<{
+ message: string
+ newBalance: number
+ }> => http.post(`/balance/admin/${userId}/gift`, { amount, remark }),
+
+ // 获取用户托管余额明细
+ getHostingBalanceLogs: (userId: number, params?: { page?: number; pageSize?: number }): Promise<{
+ user: { id: number; username: string }
+ available: number
+ frozen: number
+ logs: Array<{
+ id: number
+ type: string
+ actionType: string | null
+ amount: number
+ frozen: boolean
+ unfreezeAt: string | null
+ description: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+ }> => http.get(`/users/${userId}/hosting-balance/logs`, { params }),
+
+ // 调整用户托管余额
+ adjustHostingBalance: (userId: number, type: 'available' | 'frozen', amount: number, reason: string): Promise<{
+ success: boolean
+ available: number
+ frozen: number
+ }> => http.post(`/users/${userId}/hosting-balance/adjust`, { type, amount, reason }),
+
+ // ==================== 用户管理 ====================
+
+ // 获取用户列表(搜索)
+ getUsers: (params?: {
+ search?: string
+ searchFields?: string
+ exact?: boolean
+ page?: number
+ pageSize?: number
+ }): Promise<{
+ users: Array<{
+ id: number
+ username: string
+ email: string
+ role: string
+ status: string
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/users', { params }),
+
+ // 按用户名精确查找用户
+ lookupUser: (username: string): Promise<{
+ user: {
+ id: number
+ username: string
+ email: string | null
+ role: string
+ status: string
+ }
+ }> => http.get('/users/lookup', { params: { username } }),
+
+ // ==================== 实例管理 ====================
+
+ // 管理员创建实例(支持免费赠送或付费实例)
+ createInstance: (data: {
+ username: string
+ name: string
+ packageId: number
+ image: string
+ cpu?: number // 免费实例自定义配置
+ memory?: number // 免费实例自定义配置
+ disk?: number // 免费实例自定义配置
+ hostId?: number
+ customInitCommandIds?: number[]
+ planId?: number // 付费方案ID(传入则创建付费实例)
+ chargeFirstMonth?: boolean // 是否扣除首月费用(默认 true)
+ }): Promise<{
+ message: string
+ instance: {
+ id: number
+ name: string
+ incusId: string
+ host: string
+ status: string
+ user: { id: number; username: string }
+ sshPort: number
+ rootPassword: string
+ isPaid: boolean
+ planName: string | null
+ charged: boolean
+ amount: number
+ expiresAt: string | null
+ }
+ }> => http.post('/admin/instances/create', data)
+ },
+
+ // ==================== AFF 推荐计划 ====================
+ aff: {
+ // 获取 AFF 状态和统计
+ getStatus: (): Promise<{
+ activated: boolean
+ totalEarnings: number
+ totalConverted: number
+ currentBalance: number
+ totalCodes: number
+ totalUsed: number
+ }> => http.get('/aff/me'),
+
+ // 获取我的优惠码列表(包括全局码)
+ getCodes: (): Promise<{
+ codes: Array<{
+ id: number
+ code: string
+ packagePlanId: number | null
+ planName: string | null
+ packageName: string | null
+ price: number | null
+ isGlobal: boolean
+ discountRate: number
+ commissionRate: number
+ usedCount: number
+ totalEarnings: number
+ createdAt: string
+ }>
+ }> => http.get('/aff/me/codes'),
+
+ // 获取可创建优惠码的方案列表(包含全局码状态)
+ getAvailablePlans: (): Promise<{
+ plans: Array<{
+ id: number
+ name: string
+ price: number
+ packageName: string
+ hasCode: boolean
+ }>
+ hasGlobalCode: boolean
+ }> => http.get('/aff/me/available-plans'),
+
+ // 创建优惠码(不传 packagePlanId 则创建全局码,固定 5% 折扣/5% 返利)
+ createCode: (packagePlanId?: number): Promise<{
+ message: string
+ code: {
+ id: number
+ code: string
+ packagePlanId: number | null
+ isGlobal: boolean
+ discountRate: number
+ commissionRate: number
+ createdAt: string
+ }
+ }> => {
+ const body: { packagePlanId?: number } = {}
+ if (packagePlanId !== undefined) body.packagePlanId = packagePlanId
+ return http.post('/aff/me/codes', body)
+ },
+
+ // 删除优惠码(仅允许删除使用次数为 0 的优惠码)
+ deleteCode: (codeId: number): Promise<{ message: string }> =>
+ http.delete(`/aff/me/codes/${codeId}`),
+
+ // 获取 AFF 收益日志
+ getLogs: (params?: { page?: number; pageSize?: number; type?: string }): Promise<{
+ logs: Array<{
+ id: number
+ type: string
+ amount: number
+ originalAmount: number | null
+ balanceBefore: number
+ balanceAfter: number
+ affCodeId: number | null
+ affCode: string | null
+ instanceId: number | null
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/aff/me/logs', { params }),
+
+ // 获取 AFF 收益榜单 TOP 10
+ getLeaderboard: (): Promise<{
+ leaderboard: Array<{
+ rank: number
+ username: string
+ totalEarnings: number
+ isCurrentUser: boolean
+ }>
+ }> => http.get('/aff/leaderboard'),
+
+ // 验证优惠码
+ validateCode: (code: string, packagePlanId: number): Promise<{
+ valid: boolean
+ discountRate?: number
+ }> => http.post('/aff/validate', { code, packagePlanId }),
+
+ // 创建转化申请
+ createConvert: (amount: number): Promise<{
+ message: string
+ withdrawal: {
+ id: number
+ amount: number
+ status: string
+ createdAt: string
+ }
+ }> => http.post('/aff/me/convert', { amount }),
+
+ // 获取我的转化申请
+ getWithdrawals: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{
+ withdrawals: Array<{
+ id: number
+ amount: number
+ status: string
+ rejectReason: string | null
+ reviewedAt: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/aff/me/withdrawals', { params }),
+
+ // 管理员:获取所有转化申请
+ adminGetWithdrawals: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{
+ withdrawals: Array<{
+ id: number
+ userId: number
+ username: string
+ userAffBalance: number
+ amount: number
+ status: string
+ rejectReason: string | null
+ reviewedBy: number | null
+ reviewedAt: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/aff/admin/withdrawals', { params }),
+
+ // 管理员:审核通过
+ adminApprove: (withdrawalId: number): Promise<{ message: string }> =>
+ http.post(`/aff/admin/withdrawals/${withdrawalId}/approve`),
+
+ // 管理员:审核拒绝
+ adminReject: (withdrawalId: number, reason: string): Promise<{ message: string }> =>
+ http.post(`/aff/admin/withdrawals/${withdrawalId}/reject`, { reason })
+ },
+
+ // ==================== 娱乐系统 ====================
+ entertainment: {
+ // ==================== 积分相关 ====================
+
+ // 获取用户积分信息
+ getPoints: (): Promise<{
+ points: number
+ totalEarned: number
+ totalSpent: number
+ lastConvertedAt: string | null
+ totalConsume: number
+ convertedConsume: number
+ convertibleAmount: number
+ convertiblePoints: number
+ }> => http.get('/entertainment/points'),
+
+ // 兑换积分
+ convertPoints: (): Promise<{
+ success: boolean
+ converted: number
+ newPoints: number
+ consumeConverted: number
+ }> => http.post('/entertainment/points/convert'),
+
+ // 获取积分变动日志
+ getPointsLogs: (params?: { page?: number; pageSize?: number; type?: string }): Promise<{
+ logs: Array<{
+ id: number
+ type: string
+ amount: number
+ pointsBefore: number
+ pointsAfter: number
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/entertainment/points/logs', { params }),
+
+ // ==================== 徽章相关 ====================
+
+ getBadgeCatalog: (): Promise<{
+ series: BadgeSeriesItem[]
+ badges: BadgeCatalogItem[]
+ }> => http.get('/entertainment/badges/catalog'),
+
+ getBadgeOverview: (): Promise => http.get('/entertainment/badges/overview'),
+
+ drawBadgeRandom: (): Promise<{
+ success: boolean
+ currentPoints: number
+ ownership: BadgeOwnership
+ }> => http.post('/entertainment/badges/draw/random'),
+
+ drawBadgeRandomMulti: (): Promise => http.post('/entertainment/badges/draw/random-multi'),
+
+ drawBadgeSelect: (badgeId: string): Promise<{
+ success: boolean
+ currentPoints: number
+ ownership: BadgeOwnership
+ }> => http.post('/entertainment/badges/draw/select', { badgeId }),
+
+ applyBadgeToAvatar: (ownershipId: number): Promise<{
+ success: boolean
+ ownership: BadgeOwnership
+ }> => http.post('/entertainment/badges/apply/avatar', { ownershipId }),
+
+ applyBadgeToInstance: (ownershipId: number, instanceId: number): Promise<{
+ success: boolean
+ ownership: BadgeOwnership
+ }> => http.post('/entertainment/badges/apply/instance', { ownershipId, instanceId }),
+
+ unapplyBadge: (ownershipId: number): Promise<{
+ success: boolean
+ ownership: BadgeOwnership
+ }> => http.post('/entertainment/badges/unapply', { ownershipId }),
+
+ // ==================== 抽奖相关 ====================
+
+ // 获取可用抽奖列表
+ getLotteries: (): Promise<{
+ lotteries: Array<{
+ id: number
+ name: string
+ description: string | null
+ costPoints: number
+ startAt: string | null
+ endAt: string | null
+ totalDraws: number
+ prizes: Array<{
+ id: number
+ name: string
+ type: string
+ probability: number
+ remainQuantity: number | null
+ totalQuantity: number | null
+ displayOrder: number
+ instanceDesc: string | null
+ }>
+ }>
+ }> => http.get('/entertainment/lotteries'),
+
+ // 获取抽奖详情
+ getLottery: (id: number): Promise<{
+ lottery: {
+ id: number
+ name: string
+ description: string | null
+ costPoints: number
+ startAt: string | null
+ endAt: string | null
+ totalDraws: number
+ prizes: Array<{
+ id: number
+ name: string
+ type: string
+ probability: number
+ remainQuantity: number | null
+ totalQuantity: number | null
+ displayOrder: number
+ instanceDesc: string | null
+ }>
+ }
+ }> => http.get(`/entertainment/lotteries/${id}`),
+
+ // 执行抽奖
+ draw: (lotteryId: number): Promise<{
+ success: boolean
+ currentPoints: number
+ record: {
+ id: number
+ prizeId: number
+ prizeType: string
+ prizeName: string
+ prizeValue: number
+ badgeOwnership: BadgeOwnership | null
+ status: string
+ pointsSpent: number
+ createdAt: string
+ }
+ action: string | null
+ message: string
+ }> => http.post(`/entertainment/lotteries/${lotteryId}/draw`),
+
+ // 十连抽
+ drawMulti: (lotteryId: number): Promise<{
+ success: boolean
+ records: Array<{
+ id: number
+ prizeId: number
+ prizeType: string
+ prizeName: string
+ prizeValue: number
+ badgeOwnership: BadgeOwnership | null
+ status: string
+ pointsSpent: number
+ createdAt: string
+ }>
+ totalDraws: number
+ totalPointsSpent?: number
+ stoppedAt?: number
+ stopReason?: string
+ }> => http.post(`/entertainment/lotteries/${lotteryId}/draw-multi`),
+
+ // 获取用户中奖记录
+ getLotteryRecords: (params?: { page?: number; pageSize?: number; prizeType?: string }): Promise<{
+ records: Array<{
+ id: number
+ lotteryId: number
+ lotteryName: string
+ prizeType: string
+ prizeName: string
+ prizeValue: number
+ instanceDesc: string | null
+ status: string
+ pointsSpent: number
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/entertainment/lottery-records', { params }),
+
+ // ==================== 管理端 API ====================
+
+ // 获取所有抽奖列表
+ adminGetLotteries: (params?: { page?: number; pageSize?: number; isActive?: boolean }): Promise<{
+ lotteries: Array<{
+ id: number
+ name: string
+ description: string | null
+ costPoints: number
+ isActive: boolean
+ startAt: string | null
+ endAt: string | null
+ totalDraws: number
+ prizesCount: number
+ recordsCount: number
+ createdAt: string
+ prizes: Array<{
+ id: number
+ name: string
+ type: string
+ value: number
+ probability: number
+ totalQuantity: number | null
+ remainQuantity: number | null
+ displayOrder: number
+ instanceDesc: string | null
+ }>
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/admin/entertainment/lotteries', { params }),
+
+ // 创建抽奖
+ adminCreateLottery: (data: {
+ name: string
+ description?: string
+ costPoints: number
+ isActive?: boolean
+ startAt?: string
+ endAt?: string
+ }): Promise<{ success: boolean; lottery: { id: number; name: string } }> =>
+ http.post('/admin/entertainment/lotteries', data),
+
+ // 更新抽奖
+ adminUpdateLottery: (id: number, data: {
+ name?: string
+ description?: string
+ costPoints?: number
+ isActive?: boolean
+ startAt?: string | null
+ endAt?: string | null
+ }): Promise<{ success: boolean }> =>
+ http.put(`/admin/entertainment/lotteries/${id}`, data),
+
+ // 删除抽奖
+ adminDeleteLottery: (id: number): Promise<{ success: boolean }> =>
+ http.delete(`/admin/entertainment/lotteries/${id}`),
+
+ // 获取抽奖详情
+ adminGetLottery: (id: number): Promise<{
+ lottery: {
+ id: number
+ name: string
+ description: string | null
+ costPoints: number
+ isActive: boolean
+ startAt: string | null
+ endAt: string | null
+ totalDraws: number
+ createdAt: string
+ prizes: Array<{
+ id: number
+ name: string
+ type: string
+ value: number
+ probability: number
+ totalQuantity: number | null
+ remainQuantity: number | null
+ displayOrder: number
+ instanceDesc: string | null
+ }>
+ notificationConfig: {
+ enabled: boolean
+ type: string
+ config: Record
+ notifyBalance: boolean
+ notifyInstance: boolean
+ } | null
+ stats: {
+ totalDraws: number
+ prizeStats: Array<{ type: string; count: number }>
+ }
+ }
+ }> => http.get(`/admin/entertainment/lotteries/${id}`),
+
+ // 添加奖品
+ adminCreatePrize: (lotteryId: number, data: {
+ name: string
+ type: string
+ value?: number
+ probability: number
+ totalQuantity?: number
+ displayOrder?: number
+ instanceDesc?: string
+ }): Promise<{ success: boolean; prize: { id: number; name: string } }> =>
+ http.post(`/admin/entertainment/lotteries/${lotteryId}/prizes`, data),
+
+ // 更新奖品
+ adminUpdatePrize: (prizeId: number, data: {
+ name?: string
+ type?: string
+ value?: number
+ probability?: number
+ totalQuantity?: number | null
+ remainQuantity?: number | null
+ displayOrder?: number
+ instanceDesc?: string | null
+ }): Promise<{ success: boolean }> =>
+ http.put(`/admin/entertainment/prizes/${prizeId}`, data),
+
+ // 删除奖品
+ adminDeletePrize: (prizeId: number): Promise<{ success: boolean }> =>
+ http.delete(`/admin/entertainment/prizes/${prizeId}`),
+
+ // 更新抽奖通知配置
+ adminUpdateNotification: (lotteryId: number, data: {
+ enabled: boolean
+ type: string
+ config: Record
+ notifyBalance: boolean
+ notifyInstance: boolean
+ }): Promise<{ success: boolean }> =>
+ http.put(`/admin/entertainment/lotteries/${lotteryId}/notification`, data),
+
+ adminGetBadgeCatalog: (): Promise<{
+ series: BadgeSeriesItem[]
+ badges: BadgeCatalogItem[]
+ }> => http.get('/admin/entertainment/badges/catalog'),
+
+ adminCreateBadgeSeries: (data: {
+ id: string
+ title: string
+ nameZh: string
+ nameEn?: string | null
+ description: string
+ sourceId?: string | null
+ sourceLabel?: string | null
+ displayOrder?: number
+ isActive?: boolean
+ }): Promise<{ success: boolean; series: BadgeSeriesItem }> =>
+ http.post('/admin/entertainment/badges/series', data),
+
+ adminUpdateBadgeSeries: (id: string, data: {
+ title?: string
+ nameZh?: string
+ nameEn?: string | null
+ description?: string
+ sourceId?: string | null
+ sourceLabel?: string | null
+ displayOrder?: number
+ isActive?: boolean
+ }): Promise<{ success: boolean }> =>
+ http.put(`/admin/entertainment/badges/series/${id}`, data),
+
+ adminDeleteBadgeSeries: (id: string): Promise<{ success: boolean }> =>
+ http.delete(`/admin/entertainment/badges/series/${id}`),
+
+ adminCreateBadge: (data: {
+ id: string
+ name: string
+ nameEn?: string | null
+ fullLabel: string
+ seriesId: string
+ sourceId?: string | null
+ sourceLabel?: string | null
+ assetUrl: string
+ assetUrlDark?: string | null
+ assetUrlLight?: string | null
+ displayOrder?: number
+ isActive?: boolean
+ }): Promise<{ success: boolean; badge: BadgeCatalogItem }> =>
+ http.post('/admin/entertainment/badges', data),
+
+ adminUpdateBadge: (id: string, data: {
+ name?: string
+ nameEn?: string | null
+ fullLabel?: string
+ seriesId?: string
+ sourceId?: string | null
+ sourceLabel?: string | null
+ assetUrl?: string
+ assetUrlDark?: string | null
+ assetUrlLight?: string | null
+ displayOrder?: number
+ isActive?: boolean
+ }): Promise<{ success: boolean }> =>
+ http.put(`/admin/entertainment/badges/${id}`, data),
+
+ adminDeleteBadge: (id: string): Promise<{ success: boolean }> =>
+ http.delete(`/admin/entertainment/badges/${id}`),
+
+ // 获取所有中奖记录
+ adminGetLotteryRecords: (params?: {
+ page?: number
+ pageSize?: number
+ lotteryId?: number
+ prizeType?: string
+ status?: string
+ search?: string
+ }): Promise<{
+ records: Array<{
+ id: number
+ lotteryId: number
+ lotteryName: string
+ userId: number
+ username: string
+ userAvatar: string
+ prizeType: string
+ prizeName: string
+ prizeValue: number
+ status: string
+ pointsSpent: number
+ deliveredAt: string | null
+ deliveredBy: number | null
+ ticketId: number | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/admin/entertainment/lottery-records', { params }),
+
+ // 标记实例奖励为已发放
+ adminDeliverPrize: (recordId: number, ticketId?: number): Promise<{ success: boolean }> =>
+ http.post(`/admin/entertainment/lottery-records/${recordId}/deliver`, { ticketId }),
+
+ // 获取所有用户积分列表
+ adminGetUserPoints: (params?: {
+ page?: number
+ pageSize?: number
+ search?: string
+ orderBy?: string
+ order?: string
+ }): Promise<{
+ records: Array<{
+ userId: number
+ username: string
+ userAvatar: string
+ points: number
+ totalEarned: number
+ totalSpent: number
+ convertedConsume: number
+ lastConvertedAt: string | null
+ updatedAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/admin/entertainment/user-points', { params }),
+
+ // 调整用户积分
+ adminAdjustPoints: (userId: number, amount: number, remark: string): Promise<{
+ success: boolean
+ newPoints: number
+ }> => http.post(`/admin/entertainment/user-points/${userId}/adjust`, { amount, remark }),
+
+ // 获取用户积分详情
+ adminGetUserPointsDetail: (userId: number): Promise<{
+ user: { id: number; username: string; avatarStyle: string; avatarBadgeId?: string | null }
+ points: number
+ totalEarned: number
+ totalSpent: number
+ convertedConsume: number
+ lastConvertedAt: string | null
+ totalConsume: number
+ convertibleAmount: number
+ convertiblePoints: number
+ }> => http.get(`/admin/entertainment/user-points/${userId}`),
+
+ // 获取用户积分日志
+ adminGetUserPointsLogs: (userId: number, params?: { page?: number; pageSize?: number }): Promise<{
+ logs: Array<{
+ id: number
+ type: string
+ amount: number
+ pointsBefore: number
+ pointsAfter: number
+ remark: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get(`/admin/entertainment/user-points/${userId}/logs`, { params })
+ },
+
+ // ==================== 托管余额 API ====================
+ hosting: {
+ // 检查托管准入条件
+ checkAccess: (): Promise<{
+ allowed: boolean
+ reason?: string
+ details?: {
+ instanceCount: number
+ hasCreatedHostBefore?: boolean
+ featureEnabled?: boolean
+ hiddenBySystemSetting?: boolean
+ }
+ }> => http.get('/hosting/access-check'),
+
+ // 获取托管余额
+ getBalance: (): Promise<{
+ balance: {
+ available: number
+ frozen: number
+ pendingWithdrawal: number
+ totalIncome: number
+ totalWithdrawn: number
+ }
+ config: {
+ minWithdrawalAmount: number
+ feeRateBalance: number
+ }
+ }> => http.get('/hosting/balance'),
+
+ // 获取托管统计
+ getStats: (): Promise<{
+ stats: {
+ myHostsCount: number
+ instancesOnMyHosts: number
+ uniqueCustomersCount: number
+ monthIncome: number
+ vipLevel: number
+ vipBadgeStyle?: VipBadgeStyle | null
+ }
+ recentIncome: Array<{
+ id: number
+ amount: number
+ instanceId: number | null
+ remark: string | null
+ createdAt: string
+ }>
+ }> => http.get('/hosting/stats'),
+
+ // 获取托管余额日志
+ getLogs: (params?: { page?: number; pageSize?: number; actionType?: string; frozen?: string; search?: string }): Promise<{
+ logs: Array<{
+ id: number
+ type: string
+ actionType: string | null
+ amount: number
+ frozen: boolean
+ unfreezeAt: string | null
+ relatedId: number | null
+ remark: string | null
+ createdAt: string
+ instance: {
+ id: number
+ name: string
+ buyer: {
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ }
+ host: {
+ id: number
+ name: string
+ }
+ package: {
+ id: number
+ name: string
+ } | null
+ plan: {
+ id: number
+ name: string
+ } | null
+ } | null
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/hosting/logs', { params }),
+
+ // 申请提现
+ withdraw: (data: { amount: number }): Promise<{
+ message: string
+ withdrawal: {
+ id: number
+ amount: number
+ feeRate: number
+ feeAmount: number
+ actualAmount: number
+ target: string
+ status: string
+ createdAt: string
+ }
+ }> => http.post('/hosting/withdraw', data),
+
+ // 获取提现记录
+ getWithdrawals: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{
+ records: Array<{
+ id: number
+ amount: number
+ feeRate: number
+ feeAmount: number
+ actualAmount: number
+ target: string
+ status: string
+ rejectReason: string | null
+ processedAt: string | null
+ createdAt: string
+ }>
+ total: number
+ page: number
+ pageSize: number
+ }> => http.get('/hosting/withdrawals', { params }),
+
+ getBlocks: (): Promise<{
+ blocks: Array<{
+ id: number
+ blockedUserId: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ remark: string | null
+ createdAt: string
+ }>
+ }> => http.get('/hosting/blocks'),
+
+ searchBlockUsers: (keyword: string): Promise<{
+ users: Array<{
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ blocked: boolean
+ }>
+ }> => http.get('/hosting/blocks/search', { params: { keyword } }),
+
+ blockUser: (data: { userId: number; remark?: string | null }): Promise<{
+ block: {
+ id: number
+ blockedUserId: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ remark: string | null
+ createdAt: string
+ }
+ }> => http.post('/hosting/blocks', data),
+
+ unblockUser: (userId: number): Promise<{ success: boolean }> =>
+ http.delete(`/hosting/blocks/${userId}`)
+ },
+
+ // ==================== VIP 等级 API ====================
+ vipLevels: {
+ getMyOverview: (): Promise => http.get('/vip-levels/me')
+ },
+
+ // ==================== VIP 福利 API ====================
+ vipBenefits: {
+ getMyOverview: (): Promise => http.get('/vip-benefits/me'),
+ claim: (rewardId: number): Promise<{
+ claim: VipBenefitClaim
+ overview: VipBenefitOverviewResponse
+ }> => http.post(`/vip-benefits/${rewardId}/claim`),
+ claimAll: (): Promise<{
+ claims: VipBenefitClaim[]
+ overview: VipBenefitOverviewResponse
+ }> => http.post('/vip-benefits/claim-all')
+ },
+
+ // 域名邮箱
+ mail: {
+ // 获取可购买的邮箱源和方案
+ getSources: (): Promise<{
+ sources: Array<{
+ id: number
+ name: string
+ code: string
+ plans: Array<{
+ id: number
+ name: string
+ description: string | null
+ domainLimit: number
+ diskLimitGb: number
+ billingCycle: 'monthly' | 'yearly'
+ price: number
+ }>
+ }>
+ }> => http.get('/mail/sources'),
+
+ // 获取我的订阅
+ getSubscription: (): Promise<{
+ subscription: {
+ id: number
+ status: 'active' | 'expired' | 'suspended'
+ expiresAt: string
+ autoRenew: boolean
+ source: { id: number; name: string; code: string }
+ plan: {
+ id: number
+ name: string
+ domainLimit: number
+ diskLimitGb: number
+ billingCycle: 'monthly' | 'yearly'
+ price: number
+ }
+ usage: {
+ domainCount: number
+ accountCount: number
+ diskUsedGb: number
+ }
+ domains: Array<{
+ id: number
+ domain: string
+ status: 'pending' | 'verified' | 'suspended'
+ accountCount: number
+ diskUsedMb: number
+ createdAt: string
+ }>
+ } | null
+ }> => http.get('/mail/subscription'),
+
+ // 购买订阅
+ purchaseSubscription: (planId: number, affCode?: string): Promise<{
+ subscription: {
+ id: number
+ status: string
+ expiresAt: string
+ source: { id: number; name: string }
+ plan: { id: number; name: string }
+ }
+ discountApplied?: boolean
+ discountAmount?: number
+ finalPrice?: number
+ }> => http.post('/mail/subscription', { planId, affCode }),
+
+ // 验证优惠码
+ validateAffCode: (code: string): Promise<{
+ valid: boolean
+ discountRate?: number
+ commissionRate?: number
+ error?: string
+ }> => http.post('/mail/validate-aff', { code }),
+
+ // 续费订阅
+ renewSubscription: (months: number): Promise<{
+ expiresAt: string
+ discountApplied?: boolean
+ discountAmount?: number
+ finalPrice?: number
+ }> =>
+ http.post('/mail/subscription/renew', { months }),
+
+ // 获取域名列表
+ getDomains: (): Promise<{
+ domains: Array<{
+ id: number
+ domain: string
+ status: 'pending' | 'verified' | 'suspended'
+ accountCount: number
+ diskUsedMb: number
+ verifiedAt: string | null
+ createdAt: string
+ }>
+ }> => http.get('/mail/domains'),
+
+ // 获取域名详情
+ getDomain: (id: number): Promise<{
+ domain: {
+ id: number
+ domain: string
+ status: 'pending' | 'verified' | 'suspended'
+ diskUsedMb: number
+ verifiedAt: string | null
+ createdAt: string
+ accounts: Array<{
+ id: number
+ email: string
+ username: string
+ displayName: string | null
+ diskLimitMb: number
+ diskUsedMb: number
+ isAdmin: boolean
+ createdAt: string
+ }>
+ }
+ }> => http.get(`/mail/domains/${id}`),
+
+ // 添加域名
+ addDomain: (domain: string): Promise<{
+ domain: {
+ id: number
+ domain: string
+ status: string
+ createdAt: string
+ }
+ }> => http.post('/mail/domains', { domain }),
+
+ // 刷新域名验证状态
+ verifyDomain: (id: number): Promise<{
+ status: string
+ verified: boolean
+ txtRecord?: string
+ }> => http.post(`/mail/domains/${id}/verify`),
+
+ // 获取域名 DNS 配置
+ getDomainDns: (id: number): Promise<{
+ verified: boolean
+ txtRecord?: string
+ dnsRecords?: Array<{ type: string; record: string; value: string; prio?: number }>
+ mxRecords?: string[]
+ spfRecord?: string
+ dkimRecord?: string
+ cnameRecords?: Array<{ record: string; value: string }>
+ }> => http.get(`/mail/domains/${id}/dns`),
+
+ // 删除域名
+ deleteDomain: (id: number): Promise<{ success: boolean }> =>
+ http.delete(`/mail/domains/${id}`),
+
+ // 获取邮箱账户列表
+ getAccounts: (domainId: number): Promise<{
+ accounts: Array<{
+ id: number
+ email: string
+ username: string
+ displayName: string | null
+ diskLimitMb: number
+ diskUsedMb: number
+ isAdmin: boolean
+ createdAt: string
+ }>
+ }> => http.get(`/mail/domains/${domainId}/accounts`),
+
+ // 创建邮箱账户
+ createAccount: (domainId: number, data: {
+ username: string
+ password: string
+ displayName?: string
+ diskLimitMb?: number
+ isAdmin?: boolean
+ }): Promise<{
+ account: {
+ id: number
+ email: string
+ username: string
+ displayName: string | null
+ diskLimitMb: number
+ isAdmin: boolean
+ }
+ }> => http.post(`/mail/domains/${domainId}/accounts`, data),
+
+ // 更新邮箱账户
+ updateAccount: (domainId: number, accountId: number, data: {
+ displayName?: string
+ diskLimitMb?: number
+ }): Promise<{
+ account: {
+ id: number
+ email: string
+ displayName: string | null
+ diskLimitMb: number
+ }
+ }> => http.put(`/mail/domains/${domainId}/accounts/${accountId}`, data),
+
+ // 重置邮箱账户密码
+ resetAccountPassword: (domainId: number, accountId: number, password: string): Promise<{ success: boolean }> =>
+ http.post(`/mail/domains/${domainId}/accounts/${accountId}/reset-password`, { password }),
+
+ // 删除邮箱账户
+ deleteAccount: (domainId: number, accountId: number): Promise<{ success: boolean }> =>
+ http.delete(`/mail/domains/${domainId}/accounts/${accountId}`),
+
+ // ==================== 管理员 API ====================
+
+ // 获取所有邮箱源
+ adminGetSources: (): Promise<{
+ sources: Array<{
+ id: number
+ name: string
+ code: string
+ apiUrl: string
+ apiKey: string
+ smarterMailUrl: string
+ enabled: boolean
+ sortOrder: number
+ planCount: number
+ subscriptionCount: number
+ domainCount: number
+ createdAt: string
+ updatedAt: string
+ }>
+ }> => http.get('/mail/admin/sources'),
+
+ // 创建邮箱源
+ adminCreateSource: (data: {
+ name: string
+ code: string
+ apiUrl: string
+ apiKey: string
+ smarterMailUrl: string
+ enabled?: boolean
+ sortOrder?: number
+ }): Promise<{ source: any }> => http.post('/mail/admin/sources', data),
+
+ // 更新邮箱源
+ adminUpdateSource: (id: number, data: {
+ name?: string
+ code?: string
+ apiUrl?: string
+ apiKey?: string
+ smarterMailUrl?: string
+ enabled?: boolean
+ sortOrder?: number
+ }): Promise<{ source: any }> => http.put(`/mail/admin/sources/${id}`, data),
+
+ // 删除邮箱源
+ adminDeleteSource: (id: number): Promise<{ success: boolean }> =>
+ http.delete(`/mail/admin/sources/${id}`),
+
+ // 获取所有方案
+ adminGetPlans: (): Promise<{
+ plans: Array<{
+ id: number
+ sourceId: number
+ name: string
+ description: string | null
+ domainLimit: number
+ diskLimitGb: number
+ billingCycle: 'monthly' | 'yearly'
+ price: string
+ enabled: boolean
+ sortOrder: number
+ source: { id: number; name: string; code: string }
+ createdAt: string
+ updatedAt: string
+ }>
+ }> => http.get('/mail/admin/plans'),
+
+ // 创建方案
+ adminCreatePlan: (data: {
+ sourceId: number
+ name: string
+ description?: string
+ domainLimit: number
+ diskLimitGb: number
+ billingCycle: 'monthly' | 'yearly'
+ price: number
+ enabled?: boolean
+ sortOrder?: number
+ }): Promise<{ plan: any }> => http.post('/mail/admin/plans', data),
+
+ // 更新方案
+ adminUpdatePlan: (id: number, data: {
+ name?: string
+ description?: string
+ domainLimit?: number
+ diskLimitGb?: number
+ billingCycle?: 'monthly' | 'yearly'
+ price?: number
+ enabled?: boolean
+ sortOrder?: number
+ }): Promise<{ plan: any }> => http.put(`/mail/admin/plans/${id}`, data),
+
+ // 删除方案
+ adminDeletePlan: (id: number): Promise<{ success: boolean }> =>
+ http.delete(`/mail/admin/plans/${id}`),
+
+ // 获取所有订阅
+ adminGetSubscriptions: (params?: {
+ sourceId?: number
+ status?: string
+ search?: string
+ page?: number
+ pageSize?: number
+ }): Promise<{
+ subscriptions: Array
+ total: number
+ }> => http.get('/mail/admin/subscriptions', { params }),
+
+ // 获取所有域名
+ adminGetDomains: (params?: {
+ sourceId?: number
+ status?: string
+ search?: string
+ page?: number
+ pageSize?: number
+ }): Promise<{
+ domains: Array
+ total: number
+ }> => http.get('/mail/admin/domains', { params }),
+
+ // 管理员退订
+ adminCancelSubscription: (id: number, data: {
+ refundType: 'none' | 'full' | 'remaining'
+ reason?: string
+ }): Promise<{
+ success: boolean
+ refundAmount: number
+ refundType: string
+ }> => http.post(`/mail/admin/subscriptions/${id}/cancel`, data)
+ }
+}
+
+export default api
+
+// 导出各模块方便单独使用
+export const authApi = api.auth
+export const usersApi = api.users
+export const instancesApi = api.instances
+export const transfersApi = api.transfers
diff --git a/client/src/components/BackupManager.vue b/client/src/components/BackupManager.vue
new file mode 100644
index 0000000..7e7d49c
--- /dev/null
+++ b/client/src/components/BackupManager.vue
@@ -0,0 +1,996 @@
+
+
+
+
+
+
+
+ {{ t('backup.title') }}
+
+
+ {{ policy?.enabled ? t('backup.autoPolicy') : t('backup.manual') }}
+
+
+
+
+ {{ quotaUsed }}/{{ backupLimit }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('backup.autoPolicyEnabled') }}
+
+
+ {{ t('backup.currentPolicy') }}: {{ getIntervalLabel(policy.interval_minutes) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('backup.noQuotaAllocated') }}
+ {{ t('backup.allocateQuotaHint') }}
+
+
+ {{ t('backup.noBackups') }}
+
+
+
+
+
+
+
+
+
+ {{ backup.name }}
+
+ {{ getStatusInfo(backup.status).label }}
+
+
+
+ {{ formatDate(backup.created_at) }}
+ {{ formatSize(backup.size) }}
+
+
+
+
+
+
+
+
+
+ {{ t('backup.restoring') }}
+
+ (队列: {{ currentRestoreTask.queuePosition }})
+
+
+ ({{ formatDuration(currentRestoreTask.duration) }})
+
+
+
+
+
+
+
+
+
+ {{ uploadStatus === 'PENDING' ? t('backup.uploadStatus.pending') : t('backup.uploadStatus.processing') }}
+
+ (队列: {{ uploadQueuePosition }})
+
+
+ ({{ formatDuration(uploadDuration) }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('backup.createModal.name') }}
+
{{ uploadTargetBackup?.name }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('backup.uploadModal.noStorage') }}
+
{{ t('backup.uploadModal.noStorageHint') }}
+
+ {{ t('backup.uploadModal.goToSettings') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('backup.restoreModal.warning') }}
+
{{ t('backup.restoreModal.warningDetail') }}
+
+
+
+
+
{{ t('backup.restoreModal.dataLossWarning') }}
+
+ - {{ t('backup.restoreModal.dataLossItems.backups') }}
+ - {{ t('backup.restoreModal.dataLossItems.snapshots') }}
+
+
+
+
+
+ {{ t('backup.restoreModal.nameChangeNotice', { name: instanceName, backup: restoreTargetBackup?.name }) }}
+
+
+
+
+
{{ t('backup.restoreModal.backupName') }}: {{ restoreTargetBackup?.name }}
+
{{ t('backup.restoreModal.instanceName') }}: {{ instanceName }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/BadgeImage.vue b/client/src/components/BadgeImage.vue
new file mode 100644
index 0000000..55c9856
--- /dev/null
+++ b/client/src/components/BadgeImage.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
diff --git a/client/src/components/CheckinModal.vue b/client/src/components/CheckinModal.vue
new file mode 100644
index 0000000..82e38a4
--- /dev/null
+++ b/client/src/components/CheckinModal.vue
@@ -0,0 +1,841 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('checkin.rulesCheckin') }}
+
+ -
+ •
+ {{ t('checkin.rulesCheckin1') }}
+
+ -
+ •
+ {{ t('checkin.rulesCheckin2') }}
+
+ -
+ •
+ {{ t('checkin.rulesCheckin3') }}
+
+
+
+
+
+
{{ t('checkin.rulesRedeem') }}
+
+ -
+ •
+ {{ t('checkin.rulesRedeem1') }}
+
+ -
+ •
+ {{ t('checkin.rulesRedeem2') }}
+
+ -
+ •
+ {{ t('checkin.rulesRedeem3') }}
+
+
+
+
+
+
{{ t('checkin.rulesShare') }}
+
+ -
+ •
+ {{ t('checkin.rulesShare1') }}
+
+ -
+ •
+ {{ t('checkin.rulesShare2') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('checkin.alreadyCheckedIn') }}
+
{{ t('checkin.savedToPool') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getResourceTypeName(revealedReward.type) }}
+
+
+ +{{ revealedReward.value }}{{ getResourceUnit(revealedReward.type) }}
+
+
+
+
+
+
+ {{ t('checkin.points') }}
+
+
+ +{{ revealedReward.bonusPoints }}
+
+
+
+
+
+
+
+
+
+
+ {{ checkinStatus.hasInstances ? t('checkin.clickToOpen') : t('checkin.noInstance') }}
+
+
+ {{ t('checkin.opening') }}...
+
+
+ {{ t('checkin.revealing') }}...
+
+
+ {{ t('checkin.congratulations') }}
+
+
+
+
+
+
+
+
+ {{ t('checkin.noInstance') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('checkin.redeemHint') }}
+
+
+
+
+
+
+
+
+
+
{{ $t('checkin.noInstancesForRedeem') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ inst.host.name }}
+
+
+
+
CPU
+
{{ inst.cpu }}% / {{ inst.package.cpuMax }}%
+
+
+
{{ $t('checkin.memory') }}
+
{{ inst.memory }}MB / {{ inst.package.memoryMax }}MB
+
+
+
{{ $t('checkin.disk') }}
+
{{ inst.disk }}MB / {{ inst.package.diskMax }}MB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('checkin.checkinRecords') }}
+ {{ t('checkin.showingRecent', { count: 5, total: checkinTotal }) }}
+
+
+ {{ t('checkin.noRecords') }}
+
+
+
+
+ {{ record.redeemCode }}
+ {{ formatDate(record.createdAt) }}
+
+
+ {{ getResourceTypeName(record.codeType) }} +{{ record.codeValue }}{{ getResourceUnit(record.codeType) }}
+
+ {{ record.usedBy ? record.usedBy.username : t('checkin.self') }}
+
+
+ {{ t('checkin.codeExpired') }}
+
+
+ {{ t('checkin.unused') }}
+
+
+
+
+
+
+
+
+
+
{{ t('checkin.redeemRecords') }}
+ {{ t('checkin.showingRecent', { count: 5, total: redeemTotal }) }}
+
+
+ {{ t('checkin.noRecords') }}
+
+
+
+
+
+ {{ record.redeemCode }}
+
+ → {{ record.owner.username }}
+
+
+
{{ record.usedAt ? formatDate(record.usedAt) : '' }}
+
+
+ {{ getResourceTypeName(record.codeType) }} +{{ record.codeValue }}{{ getResourceUnit(record.codeType) }}
+
+ → {{ record.usedFor.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/FlagIcon.vue b/client/src/components/FlagIcon.vue
new file mode 100644
index 0000000..ffd0814
--- /dev/null
+++ b/client/src/components/FlagIcon.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/client/src/components/InstanceDisplayIcon.vue b/client/src/components/InstanceDisplayIcon.vue
new file mode 100644
index 0000000..1b2504c
--- /dev/null
+++ b/client/src/components/InstanceDisplayIcon.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/client/src/components/InstanceSelector.vue b/client/src/components/InstanceSelector.vue
new file mode 100644
index 0000000..17e3c31
--- /dev/null
+++ b/client/src/components/InstanceSelector.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/LazyImage.vue b/client/src/components/LazyImage.vue
new file mode 100644
index 0000000..53cd34f
--- /dev/null
+++ b/client/src/components/LazyImage.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
diff --git a/client/src/components/LoadingSpinner.vue b/client/src/components/LoadingSpinner.vue
new file mode 100644
index 0000000..e864aec
--- /dev/null
+++ b/client/src/components/LoadingSpinner.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
diff --git a/client/src/components/NotificationBell.vue b/client/src/components/NotificationBell.vue
new file mode 100644
index 0000000..e5f77d5
--- /dev/null
+++ b/client/src/components/NotificationBell.vue
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('inbox.notifications') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('inbox.noMessages') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/PackageQuotaReleaseModal.vue b/client/src/components/PackageQuotaReleaseModal.vue
new file mode 100644
index 0000000..96bc0c8
--- /dev/null
+++ b/client/src/components/PackageQuotaReleaseModal.vue
@@ -0,0 +1,414 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('quotaRelease.packageQuota') }}
+
+ {{ packageCpuMax }}% CPU · {{ formatMemory(packageMemoryMax) }}
+
+
+
+
+
+
+
+
{{ t('quotaRelease.noHosts') }}
+
{{ t('quotaRelease.noHostsHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('quotaRelease.quotaToAdd') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('quotaRelease.preview', { count: selectedHostIds.length, cpu: cpuAddInput, memory: formatMemory(memoryAddInput) }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('quotaRelease.notificationChannel') }}
+
+
+
+
+
+
+
+
+
+ {{ t('quotaRelease.noGlobalChannel') }}
+
+ {{ t('quotaRelease.noGlobalChannelHint') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/PopupAnnouncementModal.vue b/client/src/components/PopupAnnouncementModal.vue
new file mode 100644
index 0000000..4fa9c3f
--- /dev/null
+++ b/client/src/components/PopupAnnouncementModal.vue
@@ -0,0 +1,414 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('popupAnnouncement.promoLabel') }}
+
{{ promoPackage.name }}
+
+
+
+
+
+
+
+
+ {{ t('popupAnnouncement.promoLabel') }}
+
+
{{ promoPackage.name }}
+
+ {{ promoPackage.description }}
+
+
+
+
+
+
+ {{ t('popupAnnouncement.promoPlans') }}
+
+
+
+
+
+
+
{{ plan.name }}
+
+ {{ plan.cpu }}% CPU · {{ formatMemory(plan.memory) }} · {{ formatDisk(plan.disk) }}
+
+
+
+
+ ¥{{ formatPromoPrice(plan.price) }}
+
+
+ {{ getPromoBillingCycleLabel(plan.billingCycle) }}
+
+
+
+
+
+ {{ formatPromoTraffic(plan.trafficLimit) }}
+
+
+ {{ t('popupAnnouncement.soldOut') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('popupAnnouncement.title') }}
+
+ {{ t('popupAnnouncement.subtitle') }}
+
+
+
+
+
+
+ {{ announcementText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
diff --git a/client/src/components/SensitiveVerificationModal.vue b/client/src/components/SensitiveVerificationModal.vue
new file mode 100644
index 0000000..62e8c9e
--- /dev/null
+++ b/client/src/components/SensitiveVerificationModal.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('sensitiveVerification.description') }}
+
+
+ {{ t('sensitiveVerification.operationLabel') }}: {{ operationName }}
+
+
+
+
+
+
+ {{ t('sensitiveVerification.requestHint') }}
+
+
+
{{ error }}
+
+
+
+
+
+
+ {{ t('sensitiveVerification.codeSentTo', { channel: channelName }) }}
+
+
{{ target }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/Skeleton.vue b/client/src/components/Skeleton.vue
new file mode 100644
index 0000000..aba00d0
--- /dev/null
+++ b/client/src/components/Skeleton.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/SkeletonLoader.vue b/client/src/components/SkeletonLoader.vue
new file mode 100644
index 0000000..cea9835
--- /dev/null
+++ b/client/src/components/SkeletonLoader.vue
@@ -0,0 +1,306 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/SnapshotManager.vue b/client/src/components/SnapshotManager.vue
new file mode 100644
index 0000000..55331dd
--- /dev/null
+++ b/client/src/components/SnapshotManager.vue
@@ -0,0 +1,442 @@
+
+
+
+
+
+
+
+ {{ t('snapshot.title') }}
+
+
+ {{ policy?.enabled ? t('snapshot.autoPolicy') : t('snapshot.manual') }}
+
+
+
+
+ {{ quotaUsed }}/{{ snapshotLimit }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('snapshot.autoPolicyEnabled') }}
+
+
+ {{ t('snapshot.currentPolicy') }}: {{ getIntervalLabel(policy.interval_minutes) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('snapshot.noQuotaAllocated') }}
+ {{ t('snapshot.allocateQuotaHint') }}
+
+
+ {{ t('snapshot.noSnapshots') }}
+
+
+
+
+
+
+
+
+
+ {{ snapshot.name }}
+
+
+ {{ formatDate(snapshot.created_at) }}
+ {{ t('snapshot.statefulSnapshot') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/TermsOfServiceModal.vue b/client/src/components/TermsOfServiceModal.vue
new file mode 100644
index 0000000..84e6c86
--- /dev/null
+++ b/client/src/components/TermsOfServiceModal.vue
@@ -0,0 +1,282 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.tos.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/Toast.vue b/client/src/components/Toast.vue
new file mode 100644
index 0000000..0a475c9
--- /dev/null
+++ b/client/src/components/Toast.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
{{ message }}
+
+
+
+
+
diff --git a/client/src/components/ToastContainer.vue b/client/src/components/ToastContainer.vue
new file mode 100644
index 0000000..8e13049
--- /dev/null
+++ b/client/src/components/ToastContainer.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/TurnstileWidget.vue b/client/src/components/TurnstileWidget.vue
new file mode 100644
index 0000000..8780e9c
--- /dev/null
+++ b/client/src/components/TurnstileWidget.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/UserAvatar.vue b/client/src/components/UserAvatar.vue
new file mode 100644
index 0000000..3553006
--- /dev/null
+++ b/client/src/components/UserAvatar.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+ {{ username?.charAt(0).toUpperCase() }}
+
+
diff --git a/client/src/components/VirtualList.vue b/client/src/components/VirtualList.vue
new file mode 100644
index 0000000..b1af552
--- /dev/null
+++ b/client/src/components/VirtualList.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
diff --git a/client/src/components/admin/BillingOverviewIcon.vue b/client/src/components/admin/BillingOverviewIcon.vue
new file mode 100644
index 0000000..fd0066b
--- /dev/null
+++ b/client/src/components/admin/BillingOverviewIcon.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
diff --git a/client/src/components/admin/VipBenefitHallSettings.vue b/client/src/components/admin/VipBenefitHallSettings.vue
new file mode 100644
index 0000000..6296dde
--- /dev/null
+++ b/client/src/components/admin/VipBenefitHallSettings.vue
@@ -0,0 +1,486 @@
+
+
+
+
+
+
+
+
{{ t('admin.vipBenefits.title') }}
+
{{ t('admin.vipBenefits.description') }}
+
+
+
+
+
+
+
{{ t('admin.vipBenefits.balanceTitle') }}
+
{{ t('admin.vipBenefits.balanceDesc') }}
+
+
+
{{ t('admin.vipBenefits.pointsTitle') }}
+
{{ t('admin.vipBenefits.pointsDesc') }}
+
+
+
{{ t('admin.vipBenefits.instanceTitle') }}
+
{{ t('admin.vipBenefits.instanceDesc') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.vipBenefits.noEnabledLevels') }}
+
+
+
+
+
+
+
+ VIP{{ rule.level }}
+
+
+
{{ t('admin.vipBenefits.levelTitle', { level: rule.level }) }}
+
{{ t('admin.vipBenefits.levelHint') }}
+
+
+
+
+
+
+ {{ t('admin.vipBenefits.noRewardsForLevel') }}
+
+
+
+
+
+
+
+
+ {{ t(`admin.vipBenefits.types.${reward.type}`) }}
+
+ {{ rewardPreview(reward) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/admin/VipLevelRulesEditor.vue b/client/src/components/admin/VipLevelRulesEditor.vue
new file mode 100644
index 0000000..d93d62a
--- /dev/null
+++ b/client/src/components/admin/VipLevelRulesEditor.vue
@@ -0,0 +1,399 @@
+
+
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
+
+
+
{{ t('admin.vipRules.userMetricTitle') }}
+
{{ t('admin.vipRules.userMetricHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/entertainment/BadgeBatchRewardModal.vue b/client/src/components/entertainment/BadgeBatchRewardModal.vue
new file mode 100644
index 0000000..af7e585
--- /dev/null
+++ b/client/src/components/entertainment/BadgeBatchRewardModal.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ ownership.seriesTitle }}
+
+
+ {{ ownership.badgeName }}
+
+
+ {{ ownership.badgeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/entertainment/BadgeCenter.vue b/client/src/components/entertainment/BadgeCenter.vue
new file mode 100644
index 0000000..be0890f
--- /dev/null
+++ b/client/src/components/entertainment/BadgeCenter.vue
@@ -0,0 +1,609 @@
+
+
+
+
+
+ {{ t('common.loading') }}...
+
+
+
+
+
+
{{ t('entertainment.currentPoints') }}
+
{{ overview.currentPoints }}
+
+
+
{{ t('entertainment.badges.randomTitle') }}
+
{{ overview.costs.randomDraw }}
+
{{ t('entertainment.badges.randomHint') }}
+
+
+
{{ t('entertainment.badges.selectTitle') }}
+
{{ overview.costs.select }}
+
{{ t('entertainment.badges.selectHint') }}
+
+
+
{{ t('entertainment.badges.myTitle') }}
+
{{ overview.ownerships.length }}
+
+ {{ t('entertainment.badges.summary', { available: availableOwnerships.length, applied: appliedOwnerships.length }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('entertainment.badges.randomTitle') }}
+
{{ t('entertainment.badges.randomDescription', { points: overview.costs.randomDraw }) }}
+
+
+
+
+
+
+
+
{{ t('entertainment.badges.selectTitle') }}
+
{{ t('entertainment.badges.selectDescription', { points: overview.costs.select }) }}
+
+
+
+
+
+
+
+
+
{{ group.title }}
+
{{ group.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('entertainment.badges.empty') }}
+
+
+
+
+
+
+
+
+
+ {{ getOwnershipStatusText(ownership.applicationTarget) }}
+
+
+
+
{{ t('entertainment.badges.obtainedAt') }}
+
{{ formatDate(ownership.createdAt) }}
+
+
+
+
+
+
+
+
+
+
{{ ownership.badgeName }}
+
{{ ownership.badgeLabel }}
+
{{ ownership.seriesTitle }}
+
+
+
+
+
+ {{ t('entertainment.badges.sourceLabel') }}
+ {{ getSourceText(ownership.source) }}
+
+
+
+ {{ t('entertainment.badges.currentInstance') }}
+ {{ ownership.appliedInstanceName }}
+
+
+
+
+
+
+
+
+ {{ t('entertainment.badges.applyInstance') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/entertainment/BadgeRewardModal.vue b/client/src/components/entertainment/BadgeRewardModal.vue
new file mode 100644
index 0000000..43f5295
--- /dev/null
+++ b/client/src/components/entertainment/BadgeRewardModal.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ ownership.seriesTitle }}
+
+
{{ ownership.badgeName }}
+
{{ ownership.badgeLabel }}
+
+ {{ t('entertainment.badges.rewardSubtitle') }}
+
+
+
+
+
+
{{ t('entertainment.badges.rewardSeriesLabel') }}
+
{{ ownership.seriesTitle }}
+
+
+
+
{{ t('entertainment.badges.rewardRemainingPoints') }}
+
{{ remainingPoints ?? '--' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/entertainment/VipBenefitHall.vue b/client/src/components/entertainment/VipBenefitHall.vue
new file mode 100644
index 0000000..cae0846
--- /dev/null
+++ b/client/src/components/entertainment/VipBenefitHall.vue
@@ -0,0 +1,453 @@
+
+
+
+
+
+
+
+
+
+
+ {{ feedback.typeLabel }}
+
+
+ {{ feedback.statusLabel }}
+
+
+
{{ feedback.title }}
+
{{ feedback.detail }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('vipBenefits.overviewLabel') }}
+
+ {{ t('vipBenefits.overviewTitle', { level: currentLevelLabel }) }}
+
+
+ {{ overview.currentLevel > 0 ? t('vipBenefits.overviewDesc', { level: currentLevelLabel }) : t('vipBenefits.noVipDesc') }}
+
+
+
+ {{ currentLevelLabel }}
+
+
+
+
+
+
{{ t('vipBenefits.summary.unlocked') }}
+
{{ overview.summary.unlockedRewards }}
+
+
+
{{ t('vipBenefits.summary.claimable') }}
+
{{ overview.summary.claimableRewards }}
+
+
+
{{ t('vipBenefits.summary.claimed') }}
+
{{ overview.summary.claimedRewards }}
+
+
+
{{ t('vipBenefits.summary.pending') }}
+
{{ overview.summary.pendingRewards }}
+
+
+
+
+
+
+
{{ t('vipBenefits.availableSummary') }}
+
{{ t('vipBenefits.availableSummaryDesc') }}
+
+
+
+
+ {{ item.label }} {{ item.value }}
+
+
+ {{ t('vipBenefits.noAvailableReward') }}
+
+
+
+
+
+
+
+ {{ t('vipBenefits.remainingSummary') }}
+
+ {{ item.label }} {{ item.value }}
+
+
+
+
+
+
+ {{ t('vipBenefits.empty') }}
+
+
+
+
+
+
+
+ VIP{{ group.level }}
+
+
+
{{ t('vipBenefits.levelRewards', { level: group.level }) }}
+
+ {{ group.level <= overview.currentLevel ? t('vipBenefits.levelUnlocked') : t('vipBenefits.levelLocked') }}
+
+
+
+
+ {{ t('vipBenefits.rewardCount', { count: group.rewards.length }) }}
+
+
+
+
+
+
+
+
+
+ {{ t(`vipBenefits.types.${reward.type}`) }}
+
+
+ {{ rewardStatusLabel(reward) }}
+
+
+
{{ reward.title }}
+
{{ reward.description }}
+
+
+
+
+
+ {{ t('vipBenefits.rewardValue') }}
+ {{ rewardValue(reward) }}
+
+
+ {{ t('vipBenefits.claimProgress') }}
+ {{ reward.claimedCount || 0 }}/{{ reward.claimLimit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/extensions/InitCommandDetailModal.vue b/client/src/components/extensions/InitCommandDetailModal.vue
new file mode 100644
index 0000000..0889b70
--- /dev/null
+++ b/client/src/components/extensions/InitCommandDetailModal.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('common.loading') }}
+
+
+
+
+
+
+
+ {{ $t('extensions.initCommands.status') }}:
+
+ {{ detail.enabled ? $t('extensions.initCommands.statusEnabled') : $t('extensions.initCommands.statusDisabled') }}
+
+
+
+ {{ $t('extensions.initCommands.createdAt') }}:
+ {{ formatDate(detail.createdAt) }}
+
+
+
+
+
+
+
+
+
+ {{ getDistroName(distro) }}
+
+
+
+
+
+
+
+
{{ detail.description }}
+
+
+
+
+
+
+
+
+
{{ detail.command }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/extensions/InitCommandModal.vue b/client/src/components/extensions/InitCommandModal.vue
new file mode 100644
index 0000000..ab7ebbe
--- /dev/null
+++ b/client/src/components/extensions/InitCommandModal.vue
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('common.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('extensions.initCommands.commandHint') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('extensions.initCommands.distrosHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/extensions/InitCommandSelector.vue b/client/src/components/extensions/InitCommandSelector.vue
new file mode 100644
index 0000000..f8f0bef
--- /dev/null
+++ b/client/src/components/extensions/InitCommandSelector.vue
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
{{ $t('extensions.initCommands.selectTitle') }}
+
+ {{ selectedCount }}
+
+
{{ $t('extensions.initCommands.optional') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('common.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('extensions.initCommands.noAvailable') }}
+
+ {{ $t('extensions.initCommands.goToManage') }} →
+
+
+
+
+
+
+
{{ $t('extensions.initCommands.selectHint') }}
+
+
+
+
+
+
+
diff --git a/client/src/components/host/BatchConfigModal.vue b/client/src/components/host/BatchConfigModal.vue
new file mode 100644
index 0000000..2367858
--- /dev/null
+++ b/client/src/components/host/BatchConfigModal.vue
@@ -0,0 +1,793 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ result.successCount }}
+
{{ t('host.batchConfig.success') }}
+
+
+
+
{{ result.failedCount }}
+
{{ t('host.batchConfig.failed') }}
+
+
+
+
+
+
{{ t('host.batchConfig.failedDetails') }}
+
+
+
+
+ | Incus ID |
+ {{ t('instance.name') }} |
+ {{ t('host.batchConfig.errorReason') }} |
+
+
+
+
+ | {{ item.incusId }} |
+ {{ item.name }} |
+ {{ item.error || '-' }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedIds.length > 0
+ ? t('host.batchConfig.targetSelected', { count: selectedIds.length })
+ : t('host.batchConfig.targetAll', { count: totalInstanceCount })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostCaddyTab.vue b/client/src/components/host/HostCaddyTab.vue
new file mode 100644
index 0000000..8953ff8
--- /dev/null
+++ b/client/src/components/host/HostCaddyTab.vue
@@ -0,0 +1,595 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('host.caddy.title') }}
+
+
+ {{ isEnabled ? t('host.caddy.enabled') : t('host.caddy.disabled') }}
+
+
+
+
+ {{ t('host.caddy.description') }}
+
+
+
+
+
+
+
+ {{ t('host.caddy.notInstalled') }}
+
+
+
+
+
+
+
+
+
+
- {{ t('host.caddy.apiPort') }}
+ -
+ {{ caddyStatus.port }}
+
+
+
+
- {{ t('host.caddy.username') }}
+ -
+ {{ caddyStatus.username || '-' }}
+
+
+
+
- {{ t('host.caddy.publicIp') }}
+ -
+ {{ caddyStatus.natPublicIp || '-' }}
+
+
+
+
- {{ t('host.caddy.sitesCount') }}
+ -
+ {{ caddyStatus.sitesCount || 0 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('host.caddy.sitesList') }}
+
+ {{ t('host.caddy.sitesTotalCount', { count: sitesTotal }) }}
+
+
+
+
+
+
+
+
+
+ {{ t('host.caddy.noSites') }}
+
+
+
+
+
+
+
+
+
+ {{ site.domain }}
+
+
+ {{ getSiteStatusBadge(site).label }}
+
+
+ HTTPS
+
+
+
+
+ {{ t('host.caddy.instance') }}:
+ {{ site.instance.name }}
+
+
+ {{ t('host.caddy.targetPort') }}:
+ {{ site.targetPort }}
+
+
+
+
+
+
+
+
+ {{ t('host.caddy.pageInfo', { current: sitesPage, total: sitesTotalPages, count: sitesTotal }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('host.caddy.installCommand') }}
+
+
+
+
+
+
+
- {{ t('host.caddy.username') }}
+ -
+ {{ installInfo.username }}
+
+
+
+
- {{ t('host.caddy.password') }}
+ -
+ {{ installInfo.password }}
+
+
+
+
- {{ t('host.caddy.apiPort') }}
+ -
+ {{ installInfo.port }}
+
+
+
+
+
+
+
+
+
+
{{ installInfo.command }}
+
+
+
+
+
+
+
{{ t('host.caddy.installHint') }}
+
+ - {{ t('host.caddy.step1') }}
+ - {{ t('host.caddy.step2') }}
+ - {{ t('host.caddy.step3') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostCreateInstanceTab.vue b/client/src/components/host/HostCreateInstanceTab.vue
new file mode 100644
index 0000000..c5dd90a
--- /dev/null
+++ b/client/src/components/host/HostCreateInstanceTab.vue
@@ -0,0 +1,714 @@
+
+
+
+
+
+
+
+
+
+
{{ t('host.createInstance.noPackages') }}
+
{{ t('host.createInstance.noPackagesHint') }}
+
+
+
+
+
+
diff --git a/client/src/components/host/HostImagesTab.vue b/client/src/components/host/HostImagesTab.vue
new file mode 100644
index 0000000..c12454b
--- /dev/null
+++ b/client/src/components/host/HostImagesTab.vue
@@ -0,0 +1,294 @@
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.imagePolicy.title') }}
+
+ {{ t('admin.images.fields.architecture') }}: {{ hostArchitecture }}
+
+
+ {{ t('admin.images.fields.instanceType') }}: {{ formatInstanceType(hostInstanceType) }}
+
+
+
{{ t('admin.hosts.imagePolicy.description', { name: props.hostName }) }}
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.imagePolicy.selectableImages') }}
+
+ {{ useDefaultMode ? t('admin.hosts.imagePolicy.defaultHint') : t('admin.hosts.imagePolicy.selectedCount', { count: selectedCount }) }}
+
+
+
+
+
+
+ {{ t('admin.hosts.imagePolicy.emptySelection') }}
+
+
+
+
💿
+
{{ t('admin.hosts.imagePolicy.noImages') }}
+
+
+
+ {{ t('common.noSearchResults') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostInfoTab.vue b/client/src/components/host/HostInfoTab.vue
new file mode 100644
index 0000000..ac112c7
--- /dev/null
+++ b/client/src/components/host/HostInfoTab.vue
@@ -0,0 +1,1039 @@
+
+
+
+
+
+
+
+ {{ t('admin.hosts.basicInfo') }}
+
+
+
+
- {{ t('admin.hosts.hostName') }}
+ -
+ {{ host.name }}
+
+
+
+
- {{ t('admin.hosts.status') }}
+ -
+
+
+ {{ statusInfo.label }}
+
+
+
+
+
- {{ t('admin.hosts.hostDesc') }}
+ -
+
+ {{ host.location || '-' }}
+
+
+
+
- {{ t('admin.hosts.instances') }}
+ -
+ {{ instanceStats.count }}
+
+
+
+
- {{ t('admin.hosts.instanceTypeLabel') }}
+ -
+ {{ host.instanceType === 'container' ? t('admin.hosts.typeContainer') : host.instanceType === 'vm' ? t('admin.hosts.typeVm') : t('admin.hosts.typeBoth') }}
+
+
+
+
- {{ t('admin.hosts.apiUrl') }}
+ -
+ {{ host.url }}
+
+
+
+
- {{ t('common.createdAt') }}
+ -
+ {{ formatDate(host.createdAt) }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.resources') }}
+
+
+
+
+
+
+
+ {{ t('admin.hosts.cpuQuota') }}
+
+ {{ instanceStats.cpuUsed }} / {{ host.cpuAllowanceMax || 0 }}
+
+
+
+
+
+
+
+ {{ t('admin.hosts.memoryQuota') }}
+
+ {{ formatMemory(instanceStats.memoryUsed) }} / {{ formatMemory(host.memoryMax) }}
+
+
+
+
+
+
+
+ {{ t('admin.hosts.diskUsage') }}
+
+ {{ formatDisk(instanceStats.diskUsed) }} / {{ formatDisk(host.resources?.diskTotal) }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.natConfig') }}
+
+
+
- {{ t('admin.hosts.natPublicIpv4') }}
+ - {{ host.natConfig.publicIp }}
+
+
+
- {{ t('admin.hosts.natPublicIpv6') }}
+ - {{ host.natConfig.publicIpv6 }}
+
+
+
- {{ t('admin.hosts.natBindIpv4') }}
+ - {{ host.natConfig.bindIp }}
+
+
+
- {{ t('admin.hosts.natBindIpv6') }}
+ - {{ host.natConfig.bindIpv6 }}
+
+
+
- {{ t('admin.hosts.portRange') }}
+ -
+ {{ host.natConfig.portRangeStart }} - {{ host.natConfig.portRangeEnd }}
+
+
+
+
- {{ t('admin.hosts.portsUsed') }}
+ - {{ host.natConfig.portsUsedCount }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.agentStatusTitle') }}
+
+
+ {{ agentStatusDescription }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.agentInstallCommandTitle') }}
+
+
+ {{ t('admin.hosts.agentInstallCommandHint') }}
+
+ {{ t('admin.hosts.agentInstallTokenExpiresAt', { time: formatDate(agentInstallTokenExpiresAt) }) }}
+
+
+
+
+
+
{{ agentInstallCommand }}
+
+
+
+
+
+ {{ t('admin.hosts.agentStatusLoadFailed') }}: {{ agentStatusError }}
+
+
+
+
+
+
+ {{ agentStatusInfo.label }}
+
+
+ {{ t('admin.hosts.agentNoRecordHint') }}
+
+
+
+
+
+
+
+
+
+ {{ agentStatusInfo.label }}
+
+
+
+ {{ agentStatus.version }}
+ {{ agentVersionInfo.label }}
+
+
+ {{ capability }}
+
+
+
+
+
+ {{ t('admin.hosts.agentIncus') }}:
+ {{ agentIncusStatus }}
+
+ {{ agentReport.incus.socket }}
+
+
+
+ {{ t('admin.hosts.agentLastSeen') }}:
+ {{ formatDate(agentStatus.lastSeenAt || undefined) }}
+
+
+ {{ t('admin.hosts.agentHeartbeatIp') }}:
+ {{ agentStatus.lastHeartbeatIp || '-' }}
+
+
+ {{ t('admin.hosts.agentUptime') }}:
+ {{ formatUptime(hostMetrics.uptimeSeconds) }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.agentCpuUsage') }}
+
+ {{ hostResources.cpuTotal || '-' }} {{ t('admin.hosts.agentCpuCores') }}
+
+
+
+ {{ formatPercent(hostResources.cpuUsagePercent) }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.agentMemoryUsage') }}
+
+ {{ formatMemoryPair(hostResources.memoryUsedMb, hostResources.memoryTotalMb) }}
+
+
+
+ {{ formatPercent(hostResources.memoryUsagePercent) }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.agentSwapUsage') }}
+
+ {{ formatMemoryPair(hostResources.swapUsedMb, hostResources.swapTotalMb) }}
+
+
+
+ {{ formatPercent(hostResources.swapUsagePercent) }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.agentDiskUsage') }}
+ ({{ hostResources.diskMountpoint }})
+
+
+ {{ formatBytesPair(hostResources.diskUsedBytes, hostResources.diskTotalBytes) }}
+
+
+
+ {{ formatPercent(hostResources.diskUsagePercent) }}
+
+
+
+
+
+
+
{{ t('admin.hosts.agentLoadAverage') }}
+
+ {{ formatLoadAverage(hostMetrics.load1, hostMetrics.load5, hostMetrics.load15) }}
+
+
{{ t('admin.hosts.agentLoadAverageHint') }}
+
+
+
+
{{ t('admin.hosts.agentProcessCount') }}
+
+ {{ hostResources.processCount ?? '-' }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.trafficStats') }}
+
+ ({{ trafficPeriod.periodStart.slice(5) }} ~ {{ trafficPeriod.periodEnd.slice(5) }})
+
+
+
+ {{ t('admin.hosts.monthlyUsed') }}: {{ trafficSummary.totalUsedFormatted }} | {{ t('admin.hosts.hostTotalLimit') }}: {{ trafficSummary.totalLimitFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ yAxisLabels.top }}
+ {{ yAxisLabels.mid }}
+ {{ yAxisLabels.bottom }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.date }}
+
+
+ {{ t('traffic.download') }}: {{ item.rxFormatted }}
+
+
+
+ {{ t('traffic.upload') }}: {{ item.txFormatted }}
+
+
+ {{ t('traffic.total') }}: {{ item.totalFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('traffic.noHistoryData') }}
+
+
+
+
diff --git a/client/src/components/host/HostInstancesList.vue b/client/src/components/host/HostInstancesList.vue
new file mode 100644
index 0000000..bd5e043
--- /dev/null
+++ b/client/src/components/host/HostInstancesList.vue
@@ -0,0 +1,329 @@
+
+
+
+
+
+
{{ t('nav.instances') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ props.getStatusInfo(instance.status).label }}
+ {{ props.getInstanceTypeLabel(instance) }}
+ {{ props.formatMoney(instance.billingPrice) }}
+
+
+
+ #{{ instance.id }}
+ Incus {{ instance.incusId || instance.incus_id || '-' }}
+ {{ instance.imageName || instance.image?.replace(/^images:/, '') || '-' }}
+ {{ props.formatDateTime(instance.createdAt || instance.created_at || null) }}
+
+
+
+ {{ meta }}
+ {{ props.getNetworkModeLabel(instance) }}
+
+
+
+
+
+
{{ instance.username }}
+
{{ instance.userEmail }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.trafficUsage') }}
+ {{ props.getTrafficSummary(instance) }}
+
+
+
+
+
+
{{ t('billing.renewPrice') }}
+
{{ instance.packagePlanId ? props.formatMoney(instance.billingPrice) : '-' }}
+
+
+
{{ t('billing.expiresAt') }}
+
{{ props.getExpirySummary(instance) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
{{ t('common.loadFailed') }}
+
{{ getDetailError(instance.id) }}
+
+
+
+
+
+
+
{{ t('common.details') }}
+
+ - {{ t('instance.detail.info.instanceId') }}
- {{ instance.id }}
+ - Incus ID
- {{ instance.incusId || instance.incus_id || '-' }}
+ - {{ t('packageForm.fields.networkMode') }}
- {{ props.getNetworkModeLabel(instance) }}
+ - {{ t('packageForm.fields.instanceType') }}
- {{ props.getInstanceTypeLabel(instance) }}
+ - {{ t('instance.user') }}
- {{ instance.username || '-' }}
+ - {{ t('instance.createdAt') }}
- {{ props.formatDateTime(instance.createdAt || instance.created_at || null) }}
+ - {{ t('common.updatedAt') }}
- {{ props.formatDateTime(instance.updatedAt || instance.updated_at || null) }}
+ - {{ t('billing.expiresAt') }}
- {{ props.formatDateTime(props.getExpiryValue(instance)) }}
+ - {{ t('admin.hosts.suspendReason') }}
- {{ props.getSuspendReason(instance) }}
+
+
+
+
+
{{ t('admin.hosts.resources') }}
+
+
{{ t('admin.hosts.resources') }}
CPU {{ instance.cpu }}%
{{ props.formatMemory(instance.memory) }}
{{ props.formatDisk(instance.disk) }}
SWAP {{ props.getSwapDisplay(instance) }}
+
{{ t('admin.hosts.trafficUsage') }}
{{ props.getTrafficSummary(instance) }}
{{ props.formatBandwidth(props.getDetailConfig(instance.id)?.config.limits_ingress) }}
{{ props.formatBandwidth(props.getDetailConfig(instance.id)?.config.limits_egress) }}
+
{{ item.label }}
{{ props.formatQuotaValue(item.value) }}
+
IP
{{ props.getDisplayIp(instance).ipv4 || '-' }}
{{ props.getDisplayIp(instance).ipv6 || '-' }}
+
+
+
+
+
{{ t('instanceConfig.title') }}
+
+ - SWAP
- {{ props.getSwapDisplay(instance) }}
+ - {{ t('packageForm.fields.ioLimitMode') }}
- {{ props.getDetailConfig(instance.id)?.ioLimitMode === 'iops' ? t('packageForm.ioMode.iops') : t('packageForm.ioMode.throughput') }}
+ - {{ t('packageForm.fields.limitsRead') }}
- {{ props.getDetailConfig(instance.id)?.ioLimitMode === 'iops' ? `${props.getDetailConfig(instance.id)?.config.limits_read_iops ?? '-'} IOPS` : (props.getDetailConfig(instance.id)?.config.limits_read || '-') }}
+ - {{ t('packageForm.fields.limitsWrite') }}
- {{ props.getDetailConfig(instance.id)?.ioLimitMode === 'iops' ? `${props.getDetailConfig(instance.id)?.config.limits_write_iops ?? '-'} IOPS` : (props.getDetailConfig(instance.id)?.config.limits_write || '-') }}
+ - {{ t('packageForm.fields.limitsIngress') }}
- {{ props.formatBandwidth(props.getDetailConfig(instance.id)?.config.limits_ingress) }}
+ - {{ t('packageForm.fields.limitsEgress') }}
- {{ props.formatBandwidth(props.getDetailConfig(instance.id)?.config.limits_egress) }}
+ - {{ t('packageForm.fields.limitsProcesses') }}
- {{ props.getDetailConfig(instance.id)?.config.limits_processes ?? '-' }}
+ - {{ t('packageForm.fields.limitsCpuPriority') }}
- {{ props.getDetailConfig(instance.id)?.config.limits_cpu_priority ?? '-' }}
+ - {{ t('packageForm.fields.bootAutostart') }}
- {{ props.formatBoolean(props.getDetailConfig(instance.id)?.config.boot_autostart) }}
+ - {{ t('packageForm.fields.bootAutostartPriority') }}
- {{ props.getDetailConfig(instance.id)?.config.boot_autostart_priority ?? '-' }}
+ - {{ t('packageForm.fields.bootAutostartDelay') }}
- {{ props.getDetailConfig(instance.id)?.config.boot_autostart_delay ?? '-' }}s
+ - {{ t('packageForm.fields.bootHostShutdownTimeout') }}
- {{ props.getDetailConfig(instance.id)?.config.boot_host_shutdown_timeout ?? '-' }}s
+
+
+
+
+
{{ t('common.actions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostInstancesTab.vue b/client/src/components/host/HostInstancesTab.vue
new file mode 100644
index 0000000..1d26128
--- /dev/null
+++ b/client/src/components/host/HostInstancesTab.vue
@@ -0,0 +1,2034 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.noInstances') }}
+
+
+
+
+
+ {{ selectedCount > 0 ? t('admin.hosts.selectedCount', { count: selectedCount }) : t('admin.hosts.noInstanceSelected') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { void toggleExpandInstance(instance as Instance) }"
+ @retry-detail="(id) => { void loadInstanceDetailConfig(id, true) }"
+ @sync-instance="(instance) => { void syncSingleInstance(instance as Instance) }"
+ @toggle-swap="(instance) => { void toggleInstanceSwap(instance as Instance) }"
+ @open-single-config="openSingleConfigModal"
+ @open-price="openPriceModal"
+ @open-reset-traffic="openResetTrafficModal"
+ @open-recreate="(instance) => { void openRecreateModal(instance as Instance) }"
+ @open-delete="openSingleDeleteModal"
+ @open-detail="goToInstance"
+ />
+
+
+
+
+
+ {{ t('admin.users.totalRecords', { count: total }) }}
+
+
+
+
+
+
+ {{ page }}
+ /
+ {{ totalPages }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.batchDeleteWarning') }}
+
+
+ {{ t('admin.hosts.batchDeleteConfirm', { count: effectiveDeleteCount }) }}
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+ {{ t('admin.hosts.batchDeleteRefundWarning') }}
+
+
+
+
+
+
+
+ | {{ t('admin.hosts.instanceName') }} |
+ {{ t('admin.hosts.instanceUser') }} |
+ {{ t('admin.hosts.refundAmount') }} |
+
+
+
+
+ | {{ item.name }} |
+ {{ item.username }} |
+ ¥{{ item.refundAmount.toFixed(2) }} |
+
+
+
+
+
+
+ {{ t('admin.hosts.batchDeleteRefundTotal') }}
+ ¥{{ deletePreview.totalRefundAmount.toFixed(2) }}
+
+
+
+
+
+
+
+
{{ t('admin.hosts.deleteReasonHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.batchSuspendWarning') }}
+
+
+ {{ t('admin.hosts.batchSuspendConfirm', { count: selectedCount }) }}
+
+
+
+
+
+
{{ suspendReason.length }}/500
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('host.giftDays.hint') }}
+
+
+ {{ t('host.giftDays.confirm', { count: selectedPaidCount }) }}
+
+
+
+
+
{{ t('host.giftDays.daysRange') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('host.notify.hintSelected', { count: selectedCount }) }}
+
+
+ {{ t('host.notify.deliveryHint') }}
+
+
+
+
+
+
{{ notifyTitle.length }}/200
+
+
+
+
+
{{ notifyContent.length }}/5000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('host.price.hint', { instance: priceModalTarget?.name }) }}
+
+
+
+
+
¥{{ priceModalTarget?.billingPrice ? Number(priceModalTarget.billingPrice).toFixed(2) : '0.00' }}/月
+
+
+
+
+ ¥
+
+ /月
+
+
{{ t('host.price.effectHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.resetTrafficWarning') }}
+
+
+
+ {{ t('admin.hosts.resetTrafficDesc', { instance: resetTrafficTarget?.name }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostOpsTab.vue b/client/src/components/host/HostOpsTab.vue
new file mode 100644
index 0000000..6b38cf8
--- /dev/null
+++ b/client/src/components/host/HostOpsTab.vue
@@ -0,0 +1,2080 @@
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.title') }}
+
{{ t('admin.hosts.ops.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.discover') }}
+
{{ t('admin.hosts.ops.discoverHint') }}
+
+
Incus
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.baselineSync') }}
+
{{ t('admin.hosts.ops.baselineHint') }}
+
+
Safe
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.networkRepair') }}
+
{{ t('admin.hosts.ops.networkHint') }}
+
+
IP
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.sectionInventory') }}
+
{{ t('admin.hosts.ops.selectInstanceHint') }}
+
+
+
+
+
+
{{ t('admin.hosts.ops.totalIncus') }}
+
{{ discoverResult.summary.totalIncus }}
+
+
+
{{ t('admin.hosts.ops.totalDb') }}
+
{{ discoverResult.summary.totalDb }}
+
+
+
{{ t('admin.hosts.ops.managedCount') }}
+
{{ discoverResult.summary.managedCount }}
+
+
+
{{ t('admin.hosts.ops.orphanedCount') }}
+
{{ discoverResult.summary.orphanedCount }}
+
+
+
{{ t('admin.hosts.ops.missingCount') }}
+
{{ discoverResult.summary.missingCount }}
+
+
+
+
+
+
{{ t('admin.hosts.ops.managed') }}
+
+
+
+
{{ t('admin.hosts.ops.noManaged') }}
+
+
+
+
{{ t('admin.hosts.ops.orphaned') }}
+
+
+
{{ item.incusName }}
+
{{ t('admin.hosts.ops.instanceType') }}: {{ localizeType(item.incusType) }} · {{ t('admin.hosts.ops.incusStatus') }}: {{ localizeStatus(item.incusStatus) }}
+
+
+
{{ t('admin.hosts.ops.noOrphaned') }}
+
+
+
+
{{ t('admin.hosts.ops.missing') }}
+
+
+
{{ item.dbName }}
+
ID #{{ item.dbId }} · Incus: {{ item.incusId }} · {{ t('admin.hosts.ops.dbStatus') }}: {{ item.dbStatus }}
+
+
+
{{ t('admin.hosts.ops.noMissing') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.instancePanel') }}
+
{{ selectedManaged.incusName }} · #{{ selectedManaged.dbId }}
+
+
+
+
+
+
{{ t('admin.hosts.ops.dbStatus') }}: {{ localizeStatus(previewResult.instanceStatus) }}
+
{{ t('admin.hosts.ops.suggestedAction') }}: {{ localizeSuggestedAction(previewResult.risk.suggestedAction) }}
+
{{ t('admin.hosts.ops.activeTask') }}: {{ previewResult.activeTask ? `${previewResult.activeTask.taskType} #${previewResult.activeTask.id}` : '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.dangerZone') }}
+
{{ t('admin.hosts.ops.dangerHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.loadingImages') }}
+
+
+ {{ t('admin.hosts.ops.noImagesAvailable') }}
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.loadingSshKeys') }}
+
+
+ {{ availableSshKeys.length === 0 && !loadingSshKeys ? t('admin.hosts.ops.noSshKeysAvailable') : t('admin.hosts.ops.sshKeyOptionalHint') }}
+
+
+
+
+
+
{{ t('admin.hosts.ops.loadingInitCommands') }}
+
+
+
+
{{ t('admin.hosts.ops.noInitCommandsAvailable') }}
+
{{ t('admin.hosts.ops.optionalField') }}
+
+
+
+
{{ t('admin.hosts.ops.confirmDangerTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
实例审查
+
人工扫描进程、网络连接和启动项,发现可疑项后由运维人员手动处置。
+
+
+
+
+
+
+
选择审查实例
+
+
+
+
当前没有已纳管实例。
+
+
+
+
+
+
{{ selectedManaged?.incusName || '未选择实例' }}
+
扫描不会自动封禁,也不会自动停止进程。
+
+
+
+
+
+
+
风险等级
+
{{ localizeSeverity(auditResult.summary.riskLevel) }}
+
+
+
发现项
+
{{ auditResult.summary.findingCount }}
+
+
+
进程
+
{{ auditResult.summary.processCount }}
+
+
+
连接
+
{{ auditResult.summary.connectionCount }}
+
+
+
监听端口
+
{{ auditResult.summary.listeningCount }}
+
+
+
+
+
+
+ 请先刷新实例列表,选择一个已纳管实例后再进行人工审查。
+
+
+
+
+
+
+
可疑发现
+
结果只作为人工判断线索,处置前请结合进程参数和业务背景确认。
+
+
+
+
+
{{ localizeAuditText(finding.title) }}
+
{{ localizeSeverity(finding.severity) }}
+
+
{{ localizeFindingDetail(finding.detail) }}
+
+
规则:{{ localizeAuditText(finding.ruleName || finding.ruleId) }} / {{ localizeRuleSource(finding.ruleSource) }}
+
分类:{{ localizeAuditCategory(finding.category) }} / 命中:{{ finding.matchedText || '-' }}
+
建议:{{ localizeAuditText(finding.recommendation) }}
+
已被白名单忽略:{{ finding.ignoreReason || '-' }}
+
+
+
+
+
+
{{ finding.evidence }}
+
+
+
未命中当前启用的审查规则。
+
+
+
+
+
手动停止进程
+
仅对选中的 PID 发送信号,不会自动封禁实例或用户。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
进程列表
+
优先显示命中规则或 CPU 较高的进程。
+
+
+
+
+
+
+
+ | PID |
+ 用户 |
+ CPU |
+ 内存 |
+ 命令 |
+ 操作 |
+
+
+
+
+ | {{ process.pid }} |
+ {{ process.user }} |
+ {{ process.cpuPercent ?? '-' }} |
+ {{ process.memoryPercent ?? '-' }} |
+
+ {{ process.command }}
+ {{ process.args }}
+ {{ localizeProcessFindings(process.findings) }}
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
网络连接
+
+
+
{{ connection.protocol }} {{ connection.state }} {{ connection.local }} -> {{ connection.peer }}
+
{{ connection.process }}
+
+
+
+
+
+
启动项摘要
+
+
+
{{ item.source }}
+
{{ item.command }}
+
{{ localizeProcessFindings(item.findings) }}
+
+
+
+
+
+
+
+
+
规则编辑
+
+ {{ auditRuleForm.builtinRuleId ? '正在调整系统内置规则的本节点覆盖配置,只影响当前节点。' : '节点所有者可创建当前节点规则,管理员还可以创建全局规则。' }}
+
+
+
+
快速模板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
规则库
+
+
+
+
+
+
{{ localizeAuditText(rule.name) }}
+
{{ localizeSeverity(rule.severity) }}
+
+
+ {{ localizeRuleSource(rule.source) }} / {{ localizeRuleScope(rule.scope) }} / {{ localizeAuditCategory(rule.category) }} / {{ localizeAuditMatchType(rule.matchType) }}
+
+
+ {{ rule.enabled ? '当前启用' : '当前已停用' }}{{ rule.overridden ? ' / 本节点已覆盖' : '' }}
+
+
检查对象:{{ localizeAuditTargets(rule.targetTypes) }}
+
{{ rule.pattern }}
+
建议:{{ localizeAuditText(rule.recommendation) }}
+
+
+
+
+
+
+
+
+
暂无规则,点击刷新加载系统内置规则。
+
+
+
+
+
+
+
新增白名单
+
+
+
+
+
+
+
+
+
+
+
+
白名单列表
+
+
+
+
+
{{ localizeRuleScope(ignore.scope) }} / {{ ignore.ruleId || '任意规则' }}
+
目标:{{ localizeAuditTarget(ignore.targetType) }} / 文本:{{ ignore.matchText || '-' }}
+
原因:{{ ignore.reason || '-' }}
+
到期:{{ ignore.expiresAt ? new Date(ignore.expiresAt).toLocaleString() : '永久' }}
+
+
+
+
+
暂无白名单。
+
+
+
+
+
+
+
+
扫描历史
+
+
+
+
+
+
#{{ scan.id }} / {{ localizeAuditStatus(scan.status) }}
+
{{ localizeSeverity(scan.riskLevel) }}
+
+
{{ new Date(scan.createdAt).toLocaleString() }} / {{ scan.user?.username || scan.userId }}
+
发现 {{ scan.findingCount }},忽略 {{ scan.ignoredCount }},进程 {{ scan.processCount }},连接 {{ scan.connectionCount }}
+
+
暂无扫描历史。
+
+
+
+
+
处置历史
+
+
+
#{{ action.id }} / {{ localizeAuditActionType(action.actionType) }} / {{ localizeAuditActionResult(action.result) }}
+
{{ new Date(action.createdAt).toLocaleString() }} / {{ action.user?.username || action.userId }}
+
PID {{ action.pid || '-' }} / {{ localizeAuditSignal(action.signal) }}
+
原因:{{ action.reason }}
+
{{ action.processCommand }}
+
+
暂无处置历史。
+
+
+
+
+
+
+
+
+
{{ resultTitle() }}
+
{{ t('admin.hosts.ops.lastRunAt') }}:{{ lastRunAt }}
+
+
+
+
+
+
+ {{ t('admin.hosts.ops.empty') }}
+
+
+
+
+
+
+
已用 CPU
+
{{ baselineResult.resources.cpuUsed }}
+
+
+
已用内存
+
{{ baselineResult.resources.memoryUsed }}
+
+
+
已用磁盘
+
{{ baselineResult.resources.diskUsed }}
+
+
+
{{ t('admin.hosts.ops.synced') }}
+
{{ baselineResult.instanceSync.synced }} / {{ baselineResult.instanceSync.total }}
+
+
+
{{ t('admin.hosts.ops.changes') }}
+
{{ baselineResult.instanceSync.ipChanged }}
+
+
+
+
+
+
+
+ | ID |
+ {{ t('admin.hosts.ops.instanceName') }} |
+ {{ t('admin.hosts.ops.dbStatus') }} |
+ {{ t('admin.hosts.ops.ipv4') }} |
+ {{ t('admin.hosts.ops.ipv6') }} |
+ {{ t('admin.hosts.ops.details') }} |
+
+
+
+
+ | {{ row.id }} |
+ {{ row.name }} |
+ {{ row.newStatus || row.oldStatus || '-' }} |
+ {{ row.ipv4Changed ? `${formatValue(row.oldIpv4)} → ${formatValue(row.newIpv4)}` : '-' }} |
+ {{ row.ipv6Changed ? `${formatValue(row.oldIpv6)} → ${formatValue(row.newIpv6)}` : '-' }} |
+
+ {{ row.statusChanged ? `${formatValue(row.oldStatus)} → ${formatValue(row.newStatus)}` : '-' }}
+ {{ row.error }}
+ |
+
+
+
+
+
+
+
{{ t('admin.hosts.ops.latestInstanceAction') }}
+
+
{{ latestInstanceActionResult.message }}
+
Task #{{ latestInstanceActionResult.taskId }}
+
{{ t('admin.hosts.ops.dbStatus') }}: {{ latestInstanceActionResult.currentStatus }}
+
{{ formatValue(latestInstanceActionResult.from) }} → {{ formatValue(latestInstanceActionResult.to) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dangerConfirmStep === 1 ? t('admin.hosts.ops.dangerConfirm1Title') : t('admin.hosts.ops.dangerConfirm2Title') }}
+
+
+ {{ dangerConfirmStep === 1 ? t('admin.hosts.ops.dangerConfirm1Hint') : t('admin.hosts.ops.dangerConfirm2Hint') }}
+
+
+
+
+
+
+
+ {{ t('admin.hosts.ops.instanceName') }}
+ {{ previewResult?.instanceName }}
+
+
+ {{ t('admin.hosts.ops.dangerActionType') }}
+ {{ dangerousAction === 'rebuild' ? t('admin.hosts.ops.rebuild') : t('admin.hosts.ops.recreate') }}
+
+
+ {{ t('admin.hosts.ops.selectImage') }}
+ {{ selectedImageAlias }}
+
+
+
+
+
+
+ {{ t('admin.hosts.ops.dangerConfirmStep', { step: dangerConfirmStep, total: 2 }) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostRedeemCodesTab.vue b/client/src/components/host/HostRedeemCodesTab.vue
new file mode 100644
index 0000000..35cd1e8
--- /dev/null
+++ b/client/src/components/host/HostRedeemCodesTab.vue
@@ -0,0 +1,928 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('redeemCodes.empty') }}
+
+
+
+
+
+
+
+ |
+
+ |
+
+ {{ t('redeemCodes.code') }}
+ |
+
+ {{ t('redeemCodes.type') }}
+ |
+
+ {{ t('redeemCodes.usage') }}
+ |
+
+ {{ t('redeemCodes.status') }}
+ |
+
+ {{ t('redeemCodes.actions') }}
+ |
+
+
+
+
+ |
+
+ |
+
+
+ {{ code.code }}
+
+
+ {{ code.remark }}
+
+ {{ t('redeemCodes.batch') }}: {{ code.batchId.slice(0, 8) }}
+
+ |
+
+
+ {{ getTypeLabel(code.codeType) }} +{{ formatValue(code.codeType, code.codeValue) }}
+
+ |
+
+
+ |
+
+
+
+ {{ t('redeemCodes.expired') }}
+
+
+ {{ t('redeemCodes.exhausted') }}
+
+
+ {{ t('redeemCodes.active') }}
+
+
+ {{ t('redeemCodes.paused') }}
+
+
+ {{ t('redeemCodes.expiresAt') }}: {{ formatDate(code.expiresAt) }}
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+ {{ t('common.pagination', { current: page, total: Math.ceil(total / pageSize) }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('redeemCodes.createTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentRange.unit }}
+
+
+
+ {{ t('redeemCodes.valueRange', { min: currentRange.min, max: currentRange.max }) }}
+
+
+
+
+
+
+
+
{{ t('redeemCodes.batchCountHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('redeemCodes.expiresAtHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('redeemCodes.batchResult') }}
+
+
+
+
+
+
+
+ {{ t('redeemCodes.batchId') }}:
+ {{ batchCreatedBatchId }}
+
+
+ {{ t('redeemCodes.batchLimitHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('redeemCodes.usageRecords') }}
+
+
+
+
+
+
+
{{ t('redeemCodes.noUsages') }}
+
+
+
+
+
{{ usage.user.username }}
+
{{ usage.instance.name }}
+
+
{{ formatDate(usage.usedAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('redeemCodes.confirmDelete') }}
+
+ {{ t('redeemCodes.confirmDeleteMessage', { count: selectedIds.size }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/HostStorageTab.vue b/client/src/components/host/HostStorageTab.vue
new file mode 100644
index 0000000..693a2e2
--- /dev/null
+++ b/client/src/components/host/HostStorageTab.vue
@@ -0,0 +1,801 @@
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.title') }}
+
{{ t('admin.hosts.storage.subtitle') }}
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
{{ t('admin.hosts.storage.empty') }}
+
+
+
+
+
+
+
+
+
+ {{ pool.name }}
+ {{ pool.driver }}
+
+ {{ pool.purpose === 'instance_data' ? t('admin.hosts.storage.purposeSystemDisk') : t('admin.hosts.storage.purposeStorageDisk') }}
+
+
+
+
{{ pool.description }}
+
+
+
+
+ {{ formatBytes(pool.space?.used) }} / {{ formatBytes(pool.space?.total) }}
+ {{ getUsagePercent(pool) }}%
+
+
+
+
+
+
+
+ Source: {{ pool.config.source }}
+
+
+ Size: {{ pool.config.size }}
+
+
+ ZFS Pool: {{ pool.config['zfs.pool_name'] }}
+
+
+ VG: {{ pool.config['lvm.vg_name'] }}
+
+
+
+
+
+ {{ t('admin.hosts.storage.usedBy') }}:
+ {{ pool.usedBy }} {{ t('admin.hosts.storage.volumes') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ createMode === 'create' ? t('admin.hosts.storage.modeCreateHint') : t('admin.hosts.storage.modeImportHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ importDriver === 'zfs' ? t('admin.hosts.storage.importZfsHint') :
+ importDriver === 'lvm' ? t('admin.hosts.storage.importLvmHint') :
+ importDriver === 'btrfs' ? t('admin.hosts.storage.importBtrfsHint') :
+ t('admin.hosts.storage.importDirHint') }}
+
+
+
+
+
+
+
+
+
+
+
{{ selectedDriverDesc }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.zfsSourceHint') }}
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.loopSizeHint') }}
+
+
+
+
+
{{ t('admin.hosts.storage.zfsPoolNameHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.lvmSourceHint') }}
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.loopSizeHint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.storage.lvmThinpoolHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.btrfsSourceHint') }}
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.loopSizeHint') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.dirPathHint') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.forInstancesHint') }}
+
+
{{ t('admin.hosts.storage.forVolumesHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.newSizeHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.storage.forInstancesHint') }}
+
+
{{ t('admin.hosts.storage.forVolumesHint') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/MigrateHostModal.vue b/client/src/components/host/MigrateHostModal.vue
new file mode 100644
index 0000000..66baaa7
--- /dev/null
+++ b/client/src/components/host/MigrateHostModal.vue
@@ -0,0 +1,480 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ result.message }}
+
+ {{ t('host.migrate.resultSummary', { success: result.successCount, failed: result.failedCount }) }}
+
+
+
+
+
{{ t('host.migrate.failedInstances') }}:
+
+ -
+ {{ item.name }}: {{ item.error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('host.migrate.selectedCount', { count: selectedIds.length }) }}
+
+
+
+
+
+
+
+
+ {{ t('common.loading') }}...
+
+
+
+
+
+
+
+
+
+ {{ t('host.migrate.imageHint') }}
+
+
+ {{ t('common.loading') }}...
+
+
+ {{ t('host.migrate.noImageAvailable') }}
+
+
+
+
+
+
+
+
+ {{ t('common.loading') }}...
+
+
+ {{ t('host.migrate.noPlanAvailable') }}
+
+
+ {{ t('host.migrate.planHint') }}
+
+
+
+
+
+
+
+
+ {{ t('host.migrate.warning') }}
+
+
+ - {{ t('host.migrate.warningCloudInit') }}
+ - {{ t('host.migrate.warningImage') }}
+ - {{ t('host.migrate.warningIp') }}
+ - {{ t('host.migrate.warningNotify') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/host/MyHostConfigTab.vue b/client/src/components/host/MyHostConfigTab.vue
new file mode 100644
index 0000000..91d3701
--- /dev/null
+++ b/client/src/components/host/MyHostConfigTab.vue
@@ -0,0 +1,667 @@
+
+
+
+
+
diff --git a/client/src/components/icons/AnnouncementIcon.vue b/client/src/components/icons/AnnouncementIcon.vue
new file mode 100644
index 0000000..3dfac3d
--- /dev/null
+++ b/client/src/components/icons/AnnouncementIcon.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
diff --git a/client/src/components/icons/DistroIcon.vue b/client/src/components/icons/DistroIcon.vue
new file mode 100644
index 0000000..dff0f25
--- /dev/null
+++ b/client/src/components/icons/DistroIcon.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
diff --git a/client/src/components/instance/ChangeHostCard.vue b/client/src/components/instance/ChangeHostCard.vue
new file mode 100644
index 0000000..449afc0
--- /dev/null
+++ b/client/src/components/instance/ChangeHostCard.vue
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.changeHost.title') }}
+
+
+ {{ t('instanceConfig.changeHost.description') }}
+
+
+ {{ t('instanceConfig.changeHost.currentHost') }}
+
+
+ {{ currentHost.name }}
+
+
+ {{ t('instanceConfig.changeHost.availableCount', { count: selectableHosts.length }) }}
+
+
+
+
+
+
+
+ {{ t('instanceConfig.changeHost.noSshKey') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.changeHost.modalTitle') }}
+
+
+ {{ t('instanceConfig.changeHost.modalSubtitle') }}
+
+
+
+
+
+
+
+ {{ t('instanceConfig.changeHost.warning') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/HostSelector.vue b/client/src/components/instance/HostSelector.vue
new file mode 100644
index 0000000..84be426
--- /dev/null
+++ b/client/src/components/instance/HostSelector.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+ *
+
+ {{ props.title || t('instance.selector.selectHost') }}
+
+ {{ props.autoSelectedLabel || t('instance.selector.hostAutoSelected') }}
+
+
+
+
+
+
+
+
+
+
{{ t('instance.selector.hostInsufficient') }}
+
+ {{ t('instance.selector.hostInsufficientDesc', { cpu: props.cpu, memory: (props.memory / 1024).toFixed(1) }) }}
+
+
+ {{ t('instance.selector.hostInsufficientSuggest') }}
+
+
+
+
+
+
+
+
+
+
+ {{ host.name?.toUpperCase() || host.name }}
+
+ {{ host.architecture }}
+
+
+
+
+
+ {{ t('instance.selector.hostTraffic') }} {{ formatTraffic(host.effectiveTrafficLimit) }}
+ ({{ host.trafficMultiplier }}x)
+
+
{{ host.location }}
+
+
+ {{ t('instance.selector.available') }}: {{ formatResourceDisplay(host.resources?.cpuAvailable, props.cpu, '%') }} / {{ formatMemoryDisplay(host.resources?.memoryAvailable, props.memory) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/ImageSelector.vue b/client/src/components/instance/ImageSelector.vue
new file mode 100644
index 0000000..854bfbd
--- /dev/null
+++ b/client/src/components/instance/ImageSelector.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+ {{ props.stepNumber }}
+
+ {{ props.title || t('instance.selector.selectSystem') }}
+
+
+
+
+
+
+
+
+
💿
+
+ {{ props.emptyMessage || t('instance.selector.noImages') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ distro }}
+
+
+
+
+
+
+
+ {{ t('common.noResults') }}
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceBadgeModal.vue b/client/src/components/instance/InstanceBadgeModal.vue
new file mode 100644
index 0000000..75093cb
--- /dev/null
+++ b/client/src/components/instance/InstanceBadgeModal.vue
@@ -0,0 +1,451 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.loading') }}...
+
+
+
+ {{ loadError }}
+
+
+
+
+
+
+
+
+
+
+ {{ currentBadgeSeriesTitle || t('instance.badgeModal.noBadgeSeries') }}
+
+
+ {{ currentOwnership?.badgeName || currentBadgeCatalog?.name || t('instance.badgeModal.noBadgeTitle') }}
+
+
+ {{ currentOwnership?.badgeLabel || currentBadgeCatalog?.fullLabel || t('instance.badgeModal.noBadgeSummary') }}
+
+
+ {{ currentBadgeCatalog?.seriesDescription || t('instance.badgeModal.noBadgeDescription') }}
+
+
+
+
+
{{ t('instance.badgeModal.statusLabel') }}
+
+ {{ currentOwnership ? t('instance.badgeModal.statusApplied') : t('instance.badgeModal.statusNotApplied') }}
+
+
+
+
+
{{ t('instance.badgeModal.ownedCountLabel') }}
+
{{ currentBadgeOwnedCount }}
+
+
+
+
{{ t('entertainment.badges.sourceLabel') }}
+
+ {{ currentOwnership ? getSourceText(currentOwnership.source) : t('common.none') }}
+
+
+
+
+
{{ t('entertainment.badges.obtainedAt') }}
+
+ {{ currentOwnership ? formatDateTime(currentOwnership.createdAt) : t('common.none') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.badgeModal.manageUnavailable') }}
+
+
+
+
+
+ {{ t('instance.badgeModal.manageUnavailable') }}
+
+
+
+
{{ t('instance.badgeModal.emptyOwnedTitle') }}
+
{{ t('instance.badgeModal.emptyOwnedHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ ownership.badgeName }}
+
+ {{ getOwnershipStatusText(ownership) }}
+
+
+
+
{{ ownership.badgeLabel }}
+
+
+ {{ ownership.seriesTitle }}
+
+
+ {{ getSourceText(ownership.source) }}
+
+
+ {{ ownership.appliedInstanceName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceConfigTab.vue b/client/src/components/instance/InstanceConfigTab.vue
new file mode 100644
index 0000000..99e0c1f
--- /dev/null
+++ b/client/src/components/instance/InstanceConfigTab.vue
@@ -0,0 +1,685 @@
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
+ {{ t('instanceConfig.title') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.sections.swap') }}
+
+
+ {{ t('instanceConfig.swap.size') }}: {{ formatSwapSize(config.swap.sizeMb) }}
+
+
+ {{ config.swap.requiresRunning ? t('instanceConfig.swap.vmHint') : t('instanceConfig.swap.containerHint') }}
+
+
+ {{ t('instanceConfig.swap.toggleHint') }}
+
+
+
+
+ {{ config.swap.enabled ? t('instanceConfig.swap.enabled') : t('instanceConfig.swap.disabled') }}
+
+
+
+
+ {{ t('instanceConfig.swap.runningRequired') }}
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.sections.storageIO') }}
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('limits_read') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('limits_write') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('limits_read_iops') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('limits_write_iops') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.sections.networkLimits') }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatNetworkValue('limits_ingress') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatNetworkValue('limits_egress') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.sections.processScheduling') }}
+
+
+
+
+
+
+
+
+
+
+
{{ getEffectiveValue('limits_processes') }}
+
{{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('limits_cpu_priority') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.sections.bootSettings') }}
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('boot_autostart') ? t('common.yes') : t('common.no') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('boot_autostart_priority') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('boot_autostart_delay') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getEffectiveValue('boot_host_shutdown_timeout') }}
+ {{ t('instanceConfig.overridden') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instanceConfig.boostProcesses.title') }}
+
+
+
+
+
+
+
+ {{ t('instanceConfig.boostProcesses.confirm', {
+ type: instanceType === 'vm' ? 'KVM' : 'LXC',
+ limit: processLimit
+ }) }}
+
+
+
+
+ {{ t('instanceConfig.boostProcesses.hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(`instanceConfig.swap.${swapAction}ConfirmTitle`) }}
+
+
+
+
+ {{ t(`instanceConfig.swap.${swapAction}ConfirmText`, { size: formatSwapSize(config?.swap.sizeMb || 0) }) }}
+
+
+
{{ t('instanceConfig.swap.toggleHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceInfoTab.vue b/client/src/components/instance/InstanceInfoTab.vue
new file mode 100644
index 0000000..936dcf6
--- /dev/null
+++ b/client/src/components/instance/InstanceInfoTab.vue
@@ -0,0 +1,655 @@
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.title') }}
+
+
+
+
- {{ t('instance.detail.info.instanceId') }}
+ -
+
+ {{ instance.id }}
+
+ /
+ {{ instance.incus_id }}
+
+
+
+
+
+
+
+
- {{ t('instance.detail.info.image') }}
+ - {{ formatImageName(instance.image, (instance as any).imageName) }}
+
+
+
- {{ t('instance.detail.info.instanceMode') }}
+ -
+
+ {{ getInstanceTypeLabel((instance as any).instance_type) }}
+
+
+ {{ getNetworkModeLabel(instance.network_mode) }}
+
+
+
+
+
- {{ t('instance.detail.info.sshPort') }}
+
-
+ {{ instance.ssh_port }}
+
+
+
+
+
+
- {{ t('instance.detail.info.rootPassword') }}
+ -
+ {{ showPassword[instance.id] && instancePassword?.[instance.id] ? instancePassword[instance.id] : '••••••••' }}
+
+
+
+
+
+
+
- {{ t('instance.detail.info.host') }}
+ -
+
+ {{ (instance as any).host?.name || (instance as any).host || '-' }}
+
+
+
+
- {{ t('instance.detail.info.createdAt') }}
+ -
+ {{ formatDate(instance.created_at) }}
+
+
+
+
+
- {{ t('instance.detail.info.expiresAt') }}
+ -
+ {{ formatDate(instance.expires_at) }}
+
+
+
+
+
+
+
{{ t('instance.detail.info.suspended') }}
+
+
+ {{ t('instance.detail.info.suspendReasonLabel') }}:
+ {{ getSuspendReasonText(instance.suspend_reason) }}
+
+
+ {{ t('instance.detail.info.suspendedAt') }}: {{ formatDate(instance.suspended_at) }}
+
+
+ {{ t('instance.detail.info.suspendTip') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.resourceUsage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.cpu') }}
+
+
{{ instance.cpu }}%
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.memory') }}
+ ({{ t('instance.detail.info.includesCache') }})
+
+
+
+ {{ formatMemory(stats.memory.usage) }} /
+
+ {{ formatMemory(stats.memory.limit > 0 ? stats.memory.limit : instance.memory) }}
+
+ ({{ stats.memory.usagePercent }}%)
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.disk') }}
+
+
+
+ {{ formatDisk(stats.disk.usage) }} /
+
+ {{ formatDisk(stats.disk.limit > 0 ? stats.disk.limit : instance.disk) }}
+
+ ({{ stats.disk.usagePercent }}%)
+
+
+
+
+
+
+
+
+
+
+ {{ t('traffic.monthlyUsage') }}
+
+
+
+ {{ t('common.loading') }}
+
+
+ {{ trafficData.monthlyUsedFormatted }}
+
+ / {{ trafficData.monthlyLimitFormatted }}
+
+
+ / {{ t('traffic.unlimited') }}
+
+
+ ({{ (trafficData.percentage || 0).toFixed(1) }}%)
+
+
+
+ {{ t('traffic.noData') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.ingressLimit') }}: {{ formatBandwidth(instance.limitsIngress) }}
+
+
+
+ {{ t('instance.detail.info.egressLimit') }}: {{ formatBandwidth(instance.limitsEgress) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ IPv4
+
+
+ {{ t('instance.detail.info.sshHelpIpv4') }}
+
+
+
+
+
+ IPv6
+
+
+ {{ t('instance.detail.info.sshHelpIpv6') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ instance.hostOwnerInfo.username }}
+
UID: {{ instance.hostOwnerInfo.id }}
+
+
+
+
+ VIP{{ instance.hostOwnerInfo.vipLevel }}
+
+
+
+
+
+
+
+ {{ t('instance.detail.info.hostOwnerHostCount') }}
+
+
+ {{ instance.hostOwnerInfo.hostCount }}
+
+
+
+
+
+ {{ t('instance.detail.info.hostOwnerInstanceCount') }}
+
+
+ {{ instance.hostOwnerInfo.instanceCount }}
+
+
+
+
+
+ {{ t('instance.detail.info.hostOwnerRegisteredDays') }}
+
+
+ {{ instance.hostOwnerInfo.registeredDays }} {{ t('common.days') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceLogsTab.vue b/client/src/components/instance/InstanceLogsTab.vue
new file mode 100644
index 0000000..effd061
--- /dev/null
+++ b/client/src/components/instance/InstanceLogsTab.vue
@@ -0,0 +1,424 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ $t('logs.time') }} |
+ {{ $t('logs.action') }} |
+ {{ $t('logs.content') }} |
+ {{ $t('logs.result') }} |
+
+
+
+
+ |
+ {{ $t('logs.noLogs') }}
+ |
+
+
+ |
+ {{ formatDate(log.created_at) }}
+ |
+
+ {{ log.action }}
+ |
+
+
+
+
+ {{ log.content }}
+
+
+
+
+ {{ log.content.slice(0, 40) }}...
+
+
+
+ {{ log.content }}
+
+
+
+
+ |
+
+
+ {{ log.result }}
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ $t('logs.totalRecords', { total, page, totalPages }) }}
+
+
+
+
+
+
+ {{ page }}
+ /
+ {{ totalPages }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('logs.noLogs') }}
+
+
+
+
+
+
+ {{ formatDateShort(log.created_at) }}
+
+ {{ log.result }}
+
+
+
+
+
+ {{ log.action }}
+
+
+
+
+
+ {{ log.content }}
+
+
+
+ {{ log.content.slice(0, 60) }}...
+
+
+
+
{{ log.content }}
+
+
+
+
+
+
+
+
+
+
+ {{ page }} / {{ totalPages }}
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceNetworkTab.vue b/client/src/components/instance/InstanceNetworkTab.vue
new file mode 100644
index 0000000..3cd75c0
--- /dev/null
+++ b/client/src/components/instance/InstanceNetworkTab.vue
@@ -0,0 +1,696 @@
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.title') }}
+
+
+
+
+
+
+
- {{ t('instance.detail.network.privateIpv4') }}
+
-
+
{{ instance.ipv4 }}
+
+
+
- -
+
+
+
+
- {{ t('instance.detail.network.publicIpv4') }}
+
-
+
{{ publicIpv4Address }}
+
+
+
+
+
+
- {{ t('instance.detail.network.publicIpv6') }}
+
-
+
+
+
{{ displayIpv6 }}
+
+
+
- -
+
+
+
+
+
+
- {{ t('instance.detail.network.publicIpv4') }}
+
-
+
{{ instance.ipv4 }}
+
+
+
- -
+
+
+
- {{ t('instance.detail.network.publicIpv6') }}
+
-
+
{{ instance.ipv6 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.portMappings') }}
+
+
+ {{ portQuotaUsed }} / {{ portLimit }}
+
+
+
+ {{ t('instance.detail.network.publicIp') }}: {{ publicIpv4Address }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.selectedCount', { count: selectedPorts.size }) }}
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.ipv6OnlyPortMappingHint') }}
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.selectAll') }}
+
+
+
+
+
+
+
+
+
+
+
{{ m.protocol.toUpperCase() }}
+
+
{{ getPublicPort(m) }}
+
+
→
+
{{ getPrivatePort(m) }}
+
{{ m.remark }}
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.perPage') }}
+
+
+
+
+ {{ portPage }} / {{ portTotalPages }}
+
+
+
+
+
+
+
+
+ {{ t('instance.detail.network.noPortQuota') }}
+ {{ t('instance.detail.network.allocateQuotaHint') }}
+
+
+ {{ t('instance.detail.network.noFilterResults') }}
+
+
+ {{ t('instance.detail.network.noPortMappings') }}
+ {{ t('instance.detail.network.addPortMapping') }}
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceOrderMenu.vue b/client/src/components/instance/InstanceOrderMenu.vue
new file mode 100644
index 0000000..402aa79
--- /dev/null
+++ b/client/src/components/instance/InstanceOrderMenu.vue
@@ -0,0 +1,409 @@
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceQuotaTab.vue b/client/src/components/instance/InstanceQuotaTab.vue
new file mode 100644
index 0000000..7d8b365
--- /dev/null
+++ b/client/src/components/instance/InstanceQuotaTab.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+ {{ t('instance.detail.quotaTab.title') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('instance.detail.quotaTab.currentUsage') }}: {{ portMappingsCount }} {{ t('instance.detail.quotaTab.portMappings') }}
+
+ {{ t('instance.detail.quotaTab.quotaLimit') }}: {{ instance.port_limit }} {{ t('instance.detail.quotaTab.unit') }}
+
+ ({{ portMappingsCount >= instance.port_limit ? t('instance.detail.quotaTab.full') : `${t('instance.detail.quotaTab.remaining')} ${instance.port_limit - portMappingsCount}` }})
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('instance.detail.quotaTab.currentUsage') }}: {{ snapshots.length }} {{ t('instance.detail.quotaTab.snapshots') }}
+
+ {{ t('instance.detail.quotaTab.quotaLimit') }}: {{ instance.snapshot_limit }} {{ t('instance.detail.quotaTab.unit') }}
+
+ ({{ snapshots.length >= instance.snapshot_limit ? t('instance.detail.quotaTab.full') : `${t('instance.detail.quotaTab.remaining')} ${instance.snapshot_limit - snapshots.length}` }})
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ quotaError }}
+
+
+
+
+
diff --git a/client/src/components/instance/InstanceSitesTab.vue b/client/src/components/instance/InstanceSitesTab.vue
new file mode 100644
index 0000000..5a5dae5
--- /dev/null
+++ b/client/src/components/instance/InstanceSitesTab.vue
@@ -0,0 +1,989 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.caddyNotEnabled') }}
+
+
+ {{ t('instance.sites.caddyNotEnabledHint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.title') }}
+
+
+ {{ t('instance.sites.quotaInfo', { used: siteQuota.used, limit: quotaLimitDisplay }) }}
+
+ ({{ t('instance.sites.quotaFull') }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ site.domain }}
+
+
+
+
+ HTTP
+
+
+ {{ getStatusText(site.status) }}
+
+
+ {{ t('instance.sites.disabled') }}
+
+
+
+ → :{{ site.targetPort }}
+
+ {{ site.remark }}
+
+ {{ site.error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.empty') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.addSite') }}
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.domainHint') }}
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.portHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.editSite') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.portHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.addedSuccess') }}
+
+
+
+
+ {{ t('instance.sites.dnsHintDesc') }}
+
+
+
+
+
+ {{ t('instance.sites.dnsType') }}:
+ {{ dnsHint.type }}
+
+
+ {{ t('instance.sites.dnsHost') }}:
+ {{ dnsHint.host }}
+
+
+ {{ t('instance.sites.dnsValue') }}:
+ {{ dnsHint.value }}
+
+
+
+
+ {{ t('instance.sites.dnsHintWithCheck') }}
+
+
+
+ {{ t('instance.sites.sslAutoHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.sites.cert.title') }}
+
+
+ {{ certStatus.domain }}
+
+
+
+
+
+
+
+ {{ getCertStatusText(certStatus.status) }}
+
+
+
+
+
+
+ {{ t('instance.sites.cert.issuer') }}
+ {{ certStatus.certificate.issuer }}
+
+
+ {{ t('instance.sites.cert.validFrom') }}
+ {{ formatDate(certStatus.certificate.validFrom) }}
+
+
+ {{ t('instance.sites.cert.validTo') }}
+ {{ formatDate(certStatus.certificate.validTo) }}
+
+
+ {{ t('instance.sites.cert.daysRemaining') }}
+
+ {{ certStatus.certificate.daysRemaining }} {{ t('instance.sites.cert.days') }}
+
+
+
+
+
+
+
+ {{ certStatus.hint }}
+
+
+ {{ certStatus.error }}
+
+
+
+
+
+ {{ certStatus.message }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/PackageSelector.vue b/client/src/components/instance/PackageSelector.vue
new file mode 100644
index 0000000..3894b19
--- /dev/null
+++ b/client/src/components/instance/PackageSelector.vue
@@ -0,0 +1,428 @@
+
+
+
+
+
+
+ {{ props.stepNumber }}
+
+ {{ props.title || t('instance.selector.selectPackage') }}
+
+
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+ {{ props.emptyMessage || t('instance.selector.noPackages') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getPackageBadgeLabel(detailPackage) }}
+
+
+ {{ getInstanceTypeLabel(detailPackage.instance_type) }}
+
+
+ {{ getNetworkModeLabel(resolvePackageNetworkMode(detailPackage)) }}
+
+
+ {{ t('instance.selector.docker') }}
+
+
+ {{ t('instance.selector.privileged') }}
+
+
+
+
+
+ {{ t('common.description') }}
+
+
+ {{ detailPackage.description || t('common.none') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/PlanSelector.vue b/client/src/components/instance/PlanSelector.vue
new file mode 100644
index 0000000..0f7249c
--- /dev/null
+++ b/client/src/components/instance/PlanSelector.vue
@@ -0,0 +1,374 @@
+
+
+
+
+
+ {{ props.stepNumber }}
+
+ {{ props.title || t('instance.selector.selectPlan') }}
+
+
+ {{ props.description || t('instance.selector.planDesc') }}
+
+
+
+
+ {{ prerequisiteMessage }}
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+
+ {{ props.emptyMessage || t('instance.selector.noPlans') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ plan.name }}
+
+
+ {{ props.soldOutLabel || t('instance.selector.planSoldOut') }}
+
+
+
+
+ SLA {{ plan.slaGuarantee }}%
+
+
+
+ {{ plan.description }}
+
+
+
+
+
+ ¥{{ formatPrice(plan.price) }}
+
+
+ {{ getBillingCycleLabel(plan.billingCycle) }}
+
+
+
+
+
+
+
+
+
CPU
+
+ {{ plan.cpu }}%
+
+
+
+
+
+
{{ t('instance.selector.memory') }}
+
SWAP ✅
+
+
+ {{ formatMemory(plan.memory) }}
+
+
+
+
+
{{ t('instance.selector.disk') }}
+
+ {{ formatDisk(plan.disk) }}
+
+
+
+
+
+ {{ t('billing.traffic') }}
+ ({{ t('billing.trafficBidirectional') }})
+
+
+ {{ formatTraffic(plan.trafficLimit) }}
+
+
+
+
+
+
+
+
+
{{ t('instance.selector.ports') }}:
+
{{ formatLimit(plan.portLimit) }}
+
+
+
+
{{ t('instance.selector.snapshots') }}:
+
{{ formatLimit(plan.snapshotLimit) }}
+
+
+
+
{{ t('instance.selector.sites') }}:
+
{{ formatLimit(plan.siteLimit) }}
+
+
+
+ {{ t('instance.selector.bandwidth') }}:
+ {{ formatBandwidth(plan.trafficLimitSpeed) }}
+
+
+
+
+
+
+
+ {{ props.customPlanHint || t('instance.selector.customPlanHint') }}
+
+
+
diff --git a/client/src/components/instance/RegionSelector.vue b/client/src/components/instance/RegionSelector.vue
new file mode 100644
index 0000000..39784be
--- /dev/null
+++ b/client/src/components/instance/RegionSelector.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+ 1
+
+ {{ props.title || t('instance.selector.selectRegion') }}
+
+
+
+
+
+
+
+
+ {{ t('instance.selector.noRegions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.selector.allRegions') }}
+
+
+
+ {{ getPackageCountLabel(totalPackageCount) }}
+
+
+
+
+
+
+
+
+
+
+ {{ getRegionLabel(region.code) }}
+
+
+
+ {{ getPackageCountLabel(region.packageCount) }}
+
+
+
+
+
+
diff --git a/client/src/components/instance/ResourceSlider.vue b/client/src/components/instance/ResourceSlider.vue
new file mode 100644
index 0000000..876277b
--- /dev/null
+++ b/client/src/components/instance/ResourceSlider.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ label }}
+
+
+ {{ subtitle }}
+
+
+
+
+
+
+
+
+ {{ displayValue }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatValue ? formatValue(min) : `${min}${unit}` }}
+
+
+
+
+
+
+
+
+ {{ formatValue ? formatValue(max) : `${max}${unit}` }}
+
+
+
+
+
+
diff --git a/client/src/components/instance/ResourceSliders.vue b/client/src/components/instance/ResourceSliders.vue
new file mode 100644
index 0000000..45d61a6
--- /dev/null
+++ b/client/src/components/instance/ResourceSliders.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+ {{ props.stepNumber }}
+
+ {{ t('instance.selector.configureResources') }}
+
+ {{ t('instance.selector.adjustBasedOnPackage') }}
+
+
+
+
+
{{ t('instance.createPage.quotaInfo.remaining') }}
+
+
+ {{ t('instance.createPage.quotaInfo.count') }}
+
+ {{ selectedPackage.quotaInfo.remainingInstances === null ? '∞' : selectedPackage.quotaInfo.remainingInstances }}
+
+
+ |
+
+ CPU
+
+ {{ `${selectedPackage.quotaInfo.remainingCpu}%` }}
+
+
+ |
+
+ {{ t('instance.createPage.quotaInfo.memory') }}
+
+ {{ formatMemory(selectedPackage.quotaInfo.remainingMemory) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/SSHKeySelector.vue b/client/src/components/instance/SSHKeySelector.vue
new file mode 100644
index 0000000..d22f76c
--- /dev/null
+++ b/client/src/components/instance/SSHKeySelector.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+ {{ props.stepNumber }}
+
+ {{ props.title || t('instance.selector.selectSshKey') }}
+
+
+
+
+
🔑
+
+ {{ t('instance.selector.noSshKeys') }}
+
+
+ {{ t('instance.selector.addSshKeyHint') }}
+
+
+
+
+
+
+
+
{{ key.name }}
+
{{ key.fingerprint }}
+
+
+
+
+
+
+
+ {{ t('instance.selector.pageInfo', { current: currentPage, total: totalPages, count: sshKeys.length }) }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/TerminalAccessoryBar.vue b/client/src/components/instance/TerminalAccessoryBar.vue
new file mode 100644
index 0000000..da59ffe
--- /dev/null
+++ b/client/src/components/instance/TerminalAccessoryBar.vue
@@ -0,0 +1,296 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/TerminalContextMenu.vue b/client/src/components/instance/TerminalContextMenu.vue
new file mode 100644
index 0000000..9f2387c
--- /dev/null
+++ b/client/src/components/instance/TerminalContextMenu.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/TerminalModal.vue b/client/src/components/instance/TerminalModal.vue
new file mode 100644
index 0000000..2cd6d8b
--- /dev/null
+++ b/client/src/components/instance/TerminalModal.vue
@@ -0,0 +1,1802 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText }}
+
+
+ {{ getConnectionModeLabel(activeTab.connectionMode) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.cloudInitChecking') }}
+
+
+
+
+
+
+
+
+
+
+
{{ cloudInitPromptTitle }}
+
{{ cloudInitPromptHint }}
+
+
+
+
+
+ {{ t('terminal.cloudInitChecking') }}
+
+ {{ t('terminal.cloudInitRetry') }}
+
+
+ {{ t('terminal.cloudInitSkip') }}
+
+
+
+
+ {{ t('terminal.cloudInitManualComplete') }}
+
+ {{ t('terminal.cloudInitManualComplete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.connectionFailed') }}
+
{{ activeTab.error }}
+
+
+ {{ t('terminal.reconnect') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ linkTooltip.url }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.helpTitle') }}
+
+
+
+
+
+
+
+
{{ t('terminal.helpShortcuts') }}
+
+
{{ t('terminal.helpShortcutSearch') }}Ctrl+Shift+F
+
{{ t('terminal.helpShortcutCopy') }}Ctrl+Shift+C
+
{{ t('terminal.helpShortcutPaste') }}Ctrl+Shift+V
+
{{ t('terminal.helpShortcutFontIncrease') }}Ctrl++
+
{{ t('terminal.helpShortcutFontDecrease') }}Ctrl+-
+
{{ t('terminal.helpShortcutFontReset') }}Ctrl+0
+
{{ t('terminal.helpShortcutExport') }}Ctrl+Shift+S
+
{{ t('terminal.helpShortcutNewTab') }}Ctrl+Shift+T
+
{{ t('terminal.helpShortcutCloseTab') }}Ctrl+Shift+W
+
+
+
+
+
+
{{ t('terminal.helpMouseOps') }}
+
+ - • {{ t('terminal.helpMouseSelect') }}
+ - • {{ t('terminal.helpMouseCopy') }}
+ - • {{ t('terminal.helpMouseScroll') }}
+
+
+
+
+
+
{{ t('terminal.helpTouchOps') }}
+
+ - • {{ t('terminal.helpTouchPinchZoom') }}
+ - • {{ t('terminal.helpTouchSwipeScroll') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settings') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingBell') }}
+
{{ t('terminal.settingBellDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingAutoCopy') }}
+
{{ t('terminal.settingAutoCopyDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingLinkPreview') }}
+
{{ t('terminal.settingLinkPreviewDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingTouch') }}
+
{{ t('terminal.settingTouchDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingTheme') }}
+
{{ t('terminal.settingThemeDesc') }}
+
+
+
+
+
+
+
{{ t('terminal.currentStatus') }}
+
+
+
+ {{ activeTab?.isWebGLEnabled ? 'WebGL' : 'Canvas' }}
+
+
+ {{ t('terminal.latency') }}: {{ networkLatency.get(activeTabId) }}ms
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/TrafficStats.vue b/client/src/components/instance/TrafficStats.vue
new file mode 100644
index 0000000..a5733ce
--- /dev/null
+++ b/client/src/components/instance/TrafficStats.vue
@@ -0,0 +1,357 @@
+
+
+
+
+
+
+
+
+ {{ $t('traffic.monthlyUsage') }}
+
+ ({{ periodDateRange }})
+
+
+
+ {{ statusLabel }}
+
+
+
+
+
+
+
+
+
+ {{ $t('traffic.used') }}
+
+
+ {{ trafficData.monthlyUsedFormatted }}
+
+ / {{ trafficData.monthlyLimitFormatted }}
+
+
+ / {{ $t('traffic.unlimited') }}
+
+
+
+
+
+
+
+
+
+ {{ trafficData.percentage.toFixed(1) }}%
+
+
+
+ {{ $t('traffic.throttledHint') }}
+ ·
+
+ {{ $t('traffic.periodResetHint', { date: trafficData.trafficResetDay }) }}
+
+
+
+
+
+
+
+ {{ $t('traffic.noData') }}
+
+
+
+
+
+
+
+ {{ $t('traffic.historyPeriod') }}
+
+ ({{ periodInfo.periodStart.slice(5) }} ~ {{ periodInfo.periodEnd.slice(5) }})
+
+
+
+
+
+
+
+
+
+
+ {{ yAxisLabels.top }}
+ {{ yAxisLabels.mid }}
+ {{ yAxisLabels.bottom }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.date }}
+
+
+ {{ $t('traffic.download') }}: {{ item.rxFormatted }}
+
+
+
+ {{ $t('traffic.upload') }}: {{ item.txFormatted }}
+
+
+ {{ $t('traffic.total') }}: {{ item.totalFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('traffic.noHistoryData') }}
+
+
+
+
+
diff --git a/client/src/components/instance/modals/AddPortModal.vue b/client/src/components/instance/modals/AddPortModal.vue
new file mode 100644
index 0000000..89b187b
--- /dev/null
+++ b/client/src/components/instance/modals/AddPortModal.vue
@@ -0,0 +1,380 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('portModal.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/ApplyAffCodeModal.vue b/client/src/components/instance/modals/ApplyAffCodeModal.vue
new file mode 100644
index 0000000..1094fbf
--- /dev/null
+++ b/client/src/components/instance/modals/ApplyAffCodeModal.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/ChangePlanModal.vue b/client/src/components/instance/modals/ChangePlanModal.vue
new file mode 100644
index 0000000..5a34465
--- /dev/null
+++ b/client/src/components/instance/modals/ChangePlanModal.vue
@@ -0,0 +1,696 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('billing.changePlanTitle') }}
+
+
+
+ {{ showRules ? t('billing.hideRules') : t('billing.viewRules') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('billing.changePlanRulesTitle') }}
+
+
+ - {{ t('billing.changePlanRule1') }}
+ - {{ t('billing.changePlanRule2') }}
+ - {{ t('billing.changePlanRule3') }}
+ - {{ t('billing.changePlanRule4') }}
+
+
+
+
+
+
+
+ {{ instanceType === 'vm' ? t('billing.kvmRestartHint') : t('billing.lxcInstantHint') }}
+
+
+
+
+
+
+ {{ t('billing.alreadyHighestPlan') }}
+
+
+ {{ t('billing.contactForCustomPlan') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ plan.name }}
+
+
+ {{ plan.cpu }}% CPU · {{ formatMemory(plan.memory) }} · {{ formatDisk(plan.disk) }}
+
+
+ {{ t('resources.plans.swapSize') }} · {{ plan.swapSize }} MB
+
+
+
+
+ ¥{{ formatPriceCents(plan.price) }}
+
+
+ {{ getBillingCycleText(plan.billingCycle) }}
+
+
+
+
+
+
+ {{ plan.isSoldOut ? t('billing.planSoldOut') : t('billing.planInactive') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('billing.cannotChange') }}
+
+
{{ cannotChangeReasonText }}
+
+
+
+
+
+ {{ t('billing.isUpgrade') }}
+
+
+
+
+
+
+ {{ t('billing.remainingDays') }}
+
+
+ {{ Math.ceil(preview.remainingDays) }} {{ t('billing.days') }}
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.oldDailyPrice : t('billing.oldDailyPrice') }}
+
+
+ ¥{{ preview.oldDailyPrice.toFixed(4) }}/{{ t('billing.day') }}
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.remainingValue : t('billing.remainingValue') }}
+
+
+ ¥{{ formatMoney(preview.remainingValue) }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.newDailyPrice : t('billing.newDailyPrice') }}
+
+
+ ¥{{ preview.newDailyPrice.toFixed(4) }}/{{ t('billing.day') }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.newPlanCost : t('billing.newPlanCostOriginal') }}
+
+
+ ¥{{ formatMoney(preview.newPlanCost + preview.discountAmount) }}
+
+
+
+
+
+ {{ t('billing.discountAmount') }} (-{{ (preview.discountRate * 100).toFixed(0) }}%)
+
+
+ -¥{{ formatMoney(preview.discountAmount) }}
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.newPlanCost : t('billing.newPlanCostFinal') }}
+
+
+ ¥{{ formatMoney(preview.newPlanCost) }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.newPlanCost : t('billing.newPlanCostOriginal') }}
+
+
+ ¥{{ formatMoney(preview.newPlanCost) }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.needPay : t('billing.needPay') }}
+
+
+ ¥{{ formatMoney(preview.priceDiff) }}
+
+
+
+
+ {{ t('billing.newExpiresAt') }}
+
+
+ {{ formatDate(preview.newExpiresAt) }}
+
+
+
+
+
+
+
+ {{ t('billing.newConfig') }}:
+ {{ preview.newConfig.cpu }}% CPU ·
+ {{ formatMemory(preview.newConfig.memory) }} ·
+ {{ formatDisk(preview.newConfig.disk) }}
+
+
+
+
+
+
+ {{ t('billing.insufficientBalance') }}
+
{{ t('billing.goRecharge') }}
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ changing ? t('billing.changePlanInProgress') : t('billing.upgrade') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/ConfigEditModal.vue b/client/src/components/instance/modals/ConfigEditModal.vue
new file mode 100644
index 0000000..0ad7485
--- /dev/null
+++ b/client/src/components/instance/modals/ConfigEditModal.vue
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.configEdit.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+
+ {{ loading ? t('common.saving') : t('common.save') }}
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/DestroyInstanceModal.vue b/client/src/components/instance/modals/DestroyInstanceModal.vue
new file mode 100644
index 0000000..571946a
--- /dev/null
+++ b/client/src/components/instance/modals/DestroyInstanceModal.vue
@@ -0,0 +1,478 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ destroyInfo.isFreeInstance ? t('instance.destroy.warningFree') : t('instance.destroy.warning') }}
+
+
+
+
+
+
+
+
+
+ {{ destroyInfo.cannotDestroyReason || t('instance.destroy.cannotDestroy') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.rulesTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.ruleFirstFree') }}
+
+
+ {{ t('instance.destroy.ruleFirstFreeDesc') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.ruleFeeRate', { rate: destroyInfo.rules.feeRate * 100 }) }}
+
+
+ {{ t('instance.destroy.ruleFeeRateDesc', { rate: destroyInfo.rules.feeRate * 100 }) }}
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.ruleTrafficThreshold') }}
+
+
+ {{ t('instance.destroy.ruleTrafficThresholdDesc') }}
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.ruleFreeInstance') }}
+
+
+ {{ t('instance.destroy.ruleFreeInstanceDesc') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.instanceInfo') }}
+
+
+
+
+ {{ t('instance.destroy.instanceName') }}
+
+
+
+ {{ destroyInfo.instance.name }}
+
+
+
+ {{ t('instance.destroy.hostName') }}
+
+
+
+ {{ destroyInfo.instance.hostName }}
+
+
+
+ {{ t('instance.destroy.planName') }}
+
+
+
+ {{ destroyInfo.instance.planName }}
+
+
+
+
+
+
+
+ {{ t('instance.destroy.refundInfo') }}
+
+
+
+
+ {{ t('instance.destroy.remainingDays') }}
+
+
+
+ {{ destroyInfo.refund.remainingDays }} {{ t('instance.destroy.days') }}
+
+
+
+ {{ t('instance.destroy.remainingValue') }}
+
+
+
+ {{ formatCurrency(destroyInfo.refund.remainingValue) }}
+
+
+
+
+ {{ t('instance.destroy.maxRefundable') }}
+
+
+
+ {{ formatCurrency(destroyInfo.refund.maxRefundable) }}
+
+
+
+
+ {{ t('instance.destroy.feeRate') }}
+
+
+
+
+ {{ t('instance.destroy.firstTimeFree') }}
+
+
+ {{ (destroyInfo.refund.feeRate * 100).toFixed(0) }}%
+
+
+
+
+ {{ t('instance.destroy.feeAmount') }}
+
+
+
+ {{ formatCurrency(destroyInfo.refund.feeAmount) }}
+
+
+
+ {{ t('instance.destroy.refundAmount') }}
+
+
+
+ {{ formatCurrency(destroyInfo.refund.refundAmount) }}
+
+
+
+
+
+
+
+ {{ t('instance.destroy.freeInstanceNoRefund') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('instance.destroy.cancel') }}
+
+
+
+ {{ loading ? t('instance.destroy.destroying') : t('instance.destroy.confirmButton') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/PortConflictModal.vue b/client/src/components/instance/modals/PortConflictModal.vue
new file mode 100644
index 0000000..cc45805
--- /dev/null
+++ b/client/src/components/instance/modals/PortConflictModal.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('portConflict.title') }}
+
+
+ {{ t('portConflict.subtitle', { count: conflicts.length }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('portConflict.description') }}
+
+
+
+
+
+
+
+
{{ t('portConflict.originalPort') }}
+
+
+ {{ conflict.publicPort }}
+
+
+ {{ t('portConflict.occupied') }}
+
+
+
+
+
+
+
+
+
+
{{ t('portConflict.newPort') }}
+
+
+
+
+
+
+ {{ t('portConflict.suggested') }}
+
+
+
+
+
+
+
+ {{ t('portConflict.rangeHint', { start: portRangeStart, end: portRangeEnd }) }}
+
+
+
+
+
+
+ {{ t('portConflict.useSuggested') }}
+
+
+
+
+ {{ t('portConflict.cancel') }}
+
+
+
+ {{ t('portConflict.confirm') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/RebuildModal.vue b/client/src/components/instance/modals/RebuildModal.vue
new file mode 100644
index 0000000..876cbcf
--- /dev/null
+++ b/client/src/components/instance/modals/RebuildModal.vue
@@ -0,0 +1,361 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('rebuildModal.title') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('rebuildModal.preserveInfo') }}
+
{{ t('rebuildModal.passwordHint') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedImageData?.name || t('rebuildModal.selectImage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ distro }}
+
+
+
+
+
+ {{ img.name }}
+
+
+
+
+
+
+
+
+ {{ t('common.noResults') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('rebuildModal.addSshKeyHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('rebuildModal.manualStartHint') }}
+
+
+
+
+
+
{{ t('rebuildModal.cancel') }}
+
+
+ {{ loading ? t('rebuildModal.rebuilding') : t('rebuildModal.confirmRebuild') }}
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/RecreateModal.vue b/client/src/components/instance/modals/RecreateModal.vue
new file mode 100644
index 0000000..7636445
--- /dev/null
+++ b/client/src/components/instance/modals/RecreateModal.vue
@@ -0,0 +1,357 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('recreateModal.title') }}
+
+
+
+
+
+
+
+
+
+
⚠️ {{ t('recreateModal.dangerWarning') }}
+
+ - {{ t('recreateModal.warningList.dataLoss') }}
+ - {{ t('recreateModal.warningList.snapshotLoss') }}
+ - {{ t('recreateModal.warningList.portMappingLoss') }}
+ - {{ t('recreateModal.warningList.proxySiteLoss') }}
+ - {{ t('recreateModal.warningList.irreversible') }}
+
+
+
+
+
+
+
{{ t('recreateModal.differenceHint') }}
+
{{ t('recreateModal.preserveInfo') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedImageData?.name || t('recreateModal.selectImage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ distro }}
+
+
+
+
+
+ {{ img.name }}
+
+
+
+
+
+
+
+
+ {{ t('common.noResults') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('recreateModal.addSshKeyHint') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('recreateModal.cancel') }}
+
+
+ {{ loading ? t('recreateModal.recreating') : t('recreateModal.confirmRecreate') }}
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/RenewModal.vue b/client/src/components/instance/modals/RenewModal.vue
new file mode 100644
index 0000000..63d880e
--- /dev/null
+++ b/client/src/components/instance/modals/RenewModal.vue
@@ -0,0 +1,396 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('billing.renewTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('billing.renewMonths', { months: option.months }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.originalPrice : t('billing.originalPrice') }}
+
+
+ ¥{{ formatMoney(selectedRenewOption.price) }}
+
+
+
+
+
+ {{ t('billing.affDiscount') }}
+
+
+ -{{ discountPercent }}%
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.finalPrice : (hasDiscount ? t('billing.actualPrice') : t('billing.renewPrice')) }}
+
+
+ ¥{{ formatMoney(actualPrice) }}
+
+
+
+
+ {{ t('billing.newExpiresAt') }}
+
+
+ {{ formatDate(selectedRenewOption.expiresAt) }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.currentBalance : t('billing.currentBalance') }}
+
+
+ ¥{{ formatMoney(userBalance) }}
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.balanceAfterRenew : t('billing.balanceAfterRenew') }}
+
+
+ ¥{{ formatMoney(balanceAfterRenew) }}
+
+
+
+
+
+
+
+ {{ t('billing.insufficientBalance') }}
+
{{ t('billing.goRecharge') }}
+
+
+
+
+
+
+
{{ t('billing.hostingRenewTooEarly', { days: daysUntilExpire }) }}
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ renewing ? t('billing.renewing') : t('billing.renew') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/instance/modals/TransferModal.vue b/client/src/components/instance/modals/TransferModal.vue
new file mode 100644
index 0000000..c4df154
--- /dev/null
+++ b/client/src/components/instance/modals/TransferModal.vue
@@ -0,0 +1,303 @@
+
+
+
+
+
+
{{ $t('transfer.modal.title') }}
+
+
+
+
+
{{ instance.name }}
+
+ {{ instance.cpu }}% CPU · {{ formatMemory(instance.memory) }} · {{ formatDisk(instance.disk) }}
+
+
+
+
+
+
+
+
+
+ {{ searchLoading ? '...' : $t('transfer.modal.searchUser') }}
+
+
+
{{ searchError }}
+
+
+
+
+
+
{{ targetUser.username }}
+
+ {{ targetUser.status === 'active' ? t('admin.users.active') : t('admin.users.banned') }}
+
+
+
+
+
+ {{ $t('transfer.modal.canTransfer') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('transfer.modal.feeLabel') }}
+
+
+ ¥{{ transferFee.toFixed(2) }}
+
+
+
+
+ {{ $t('transfer.modal.balanceLabel') }}
+
+
+ ...
+ ¥{{ userBalance.toFixed(2) }}
+
+
+
+
+
{{ $t('transfer.modal.insufficientBalance') }}
+
+
+
+ {{ $t('transfer.modal.feeRefundHint') }}
+
+
+
+
+
+
+
+
{{ $t('transfer.modal.deleteWarning') }}
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+
+
+ {{ transferLoading ? $t('transfer.modal.transferring') : $t('transfer.modal.confirmTransfer') }}
+
+
+
+
+
+
diff --git a/client/src/components/layout/AppLayout.vue b/client/src/components/layout/AppLayout.vue
new file mode 100644
index 0000000..c8765a8
--- /dev/null
+++ b/client/src/components/layout/AppLayout.vue
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
{{ brand.brandName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getThemeTooltip() }}
+
+
+
+
+
+
+ {{ getCurrentLocaleShort() }}
+
+
+
+
+
+
+ {{ lang.name }}
+
+
+
+
+
+
+
+
+
+
+ {{ authStore.user?.username }}
+
+
+
+
+
+
+
+
+ {{ $t('userMenu.profile') }}
+
+
+
+ {{ $t('userMenu.myInstances') }}
+
+
+
+
+ {{ $t('userMenu.logout') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/layout/SideNav.vue b/client/src/components/layout/SideNav.vue
new file mode 100644
index 0000000..7a6fe97
--- /dev/null
+++ b/client/src/components/layout/SideNav.vue
@@ -0,0 +1,385 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/profile/AccountSection.vue b/client/src/components/profile/AccountSection.vue
new file mode 100644
index 0000000..92acefc
--- /dev/null
+++ b/client/src/components/profile/AccountSection.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
{{ $t('profile.account.title') }}
+
+
+
+
+
- {{ $t('profile.account.username') }}
+ -
+ {{ authStore.user?.username }}
+
+ {{ $t('profile.account.uid') }} {{ authStore.user?.id }}
+
+
+
+
+
- {{ $t('profile.account.role') }}
+ -
+
+ {{ authStore.isAdmin ? $t('profile.account.admin') : $t('profile.account.user') }}
+
+
+
+
+
- {{ $t('profile.account.email') }}
+ -
+ {{ authStore.user?.email || $t('profile.account.notSet') }}
+
+ {{ authStore.user?.email ? $t('profile.account.changeEmail') : $t('profile.account.bindEmail') }}
+
+
+
+
+
+
+ {{ $t('profile.avatar.title') }}
+
+
+
+
+
+
+
+
+ {{ getStyleLabel(selectedAvatarStyle) }}
+
+
+
+
+
+
+ {{ savingAvatar ? $t('common.saving') : $t('common.save') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/profile/BillingSection.vue b/client/src/components/profile/BillingSection.vue
new file mode 100644
index 0000000..9c863f1
--- /dev/null
+++ b/client/src/components/profile/BillingSection.vue
@@ -0,0 +1,653 @@
+
+
+
+
+
{{ $t('profile.billing.title') }}
+
+
+
+
+
+
+
{{ formatMoney(balance.balance) }}
+
{{ $t('profile.billing.currentBalance') }}
+
+
+ {{ $t('profile.billing.recharge') }}
+
+
+
+
+
+
+
{{ $t('profile.billing.totalRecharge') }}
+
{{ formatMoney(balance.totalRecharge) }}
+
+
+
{{ $t('profile.billing.totalConsume') }}
+
{{ formatMoney(balance.totalConsume) }}
+
+
+
+
+
+
+
+
+ {{ $t('profile.billing.balanceLogs') }}
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+
+
+
{{ getLogTypeName(log.type) }}
+
{{ formatDate(log.createdAt) }}
+
+
+ {{ log.amount >= 0 ? '+' : '' }}{{ formatMoney(log.amount) }}
+
+
+
+
+
+
+ {{ $t('common.prev') }}
+
+ {{ logsPage }}/{{ logsTotalPages }}
+
+ {{ $t('common.next') }}
+
+
+
+
+ {{ $t('profile.billing.noLogs') }}
+
+
+
+
+
+
+
+ {{ $t('profile.billing.rechargeRecords') }}
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+
+
+
{{ formatMoney(rec.amount) }}
+
{{ formatDate(rec.createdAt) }}
+
+ {{ rec.provider?.name || '-' }} · {{ getRechargeMethodDisplay(rec) }}
+
+
+ {{ $t(getRechargeCreditLabelKey(rec.status)) }} {{ formatMoney(rec.actualAmount) }}
+
+
+ {{ getRechargeGatewayStatusText(rec) }}
+
+
+ {{ $t('wallet.paymentUuid') }} {{ rec.paymentUuid }}
+
+
+ {{ $t('wallet.paymentTxid') }} {{ rec.paymentTxid }}
+
+
+ {{ $t('wallet.completedAt') }} {{ formatDate(rec.completedAt) }}
+
+
+
+ {{ getStatusName(rec.status) }}
+
+
+
+
+
+
+ {{ $t('common.prev') }}
+
+ {{ recordsPage }}/{{ recordsTotalPages }}
+
+ {{ $t('common.next') }}
+
+
+
+
+ {{ $t('profile.billing.noRecords') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.billing.noProviders') }}
+
+
+
+ {{ p.name }}
+
+
+
+
+
+
+
+
+
+ {{ getPaymentMethodName(method) }}
+
+
+
+
+
+ {{ $t('wallet.heleketSelectionHint') }}
+
+
+
+
+
+
+
+ ¥{{ amt }}
+
+
+
+
+ {{ $t('profile.billing.amountRange') }}:
+ {{ selectedProviderInfo.minAmount.toFixed(2) }} -
+ {{ selectedProviderInfo.maxAmount ? selectedProviderInfo.maxAmount.toFixed(2) : $t('common.unlimited') }}
+
+
+
+
+
+
+ {{ $t('profile.billing.feeNote') }}:
+ {{ (selectedFeeConfig.feeRate * 100).toFixed(2) }}%
+ +
+ ¥{{ selectedFeeConfig.feeFixed.toFixed(2) }}
+ = ¥{{ selectedRechargeFee.toFixed(2) }}
+
+
+ {{ $t('wallet.payableAmount') }}:
+ ¥{{ selectedPayableAmount.toFixed(2) }}
+ / {{ $t('wallet.actualAmount') }} ¥{{ selectedCreditAmount.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/profile/ChangeEmailModal.vue b/client/src/components/profile/ChangeEmailModal.vue
new file mode 100644
index 0000000..6d7eded
--- /dev/null
+++ b/client/src/components/profile/ChangeEmailModal.vue
@@ -0,0 +1,468 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ {{ hasCurrentEmail ? t('profile.account.emailDialog.stepCurrent') : t('profile.account.emailDialog.stepCurrentSkipped') }}
+
+
+ {{ hasCurrentEmail ? t('profile.account.emailDialog.stepCurrentHint') : t('profile.account.emailDialog.noCurrentEmailHint') }}
+
+
+
+
+ 2
+
+
{{ t('profile.account.emailDialog.stepNew') }}
+
{{ t('profile.account.emailDialog.stepNewHint') }}
+
+
+
+
+
+
+ {{ t('profile.account.emailDialog.verifyCurrentTitle') }}
+
+
+ {{ t('profile.account.emailDialog.verifyCurrentDesc', { email: currentTarget || maskedCurrentEmail }) }}
+
+
+
+
+
+
+
+
+
+ {{
+ currentCountdown > 0
+ ? t('profile.account.emailDialog.resendIn', { seconds: currentCountdown })
+ : (currentCodeSent ? t('profile.account.emailDialog.resendCurrentCode') : t('profile.account.emailDialog.sendCurrentCode'))
+ }}
+
+
+
+
{{ currentStepError }}
+
+
+
+
+
+ {{ t('profile.account.emailDialog.verifyNewTitle') }}
+
+
+ {{
+ hasCurrentEmail
+ ? t('profile.account.emailDialog.verifyNewDesc')
+ : t('profile.account.emailDialog.bindEmailDesc')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ newCountdown > 0
+ ? t('profile.account.emailDialog.resendIn', { seconds: newCountdown })
+ : (newCodeSent ? t('profile.account.emailDialog.resendNewCode') : t('profile.account.emailDialog.sendNewCode'))
+ }}
+
+
+
+
{{ newStepError }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/profile/LoginHistorySection.vue b/client/src/components/profile/LoginHistorySection.vue
new file mode 100644
index 0000000..1b5aedf
--- /dev/null
+++ b/client/src/components/profile/LoginHistorySection.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
{{ t('profile.loginHistory.title') }}
+
{{ t('profile.loginHistory.description') }}
+
+
+ {{ t('common.refresh') }}
+
+
+
+
+
+
+
+
+
+ {{ t('profile.loginHistory.empty') }}
+
+
+
+
+
+
+
+
+
+
+ {{ record.ip }}
+
+
+
+
+ {{ formatLocation(record) }}
+ · {{ record.isp }}
+
+
+
+
+ {{ formatDevice(record.userAgent) }} · {{ formatBrowser(record.userAgent) }}
+
+
+
+
+
+ {{ formatTime(record.createdAt) }}
+
+
+
+
+
+
+
+ {{ t('common.previous') }}
+
+
+ {{ page }} / {{ totalPages }}
+
+
+ {{ t('common.next') }}
+
+
+
+
+
diff --git a/client/src/components/profile/NotificationSection.vue b/client/src/components/profile/NotificationSection.vue
new file mode 100644
index 0000000..ad5fd71
--- /dev/null
+++ b/client/src/components/profile/NotificationSection.vue
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+
{{ $t('profile.notifications.title') }}
+
{{ $t('profile.notifications.description') }}
+
+
+
+
+ {{ $t('profile.notifications.history') }}
+
+
+
+ {{ $t('profile.notifications.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ channel.name }}
+
+ {{ getNotificationTypeLabel(channel.type) }}
+
+ {{ $t('profile.notifications.disabledSuffix') }}
+
+
{{ channel.configPreview }}
+
+
+
+
+
+
+
+ {{ channel.enabled ? $t('profile.notifications.disable') : $t('profile.notifications.enable') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.notifications.noChannels') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('profile.notifications.historyTitle') }}
+
+ {{ $t('profile.notifications.statsTotal', { count: notificationStats.total }) }}
+ {{ $t('profile.notifications.statsSent', { count: notificationStats.sent }) }}
+ {{ $t('profile.notifications.statsFailed', { count: notificationStats.failed }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.notifications.filterAll') }}
+
+
+ {{ $t('profile.notifications.filterSent') }}
+
+
+ {{ $t('profile.notifications.filterFailed') }}
+
+
+
+
+
+
+ {{ $t('profile.notifications.loadingLogs') }}
+
+
+ {{ $t('profile.notifications.noLogs') }}
+
+
+
+
+
+
+
+ {{ getLogStatusLabel(log.status) }}
+
+ {{ getEventTypeLabel(log.eventType) }}
+ {{ log.channelName }} ({{ getNotificationTypeLabel(log.channelType) }})
+
+
{{ log.message }}
+
{{ $t('profile.notifications.errorPrefix', { error: log.error }) }}
+
+
{{ formatDate(log.createdAt) }}
+
+
+
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ logsPage }} / {{ totalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/profile/OAuthSection.vue b/client/src/components/profile/OAuthSection.vue
new file mode 100644
index 0000000..cfcef4a
--- /dev/null
+++ b/client/src/components/profile/OAuthSection.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
{{ $t('profile.oauth.title') }}
+
{{ $t('profile.oauth.description') }}
+
+
+
+
+
+
+
+
+
GitHub
+
+ {{ $t('profile.oauth.bound') }}: {{ (() => { const b = getOAuthBinding('github'); return b?.username || b?.email || ''; })() }}
+
+
{{ $t('profile.oauth.notBound') }}
+
+
+
+ {{ $t('profile.oauth.unbind') }}
+
+
+ {{ $t('profile.oauth.bind') }}
+
+
+
+
+
+
+
+
+
Google
+
+ {{ $t('profile.oauth.bound') }}: {{ (() => { const b = getOAuthBinding('google'); return b?.username || b?.email || ''; })() }}
+
+
{{ $t('profile.oauth.notBound') }}
+
+
+
+ {{ $t('profile.oauth.unbind') }}
+
+
+ {{ $t('profile.oauth.bind') }}
+
+
+
+
+
+ {{ $t('profile.oauth.noProviders') }}
+
+
+
+
diff --git a/client/src/components/profile/PasswordSection.vue b/client/src/components/profile/PasswordSection.vue
new file mode 100644
index 0000000..934fc0a
--- /dev/null
+++ b/client/src/components/profile/PasswordSection.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
{{ $t('profile.password.title') }}
+
+
+
+
+
+
diff --git a/client/src/components/profile/SSHKeysSection.vue b/client/src/components/profile/SSHKeysSection.vue
new file mode 100644
index 0000000..287292a
--- /dev/null
+++ b/client/src/components/profile/SSHKeysSection.vue
@@ -0,0 +1,383 @@
+
+
+
+
+
+
+
{{ $t('profile.sshKeys.title') }}
+
{{ $t('profile.sshKeys.description') }}
+
+
+
+
+
+ {{ $t('profile.sshKeys.generate') }}
+
+
+
+ {{ $t('profile.sshKeys.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.sshKeys.save') }}
+ {{ $t('profile.sshKeys.cancel') }}
+
+
+
+
+
+
+
+
+
+
{{ key.name }}
+
{{ key.fingerprint }}
+
+
+
+
{{ key.created_at }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.sshKeys.pageInfo', { current: currentPage, total: totalPages, count: sshKeys.length }) }}
+
+
+
+
+ {{ $t('profile.sshKeys.prevPage') }}
+
+
+ {{ $t('profile.sshKeys.nextPage') }}
+
+
+
+
+
+
+
+ {{ $t('profile.sshKeys.noKeys') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('profile.sshKeys.privateKeyWarning') }}
+
{{ $t('profile.sshKeys.privateKeyWarningDesc') }}
+
+
+
+
+
+
{{ generatedPrivateKey }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/profile/StorageSection.vue b/client/src/components/profile/StorageSection.vue
new file mode 100644
index 0000000..6ee6102
--- /dev/null
+++ b/client/src/components/profile/StorageSection.vue
@@ -0,0 +1,331 @@
+
+
+
+
+
+
+
{{ $t('profile.storage.title') }}
+
{{ $t('profile.storage.description') }}
+
+
+
+ {{ $t('profile.storage.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? $t('common.saving') : $t('common.save') }}
+
+ {{ $t('common.cancel') }}
+
+
+
+
+
+
+
+
+ {{ typeIcons[config.type] }}
+
+
+
+ {{ config.name }}
+
+ {{ typeLabels[config.type] }}
+
+
+ {{ $t('profile.storage.default') }}
+
+
+
{{ config.host }}{{ config.basePath }}
+
+
+
+
+
+ {{ $t('profile.storage.test') }}
+
+
+ {{ $t('profile.storage.setDefault') }}
+
+
+ {{ $t('common.edit') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('profile.storage.noConfigs') }}
+
+
+
diff --git a/client/src/components/profile/TelegramBindingSection.vue b/client/src/components/profile/TelegramBindingSection.vue
new file mode 100644
index 0000000..828f3b4
--- /dev/null
+++ b/client/src/components/profile/TelegramBindingSection.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
{{ t('profile.telegramBinding.title') }}
+
+ {{ t('profile.telegramBinding.description') }}
+
+
+
+ {{ loading ? t('profile.telegramBinding.refreshing') : t('profile.telegramBinding.refresh') }}
+
+
+
+
+
+
+
{{ t('profile.telegramBinding.unavailableTitle') }}
+
+ {{ t('profile.telegramBinding.unavailableDescription') }}
+
+
+
+
+
+
+
{{ t('profile.telegramBinding.boundTitle', { name: telegramDisplayName }) }}
+
+ {{ t('profile.telegramBinding.telegramId', { id: binding.telegramUserId }) }}
+
+
+ {{ t('profile.telegramBinding.boundAt', { date: formatDate(binding.boundAt) }) }}
+
+
+ {{ t('profile.telegramBinding.joinHint', { bot: botLabel }) }}
+ /join
+
+
+
+ {{ unlinking ? t('profile.telegramBinding.unlinking') : t('profile.telegramBinding.unlink') }}
+
+
+
+
+
+
+
{{ t('profile.telegramBinding.unboundTitle') }}
+
+ {{ t('profile.telegramBinding.unboundDescription', { bot: botLabel }) }}
+
+
+
+
+
+
+
+ {{ t('profile.telegramBinding.linkHint') }}
+
+
{{ bindUrl }}
+
+ {{ t('profile.telegramBinding.expiresAt', { date: formatDate(expiresAt) }) }}
+
+
+
+
+
diff --git a/client/src/components/profile/TwoFactorSection.vue b/client/src/components/profile/TwoFactorSection.vue
new file mode 100644
index 0000000..7ca8747
--- /dev/null
+++ b/client/src/components/profile/TwoFactorSection.vue
@@ -0,0 +1,344 @@
+
+
+
+
+
{{ $t('profile.twoFactorAuth.title') }}
+
+
+
+
+
+ {{ $t('profile.twoFactorAuth.status') }}:
+
+ {{ enabled ? $t('profile.twoFactorAuth.enabled') : $t('profile.twoFactorAuth.notEnabled') }}
+
+
+
+ {{ loading ? $t('profile.twoFactorAuth.loading') : $t('profile.twoFactorAuth.enable') }}
+
+
+ {{ $t('profile.twoFactorAuth.disable') }}
+
+
+
+ {{ $t('profile.twoFactorAuth.description') }}
+
+
+
+
+
+ {{ $t('profile.twoFactorAuth.recoveryCodesStatus') }}
+
+ {{ $t('profile.twoFactorAuth.regenerate') }}
+
+
+
+
+ {{ $t('profile.twoFactorAuth.remaining') }}: {{ recoveryCodesStatus.remaining }} / {{ recoveryCodesStatus.total }}
+
+
+ {{ $t('profile.twoFactorAuth.used') }}: {{ recoveryCodesStatus.used }}
+
+
+
+ ⚠️ {{ $t('profile.twoFactorAuth.lowCodesWarning') }}
+
+
+
+
+
+
+
{{ $t('profile.twoFactorAuth.setup') }}
+
+
+
+ {{ $t('profile.twoFactorAuth.scanQrCode') }}
+
+
![2FA QR Code]()
+
+ {{ $t('profile.twoFactorAuth.manualEntry') }}:{{ secret }}
+
+
+
+
+
⚠️ {{ $t('profile.twoFactorAuth.saveRecoveryCodes') }}:
+
+
+ {{ code }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? $t('profile.twoFactorAuth.verifying') : $t('profile.twoFactorAuth.confirmEnable') }}
+
+ {{ $t('profile.twoFactorAuth.cancel') }}
+
+
+
+
+
+
{{ $t('profile.twoFactorAuth.disableTitle') }}
+
{{ $t('profile.twoFactorAuth.disableDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? $t('profile.twoFactorAuth.processing') : $t('profile.twoFactorAuth.confirmDisable') }}
+
+ {{ $t('profile.twoFactorAuth.cancel') }}
+
+
+
+
+
+
{{ $t('profile.twoFactorAuth.regenerateTitle') }}
+
{{ $t('profile.twoFactorAuth.regenerateDesc') }}
+
+
+
+
✓ {{ $t('profile.twoFactorAuth.newCodesGenerated') }}:
+
+
+ {{ code }}
+
+
+
{{ $t('profile.twoFactorAuth.done') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? $t('profile.twoFactorAuth.generating') : $t('profile.twoFactorAuth.confirmGenerate') }}
+
+ {{ $t('profile.twoFactorAuth.cancel') }}
+
+
+
+
+
+
{{ error }}
+
{{ success }}
+
+
diff --git a/client/src/components/public/PublicAuthShell.vue b/client/src/components/public/PublicAuthShell.vue
new file mode 100644
index 0000000..2135877
--- /dev/null
+++ b/client/src/components/public/PublicAuthShell.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
![]()
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/public/PublicSiteFooter.vue b/client/src/components/public/PublicSiteFooter.vue
new file mode 100644
index 0000000..2f7b203
--- /dev/null
+++ b/client/src/components/public/PublicSiteFooter.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
diff --git a/client/src/components/public/PublicSiteHeader.vue b/client/src/components/public/PublicSiteHeader.vue
new file mode 100644
index 0000000..7efa2c4
--- /dev/null
+++ b/client/src/components/public/PublicSiteHeader.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+ {{ brand.brandName }}
+
+
+ {{ brand.brandSubtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentLocaleShort() }}
+
+
+
+
+
+ {{ lang.name }}
+
+
+
+
+
+
+
+ {{ primaryActionCompactLabel }}
+ {{ primaryActionLabel }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/public/PublicSiteLayout.vue b/client/src/components/public/PublicSiteLayout.vue
new file mode 100644
index 0000000..8897e37
--- /dev/null
+++ b/client/src/components/public/PublicSiteLayout.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/terminal/TerminalSavedCommandsSidebar.vue b/client/src/components/terminal/TerminalSavedCommandsSidebar.vue
new file mode 100644
index 0000000..f395d1e
--- /dev/null
+++ b/client/src/components/terminal/TerminalSavedCommandsSidebar.vue
@@ -0,0 +1,448 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('terminal.savedCommands.cloud') }}
+
+
+ {{ t('terminal.savedCommands.title') }}
+
+
+ {{ t('terminal.savedCommands.subtitle') }}
+
+
+
+
+
+ {{ t('terminal.savedCommands.add') }}
+
+
+
+
+
+
+
+
+
+ {{ t('terminal.savedCommands.synced') }}
+
+
+ {{ t('terminal.savedCommands.encrypted') }}
+
+ {{ t('terminal.savedCommands.count', { count: commandCount }) }}
+
+
+
+
+
+
+
+ {{ isEditing ? t('terminal.savedCommands.edit') : t('terminal.savedCommands.new') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ saving ? t('common.saving') : t('common.save') }}
+
+
+
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+
+
+
{{ t('terminal.savedCommands.emptyTitle') }}
+
{{ t('terminal.savedCommands.emptyDescription') }}
+
+
+
+
+
+
+
+
+ {{ command.name }}
+
+
+ {{ command.description }}
+
+
+ {{ getCommandPreview(command.command) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('terminal.savedCommands.selected', { name: selectedCommand.name }) }}
+
+
+ {{ t('terminal.savedCommands.notSelected') }}
+
+
+
+
+
+
+ {{ t('terminal.savedCommands.execute') }}
+
+
+
+ {{ t('common.delete') }}
+
+
+
+
+ {{ connected ? t('terminal.savedCommands.runHint') : t('terminal.savedCommands.disconnectedHint') }}
+
+
+
+
+
+
diff --git a/client/src/components/tickets/TicketImageLightbox.vue b/client/src/components/tickets/TicketImageLightbox.vue
new file mode 100644
index 0000000..4814786
--- /dev/null
+++ b/client/src/components/tickets/TicketImageLightbox.vue
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+ {{ currentIndex + 1 }} / {{ images.length }}
+
+
+
+ -
+
+
+ 100%
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/tickets/TicketImageUploader.vue b/client/src/components/tickets/TicketImageUploader.vue
new file mode 100644
index 0000000..2b93d71
--- /dev/null
+++ b/client/src/components/tickets/TicketImageUploader.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+ {{ t('tickets.images.label') }}
+
+
+ {{ t('tickets.images.hint', { count: props.maxFiles, size: 50 }) }}
+
+
+
+ {{ t('tickets.images.add') }}
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+ {{ file.name }}
+
+
+ {{ formatFileSize(file.size) }}
+
+
+
+
+
+
+ {{ t('tickets.images.selected', { count: props.modelValue.length, max: props.maxFiles }) }}
+
+
+
diff --git a/client/src/components/tickets/TicketInstanceOwnerCard.vue b/client/src/components/tickets/TicketInstanceOwnerCard.vue
new file mode 100644
index 0000000..8d6789d
--- /dev/null
+++ b/client/src/components/tickets/TicketInstanceOwnerCard.vue
@@ -0,0 +1,874 @@
+
+
+
+
+
+
+
+
+
+
+ {{ mergedInstance.name }}
+
+ {{ statusInfo.label }}
+
+ {{ instanceType }}
+
+
+ {{ planLabel }}
+
+
+ {{ priceLabel }}
+
+
+
+
+ #{{ mergedInstance.id }}
+ Incus {{ mergedInstance.incusId || '-' }}
+ {{ imageLabel }}
+ {{ hostName }}
+ {{ formatDateTime((mergedInstance as any).created_at || null) }}
+
+
+
+
+
{{ t('admin.hosts.resources') }}
+
+
CPU {{ mergedInstance.cpu || 0 }}%
+
{{ formatMemory(mergedInstance.memory) }}
+
{{ formatDisk(mergedInstance.disk) }}
+
+
+
+
{{ t('tickets.host') }}
+
{{ hostName }}
+
{{ networkModeLabel }}
+
+
+
{{ t('instance.quotaLabel') }}
+
+
+ {{ item.label }}
+ {{ item.value }}
+
+
+
+
+
{{ t('billing.expiresAt') }}
+
{{ expirySummary }}
+
{{ priceLabel || '-' }}
+
+
+
+
+
+
+
+ {{ activeTaskLabel }}
+ {{ activeTask?.status }}
+
+
+ {{ activeTaskHint }}
+
+
+
+
+
+
+
+ {{ t('tickets.memory') }}
+ {{ stats ? `${formatMemory(stats.memory.usage)} / ${formatMemory(stats.memory.limit)}` : formatMemory(mergedInstance.memory) }}
+
+
+
+
+
+ {{ t('tickets.disk') }}
+ {{ stats ? `${formatDisk(stats.disk.usage)} / ${formatDisk(stats.disk.limit)}` : formatDisk(mergedInstance.disk) }}
+
+
+
+
+
+
+
+
IP
+
+
{{ displayIp.ipv4 || '-' }}
+
{{ displayIp.ipv6 || '-' }}
+
+
+
+
{{ t('billing.expiresAt') }}
+
+ {{ formatDateTime(expiryValue) }}
+
+
+ {{ stats ? `${formatBytes(stats.network.bytesReceived)} / ${formatBytes(stats.network.bytesSent)}` : '-' }}
+
+
+
+
+
+
+ {{ detailToggleLabel }}
+
+
+
+
+
+
+ {{ t('terminal.title') }}
+
+
+ {{ actionLoading === 'start' ? t('common.processing') : t('instance.actions.start') }}
+
+
+ {{ actionLoading === 'stop' ? t('common.processing') : t('instance.actions.stop') }}
+
+
+ {{ actionLoading === 'restart' ? t('common.processing') : t('instance.actions.restart') }}
+
+
+ {{ actionLoading === 'suspend' ? t('instance.detail.actions.suspending') : t('instance.detail.actions.suspend') }}
+
+
+ {{ actionLoading === 'unsuspend' ? t('instance.detail.actions.unsuspending') : t('instance.detail.actions.unsuspend') }}
+
+
+ {{ actionLoading === 'delete' ? t('common.deleting') : (mergedInstance.packagePlanId ? t('admin.billing.deleteRefund') : t('common.delete')) }}
+
+
+ {{ t('common.details') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
{{ t('common.loadFailed') }}
+
{{ detailError }}
+
+
{{ t('common.retry') }}
+
+
+
+
+
{{ t('common.details') }}
+
+
+
- {{ t('tickets.instanceId') }}
+ - {{ mergedInstance.id }}
+
+
+
- {{ t('tickets.incusId') }}
+ - {{ mergedInstance.incusId || '-' }}
+
+
+
- {{ t('tickets.packageName') }}
+ - {{ planLabel }}
+
+
+
- {{ t('tickets.host') }}
+ - {{ hostName }}
+
+
+
- {{ t('packageForm.fields.networkMode') }}
+ - {{ networkModeLabel }}
+
+
+
- {{ t('packageForm.fields.instanceType') }}
+ - {{ instanceType }}
+
+
+
- {{ t('instance.createdAt') }}
+ - {{ formatDateTime((mergedInstance as any).created_at || null) }}
+
+
+
- {{ t('billing.expiresAt') }}
+ - {{ formatDateTime(expiryValue) }}
+
+
+
- {{ t('admin.hosts.suspendReason') }}
+ - {{ (mergedInstance as any).suspend_reason }}
+
+
+
+
+
+
{{ t('admin.hosts.resources') }}
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
CPU
+
{{ `${mergedInstance.cpu || 0}%` }}
+
+
+
SWAP
+
{{ getSwapDisplay() }}
+
+
+
RX / TX
+
{{ stats ? `${formatBytes(stats.network.bytesReceived)} / ${formatBytes(stats.network.bytesSent)}` : '-' }}
+
+
+
IPv4
+
{{ displayIp.ipv4 || '-' }}
+
+
+
IPv6
+
{{ displayIp.ipv6 || '-' }}
+
+
+
+
+
+
{{ t('instanceConfig.title') }}
+
+
+
- {{ t('packageForm.fields.limitsIngress') }}
+ - {{ detailConfig?.config.limits_ingress || '-' }}
+
+
+
- {{ t('packageForm.fields.limitsEgress') }}
+ - {{ detailConfig?.config.limits_egress || '-' }}
+
+
+
- {{ t('packageForm.fields.limitsProcesses') }}
+ - {{ detailConfig?.config.limits_processes ?? '-' }}
+
+
+
- {{ t('packageForm.fields.bootAutostart') }}
+ - {{ formatBooleanValue(detailConfig?.config.boot_autostart) }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/composables/useBrand.ts b/client/src/composables/useBrand.ts
new file mode 100644
index 0000000..e086711
--- /dev/null
+++ b/client/src/composables/useBrand.ts
@@ -0,0 +1,17 @@
+import { useConfigStore } from '@/stores/config'
+
+export function useBrand() {
+ const configStore = useConfigStore()
+
+ return {
+ get brandName() {
+ return configStore.brandName?.trim() || 'Incudal'
+ },
+ get brandSubtitle() {
+ return configStore.brandSubtitle?.trim() || '基于 Incus 的低价 NAT VPS'
+ },
+ get brandLogoUrl() {
+ return configStore.brandLogoUrl?.trim() || '/incudal_logo.webp'
+ }
+ }
+}
diff --git a/client/src/composables/useM3Auth.ts b/client/src/composables/useM3Auth.ts
new file mode 100644
index 0000000..1b3060e
--- /dev/null
+++ b/client/src/composables/useM3Auth.ts
@@ -0,0 +1,69 @@
+import { computed } from 'vue'
+import { useThemeStore } from '@/stores/theme'
+
+export function useM3Auth() {
+ const themeStore = useThemeStore()
+
+ const ui = computed(() => themeStore.isDark
+ ? {
+ card: 'bg-[#1d2024] border border-[#43474e] shadow-[0_1px_2px_rgba(0,0,0,0.3),0_1px_3px_1px_rgba(0,0,0,0.15)]',
+ title: 'text-[#e3e2e6]',
+ body: 'text-[#c3c6cf]',
+ muted: 'text-[#8e9199]',
+ label: 'text-[#c3c6cf]',
+ link: 'text-[#a8c7fa] hover:text-[#bdd3fb]',
+ linkSubtle: 'text-[#c3c6cf] hover:text-[#e3e2e6]',
+ input: 'h-11 w-full rounded-xl border border-[#43474e] bg-[#111418] px-4 text-sm text-[#e3e2e6] placeholder:text-[#8e9199] transition-colors focus:border-[#a8c7fa] focus:outline-none focus:ring-2 focus:ring-[#a8c7fa]/30 disabled:cursor-not-allowed disabled:opacity-60',
+ select: 'h-11 rounded-xl border border-[#43474e] bg-[#111418] px-3 text-sm text-[#e3e2e6] transition-colors focus:border-[#a8c7fa] focus:outline-none focus:ring-2 focus:ring-[#a8c7fa]/30',
+ filled: 'bg-[#a8c7fa] text-[#062e6f] shadow-[0_1px_2px_rgba(0,0,0,0.3),0_1px_3px_1px_rgba(0,0,0,0.15)] hover:bg-[#bdd3fb] focus-visible:ring-[#a8c7fa]/40',
+ tonal: 'bg-[#284777] text-[#d3e3fd] hover:bg-[#304f81] focus-visible:ring-[#a8c7fa]/40',
+ outlined: 'border border-[#8e9199] text-[#a8c7fa] hover:bg-[#a8c7fa]/[0.08] focus-visible:ring-[#a8c7fa]/40',
+ text: 'text-[#a8c7fa] hover:bg-[#a8c7fa]/[0.08]',
+ errorBanner: 'bg-[#3a1618] text-[#ffb4ab]',
+ successBanner: 'bg-[#16311f] text-[#a1cdb3]',
+ divider: 'border-[#43474e]',
+ heroBadge: 'border border-[#284777] bg-[#1a2c52] text-[#d3e3fd]',
+ heroPill: 'border border-[#43474e] bg-[#272a2f] text-[#c3c6cf]',
+ heroSurface: 'bg-[#1d2024] border border-[#43474e]',
+ heroPoint: 'bg-[#272a2f] text-[#e3e2e6]',
+ dividerTextBg: 'bg-[#1d2024]',
+ dividerText: 'text-[#8e9199]',
+ checkbox: 'h-5 w-5 rounded-[4px] border-2 border-[#8e9199] bg-transparent text-[#a8c7fa] focus:ring-2 focus:ring-[#a8c7fa]/30 focus:ring-offset-0',
+ linkInline: 'text-[#a8c7fa] hover:underline underline-offset-2',
+ primaryDot: 'bg-[#a8c7fa]',
+ tertiaryDot: 'bg-[#a1cdb3]',
+ secondaryDot: 'bg-[#ffb59a]'
+ }
+ : {
+ card: 'bg-white border border-[#e3e5ec] shadow-[0_1px_2px_rgba(15,23,42,0.08),0_1px_3px_1px_rgba(15,23,42,0.06)]',
+ title: 'text-[#1a1b20]',
+ body: 'text-[#43474e]',
+ muted: 'text-[#74777f]',
+ label: 'text-[#43474e]',
+ link: 'text-[#0b57d0] hover:text-[#0848ad]',
+ linkSubtle: 'text-[#43474e] hover:text-[#1a1b20]',
+ input: 'h-11 w-full rounded-xl border border-[#c3c6cf] bg-white px-4 text-sm text-[#1a1b20] placeholder:text-[#74777f] transition-colors focus:border-[#0b57d0] focus:outline-none focus:ring-2 focus:ring-[#0b57d0]/30 disabled:cursor-not-allowed disabled:opacity-60 disabled:bg-[#f3f4fa]',
+ select: 'h-11 rounded-xl border border-[#c3c6cf] bg-white px-3 text-sm text-[#1a1b20] transition-colors focus:border-[#0b57d0] focus:outline-none focus:ring-2 focus:ring-[#0b57d0]/30',
+ filled: 'bg-[#0b57d0] text-white shadow-[0_1px_2px_rgba(11,87,208,0.3),0_1px_3px_1px_rgba(11,87,208,0.15)] hover:bg-[#0848ad] focus-visible:ring-[#0b57d0]/30',
+ tonal: 'bg-[#d3e3fd] text-[#041e49] hover:bg-[#c1d6fc] focus-visible:ring-[#0b57d0]/30',
+ outlined: 'border border-[#74777f] text-[#0b57d0] hover:bg-[#0b57d0]/[0.08] focus-visible:ring-[#0b57d0]/30',
+ text: 'text-[#0b57d0] hover:bg-[#0b57d0]/[0.08]',
+ errorBanner: 'bg-[#ffedea] text-[#93000a]',
+ successBanner: 'bg-[#e1f0e3] text-[#1e5531]',
+ divider: 'border-[#e3e5ec]',
+ heroBadge: 'border border-[#aac7fa]/60 bg-[#d3e3fd] text-[#041e49]',
+ heroPill: 'border border-[#c3c6cf] bg-[#eef0f8] text-[#43474e]',
+ heroSurface: 'bg-[#eef0f8] border border-[#e3e5ec]',
+ heroPoint: 'bg-white text-[#1a1b20]',
+ dividerTextBg: 'bg-white',
+ dividerText: 'text-[#74777f]',
+ checkbox: 'h-5 w-5 rounded-[4px] border-2 border-[#74777f] bg-white text-[#0b57d0] focus:ring-2 focus:ring-[#0b57d0]/30 focus:ring-offset-0',
+ linkInline: 'text-[#0b57d0] hover:underline underline-offset-2',
+ primaryDot: 'bg-[#0b57d0]',
+ tertiaryDot: 'bg-[#3a6a49]',
+ secondaryDot: 'bg-[#8a5100]'
+ }
+ )
+
+ return { ui, themeStore }
+}
diff --git a/client/src/composables/usePageSeo.ts b/client/src/composables/usePageSeo.ts
new file mode 100644
index 0000000..f0fc198
--- /dev/null
+++ b/client/src/composables/usePageSeo.ts
@@ -0,0 +1,136 @@
+import { onUnmounted, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
+import { useConfigStore } from '@/stores/config'
+
+interface SeoOptions {
+ title: string
+ description: string
+ canonical?: string
+ keywords?: string
+ robots?: string
+ ogType?: string
+ image?: string
+}
+
+const defaultTitle = typeof document !== 'undefined' ? document.title : 'Incudal'
+const defaultDescription = typeof document !== 'undefined'
+ ? (document.querySelector('meta[name="description"]') as HTMLMetaElement | null)?.content || ''
+ : ''
+const defaultKeywords = typeof document !== 'undefined'
+ ? (document.querySelector('meta[name="keywords"]') as HTMLMetaElement | null)?.content || ''
+ : ''
+const defaultRobots = typeof document !== 'undefined'
+ ? (document.querySelector('meta[name="robots"]') as HTMLMetaElement | null)?.content || 'index,follow'
+ : 'index,follow'
+const defaultCanonical = typeof document !== 'undefined'
+ ? (document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null)?.href || ''
+ : ''
+
+function toAbsoluteUrl(url: string): string {
+ if (typeof window === 'undefined') {
+ return url
+ }
+ return new URL(url, window.location.origin).href
+}
+
+function upsertMetaByName(name: string, content: string): void {
+ let element = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement | null
+ if (!element) {
+ element = document.createElement('meta')
+ element.setAttribute('name', name)
+ document.head.appendChild(element)
+ }
+
+ element.content = content
+}
+
+function upsertMetaByProperty(property: string, content: string): void {
+ let element = document.querySelector(`meta[property="${property}"]`) as HTMLMetaElement | null
+ if (!element) {
+ element = document.createElement('meta')
+ element.setAttribute('property', property)
+ document.head.appendChild(element)
+ }
+
+ element.content = content
+}
+
+function upsertCanonical(href: string): void {
+ let element = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null
+ if (!element) {
+ element = document.createElement('link')
+ element.setAttribute('rel', 'canonical')
+ document.head.appendChild(element)
+ }
+
+ element.href = href
+}
+
+function applySeo(options: SeoOptions): void {
+ const configStore = useConfigStore()
+ const brandName = configStore.brandName?.trim() || 'Incudal'
+ const image = toAbsoluteUrl(options.image || configStore.brandLogoUrl?.trim() || '/incudal_logo.webp')
+ const canonical = options.canonical || window.location.href
+ const robots = options.robots || defaultRobots
+
+ document.title = options.title
+ upsertMetaByName('description', options.description)
+ upsertMetaByName('keywords', options.keywords || defaultKeywords)
+ upsertMetaByName('robots', robots)
+
+ upsertMetaByProperty('og:site_name', brandName)
+ upsertMetaByProperty('og:type', options.ogType || 'website')
+ upsertMetaByProperty('og:title', options.title)
+ upsertMetaByProperty('og:description', options.description)
+ upsertMetaByProperty('og:url', canonical)
+ upsertMetaByProperty('og:image', image)
+
+ upsertMetaByName('twitter:card', 'summary_large_image')
+ upsertMetaByName('twitter:title', options.title)
+ upsertMetaByName('twitter:description', options.description)
+ upsertMetaByName('twitter:image', image)
+
+ upsertCanonical(canonical)
+}
+
+function restoreDefaults(): void {
+ if (typeof document === 'undefined') {
+ return
+ }
+
+ const configStore = useConfigStore()
+ const brandName = configStore.brandName?.trim() || 'Incudal'
+ const brandSubtitle = configStore.brandSubtitle?.trim() || '基于 Incus 的低价 NAT VPS'
+ const brandLogoUrl = toAbsoluteUrl(configStore.brandLogoUrl?.trim() || '/incudal_logo.webp')
+ const title = defaultTitle.replace(/Incudal/g, brandName)
+ const description = defaultDescription || brandSubtitle
+
+ document.title = title
+ upsertMetaByName('description', description)
+ upsertMetaByName('keywords', defaultKeywords)
+ upsertMetaByName('robots', defaultRobots)
+ upsertMetaByProperty('og:site_name', brandName)
+ upsertMetaByProperty('og:type', 'website')
+ upsertMetaByProperty('og:title', title)
+ upsertMetaByProperty('og:description', description)
+ upsertMetaByProperty('og:url', window.location.href)
+ upsertMetaByProperty('og:image', brandLogoUrl)
+ upsertMetaByName('twitter:card', 'summary_large_image')
+ upsertMetaByName('twitter:title', title)
+ upsertMetaByName('twitter:description', description)
+ upsertMetaByName('twitter:image', brandLogoUrl)
+ upsertCanonical(defaultCanonical || window.location.href)
+}
+
+export function usePageSeo(options: MaybeRefOrGetter): void {
+ watchEffect(() => {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ applySeo(toValue(options))
+ })
+
+ onUnmounted(() => {
+ restoreDefaults()
+ })
+}
diff --git a/client/src/composables/useTerminal.ts b/client/src/composables/useTerminal.ts
new file mode 100644
index 0000000..891161e
--- /dev/null
+++ b/client/src/composables/useTerminal.ts
@@ -0,0 +1,705 @@
+/**
+ * 终端连接 Composable
+ *
+ * 管理 WebSocket 连接、xterm.js 终端实例和生命周期
+ *
+ * 功能特性:
+ * - WebGL 硬件加速渲染(带 Canvas 回退)
+ * - Unicode 11 宽字符支持(中文、Emoji)
+ * - 终端内图片显示支持
+ * - 内容序列化导出
+ * - 搜索功能
+ * - 可点击链接
+ * - 剪贴板增强
+ * - 快捷键支持
+ */
+
+import { ref, shallowRef, onMounted, onUnmounted, watch, type Ref } from 'vue'
+import { Terminal } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import { SearchAddon } from '@xterm/addon-search'
+import { Unicode11Addon } from '@xterm/addon-unicode11'
+import { WebglAddon } from '@xterm/addon-webgl'
+import { SerializeAddon } from '@xterm/addon-serialize'
+import { ClipboardAddon } from '@xterm/addon-clipboard'
+import api from '@/api'
+import { useAuthStore } from '@/stores/auth'
+import {
+ buildTerminalWebSocketUrl,
+ createTerminalRuntime,
+ disposeTerminalRuntime,
+ handleTerminalSocketPayload,
+ shouldRetryTerminalClose,
+ TERMINAL_MAX_RECONNECT_ATTEMPTS
+} from '@/lib/terminal-core'
+
+// Vercel 极简风格 - 纯黑背景
+const TERMINAL_THEME = {
+ background: '#0a0a0a',
+ foreground: '#ededed',
+ cursor: '#ffffff',
+ cursorAccent: '#0a0a0a',
+ selectionBackground: '#444444',
+ selectionForeground: '#ffffff',
+ black: '#0a0a0a',
+ red: '#ff6369',
+ green: '#52c41a',
+ yellow: '#faad14',
+ blue: '#1890ff',
+ magenta: '#eb2f96',
+ cyan: '#13c2c2',
+ white: '#ededed',
+ brightBlack: '#666666',
+ brightRed: '#ff8a8a',
+ brightGreen: '#73d13d',
+ brightYellow: '#ffc53d',
+ brightBlue: '#40a9ff',
+ brightMagenta: '#f759ab',
+ brightCyan: '#36cfc9',
+ brightWhite: '#ffffff'
+}
+
+// 连接状态
+export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
+
+// 终端配置
+export interface TerminalOptions {
+ fontSize?: number
+ fontFamily?: string
+ cursorBlink?: boolean
+ cursorStyle?: 'block' | 'underline' | 'bar'
+ enableWebGL?: boolean // 是否启用 WebGL 渲染
+ enableImages?: boolean // 是否启用图片支持
+ scrollback?: number // 回滚行数
+}
+
+// WebSocket 消息类型
+interface TerminalMessage {
+ type: 'connected' | 'disconnected' | 'reconnecting' | 'error' | 'data'
+ sessionId?: string
+ message?: string
+ code?: string
+ reason?: string
+}
+
+export function useTerminal(instanceId: Ref, options: TerminalOptions = {}) {
+ const authStore = useAuthStore()
+
+ // 移动端检测(用于禁用 WebGL 等)
+ // 注意:新版 iPadOS 在桌面模式下 User-Agent 不包含 "iPad",需结合 maxTouchPoints 检测
+ const isMobileDevice = (() => {
+ const ua = navigator.userAgent
+ // 传统移动端 UA 检测
+ if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
+ return true
+ }
+ // iPadOS 桌面模式检测:UA 显示为 Mac,但支持触摸
+ if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) {
+ return true
+ }
+ // 通用触摸设备检测(作为补充)
+ if (navigator.maxTouchPoints > 0 && /Mac|Windows/i.test(navigator.platform) === false) {
+ return true
+ }
+ return false
+ })()
+
+ // 状态
+ const status = ref('disconnected')
+ const error = ref(null)
+ const sessionId = ref(null)
+
+ // 终端实例(使用 shallowRef 避免响应式代理)
+ const terminal = shallowRef(null)
+ const fitAddon = shallowRef(null)
+ const searchAddon = shallowRef(null)
+ const unicodeAddon = shallowRef(null)
+ const webglAddon = shallowRef(null)
+ const serializeAddon = shallowRef(null)
+ const clipboardAddon = shallowRef(null)
+
+ // 渲染状态
+ const isWebGLEnabled = ref(false)
+
+ // WebSocket
+ let ws: WebSocket | null = null
+ let reconnectTimer: ReturnType | null = null
+ let reconnectAttempts = 0
+ // 更激进的重连策略,后台时也能保持连接
+ let terminalEventsBound = false // 防止重复绑定事件
+
+ // 配置
+ const fontSize = ref(options.fontSize || 14)
+ const fontFamily = options.fontFamily || "'JetBrains Mono', 'Fira Code', 'Consolas', monospace"
+
+ /**
+ * 初始化终端
+ */
+ function initTerminal(container: HTMLElement): Terminal {
+ const runtime = createTerminalRuntime({
+ container,
+ fontSize: fontSize.value,
+ fontFamily,
+ theme: TERMINAL_THEME,
+ isMobileDevice,
+ enableWebgl: options.enableWebGL,
+ onSelectionCopy: () => undefined
+ })
+
+ terminal.value = runtime.terminal
+ fitAddon.value = runtime.fitAddon
+ searchAddon.value = runtime.searchAddon
+ unicodeAddon.value = runtime.unicodeAddon
+ webglAddon.value = runtime.webglAddon
+ serializeAddon.value = runtime.serializeAddon
+ clipboardAddon.value = runtime.clipboardAddon
+ isWebGLEnabled.value = runtime.isWebGLEnabled
+
+ return runtime.terminal
+ }
+
+ /**
+ * 连接到终端
+ */
+ async function connect() {
+ if (!instanceId.value) {
+ error.value = 'Instance ID is required'
+ return
+ }
+
+ // 检查认证状态
+ const token = authStore.token
+ if (!token) {
+ error.value = 'Authentication required'
+ status.value = 'error'
+ return
+ }
+
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ return
+ }
+
+ status.value = reconnectAttempts > 0 ? 'reconnecting' : 'connecting'
+ error.value = null
+
+ try {
+ const ticketResponse = await api.instances.createTerminalTicket(instanceId.value)
+ const wsUrl = buildTerminalWebSocketUrl(instanceId.value, ticketResponse.ticket)
+ ws = new WebSocket(wsUrl)
+ // 使用 arraybuffer 确保二进制数据按顺序处理(避免 Blob 异步导致的乱序)
+ ws.binaryType = 'arraybuffer'
+
+ ws.onopen = () => {
+ console.log('[Terminal] WebSocket connected')
+ reconnectAttempts = 0
+ }
+
+ ws.onmessage = (event) => {
+ handleTerminalSocketPayload(event.data, () => terminal.value, (message) => {
+ handleControlMessage(message as TerminalMessage)
+ })
+ }
+
+ ws.onerror = (event) => {
+ console.error('[Terminal] WebSocket error:', event)
+ error.value = 'Connection error'
+ }
+
+ ws.onclose = (event) => {
+ console.log('[Terminal] WebSocket closed:', event.code, event.reason)
+
+ if (shouldRetryTerminalClose(event.code) && (status.value === 'connected' || status.value === 'connecting' || status.value === 'reconnecting')) {
+ // 意外断开,尝试重连
+ if (reconnectAttempts < TERMINAL_MAX_RECONNECT_ATTEMPTS) {
+ reconnectAttempts++
+ status.value = 'reconnecting'
+ // 线性退避:1秒, 3秒, 5秒... 最多 15秒
+ const delay = Math.min(1000 + (reconnectAttempts - 1) * 2000, 15000)
+ reconnectTimer = setTimeout(() => {
+ void connect()
+ }, delay)
+ } else {
+ status.value = 'disconnected'
+ error.value = 'Connection lost'
+ }
+ } else {
+ status.value = 'disconnected'
+ }
+ }
+
+ // 绑定终端输入(只绑定一次)
+ if (terminal.value && !terminalEventsBound) {
+ terminalEventsBound = true
+
+ terminal.value.onData((data) => {
+ // 使用当前的 ws 引用,而不是闭包捕获的
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(data)
+ } catch {
+ // WebSocket 可能在发送时已关闭,忽略错误
+ }
+ }
+ })
+
+ // 监听终端大小变化
+ terminal.value.onResize(({ cols, rows }) => {
+ sendResize(cols, rows)
+ })
+ }
+ } catch (err) {
+ console.error('[Terminal] Failed to create WebSocket:', err)
+ status.value = 'error'
+ error.value = err instanceof Error ? err.message : 'Failed to connect'
+ }
+ }
+
+ /**
+ * 处理控制消息
+ */
+ function handleControlMessage(message: TerminalMessage) {
+ switch (message.type) {
+ case 'connected':
+ status.value = 'connected'
+ sessionId.value = message.sessionId || null
+ error.value = null
+ // 连接成功后发送初始大小
+ if (terminal.value && fitAddon.value) {
+ fitAddon.value.fit()
+ sendResize(terminal.value.cols, terminal.value.rows)
+ }
+ break
+
+ case 'reconnecting':
+ status.value = 'reconnecting'
+ error.value = message.reason || null
+ break
+
+ case 'disconnected':
+ status.value = 'disconnected'
+ error.value = message.reason || null
+ sessionId.value = null
+ break
+
+ case 'error':
+ status.value = 'error'
+ error.value = message.message || 'Unknown error'
+ if (terminal.value) {
+ terminal.value.write(`\r\n\x1b[31mError: ${message.message}\x1b[0m\r\n`)
+ }
+ break
+ }
+ }
+
+ /**
+ * 发送调整大小命令
+ */
+ function sendResize(cols: number, rows: number) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(JSON.stringify({
+ type: 'resize',
+ cols,
+ rows
+ }))
+ } catch {
+ // WebSocket 可能在发送时已关闭,忽略错误
+ }
+ }
+ }
+
+ /**
+ * 断开连接
+ */
+ function disconnect() {
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+
+ // 先关闭 WebSocket,然后重置重连计数
+ if (ws) {
+ // 临时设置为最大值以阻止 onclose 中的自动重连
+ reconnectAttempts = TERMINAL_MAX_RECONNECT_ATTEMPTS
+ ws.close(1000, 'User disconnect')
+ ws = null
+ }
+
+ // 重置重连计数,以便用户可以手动重连
+ reconnectAttempts = 0
+
+ status.value = 'disconnected'
+ sessionId.value = null
+ }
+
+ /**
+ * 调整终端大小
+ */
+ function fit() {
+ if (fitAddon.value && terminal.value) {
+ fitAddon.value.fit()
+ }
+ }
+
+ /**
+ * 清空终端
+ */
+ function clear() {
+ if (terminal.value) {
+ terminal.value.clear()
+ }
+ }
+
+ /**
+ * 搜索文本
+ */
+ function search(text: string, options?: { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean }) {
+ if (searchAddon.value) {
+ return searchAddon.value.findNext(text, {
+ caseSensitive: options?.caseSensitive,
+ wholeWord: options?.wholeWord,
+ regex: options?.regex
+ })
+ }
+ return false
+ }
+
+ /**
+ * 搜索上一个
+ */
+ function searchPrevious(text: string) {
+ if (searchAddon.value) {
+ return searchAddon.value.findPrevious(text)
+ }
+ return false
+ }
+
+ /**
+ * 更改字体大小
+ */
+ function setFontSize(size: number) {
+ fontSize.value = size
+ if (terminal.value) {
+ terminal.value.options.fontSize = size
+ fit()
+ }
+ }
+
+ /**
+ * 聚焦终端
+ */
+ function focus() {
+ if (terminal.value) {
+ terminal.value.focus()
+ }
+ }
+
+ /**
+ * 写入文本
+ */
+ function write(text: string) {
+ if (terminal.value) {
+ terminal.value.write(text)
+ }
+ }
+
+ /**
+ * 复制选中的文本
+ */
+ async function copySelection(): Promise {
+ if (!terminal.value) return false
+ const selection = terminal.value.getSelection()
+ if (!selection) return false
+
+ try {
+ await navigator.clipboard.writeText(selection)
+ return true
+ } catch {
+ // 回退到 execCommand
+ try {
+ const textarea = document.createElement('textarea')
+ textarea.value = selection
+ textarea.style.position = 'fixed'
+ textarea.style.opacity = '0'
+ document.body.appendChild(textarea)
+ textarea.select()
+ document.execCommand('copy')
+ document.body.removeChild(textarea)
+ return true
+ } catch {
+ return false
+ }
+ }
+ }
+
+ /**
+ * 获取选中的文本
+ */
+ function getSelection(): string {
+ return terminal.value?.getSelection() || ''
+ }
+
+ /**
+ * 是否有选中文本
+ */
+ function hasSelection(): boolean {
+ return !!terminal.value?.hasSelection()
+ }
+
+ /**
+ * 粘贴文本到终端
+ */
+ async function paste(): Promise {
+ try {
+ const text = await navigator.clipboard.readText()
+ if (text && ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(text)
+ return true
+ } catch {
+ return false
+ }
+ }
+ return false
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * 粘贴指定文本到终端
+ */
+ function pasteText(text: string): boolean {
+ if (text && ws && ws.readyState === WebSocket.OPEN) {
+ try {
+ ws.send(text)
+ return true
+ } catch {
+ return false
+ }
+ }
+ return false
+ }
+
+ /**
+ * 去除 ANSI 转义序列(颜色控制码等)
+ * 这些在纯文本编辑器中会显示为方框
+ */
+ function stripAnsiSequences(text: string): string {
+ // 匹配所有 ANSI 转义序列:ESC [ ... 字母
+ // 包括颜色、光标移动、清屏等控制码
+ // 使用 RegExp 构造函数避免 ESLint 报错
+ const ansiRegex = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*[a-zA-Z]', 'g')
+ return text.replace(ansiRegex, '')
+ }
+
+ /**
+ * 导出终端内容
+ * @param format 'text' 返回纯文本(去除颜色码),'html' 返回带样式的 HTML
+ */
+ function exportContent(format: 'text' | 'html' = 'text'): string {
+ if (!serializeAddon.value) return ''
+ try {
+ if (format === 'html') {
+ return serializeAddon.value.serializeAsHTML()
+ }
+ // 纯文本格式:去除 ANSI 转义序列
+ const raw = serializeAddon.value.serialize()
+ return stripAnsiSequences(raw)
+ } catch (err) {
+ console.error('[Terminal] Failed to export content:', err)
+ return ''
+ }
+ }
+
+ /**
+ * 下载终端日志
+ */
+ function downloadLog(filename?: string): boolean {
+ const content = exportContent('text')
+ if (!content) return false
+
+ try {
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename || `terminal-${Date.now()}.log`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ return true
+ } catch (err) {
+ console.error('[Terminal] Failed to download log:', err)
+ return false
+ }
+ }
+
+ /**
+ * 增大字体
+ */
+ function increaseFontSize(): void {
+ const newSize = Math.min(fontSize.value + 2, 32)
+ setFontSize(newSize)
+ }
+
+ /**
+ * 减小字体
+ */
+ function decreaseFontSize(): void {
+ const newSize = Math.max(fontSize.value - 2, 8)
+ setFontSize(newSize)
+ }
+
+ /**
+ * 重置字体大小
+ */
+ function resetFontSize(): void {
+ setFontSize(options.fontSize || 14)
+ }
+
+ /**
+ * 滚动到底部
+ */
+ function scrollToBottom(): void {
+ terminal.value?.scrollToBottom()
+ }
+
+ /**
+ * 滚动到顶部
+ */
+ function scrollToTop(): void {
+ terminal.value?.scrollToTop()
+ }
+
+ /**
+ * 获取渲染器类型
+ */
+ function getRendererType(): 'webgl' | 'canvas' {
+ return isWebGLEnabled.value ? 'webgl' : 'canvas'
+ }
+
+ /**
+ * 销毁终端
+ */
+ function dispose() {
+ // 1. 先断开 WebSocket 连接
+ disconnect()
+
+ disposeTerminalRuntime({
+ terminal: terminal.value,
+ fitAddon: fitAddon.value,
+ searchAddon: searchAddon.value,
+ unicodeAddon: unicodeAddon.value,
+ webglAddon: webglAddon.value,
+ serializeAddon: serializeAddon.value,
+ clipboardAddon: clipboardAddon.value,
+ eventsBound: terminalEventsBound,
+ isWebGLEnabled: isWebGLEnabled.value
+ })
+
+ terminal.value = null
+ fitAddon.value = null
+ searchAddon.value = null
+ unicodeAddon.value = null
+ webglAddon.value = null
+ serializeAddon.value = null
+ clipboardAddon.value = null
+ isWebGLEnabled.value = false
+ terminalEventsBound = false
+ }
+
+ // 监听实例 ID 变化,切换实例时断开旧连接
+ watch(instanceId, (newId, oldId) => {
+ if (newId !== oldId && oldId !== null) {
+ disconnect()
+ }
+ })
+
+ // 移动端 visualViewport 变化处理(软键盘弹出/收起)
+ const handleViewportResize = () => {
+ setTimeout(() => {
+ if (fitAddon.value && terminal.value) {
+ fitAddon.value.fit()
+ terminal.value.refresh(0, terminal.value.rows - 1)
+ }
+ }, 100)
+ }
+
+ // 页面可见性变化时自动重连
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible' && status.value === 'disconnected' && instanceId.value) {
+ // 用户回来了,如果已断开则自动重连
+ console.log('[Terminal] Page visible, auto reconnecting...')
+ reconnectAttempts = 0 // 重置重连计数
+ connect()
+ }
+ }
+
+ // 组件挂载时添加监听
+ onMounted(() => {
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ // 移动端:监听 visualViewport 变化(软键盘弹出/收起)
+ if (isMobileDevice && window.visualViewport) {
+ window.visualViewport.addEventListener('resize', handleViewportResize)
+ }
+ })
+
+ // 组件卸载时清理
+ onUnmounted(() => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+
+ // 移动端:移除 visualViewport 监听
+ if (isMobileDevice && window.visualViewport) {
+ window.visualViewport.removeEventListener('resize', handleViewportResize)
+ }
+
+ dispose()
+ })
+
+ return {
+ // 状态
+ status,
+ error,
+ sessionId,
+ terminal,
+ fontSize,
+ isWebGLEnabled,
+
+ // 方法
+ initTerminal,
+ connect,
+ disconnect,
+ fit,
+ clear,
+ search,
+ searchPrevious,
+ setFontSize,
+ increaseFontSize,
+ decreaseFontSize,
+ resetFontSize,
+ focus,
+ write,
+ dispose,
+
+ // 复制粘贴
+ copySelection,
+ getSelection,
+ hasSelection,
+ paste,
+ pasteText,
+
+ // 导出
+ exportContent,
+ downloadLog,
+
+ // 滚动
+ scrollToBottom,
+ scrollToTop,
+
+ // 渲染器
+ getRendererType
+ }
+}
diff --git a/client/src/composables/useTurnstile.ts b/client/src/composables/useTurnstile.ts
new file mode 100644
index 0000000..2173c31
--- /dev/null
+++ b/client/src/composables/useTurnstile.ts
@@ -0,0 +1,311 @@
+/**
+ * Turnstile 隐式验证 Composable
+ * 用于在后台自动获取验证 token
+ */
+import { ref, onMounted, onUnmounted } from 'vue'
+import api from '@/api'
+
+// 扩展 Window 类型以支持 Turnstile API
+interface TurnstileAPI {
+ render: (container: HTMLElement | string, options: {
+ sitekey: string
+ action?: string
+ size?: 'normal' | 'compact' | 'flexible'
+ theme?: 'light' | 'dark' | 'auto'
+ callback?: (token: string) => void
+ 'error-callback'?: () => void
+ 'expired-callback'?: () => void
+ }) => string
+ reset: (widgetId: string) => void
+ remove: (widgetId: string) => void
+ getResponse: (widgetId: string) => string | undefined
+}
+
+declare const window: Window & { turnstile?: TurnstileAPI }
+
+// 全局配置缓存
+let cachedConfig: { enabled: boolean; siteKey: string | null } | null = null
+let configPromise: Promise<{ enabled: boolean; siteKey: string | null }> | null = null
+
+// Turnstile 脚本加载状态
+let scriptLoaded = false
+let scriptLoading = false
+let scriptLoadPromise: Promise | null = null
+
+/**
+ * 加载 Turnstile 配置
+ */
+async function loadTurnstileConfig(): Promise<{ enabled: boolean; siteKey: string | null }> {
+ if (cachedConfig) {
+ return cachedConfig
+ }
+
+ if (configPromise) {
+ return configPromise
+ }
+
+ configPromise = (async () => {
+ try {
+ const response = await api.systemConfig.getPublic()
+ cachedConfig = {
+ enabled: response.turnstileEnabled || false,
+ siteKey: response.turnstileSiteKey || null
+ }
+ return cachedConfig
+ } catch (error) {
+ console.error('Failed to load Turnstile config:', error)
+ cachedConfig = { enabled: false, siteKey: null }
+ return cachedConfig
+ }
+ })()
+
+ return configPromise
+}
+
+/**
+ * 加载 Turnstile 脚本
+ */
+function loadTurnstileScript(): Promise {
+ // 如果已加载或 window.turnstile 已存在(可能由 vue-turnstile 加载)
+ if (scriptLoaded || window.turnstile) {
+ scriptLoaded = true
+ return Promise.resolve()
+ }
+
+ // 如果正在加载,返回现有的 Promise
+ if (scriptLoading && scriptLoadPromise) {
+ return scriptLoadPromise
+ }
+
+ scriptLoading = true
+ scriptLoadPromise = new Promise((resolve, reject) => {
+ // 再次检查,防止竞态条件
+ if (window.turnstile) {
+ scriptLoaded = true
+ scriptLoading = false
+ resolve()
+ return
+ }
+
+ // 检查是否已有脚本标签(可能由其他组件添加)
+ const existingScript = document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')
+ if (existingScript) {
+ // 等待脚本加载完成
+ const checkInterval = setInterval(() => {
+ if (window.turnstile) {
+ clearInterval(checkInterval)
+ scriptLoaded = true
+ scriptLoading = false
+ resolve()
+ }
+ }, 100)
+ // 设置超时
+ setTimeout(() => {
+ clearInterval(checkInterval)
+ if (!window.turnstile) {
+ scriptLoading = false
+ reject(new Error('Turnstile script load timeout'))
+ }
+ }, 10000)
+ return
+ }
+
+ const script = document.createElement('script')
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
+ script.async = true
+ script.defer = true
+
+ script.onload = () => {
+ // 等待 turnstile 对象可用
+ const checkInterval = setInterval(() => {
+ if (window.turnstile) {
+ clearInterval(checkInterval)
+ scriptLoaded = true
+ scriptLoading = false
+ resolve()
+ }
+ }, 50)
+ // 设置超时
+ setTimeout(() => {
+ clearInterval(checkInterval)
+ if (!window.turnstile) {
+ scriptLoading = false
+ reject(new Error('Turnstile API not available after script load'))
+ }
+ }, 5000)
+ }
+
+ script.onerror = () => {
+ scriptLoading = false
+ reject(new Error('Failed to load Turnstile script'))
+ }
+
+ document.head.appendChild(script)
+ })
+
+ return scriptLoadPromise
+}
+
+/**
+ * 清除配置缓存(用于管理员更新配置后刷新)
+ */
+export function clearTurnstileConfigCache() {
+ cachedConfig = null
+ configPromise = null
+}
+
+/**
+ * Turnstile 隐式验证 Composable
+ */
+export function useTurnstile(action?: string) {
+ const token = ref('')
+ const isReady = ref(false)
+ const isEnabled = ref(false)
+ const siteKey = ref(null)
+ const error = ref(null)
+ const widgetId = ref(null)
+ const containerId = ref(`turnstile-${Math.random().toString(36).substring(7)}`)
+
+ // 初始化
+ async function init() {
+ try {
+ const config = await loadTurnstileConfig()
+ isEnabled.value = config.enabled
+ siteKey.value = config.siteKey
+
+ if (!config.enabled || !config.siteKey) {
+ isReady.value = true
+ return
+ }
+
+ await loadTurnstileScript()
+ isReady.value = true
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : String(err)
+ isReady.value = true
+ }
+ }
+
+ // 执行隐式验证
+ async function execute(): Promise {
+ if (!isEnabled.value || !siteKey.value) {
+ return ''
+ }
+
+ if (!isReady.value) {
+ await init()
+ }
+
+ // 再次检查,init 可能因为配置问题而跳过
+ if (!isEnabled.value || !siteKey.value) {
+ return ''
+ }
+
+ return new Promise((resolve, reject) => {
+ if (!window.turnstile) {
+ reject(new Error('Turnstile not loaded'))
+ return
+ }
+
+ // 设置超时
+ const timeout = setTimeout(() => {
+ reject(new Error('Turnstile verification timeout'))
+ }, 30000) // 30秒超时
+
+ // 创建隐藏容器
+ // 注意:不能使用 display:none 或 visibility:hidden,否则 Turnstile 无法正常渲染
+ // 使用 position:fixed + 移出可视区域 + opacity:0 的方式隐藏
+ let container = document.getElementById(containerId.value)
+ if (!container) {
+ container = document.createElement('div')
+ container.id = containerId.value
+ container.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;pointer-events:none;'
+ document.body.appendChild(container)
+ }
+
+ // 如果已有 widget,先移除
+ if (widgetId.value) {
+ try {
+ window.turnstile.remove(widgetId.value)
+ widgetId.value = null
+ } catch {
+ // 忽略移除错误
+ }
+ }
+
+ try {
+ // 渲染新的 widget
+ // 注意:Turnstile 不支持 'invisible' size,使用 'compact' 配合隐藏容器实现隐式验证
+ // 实际的隐式/非交互模式需要在 Cloudflare Dashboard 中配置 widget 类型
+ widgetId.value = window.turnstile.render(container, {
+ sitekey: siteKey.value!,
+ action: action,
+ size: 'compact',
+ callback: (response: string) => {
+ clearTimeout(timeout)
+ token.value = response
+ resolve(response)
+ },
+ 'error-callback': () => {
+ clearTimeout(timeout)
+ reject(new Error('Turnstile verification failed'))
+ },
+ 'expired-callback': () => {
+ token.value = ''
+ }
+ })
+ } catch (err) {
+ clearTimeout(timeout)
+ reject(err instanceof Error ? err : new Error(String(err)))
+ }
+ })
+ }
+
+ // 重置
+ function reset() {
+ token.value = ''
+ if (widgetId.value && window.turnstile) {
+ try {
+ window.turnstile.reset(widgetId.value)
+ } catch {
+ // 忽略重置错误
+ }
+ }
+ }
+
+ // 清理
+ function cleanup() {
+ if (widgetId.value && window.turnstile) {
+ try {
+ window.turnstile.remove(widgetId.value)
+ } catch {
+ // 忽略移除错误
+ }
+ }
+ const container = document.getElementById(containerId.value)
+ if (container) {
+ container.remove()
+ }
+ }
+
+ onMounted(() => {
+ init()
+ })
+
+ onUnmounted(() => {
+ cleanup()
+ })
+
+ return {
+ token,
+ isReady,
+ isEnabled,
+ siteKey,
+ error,
+ execute,
+ reset,
+ cleanup
+ }
+}
+
+export default useTurnstile
diff --git a/client/src/constants/adminSettings.ts b/client/src/constants/adminSettings.ts
new file mode 100644
index 0000000..979d12a
--- /dev/null
+++ b/client/src/constants/adminSettings.ts
@@ -0,0 +1,62 @@
+export type SystemSettingsSectionKey = 'access' | 'hosting' | 'brand' | 'security' | 'mail' | 'tickets' | 'popup'
+
+export interface SystemSettingsNavigationItem {
+ key: SystemSettingsSectionKey | 'telegram'
+ path: string
+ labelKey: string
+ descriptionKey?: string
+}
+
+export const systemSettingsSections: Array = [
+ {
+ key: 'access',
+ path: '/admin/settings/access',
+ labelKey: 'admin.system.sections.access.title',
+ descriptionKey: 'admin.system.sections.access.description'
+ },
+ {
+ key: 'hosting',
+ path: '/admin/settings/hosting',
+ labelKey: 'admin.system.sections.hosting.title',
+ descriptionKey: 'admin.system.sections.hosting.description'
+ },
+ {
+ key: 'brand',
+ path: '/admin/settings/brand',
+ labelKey: 'admin.system.sections.brand.title',
+ descriptionKey: 'admin.system.sections.brand.description'
+ },
+ {
+ key: 'security',
+ path: '/admin/settings/security',
+ labelKey: 'admin.system.sections.security.title',
+ descriptionKey: 'admin.system.sections.security.description'
+ },
+ {
+ key: 'mail',
+ path: '/admin/settings/mail',
+ labelKey: 'admin.system.sections.mail.title',
+ descriptionKey: 'admin.system.sections.mail.description'
+ },
+ {
+ key: 'tickets',
+ path: '/admin/settings/tickets',
+ labelKey: 'admin.system.sections.tickets.title',
+ descriptionKey: 'admin.system.sections.tickets.description'
+ },
+ {
+ key: 'popup',
+ path: '/admin/settings/popup-announcement',
+ labelKey: 'admin.system.tabs.popupAnnouncement',
+ descriptionKey: 'admin.system.popupAnnouncement.description'
+ }
+]
+
+export const systemSettingsNavigationItems: SystemSettingsNavigationItem[] = [
+ ...systemSettingsSections,
+ {
+ key: 'telegram',
+ path: '/admin/settings/telegram',
+ labelKey: 'admin.system.tabs.telegram'
+ }
+]
diff --git a/client/src/data/funnyQuotes.ts b/client/src/data/funnyQuotes.ts
new file mode 100644
index 0000000..363dc5c
--- /dev/null
+++ b/client/src/data/funnyQuotes.ts
@@ -0,0 +1,625 @@
+// 随机骚话数据(中英文)
+// 根据用户浏览器语言随机显示
+
+export const funnyQuotesZh: string[] = [
+ '不是真爱,立刻提桶跑路,绝不留恋!',
+ '有图有真相,有针会友,基情满满!',
+ '羊毛不薅白不薅,薅完吃灰也开心!',
+ '买服务器像开盲盒,中了是大盘,炸了是传家宝。',
+ '价格低到离谱,配置高到飞起,谁能不心动?',
+ '十年后咱们都吃不上饭,但至少都有小姨子域名。',
+ '没有服务器的日子,总是缺了点什么。',
+ '跑路并不可怕,可怕的是没备份!',
+ '以针会友,私下交易,友情永流传!',
+ '配置不够豪,线路不够优,谁会多看一眼?',
+ '年付99还能忍,年付9.9直接让人失控。',
+ '探针一发,延迟低到爆,群里瞬间沸腾!',
+ '不怕机器炸,就怕炸之前没备份好数据。',
+ '商家跑路最可怕的时刻:昨晚刚续了费。',
+ '小鸡再小,也得硬撑一年不掉线!',
+ '薅羊毛薅到手软,囤机器囤到面板卡。',
+ '看到年付9.9,理智瞬间下线。',
+ '有机不玩是暴殄天物,有针不发是藏私。',
+ '商家说永不跑路,我就信;说送小姨子,我就冲!',
+ '服务器圈风险高,下单需谨慎;优惠一到,谨慎全抛脑后。',
+ '别问我为什么囤这么多,问就是空虚寂寞冷。',
+ '一键脚本装好,外网世界大门瞬间打开!',
+ '机器越大翻得越高,线路越优外网越妙。',
+ '跑路商家再多,也挡不住低价的诱惑。',
+ '没被跑路坑过的,不算真正老鸟。',
+ '私下交易友情升温,以针会友通宵不睡!',
+ '商家说限量100台我信,只剩1台我还信!',
+ '机器不稳不给钱,稳不起全额退——敢这么写我直接爆仓。',
+ '再穷不能穷机器,再苦不能没梯子。',
+ '机器不稳我不买,一稳我就续,续完开始后悔。',
+ '商家承诺养我一辈子,结果只养在吃灰区。',
+ '探针速度一出,全身舒坦;延迟一高,瞬间提桶。',
+ '年付9.9的宝贝,再抽风也得宠它一年。',
+ '薅到手抖,下单到腿软,醒来面板又多一堆吃灰机。',
+ '没被坑过的玩家,就像没谈过恋爱的少年。',
+ '商家说最后10台我冲,最后1台我再冲,售罄了我等下一波。',
+ '圈里老话:今晚不翻,就辜负了这条优质线路。',
+ '配置再牛,带宽再大,也挡不住半夜悄悄拔电源。',
+ '以针会友,见好针就激动;私交交易,爽到不行!',
+ '低价机器的命运:下单时热血,吃灰时心凉。',
+ '夜生活:刷针、撸配置、下单、吃灰循环。',
+ '商家跑路后,我不是哭,我在缅怀昨晚充的余额。',
+ '线路不够好不买,买了不够好也得用。',
+ '老鸟不怕炸,就怕炸前没多开窗口。',
+ '看到"不限流量"四个字,自动敬礼。',
+ '服务器圈的爱情:来得快,去得也快,留下吃灰和空 wallet。',
+ '再穷也要囤机器,再苦也要翻出去。',
+ '商家说有后门我信了,结果真是他逃跑的后门。',
+ '终极梦想:囤一辈子机器,翻一辈子墙,被优惠套路一辈子。',
+ 'CN2 GIA线路一出,谁还看得上普通优化?',
+ '延迟低到单身狗都羡慕:稳定、快速、持久。',
+ '带宽小了别慌,多开几台就是大带宽。',
+ '商家说优化线路,我听成了优化利润。',
+ '探针结果完美,感觉人生达到了巅峰。',
+ '囤机器如囤老婆,多一个不嫌多。',
+ '机器炸了无所谓,数据没了才要命。',
+ '9.9的诱惑,比咖啡还提神。',
+ '刷针刷到天亮,激动到天亮,钱包空到天亮。',
+ '商家不跑才奇怪,我不上当才奇怪。',
+ '圈里真理:便宜没好货,但我就是要便宜的。',
+ '看到大盘鸡口水流,看到跑路鸡眼泪流。',
+ '日常:早上巡机,晚上撸配置。',
+ '备份才是王道,没备份纯属冒险。',
+ '商家说永不跑路,我说永不剁手,结果谁都没守住。',
+ '小机器翻墙也丝滑,低配置科学也快乐。',
+ '私下交易永不过时,好友永不散场。',
+ '低端玩家的梦想:一堆9.9撑起外网天堂。',
+ '跑路不可怕,可怕的是跑路前没薅够羊毛。',
+ '最浪漫的事:和基友一起刷针到天明。',
+ '机器多不压身,钱包空了再充。',
+ '商家限量发售,我限量上当。',
+ '原价999特价9.9,直接情绪起飞。',
+ '服务器圈没有真爱,只有真羊毛。',
+ '延迟超过200ms,直接判死刑。',
+ '囤到破产,翻到自由。',
+ '商家跑路是常态,我冲动是日常。',
+ '线路不优不买,买了不优也认。',
+ '信仰:相信爱情,更相信9.9。',
+ '探针一挂,群友齐聚;机器一稳,钱包一空。',
+ '吃灰的机器也是宝贝,至少曾经辉煌。',
+ '商家说风控严格,我只听到有爱。',
+ '年付机器的悲剧:首月完美,之后间歇抽风。',
+ '深夜最爱:一份热腾腾的探针结果。',
+ '生存法则:不怕贵,就怕不够便宜。',
+ '看到"不跑路承诺",直接上头冲。',
+ '低配高玩,才叫真水平。',
+ '商家半夜维护,我半夜失眠祈祷。',
+ '机器多到用不过来,余额少到续不起。',
+ '口头禅:这个可以有,这个必须有!',
+ '跑路了别慌,先检查有没有备份。',
+ '9.9的机器,用着用着就超售了。',
+ '刷针如刷短视频,一刷就停不下来。',
+ '纯爱玩家:爱你,但你跑了我会爱下一台。',
+ '稳定靠商家,速度靠配置,快乐靠价格。',
+ '终极技巧:跑路前再薅最后一波。',
+ '一台炸了没关系,我还有99台吃灰。',
+ '商家说亲测稳定,我说亲测上当。',
+ '今晚不囤,人生少点乐趣。',
+ '墓志铭:一生被9.9温柔包围。',
+ '线路不香不买,香了不玩白不玩。',
+ '看到CN2线路,腿瞬间软了。',
+ '配置拉满,价格拉低,才是完美机器。',
+ '延迟低到怀疑运营商偷偷喜欢我。',
+ '带宽1G起步,才配叫入门机。',
+ '商家说轻量应用,我听成了轻量超售。',
+ '探针结果一出,群里集体高潮。',
+ 'IPV4珍贵,IPV6随便用。',
+ '机器多到面板卡,幸福到登录都难。',
+ '线路优化越多,越觉得明天要寄。',
+ '看到独享IP,直接脑补独享世界。',
+ '低价大盘鸡,才是真爱。',
+ '商家深夜回票:别急,我在练跑路姿势。',
+ '睡眠质量取决于今晚线路稳不稳。',
+ '炸机如失恋,难过三天又找下一台。',
+ '看到"不限速"四个字,自动起立。',
+ '囤机器如收集邮票,总想集齐一套。',
+ '配置越高,翻得越爽。',
+ '商家说新机房,我翻译成新借口。',
+ '真正的玩家,从不删旧机,只开新机。',
+ '延迟低到像本地盘,幸福感爆棚。',
+ '带宽小了就多开几条隧道。',
+ '看到买一年送半年,直接失守。',
+ '线路直连才是王道,优化都是浮云。',
+ '机器囤得越多,心越踏实。',
+ '探针狂人:没针睡不着,好针睡得香。',
+ '配置再高,也怕超售。',
+ '商家说7×24支持,其实7天后24小时失联。',
+ '价格越低,底线越松。',
+ '看到中国香港CN2,瞬间原地跪下。',
+ '跑路后最痛的:没提前多续几年。',
+ '战斗口号:这个线路能冲,这个配置必须冲!',
+ '不稳定是常态,太稳定反而可疑。',
+ '囤机终极理由:总有一台救命。',
+ '商家说优化中,翻译:准备把你优化掉。',
+ '月付低于5刀,理智直接关机。',
+ '服务器爱情观:一见钟情,付款续费,用着吃灰。',
+ '生存铁律:永远别信"永不跑路"。',
+ '老鸟看商家,如看前任:爱恨纠缠。',
+ '延迟高提桶,速度快续费。',
+ '囤那么多机器,唯一出国的只有钱。',
+ '减肥秘诀:每天手指疯狂下单。',
+ '"不满意退款"一出,直接当真爱冲。',
+ '炸机别哭,至少曾经拥有过低延迟。',
+ '商家越吹线路,我越信明天要凉。',
+ '夜间仪式:开机、测速、关机、安心睡。',
+ '便宜没好货,但我偏爱便宜货。',
+ '限时秒杀,直接变三秒英雄。',
+ '真玩家不怕坑,就怕没坑可跳。',
+ '商家说高端配置,我脑补高端圈钱。',
+ '囤机如开盲盒,惊喜与惊吓并存。',
+ '梦想:一台机器用一辈子,一家商家不跑一辈子。',
+ '探针群白天冷清,深夜人满为患。',
+ '价格一跌,心情一飞。',
+ '"绝对稳定"翻译成"绝对要炸"。',
+ '黑五冲一年只用一天,才叫真勇士。',
+ '这里没有初心,只有薅心。',
+ '看到终身买断,直接情绪巅峰。',
+ '日常问候:你家线路今晚香不香?',
+ '跑路不可怕,可怕的是还想再续一次。',
+ '最高境界:面板全是绿油油的勾。',
+ '延迟低到让人怀疑人生:网还能这么快?',
+ '独立IP听成了独立自由。',
+ '理财观:分散投资在不同商家的低价机。',
+ '不怕炸,就怕无声无息地炸。',
+ '终身质保一出,直接当传家宝冲。',
+ '老鸟不追赔偿,只追下一波优惠。',
+ '人生轮回:冲→宠→弃→再冲。',
+ '商家说上新,我听成上新坑。',
+ '囤到密码都记不清了。',
+ '选机三步:先看价,再看线路,最后看配置。',
+ '探针一发群里狂欢,开票一发群里安静。',
+ '价格越低,智商越低。',
+ '实名免备案,直接脱裤准备冲。',
+ '老鸟测速比看电影还过瘾。',
+ '囤机如养后宫,越囤越空虚。',
+ '终极信仰:总有更香的线路在等我。',
+ '看到1T流量,直接脑补无限可能。',
+ '跑路后最大收获:下次先薅再爱。',
+ '没有真爱,只有真线路。',
+ '延迟一降,全员狂欢。',
+ '睡前最后一件事:瞅一眼机器状态。',
+ '风控严格听成了流量控制。',
+ '囤机悲哀:用得上的少,吃灰的多。',
+ '年度总结:薅了一年,被套了一年。',
+ '圈里最高智慧:活得便宜比活得久重要。',
+ '配置拉满却超售,才是最大悲剧。',
+ '线路再好,也怕机房断电。',
+ '带宽共享的快乐:你快我慢我快你慢。',
+ '商家说原生IP,我脑补原生态生活。',
+ '探针结果分享,瞬间收获无数羡慕。',
+ '低延迟的夜晚,才是好夜晚。',
+ '看到"免实名",直接起飞。',
+ '机器多到像博物馆,自娱自乐。',
+ '线路直连一出,谁还用中转?',
+ '商家说性能翻倍,我信到下个月。',
+ '囤机是爱好,吃灰是修行。',
+ '配置越高,吃灰越心疼。',
+ '探针狂欢夜,胜过任何派对。',
+ '服务器圈的优雅:从容面对每一次炸机。',
+ '看到大流量包,直接眼神放光。',
+ '老玩家从不慌,备份在手天下我有。',
+ '延迟一好,人生一片光明。',
+ '商家承诺越多,我警惕越多。',
+ '囤机如喝茶,越囤越有味道。',
+ '夜里最温暖的事:机器安静跑着。',
+ '价格一亲民,心情一美丽。',
+ '服务器如咖啡,因人而异因机而异。',
+ '探针完美,胜过一切情话。',
+ '跑路是支线,薅毛才是主线。',
+ '囤到面板爆满,成就感拉满。',
+ '延迟低到像开了外挂。',
+ '私下交易的默契,不言而喻。',
+ '圈里优雅:从容续费,从容吃灰。',
+ '看到送流量,直接无法拒绝。',
+ '人生格言:能省则省,能薅绝不放过。',
+ '备份多几份,心安多几分。',
+ '商家说稳定如山,我说山也会崩。',
+ '囤机如存钱,越存越安心。',
+ '价格亲民,才叫真福利。',
+ '老鸟看商家:表面笑呵呵,内心已提桶。',
+ '延迟一降,世界都美好。',
+ '商家说技术升级,我说坑位升级。',
+ '囤机如养花,总有开花一天。',
+ '优惠码成功,快乐翻倍。',
+ '线路香到让人上瘾。',
+ '配置虽低,玩得开心就好。',
+ '商家说即将关站,我说即将薅完。',
+ '低配玩出花,才叫真大神。',
+ '带宽越大,梦想越大。',
+ '老玩家从不生气,只会默默再开一台。',
+ '延迟低到让人想谈恋爱。',
+ '商家吹得越狠,我信得越少。',
+ '吃灰也是种享受,至少曾经拥有。',
+ '探针结果差,就当锻炼心态。',
+ '商家深夜维护,我深夜守机。',
+ '囤机到极致,连梦里都在测速。',
+ '线路优化再多,也比不过直连。',
+ '私交优惠,友情加分。',
+ '炸机是惊喜,吃灰是日常。',
+ '看到买断终身,直接泪目。',
+ '最朴实梦想:一台永不抽风的机器。',
+ '带宽共享的哲学:你我一起慢。',
+ '商家说新活动,我说新羊毛。',
+ '囤机如读书,越囤越有内涵。',
+ '探针分享,瞬间成群宠。',
+ '配置再高,也要看线路配合。',
+ '价格一低,道德一松。',
+ '真正的自由:一台好机器走天下。',
+ '延迟高了就当环游世界。',
+ '商家说绝不超售,我说绝不错过。',
+ '囤机是艺术,吃灰是境界。',
+ '看到闪购,手指先于大脑。',
+ '线路不稳如爱情,飘忽不定。',
+ '老鸟心得:备份大于一切。',
+ '配置拉满却延迟高,才是最大讽刺。',
+ '探针结果好,感觉赢了人生。',
+ '商家说母公司支持,我说母公司先跑。',
+ '囤到100台,感觉自己是云老板。',
+ '延迟低到像本地,爽到飞起。',
+ '私下交易的快乐,只有圈里人懂。',
+ '服务器不怕吃灰,就怕从来没跑满速。',
+ '看到免费试用,直接当真爱。',
+ '夜宵首选:新鲜探针结果。',
+ '商家说永不限速,我信你到明天。',
+ '真正的浪漫:和好友一起熬夜刷针。',
+ '看到"不删机"承诺,感动到落泪。',
+ '玩家信条:能薅绝不手软。',
+ '延迟高了就当欣赏沿途风景。',
+ '商家半夜上线,我半夜精神百倍。',
+ '囤机如收集卡片,总想集满。',
+ '探针一挂,瞬间成英雄。',
+ '低价机器的骄傲:小身材大能量。',
+ '商家说优化性能,我听成优化利润。',
+ '真正的玩家,看优惠比看新闻还快。',
+ '一机在手,天下我有。',
+ '看到限量抢购,直接进入战斗状态。',
+ '最怕的不是跑路,是错过好线路。',
+ '延迟低到上瘾。',
+ '商家说升级机房,我说升级借口。',
+ '囤机如品茶,越品越香。',
+ '备份在手,心不慌。',
+ '服务器如老友,偶尔抽风也包容。',
+ '探针完美如诗,延迟高如散文。',
+ '商家跑路是剧情,优惠才是主角。',
+ '囤满面板,成就 unlocked。',
+ '线路直连的快感,无可替代。',
+ '私下交易,默契满分。',
+ '圈里最高境界:薅到商家怀疑人生。',
+ '看到大带宽,梦想起飞。',
+ '老玩家从容:炸就炸,再开一台。',
+ '延迟一低,整个人都灵了。',
+ '商家承诺如浮云,备份才是真爱。',
+ '囤机是信仰,吃灰是修行。',
+ '探针分享夜,最热闹的夜晚。',
+ '服务器如宠物,炸了再领养。',
+ '年付优惠一出,预算瞬间超支。',
+ '真正的智慧:优惠来了绝不犹豫。',
+ '多开备份,才是正道。',
+ '服务器圈的终极哲学:线路香,人生才香!',
+]
+
+export const funnyQuotesEn: string[] = [
+ 'You might never use it, but you absolutely must have it—the eternal motto of hoarders!',
+ "If it's not true love, grab your bucket and run—no lingering!",
+ "Pics or it didn't happen; speed tests bring friends together!",
+ "If you don't pluck the wool, you're missing out—even if it just gathers dust later.",
+ 'Buying a server is like opening a blind box: jackpot means premium specs, dud means family heirloom.',
+ "Price absurdly low, specs ridiculously high—who wouldn't be tempted?",
+ "Ten years from now we'll both be broke, but at least we'll have those \"sister\" domains.",
+ "Life without a server always feels like something's missing.",
+ "Hosts running away isn't scary—what's scary is having no backups!",
+ 'Speed tests unite friends, private deals keep the friendship forever!',
+ 'Specs not impressive enough, routes not premium enough—no one gives it a second glance.',
+ '$99 a year is tolerable; $9.99 a year makes you completely lose control.',
+ 'Drop a speed test—ping so low the group chat explodes!',
+ 'Not afraid of the machine crashing, just afraid of no backups before it does.',
+ 'The worst time for a host to run: right after you renewed last night.',
+ 'Even the tiniest server has to stay online for a full year!',
+ 'Pluck wool till your hands cramp, hoard servers till the panel lags.',
+ 'Spot a $9.99 yearly deal—rationality instantly logs off.',
+ "Owning a server you don't use is wasteful; not sharing speed tests is selfish.",
+ "Host says \"never shutting down\"—I believe it. Says \"free sister domains\"—I'm sold!",
+ 'High risk in the server game, order carefully—until a deal shows up and caution goes out the window.',
+ "Don't ask why I hoard so many—just lonely, bored, and cold.",
+ 'One-click script installed—gateway to the open internet swings wide!',
+ 'Bigger machine, higher you climb; better routes, sweeter the outside world.',
+ 'No number of runaway hosts can stop the allure of low prices.',
+ "If you've never been burned by a host running, you're not a true veteran.",
+ 'Private deals warm the friendship; speed tests keep you up all night!',
+ 'Host says "only 100 left"—I believe it. "Last one"—still believe it!',
+ "Unstable? No payment. Can't stay up? Full refund—if they dared advertise that, I'd go all in.",
+ "Can't be poor in servers, can't live without a ladder.",
+ 'Unstable? I pass. Stable? I renew—then start regretting.',
+ 'Host promised to keep me forever—turns out forever meant the dusty list.',
+ 'Great speed test = instant bliss; high ping = instant bucket grab.',
+ '$9.99 yearly baby—even if it throws tantrums, you pamper it for a year.',
+ 'Pluck till hands shake, order till legs go weak—wake up to another pile of dusty machines.',
+ "Never been scammed? You're like a kid who's never been in love.",
+ '"Last 10 left"—I rush. "Final one"—I rush again. Sold out—I wait for the next wave.',
+ "Old community saying: if you don't climb tonight, you've wasted a great route.",
+ 'No matter how beastly the specs or wide the bandwidth, nothing stops a midnight power pull.',
+ 'Speed tests bring friends—good result gets everyone excited; private deal feels amazing!',
+ 'Cheap server fate: burning passion at purchase, cold regret in dust.',
+ 'Night routine: test speeds, drool over specs, order, then dust cycle.',
+ "After a host runs, I'm not crying—I'm mourning last night's top-up.",
+ "Route not great? Don't buy. Bought and it's meh? Deal with it.",
+ "Veterans don't fear crashes—just fear not opening enough tabs before one.",
+ 'Seeing "unlimited traffic"—automatic salute.',
+ 'Server love: comes fierce, leaves fast, leaves dust and an empty wallet.',
+ 'Poor or not, hoard servers; tough or not, climb out.',
+ "Host said there's a back door—I believed it. Turns out it was their escape route.",
+ 'Ultimate dream: hoard servers forever, climb walls forever, get lured by deals forever.',
+ "Once you've tried CN2 GIA, who looks at regular optimized routes?",
+ 'Ping so low even single folks are jealous: stable, fast, lasts long.',
+ 'Small bandwidth? No problem—just run a few more tunnels.',
+ 'Host says "optimizing routes"—I hear "optimizing profits."',
+ "Perfect speed test feels like reaching life's peak.",
+ 'Hoard servers like wives—one more is never too many.',
+ "Machine down? Fine. Data gone? That's the killer.",
+ '$9.99 temptation—more energizing than coffee.',
+ 'Test speeds till dawn, excitement till dawn, empty wallet till dawn.',
+ "Host not running would be weird; me not falling for it would be weirder.",
+ 'Community truth: nothing good is cheap—but I only want cheap.',
+ 'Big stable server = drooling; runaway server = tears.',
+ 'Daily life: morning patrol, evening spec browsing.',
+ 'Backups are king—no backups is pure gambling.',
+ 'Host swore never to run—I swore never to buy again. Neither kept the promise.',
+ 'Tiny server, smooth climbing; low specs, still fun science.',
+ 'Private deals never go out of style, friends never part.',
+ 'Low-end dream: a pile of $9.99 machines powering internet heaven.',
+ "Runaway not scary—scary is not plucking enough wool before it happens.",
+ 'Most romantic thing: staying up till dawn testing speeds with bros.',
+ "Too many servers won't crush you—just top up when the wallet's dry.",
+ 'Host does limited drops—I do limited gullibility.',
+ 'Original $999, flash $9.99—instant emotional takeoff.',
+ 'No true love in servers—just true wool.',
+ 'Ping over 200ms? Death sentence.',
+ 'Hoard till broke, climb till free.',
+ 'Hosts running is routine, my impulse buys are daily.',
+ "Route not great? Pass. Bought and meh? Accept it.",
+ 'Faith: believe in love, believe harder in $9.99.',
+ 'Drop a test—friends swarm; server stable—wallet empty.',
+ 'Dusty servers are still treasures—at least they once shone.',
+ 'Host mentions strict controls—I just hear "love."',
+ 'Yearly server tragedy: perfect first month, random tantrums after.',
+ 'Favorite midnight snack: fresh hot speed test results.',
+ 'Survival rule: not afraid of expensive, afraid of not cheap enough.',
+ 'See "no shutdown promise"—rush in headfirst.',
+ 'Low specs, high skill—that\'s real talent.',
+ 'Host does midnight maintenance—I do midnight insomnia prayers.',
+ "Servers so many you can't use them all, balance too low to renew.",
+ 'Catchphrase: this one works, this one must-have!',
+ 'Runaway? Stay calm—first check backups.',
+ '$9.99 server eventually gets oversold.',
+ "Testing speeds like scrolling shorts—can't stop.",
+ "Pure-love player: I'll love you, but if you run I'll love the next one.",
+ 'Stability from host, speed from specs, joy from price.',
+ 'Ultimate skill: one last wool pluck before the run.',
+ "One down? No big deal—99 more gathering dust.",
+ 'Host says "personally tested stable"—I hear "personally tested gullible."',
+ "Not hoarding tonight = missing out on life's fun.",
+ 'Epitaph: Gently surrounded by countless $9.99 deals forever.',
+ "Route not great? Don't buy. Great but unused? Still worth it.",
+ 'Spot CN2 route—knees instantly weak.',
+ "Maxed specs, rock-bottom price—that's perfection.",
+ 'Ping so low you suspect the ISP secretly loves you.',
+ '1G bandwidth minimum for a proper entry-level machine.',
+ 'Host says "lightweight application"—I hear "lightweight overselling."',
+ 'Speed test drops—group chat collective peak.',
+ 'IPv4 is precious, IPv6 is free real estate.',
+ 'So many servers the panel lags—blissfully hard to log in.',
+ "More \"optimized\" routes promised, more convinced it's doomed tomorrow.",
+ 'Spot dedicated IP—imagine owning the world.',
+ 'Cheap big server = true love.',
+ 'Host replies ticket at midnight: hold on, practicing escape moves.',
+ "Sleep quality depends on tonight's route stability.",
+ 'Crash like a breakup—sad for three days, then find the next one.',
+ 'Seeing "no throttling"—automatic standing ovation.',
+ "Hoarding servers like collecting stamps—always aiming for a full set.",
+ 'Higher specs, smoother climbing.',
+ 'Host says new datacenter—I translate to new excuse.',
+ 'Real players never delete old machines—just open new ones.',
+ 'Ping so low it feels local—pure happiness overload.',
+ 'Small bandwidth? Just run a few extra tunnels.',
+ 'Buy one year get six months free—defenses completely down.',
+ 'Direct routes are king—optimized are just clouds.',
+ 'More servers hoarded, more peace of mind.',
+ "Speed test addict: no test = can't sleep, great test = sweet dreams.",
+ 'No matter how high the specs, overselling ruins everything.',
+ '"7×24 support" really means gone after 7 days for 24 hours.',
+ 'Lower price, looser standards.',
+ 'Spot Hong Kong CN2—instant kneel.',
+ 'Worst pain after a run: not renewing for a few extra years first.',
+ 'Battle cry: this route is rush-worthy, these specs are must-rush!',
+ 'Unstable is normal—too stable feels suspicious.',
+ 'Ultimate hoarding excuse: one will save the day someday.',
+ 'Host says "optimizing"—translation: preparing to optimize you out.',
+ 'Monthly under $5—rationality shuts down.',
+ 'Server romance: love at first sight, instant payment, eventual dust.',
+ 'Iron survival rule: never believe "guaranteed no shutdowns."',
+ 'Veterans view hosts like exes: tangled love and hate.',
+ 'High ping = bucket; fast speed = renew.',
+ 'Hoarded so many—the only thing that went abroad was my money.',
+ 'Diet secret: daily finger frenzy ordering.',
+ '"Not satisfied? Refund"—taken as gospel and rushed.',
+ 'Crash? Don\'t cry—at least you once had low ping.',
+ "Harder the host hypes routes, more certain tomorrow's doom.",
+ 'Night ritual: boot, test, shut down, sleep peacefully.',
+ 'Cheap has no quality—but I only want cheap.',
+ 'Limited flash sale—turn into a three-second hero.',
+ "Real players don't fear pits—just fear no pits to jump into.",
+ 'Host brags high-end specs—I picture high-end cash grab.',
+ 'Hoarding like blind boxes—thrills and scares guaranteed.',
+ 'Dream: one machine for life, one host that never runs.',
+ 'Speed test groups dead by day, packed at night.',
+ 'Price drops—mood soars.',
+ '"Absolutely stable" = "absolutely crashing soon."',
+ 'Black Friday rush on a one-day-use server = true warrior.',
+ 'No original mission here—just pure plucking spirit.',
+ 'Spot lifetime buyout—emotional climax.',
+ "Daily greeting: how's your route smelling tonight?",
+ 'Runaway not scary—scary is wanting to renew again.',
+ 'Peak realm: panel full of green checks.',
+ 'Ping so low it triggers existential questions: internet can be this fast?',
+ 'Dedicated IP sounds like dedicated freedom.',
+ "Investing: diversify across different hosts' cheap machines.",
+ 'Not afraid of crashes—afraid of silent ones.',
+ 'Lifetime warranty—treat as heirloom and rush.',
+ "Veterans don't chase refunds—chase next deal.",
+ 'Life cycle: rush → pamper → abandon → rush again.',
+ 'Host says new stock—I hear new pit.',
+ 'Hoarded till passwords are a blur.',
+ 'Machine picking order: price first, route second, specs last.',
+ 'Test drops = group frenzy; ticket opens = group silence.',
+ 'Lower price, lower IQ.',
+ 'No real-name required—pants down, ready to charge.',
+ 'Veterans find speed testing more thrilling than movies.',
+ 'Hoarding like building a harem—the more, the emptier inside.',
+ "Ultimate faith: there's always a better route waiting.",
+ 'Spot 1TB traffic—mind fills with infinite possibilities.',
+ 'Biggest lesson after a run: pluck first, love later.',
+ 'No true love—just true routes.',
+ 'Ping drops—collective ecstasy.',
+ 'Last thing before bed: glance at server status.',
+ 'Strict controls sounds like traffic control.',
+ 'Hoarding tragedy: few actually used, many dusty.',
+ 'Yearly summary: plucked all year, got lured all year.',
+ 'Community wisdom: living cheap beats living long.',
+ 'Max specs but oversold—that\'s the real tragedy.',
+ 'Best route still fears datacenter blackout.',
+ 'Shared bandwidth joy: you fast I slow, I fast you slow.',
+ 'Host says native IP—I picture wilderness living.',
+ 'Sharing speed tests = instant flood of envy.',
+ 'Low-ping nights are the best nights.',
+ 'Spot "no ID required"—instant takeoff.',
+ 'Servers so many it\'s a personal museum.',
+ 'Direct route drops—who still uses relays?',
+ 'Host says double performance—I believe till next month.',
+ 'Hoarding is hobby, dusting is discipline.',
+ 'Higher specs, more painful the dust.',
+ 'Speed test party nights beat any club.',
+ 'Server community grace: calmly facing every crash.',
+ 'Spot huge traffic pack—eyes light up.',
+ "Veterans never panic—backups mean the world is yours.",
+ 'Good ping = bright life.',
+ 'More host promises, more my caution.',
+ 'Hoarding like tea tasting—the more, the richer the flavor.',
+ 'Warmest night feeling: server quietly running.',
+ 'Friendly price = beautiful mood.',
+ 'Servers like coffee—different strokes for different folks.',
+ 'Perfect speed test beats any love confession.',
+ 'Runaway is side plot—deals are the main story.',
+ 'Panel full = achievement unlocked.',
+ 'Ping so low it feels like cheating.',
+ 'Private deal chemistry—words unnecessary.',
+ 'Community grace: calmly renewing, calmly dusting.',
+ 'Spot free traffic—impossible to resist.',
+ 'Life motto: save where you can, pluck without mercy.',
+ 'Extra backups = extra peace.',
+ 'Host says stable as mountain—I say mountains crumble too.',
+ 'Hoarding like savings—more secure the more you have.',
+ 'Friendly price = real welfare.',
+ 'Veterans smile outside, bucket ready inside.',
+ 'Ping drop = beautiful world.',
+ 'Host says tech upgrade—I say pit upgrade.',
+ "Hoarding like planting flowers—one day they'll bloom.",
+ 'Coupon success = double happiness.',
+ 'Great route = addictive.',
+ "Low specs but fun—that's enough.",
+ 'Host says closing soon—I say finishing the pluck.',
+ 'Low specs mastered = true god.',
+ 'Bigger bandwidth, bigger dreams.',
+ 'Veterans never rage—just quietly open another.',
+ 'Low ping makes you want to fall in love.',
+ 'Harder the hype, less I believe.',
+ "Dusting is also enjoyment—at least you once owned it.",
+ 'Bad test result = mindset training.',
+ 'Host midnight maintenance—I midnight vigil.',
+ 'Hoarding extreme: ordering in dreams.',
+ 'No many optimizations beat direct.',
+ 'Private deal perks = friendship bonus.',
+ 'Crash = surprise, dust = routine.',
+ 'Spot lifetime ownership—tears of joy.',
+ 'Simplest dream: one machine that never tantrums.',
+ 'Shared bandwidth philosophy: we slow together.',
+ 'Host says new event—I say new wool.',
+ 'Hoarding like reading—the more, the deeper.',
+ 'Test sharing = instant group favorite.',
+ 'High specs still need good routes to shine.',
+ 'Low price = loose morals.',
+ 'True freedom: one great machine rules the world.',
+ 'High ping = world tour.',
+ "Host says no overselling—I say no missing it.",
+ 'Hoarding is art, dusting is enlightenment.',
+ 'Flash sale—fingers faster than brain.',
+ 'Unstable route like love—fickle.',
+ 'Veteran wisdom: backups above all.',
+ 'Max specs high ping = ultimate irony.',
+ 'Great test result = winning at life.',
+ 'Host says parent company backing—I say parent runs first.',
+ 'Hoard 100 = feel like cloud tycoon.',
+ 'Low ping like local—pure bliss.',
+ 'Private deal joy—only insiders get it.',
+ "Server not afraid of dust—afraid of never hitting full speed.",
+ 'Spot free trial—treat as true love.',
+ 'Best midnight snack: fresh test results.',
+ 'Host says no throttling forever—I believe till tomorrow.',
+ 'True romance: all-night speed testing with friends.',
+ '"No deletion" promise—moved to tears.',
+ 'Player creed: never go soft on plucking.',
+ 'High ping = enjoy the scenery.',
+ "Host online at midnight—I'm wide awake.",
+ "Hoarding like collecting cards—aim for full set.",
+ 'Drop a test = instant hero.',
+ 'Cheap machine pride: small body, big energy.',
+ 'Host says optimizing performance—I hear optimizing profit.',
+ 'Real players spot deals faster than news.',
+ 'One machine in hand, the world is yours.',
+ 'Limited grab—enter battle mode.',
+ 'Worst fear: not runaway, but missing great route.',
+ 'Low ping = addiction.',
+ 'Host says datacenter upgrade—I say excuse upgrade.',
+ 'Hoarding like fine tea—the longer, the better.',
+ 'Backups in hand = no panic.',
+ 'Server like old friend—tolerate the occasional mood swing.',
+ 'Perfect test = poetry, high ping = prose.',
+ 'Runaway is subplot, deals are star.',
+ 'Full panel = achievement maxed.',
+ 'Direct route pleasure—irreplaceable.',
+ 'Private deal sync—perfect score.',
+ 'Peak realm: pluck till host questions life.',
+ 'Spot huge bandwidth—dreams soar.',
+ 'Veteran calm: crash happens, just open another.',
+ 'Low ping = soul awakened.',
+ 'Host promises like clouds—backups are true love.',
+ 'Hoarding is faith, dusting is practice.',
+ 'Test sharing night = liveliest night.',
+ 'Server like pet—adopt another when one goes.',
+ 'Yearly deal drops—budget instantly blown.',
+ 'True wisdom: never hesitate on deals.',
+ 'Multiple backups = proper way.',
+ 'Ultimate server community philosophy: great routes make life great!',
+]
+
+/**
+ * 根据语言获取随机骚话
+ * @param locale 当前语言 ('zh-CN' | 'en')
+ * @returns 随机骚话字符串
+ */
+export function getRandomFunnyQuote(locale: string): string {
+ const quotes = locale.startsWith('zh') ? funnyQuotesZh : funnyQuotesEn
+ const filteredQuotes = quotes.filter(quote => !/backup|备份/i.test(quote))
+
+ // 安全检查:确保数组存在且有内容
+ if (!Array.isArray(filteredQuotes) || filteredQuotes.length === 0) {
+ return locale.startsWith('zh') ? '这是您的资源使用概览' : 'This is your resource usage overview'
+ }
+
+ const randomIndex = Math.floor(Math.random() * filteredQuotes.length)
+ return filteredQuotes[randomIndex]
+}
diff --git a/client/src/env.d.ts b/client/src/env.d.ts
new file mode 100644
index 0000000..f4d7cda
--- /dev/null
+++ b/client/src/env.d.ts
@@ -0,0 +1,8 @@
+///
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
+
diff --git a/client/src/lib/terminal-core.ts b/client/src/lib/terminal-core.ts
new file mode 100644
index 0000000..cd63075
--- /dev/null
+++ b/client/src/lib/terminal-core.ts
@@ -0,0 +1,214 @@
+import { Terminal } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import { WebLinksAddon } from '@xterm/addon-web-links'
+import { SearchAddon } from '@xterm/addon-search'
+import { Unicode11Addon } from '@xterm/addon-unicode11'
+import { WebglAddon } from '@xterm/addon-webgl'
+import { SerializeAddon } from '@xterm/addon-serialize'
+import { ClipboardAddon } from '@xterm/addon-clipboard'
+
+export interface TerminalControlMessage {
+ type: string
+ sessionId?: string
+ message?: string
+ reason?: string
+ mode?: 'exec' | 'console'
+}
+
+export interface TerminalRuntime {
+ terminal: Terminal
+ fitAddon: FitAddon
+ searchAddon: SearchAddon
+ unicodeAddon: Unicode11Addon
+ webglAddon: WebglAddon | null
+ serializeAddon: SerializeAddon
+ clipboardAddon: ClipboardAddon
+ isWebGLEnabled: boolean
+}
+
+export interface TerminalRuntimeTarget {
+ terminal: Terminal | null
+ fitAddon: FitAddon | null
+ searchAddon: SearchAddon | null
+ unicodeAddon: Unicode11Addon | null
+ webglAddon: WebglAddon | null
+ serializeAddon: SerializeAddon | null
+ clipboardAddon: ClipboardAddon | null
+ eventsBound: boolean
+ isWebGLEnabled: boolean
+}
+
+export const TERMINAL_MAX_RECONNECT_ATTEMPTS = 10
+
+const NON_RETRYABLE_CLOSE_CODES = new Set([1000, 1001, 4000, 4001, 4002, 4003, 4004])
+
+export function shouldRetryTerminalClose(code: number): boolean {
+ return !NON_RETRYABLE_CLOSE_CODES.has(code)
+}
+
+export function buildTerminalWebSocketUrl(instanceId: number, ticket: string): string {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ const host = window.location.host
+ return `${protocol}//${host}/api/ws/instances/${instanceId}/terminal?ticket=${encodeURIComponent(ticket)}`
+}
+
+export function isTerminalControlMessage(parsed: unknown): parsed is TerminalControlMessage {
+ return typeof parsed === 'object' && parsed !== null && 'type' in parsed && typeof (parsed as Record).type === 'string'
+}
+
+export function handleTerminalSocketPayload(
+ data: string | ArrayBuffer | Blob,
+ getTerminal: () => Terminal | null,
+ onControlMessage: (message: TerminalControlMessage) => void
+): void {
+ if (typeof data === 'string') {
+ try {
+ const message = JSON.parse(data)
+ if (isTerminalControlMessage(message)) {
+ onControlMessage(message)
+ return
+ }
+ } catch {
+ // ignore
+ }
+
+ getTerminal()?.write(data)
+ return
+ }
+
+ if (data instanceof ArrayBuffer) {
+ getTerminal()?.write(new Uint8Array(data))
+ return
+ }
+
+ if (data instanceof Blob) {
+ void data.arrayBuffer().then(buffer => {
+ getTerminal()?.write(new Uint8Array(buffer))
+ }).catch(() => {
+ // ignore
+ })
+ }
+}
+
+export function createTerminalRuntime(options: {
+ container: HTMLElement
+ fontSize: number
+ fontFamily: string
+ theme: Record
+ isMobileDevice: boolean
+ enableWebgl?: boolean
+ onBell?: () => void
+ onSelectionCopy?: (selection: string) => void
+ onLinkHover?: (event: MouseEvent, uri: string) => void
+ onLinkLeave?: () => void
+}): TerminalRuntime {
+ const terminal = new Terminal({
+ fontSize: options.fontSize,
+ fontFamily: options.fontFamily,
+ cursorBlink: true,
+ cursorStyle: 'bar',
+ theme: options.theme,
+ allowTransparency: false,
+ scrollback: 10000,
+ tabStopWidth: 4,
+ convertEol: false,
+ screenReaderMode: false,
+ macOptionIsMeta: true,
+ altClickMovesCursor: true,
+ allowProposedApi: true
+ })
+
+ const fitAddon = new FitAddon()
+ const webLinksAddon = new WebLinksAddon(
+ (_event, uri) => {
+ window.open(uri, '_blank', 'noopener,noreferrer')
+ },
+ {
+ hover: (event, uri) => {
+ options.onLinkHover?.(event, uri)
+ },
+ leave: () => {
+ options.onLinkLeave?.()
+ }
+ }
+ )
+ const searchAddon = new SearchAddon()
+ const unicodeAddon = new Unicode11Addon()
+ const serializeAddon = new SerializeAddon()
+ const clipboardAddon = new ClipboardAddon()
+
+ terminal.loadAddon(fitAddon)
+ terminal.loadAddon(webLinksAddon)
+ terminal.loadAddon(searchAddon)
+ terminal.loadAddon(unicodeAddon)
+ terminal.loadAddon(serializeAddon)
+ terminal.loadAddon(clipboardAddon)
+ terminal.unicode.activeVersion = '11'
+ terminal.open(options.container)
+
+ let webglAddon: WebglAddon | null = null
+ let isWebGLEnabled = false
+
+ if (options.enableWebgl !== false && !options.isMobileDevice) {
+ try {
+ const webgl = new WebglAddon()
+ webgl.onContextLoss(() => {
+ webgl.dispose()
+ })
+ terminal.loadAddon(webgl)
+ webglAddon = webgl
+ isWebGLEnabled = true
+ } catch {
+ webglAddon = null
+ isWebGLEnabled = false
+ }
+ }
+
+ if (options.onBell) {
+ terminal.onBell(options.onBell)
+ }
+
+ if (options.onSelectionCopy) {
+ terminal.onSelectionChange(() => {
+ if (!terminal.hasSelection()) return
+ const selection = terminal.getSelection()
+ if (selection) {
+ options.onSelectionCopy?.(selection)
+ }
+ })
+ }
+
+ setTimeout(() => fitAddon.fit(), 0)
+
+ return {
+ terminal,
+ fitAddon,
+ searchAddon,
+ unicodeAddon,
+ webglAddon,
+ serializeAddon,
+ clipboardAddon,
+ isWebGLEnabled
+ }
+}
+
+export function disposeTerminalRuntime(target: TerminalRuntimeTarget): void {
+ if (target.webglAddon) {
+ target.webglAddon.dispose()
+ target.webglAddon = null
+ }
+
+ target.fitAddon = null
+ target.searchAddon = null
+ target.unicodeAddon = null
+ target.serializeAddon = null
+ target.clipboardAddon = null
+
+ if (target.terminal) {
+ target.terminal.dispose()
+ target.terminal = null
+ }
+
+ target.eventsBound = false
+ target.isWebGLEnabled = false
+}
diff --git a/client/src/locales/en.ts b/client/src/locales/en.ts
new file mode 100644
index 0000000..56a78f9
--- /dev/null
+++ b/client/src/locales/en.ts
@@ -0,0 +1,7443 @@
+export default {
+ // Common
+ common: {
+ confirm: 'Confirm',
+ cancel: 'Cancel',
+ save: 'Save',
+ saving: 'Saving...',
+ send: 'Send',
+ sending: 'Sending...',
+ syncing: 'Syncing...',
+ processing: 'Processing...',
+ submitting: 'Submitting...',
+ delete: 'Delete',
+ noIncudalHint: 'Please do not use in names or descriptions',
+ edit: 'Edit',
+ create: 'Create',
+ creating: 'Creating...',
+ deleting: 'Deleting...',
+ deleteSuccess: 'Deleted successfully',
+ search: 'Search',
+ loading: 'Loading...',
+ noData: 'No data',
+ noSearchResults: 'No results found',
+ success: 'Success',
+ error: 'Error',
+ warning: 'Warning',
+ info: 'Info',
+ yes: 'Yes',
+ no: 'No',
+ back: 'Back',
+ next: 'Next',
+ previous: 'Previous',
+ close: 'Close',
+ reset: 'Reset',
+ refresh: 'Refresh',
+ copy: 'Copy',
+ copied: 'Copied',
+ copyFailed: 'Copy failed',
+ show: 'Show',
+ hide: 'Hide',
+ done: 'Done',
+ actions: 'Actions',
+ details: 'Details',
+ status: 'Status',
+ name: 'Name',
+ description: 'Description',
+ createdAt: 'Created At',
+ updatedAt: 'Updated At',
+ none: 'None',
+ notSet: 'Not Set',
+ turnstileFailed: 'Verification failed, please try again',
+ filter: 'Filter',
+ total: 'Total',
+ items: 'items',
+ searchPlaceholder: 'Search...',
+ developing: 'In Development',
+ developingHint: 'This feature is under development, stay tuned...',
+ gotIt: 'Got it',
+ expand: 'Expand',
+ collapse: 'Collapse',
+ page: 'page',
+ pageInfo: 'Page {current}/{total}, {count} total',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ loadFailed: 'Failed to load',
+ all: 'All',
+ perPage: 'Per page',
+ totalCount: 'Total {count}',
+ day: 'Day',
+ days: 'days',
+ seconds: 'seconds',
+ month: 'Month',
+ totalRecords: '{count} records in total',
+ deleted: 'Deleted',
+ // Country names
+ countries: {
+ // Asia
+ cn: 'China',
+ hk: 'Hong Kong',
+ mo: 'Macau',
+ tw: 'Taiwan',
+ jp: 'Japan',
+ kr: 'South Korea',
+ sg: 'Singapore',
+ my: 'Malaysia',
+ th: 'Thailand',
+ vn: 'Vietnam',
+ ph: 'Philippines',
+ id: 'Indonesia',
+ in: 'India',
+ pk: 'Pakistan',
+ bd: 'Bangladesh',
+ kz: 'Kazakhstan',
+ uz: 'Uzbekistan',
+ ae: 'UAE',
+ sa: 'Saudi Arabia',
+ il: 'Israel',
+ tr: 'Turkey',
+ // Europe
+ gb: 'United Kingdom',
+ de: 'Germany',
+ fr: 'France',
+ nl: 'Netherlands',
+ be: 'Belgium',
+ lu: 'Luxembourg',
+ ch: 'Switzerland',
+ at: 'Austria',
+ it: 'Italy',
+ es: 'Spain',
+ pt: 'Portugal',
+ ie: 'Ireland',
+ se: 'Sweden',
+ no: 'Norway',
+ dk: 'Denmark',
+ fi: 'Finland',
+ pl: 'Poland',
+ cz: 'Czech Republic',
+ hu: 'Hungary',
+ ro: 'Romania',
+ bg: 'Bulgaria',
+ gr: 'Greece',
+ ua: 'Ukraine',
+ ru: 'Russia',
+ // North America
+ us: 'United States',
+ ca: 'Canada',
+ mx: 'Mexico',
+ // South America
+ br: 'Brazil',
+ ar: 'Argentina',
+ cl: 'Chile',
+ co: 'Colombia',
+ pe: 'Peru',
+ // Oceania
+ au: 'Australia',
+ nz: 'New Zealand',
+ // Africa
+ za: 'South Africa',
+ eg: 'Egypt',
+ ng: 'Nigeria',
+ ke: 'Kenya',
+ },
+ // Network modes (unified definition)
+ networkMode: {
+ nat: 'IPv4 NAT',
+ nat_ipv6: 'IPv4 NAT & IPv6',
+ nat_ipv6_nat: 'IPv4 NAT & IPv6 NAT',
+ ipv6_only: 'IPv6 Only',
+ ipv6_nat: 'IPv6 NAT',
+ },
+ // Instance types
+ instanceType: {
+ container: 'LXC',
+ vm: 'KVM',
+ },
+ // Enabled/Disabled states
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ active: 'Active',
+ inactive: 'Inactive',
+ unlimited: 'Unlimited',
+ },
+
+ validation: {
+ fields: {
+ name: 'Name',
+ identifier: 'Identifier',
+ content: 'Content',
+ serverAddress: 'Server address',
+ ipAddress: 'IP address',
+ ipOrDomain: 'IP address or domain',
+ },
+ required: '{field} cannot be empty',
+ minLength: '{field} must be at least {min} characters',
+ maxLength: '{field} cannot exceed {max} characters',
+ illegalChars: '{field} contains invalid characters',
+ safeNameChars: '{field} may only contain Chinese characters, letters, numbers, hyphens, underscores, spaces, commas, and parentheses',
+ identifierChars: '{field} may only contain letters, numbers, hyphens, and underscores, and must start with a letter',
+ invalidFormat: '{field} format is invalid',
+ urlProtocol: '{field} must start with http:// or https://',
+ hostAddressInvalid: '{field} format is invalid. Enter a valid IPv4 address, IPv6 address, or domain name',
+ ipAddressInvalid: '{field} format is invalid. Enter a valid IPv4 or IPv6 address',
+ ipv4Invalid: '{field} format is invalid. Enter a valid IPv4 address',
+ },
+
+ // Navigation
+ nav: {
+ main: 'Main',
+ dashboard: 'Dashboard',
+ instances: 'Instances',
+ transfers: 'Transfers',
+ friends: 'Friends',
+ tickets: 'Tickets',
+ resources: 'Resources',
+ myHosts: 'My Hosts',
+ myPackages: 'My Packages',
+ myImages: 'My Images',
+ logs: 'Logs',
+ inbox: 'Notifications',
+ settings: 'Settings',
+ wallet: 'Wallet',
+ invites: 'Invites',
+ help: 'Help',
+ admin: 'Admin',
+ expand: 'Expand',
+ system: 'System',
+ users: 'Users',
+ statistics: 'Statistics',
+ hosts: 'Hosts',
+ images: 'Images',
+ packages: 'Packages',
+ helpManage: 'Help',
+ oauth: 'OAuth',
+ broadcast: 'Broadcast',
+ paymentProviders: 'Payment Providers',
+ billing: 'Billing',
+ withdrawals: 'Withdrawals',
+ aff: 'Referral',
+ openMenu: 'Open menu',
+ collapseSidebar: 'Collapse sidebar',
+ toggleTheme: 'Toggle theme',
+ toggleLanguage: 'Toggle language',
+ terminal: 'Terminal',
+ extensions: 'Extensions',
+ scripts: 'Scripts',
+ operations: 'Operations',
+ createInstance: 'Create Instance',
+ create: 'Create',
+ entertainment: 'Benefits',
+ hosting: 'Hosting',
+ hostingWallet: 'Hosting Wallet',
+ earnings: 'Earnings',
+ mail: 'Mail',
+ instanceDetail: 'Instance Details',
+ mailDomain: 'Mail Domain',
+ myHostCreate: 'Create Host',
+ myHostDetail: 'Host Details',
+ myPackageCreate: 'Create Package',
+ myPackageEdit: 'Edit Package',
+ telegramSettings: 'Telegram Settings',
+ adminCreateInstance: 'Admin Create Instance',
+ },
+
+ // Theme
+ theme: {
+ dark: 'Dark Mode',
+ light: 'Light Mode',
+ system: 'System',
+ },
+
+ freeSite: {
+ billingCycleLabel: {
+ monthly: 'Monthly? The moon paid it',
+ quarterly: 'Quarterly? All seasons comped',
+ semiAnnual: 'Half-year joy pass',
+ annual: 'Yearly? Wallet is asleep',
+ custom: '{months} months, paid in air',
+ free: 'Free-roam permit',
+ },
+ billingCycleShort: {
+ monthly: '/mo, no panic',
+ quarterly: '/quarter, comped',
+ semiAnnual: '/half-year joy',
+ annual: '/year, gifted',
+ custom: '/{months} months joy',
+ },
+ copy: {
+ finalPrice: 'Ceremonial total',
+ renewPrice: 'Renewal? Stamp the tiny form',
+ billingCycle: 'Joy tier',
+ needPay: 'Symbolic due',
+ originalPrice: 'Museum price tag',
+ oldDailyPrice: 'Old daily price, for archaeology',
+ newDailyPrice: 'New daily price, decorative',
+ remainingValue: 'Remaining value: priceless joy',
+ newPlanCost: 'New plan cost: air coins',
+ currentBalance: 'Balance mascot',
+ balanceAfterRenew: 'Balance after renew: suspiciously unchanged',
+ walletBalanceTab: 'Joy Balance',
+ walletLogsTab: 'Comped Ledger',
+ walletCurrentBalance: 'Current joy meter',
+ walletDescription: 'Freebie site mode is on. Recharge went out for tea; balance is guarding the lobby.',
+ walletLogsDescription: 'Balance wiggles are recorded here. Relax, the main quest is still free play.',
+ walletTotalRecharge: 'Total comps',
+ walletTotalConsume: 'Joy vaporized',
+ walletDestroyedValue: 'Destroyed souvenir value',
+ dashboardNewInstance: 'Joy Instance',
+ dashboardCreateInstance: 'Summon joy machine',
+ dashboardCreateFirst: 'Summon the first one',
+ dashboardUserBalance: 'Joy balance',
+ dashboardBalanceValue: 'Free is priceless',
+ dashboardNewContainer: 'Wallet, stay seated. We launch.',
+ instanceCreate: 'Joy Instance',
+ instanceCreateFirst: 'Summon the first one',
+ instanceBatchRenewTitle: 'Batch refresh the joy',
+ instanceBatchRenewDescription: 'In freebie site mode, renewal is just a stamp. The machines keep grinning.',
+ instanceBatchTotalAmount: 'Air total',
+ instanceBatchBalanceAfter: 'Mood after renewal',
+ instanceBatchCurrentBalance: 'Current mascot balance',
+ instanceBatchRenewAction: 'Confirm joy refill',
+ moneyJustForShow: 'Number parade',
+ marketPriceFree: 'Comped takeoff',
+ marketPlanCount: '{count} joy tiers',
+ marketCreateNow: 'Claim the chaos',
+ marketLoginToOrder: 'Sign in to claim chaos',
+ marketSelectedPlanTitle: 'Joy tier',
+ marketCycleMonthly: 'Monthly? The moon paid it',
+ marketMonthlyPrice: 'Monthly average? Joy does not average',
+ createOrderSummary: 'Comped Summary',
+ createPromoCode: 'Secret phrase',
+ createPromoPlaceholder: 'Optional. The free train already left the station.',
+ createPromoHostedDisabled: 'Hosted plans ignore secret phrases. Hop aboard.',
+ createPromoValid: 'Secret phrase accepted. Joy boost {rate}',
+ createPromoUsing: 'Secret lamp is lit',
+ createPromoBenefit: 'Discounts and commissions are performing nearby. Emotional value is clapping.',
+ createCommissionEstimate: 'Imaginary commission snack for the sharer: ¥{amount}',
+ createPlanFee: 'Price exhibit',
+ createMonthlyEquivalent: 'Proration? Joy refuses to be prorated',
+ mailPrice: 'Mail is comped too',
+ mailCheckoutTitle: 'Confirm Comped Mail',
+ mailBillingCycle: 'Joy cycle',
+ mailCheckoutAmount: 'Symbolic checkout',
+ mailCheckoutConfirm: 'Claim mail',
+ mailBalanceRequired: 'Balance is beside the point. Freebie site mode says use first.',
+ },
+ },
+
+ publicSite: {
+ brandTagline: 'Incus-powered NAT VPS platform',
+ nav: {
+ home: 'Home',
+ overview: 'Overview',
+ products: 'Browse',
+ help: 'Help',
+ },
+ actions: {
+ signIn: 'Sign in',
+ console: 'Open console',
+ consoleCompact: 'Console',
+ browseProducts: 'Browse all products',
+ browseOfficial: 'Browse official',
+ browseMarket: 'Browse marketplace',
+ viewCatalog: 'View catalog',
+ },
+ footer: {
+ description: 'Curated global LXC and KVM plans across multiple locations, with broad configurations and tiers for high-value NAT VPS choices.',
+ explore: 'Explore',
+ account: 'Account',
+ purchaseHint: 'When an unauthenticated visitor opens a purchase link, they land on the public catalog first; signed-in users still continue with the existing create flow.',
+ },
+ seo: {
+ keywords: 'Incus,NAT VPS,LXC,KVM,VPS panel,cloud server',
+ homeTitle: 'Incus-powered NAT VPS portal and control plane',
+ homeDescription: 'Curated global LXC and KVM plans across multiple locations, with broad configurations and tiers for high-value NAT VPS choices.',
+ marketTitle: 'Browse Products',
+ marketDescription: 'Browse every public product and filter NAT VPS offers by source, region, resource profile, and billing plan.',
+ marketPackageTitle: '{name} - Browse Products',
+ marketPackageDescription: 'Review the {type} profile, traffic, and product details for {name} on the public catalog.',
+ },
+ portal: {
+ badge: 'Incus Driven NAT Platform',
+ title: 'An Incus-powered NAT VPS portal and control plane',
+ description: 'Curated global LXC and KVM plans across official and hosted supply, with broader tiers and strong value NAT VPS choices.',
+ authPanelDescription: 'Stable supply, wider region coverage, and broader price bands are brought into one place so choosing a package feels more direct.',
+ authPanelFlowLabel: 'ORDER FLOW',
+ authPanelFlowTitle: 'Browse products first, sign in when ready',
+ authPanelFlowDescription: 'Signed-out visitors can inspect public products first. Shared purchase links keep the product context, and signing in resumes the existing checkout flow.',
+ authPanelTagValue: 'High value',
+ previewLabel: 'CONTROL PLANE',
+ previewTitle: 'Platform overview',
+ previewDescription: 'The public portal and signed-in console are aligned around product selection, provisioning, operations, and conversion.',
+ controlPoint1: '$ incus launch ubuntu:24.04 edge-vm',
+ controlPoint2: '# Unified entry for LXC and KVM NAT instances',
+ controlPoint3: '# Official inventory and hosted marketplace share the same browse-to-provision path',
+ packageFallback: 'Public package ready for browsing and purchase routing.',
+ stats: {
+ packages: 'Packages',
+ regions: 'Regions',
+ official: 'Official',
+ market: 'Marketplace',
+ },
+ officialTitle: 'Official direct',
+ officialDescription: 'Standardized supply with steadier expectations for users who care about baseline stability, cleaner operations, and predictable availability.',
+ officialPoint1: 'Official nodes fit workloads that want consistent supply and a more uniform operating experience.',
+ officialPoint2: 'The public-facing browse and order path is suitable for promotions, landing pages, and continuous product exposure.',
+ officialPoint3: 'After sign-in, provisioning still follows the existing instance creation flow.',
+ marketTitle: 'Hosted marketplace',
+ marketDescription: 'A wider spread of price points and regions for users chasing flexibility, more locations, and better cost efficiency.',
+ marketPoint1: 'Hosted supply is useful when you want colder regions, sharper pricing, or more diverse resource sources.',
+ marketPoint2: 'Public browsing, regional filtering, and plan inspection are aligned into the same experience.',
+ marketPoint3: 'Shared purchase links still land gracefully for signed-out visitors and preserve the existing flow for signed-in users.',
+ experienceNoLoginTitle: 'Global node coverage',
+ experienceNoLoginDescription: 'Official and hosted products are listed side by side so popular regions and broader node choices are easier to inspect from one entry point.',
+ experienceRoutingTitle: 'Broader LXC / KVM lineup',
+ experienceRoutingDescription: 'From lightweight containers to full virtual machines, resource tiers and plan coverage are wider and easier to compare.',
+ experienceThemeTitle: 'More price bands, better value',
+ experienceThemeDescription: 'The catalog keeps adding NAT VPS options across different budgets and positions so you can compare before you commit.',
+ catalogLabel: 'ECOSYSTEM',
+ catalogTitle: 'Official inventory and hosted supply in parallel',
+ catalogDescription: 'Official and hosted products are offered side by side, whether you prioritize steadier supply or want broader region and price coverage.',
+ emptyPackages: 'No public packages are available right now.',
+ browseLabel: 'CATALOG',
+ browseTitle: 'Filter the right plans by region and configuration',
+ browseDescription: 'Browse products by region, configuration, and price so new visitors can compare quickly and existing users can top up faster.',
+ },
+ market: {
+ badge: 'Public Catalog',
+ title: 'Browse all public products',
+ description: 'Filter public products by source, region, package profile, and billing plan.',
+ publicNotice: 'This page aggregates every public product and lets you filter by source, region, and resource profile.',
+ buyLinkNotice: 'You opened a purchase link while signed out. The matching product is shown here first, and after sign-in the flow still continues to the existing create page.',
+ searchPlaceholder: 'Search by package name, description, or virtualization type',
+ allRegions: 'All regions',
+ official: 'Official',
+ market: 'Marketplace',
+ noPackages: 'No public products are available right now.',
+ noResults: 'No products match the current filters.',
+ soldOut: 'Sold out',
+ inStock: 'Available',
+ free: 'Free',
+ fromMonthly: 'From ¥{price}/mo',
+ planCount: '{count} plan(s)',
+ featuresTitle: 'Package details',
+ plansTitle: 'Available plans',
+ planCycle: '{months} month(s)',
+ selectedPlanTitle: 'Selected plan',
+ customConfigTitle: 'Custom configuration',
+ customConfigDescription: 'This product does not expose fixed plans right now. After sign-in, the create page will let you choose resources within the package limits.',
+ createNow: 'Provision now',
+ loginToOrder: 'Sign in to provision',
+ loginHint: 'After sign-in, the current product still continues into instance creation.',
+ choosePackage: 'Choose a package on the left to inspect the details.',
+ summary: {
+ total: 'Public products',
+ available: 'Available',
+ regions: 'Regions',
+ source: 'Current source',
+ },
+ labels: {
+ startingPrice: 'Starting price',
+ traffic: 'Monthly traffic',
+ plans: 'Plans',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ network: 'Network mode',
+ hosts: 'Hosts',
+ nesting: 'Nesting',
+ },
+ },
+ },
+
+ // Wallet
+ wallet: {
+ title: 'Wallet',
+ description: 'Manage your account balance and recharge',
+ tabs: {
+ balance: 'Balance',
+ logs: 'Balance Logs',
+ records: 'Recharge Records',
+ },
+ currentBalance: 'Current Balance',
+ recharge: 'Recharge',
+ totalRecharge: 'Total Recharged',
+ totalConsume: 'Total Consumed',
+ totalDestroyedValue: 'Destroyed Value',
+ noLogs: 'No balance logs',
+ noRecords: 'No recharge records',
+ type: 'Type',
+ amount: 'Amount',
+ actualAmount: 'Credited',
+ estimatedAmount: 'Estimated Credit',
+ balanceAfter: 'Balance',
+ instanceOrRemark: 'Instance/Remark',
+ time: 'Time',
+ orderNo: 'Order No.',
+ completedAt: 'Completed',
+ statusLabel: 'Status',
+ paymentMethod: 'Payment Method',
+ paymentMethodType: 'Select Payment Method',
+ heleketSelectionHint: 'You will choose the specific cryptocurrency and network on the Heleket payment page. This page does not restrict the final payment currency.',
+ paymentChannel: 'Payment Channel',
+ paymentUuid: 'UUID:',
+ paymentTxid: 'TxID:',
+ paymentMethods: {
+ alipay: 'Alipay',
+ wxpay: 'WeChat Pay',
+ qqpay: 'QQ Wallet',
+ bank: 'Bank Card',
+ jdpay: 'JD Pay',
+ },
+ noProviders: 'No payment providers available',
+ amountLabel: 'Recharge Amount',
+ amountRange: 'Amount Range',
+ feeNote: 'Fee',
+ payableAmount: 'Amount Due',
+ pay: 'Pay Now',
+ void: 'Void',
+ logTypes: {
+ recharge: 'Recharge',
+ consume: 'Consume',
+ refund: 'Refund',
+ adminAdjust: 'Admin Adjustment',
+ gift: 'Gift',
+ transferFee: 'Transfer Fee',
+ transferRefund: 'Fee Refund',
+ hostingWithdraw: 'Hosting Withdrawal',
+ hostingDeduction: 'Hosting Deduction',
+ },
+ status: {
+ pending: 'Pending',
+ paid: 'Paid',
+ completed: 'Completed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+ refunded: 'Refunded',
+ },
+ loadLogsFailed: 'Failed to load balance logs',
+ showLotteryGift: 'Lottery Gift',
+ showingLotteryGift: 'Lottery Gift',
+ loadProvidersFailed: 'Failed to load payment providers',
+ loadRecordsFailed: 'Failed to load recharge records',
+ selectProvider: 'Please select a payment method',
+ invalidAmount: 'Invalid amount',
+ orderCreated: 'Order created',
+ redirecting: 'Redirecting to payment page...',
+ createOrderFailed: 'Failed to create order',
+ noPayUrl: 'Failed to get payment URL',
+ repayFailed: 'Failed to repay order',
+ orderCancelled: 'Order cancelled',
+ noRefundNotice: 'All recharges cannot be refunded to the original payment method.',
+ rechargeNotice: 'I understand that unusable instances can be destroyed and returned to panel balance, transferable instance PUSH is free, and the email can be changed by myself.',
+ cancelFailed: 'Failed to cancel order',
+ orderExpired: 'Order expired',
+ rechargeSuccess: 'Recharge successful! Balance has been credited',
+ verifyingPayment: 'Verifying payment status...',
+ paymentProcessing: 'Payment processing, please refresh later',
+ verifyFailed: 'Failed to verify payment status',
+ amountMismatch: 'Payment amount does not match order amount, please contact support',
+ natDisclaimer: 'I understand all instances are NAT-based, IP connectivity to mainland China is not guaranteed, and all recharges are non-refundable.',
+ },
+
+ // Referral Program
+ aff: {
+ title: 'Referral Program',
+ description: 'Invite friends to use your promo code and earn commission',
+ notActivated: 'Referral program available',
+ activateHint: 'You can create promo codes and share them with friends. When they use your promo code to purchase plans you have bought, you will earn commission.',
+ goRecharge: 'Go to Recharge',
+ affBalance: 'AFF Balance',
+ totalEarnings: 'Total Earnings',
+ balanceHint: 'AFF balance can only be converted to account balance for in-panel consumption and cannot be withdrawn directly.',
+ convert: 'Request Conversion',
+ myCodes: 'My Promo Codes',
+ createCode: 'Create Promo Code',
+ noCodes: 'No promo codes yet. Create one to start promoting!',
+ code: 'Promo Code',
+ plan: 'Plan',
+ discount: 'Discount',
+ commission: 'Commission',
+ usedCount: 'Usage Count',
+ earnings: 'Earnings',
+ status: 'Status',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ toggle: 'Toggle',
+ selectPlan: 'Select Plan',
+ selectPlanHint: 'Choose global promo code or plan-specific code',
+ alreadyCreated: 'Already Created',
+ globalCode: 'Global Promo Code',
+ globalCodeBadge: 'Site-wide',
+ globalCodeHint: 'Can be used for all paid packages and plans site-wide',
+ orSelectPlan: 'or select a plan-specific code',
+ discountCommission: 'Discount/Commission Rate',
+ discountCommissionHint: 'Buyer gets {discount} discount, you get {commission} commission',
+ fixedRate: 'Discount & Commission Rate',
+ fixedRateHint: 'Fixed discount and commission rate at 5%',
+ createSuccess: 'Promo code created successfully',
+ createFailed: 'Failed to create',
+ deleteCodeConfirm: 'Are you sure to delete promo code {code}?',
+ deleteCodeSuccess: 'Promo code deleted',
+ deleteCodeFailed: 'Failed to delete',
+ toggleSuccess: 'Status toggled',
+ toggleFailed: 'Failed to toggle',
+ earningsLog: 'Earnings Details',
+ noLogs: 'No earnings records yet',
+ logType: {
+ new_purchase: 'New Purchase Commission',
+ renew: 'Renewal Commission',
+ convert: 'Balance Conversion',
+ },
+ convertModal: {
+ title: 'Request Conversion',
+ currentBalance: 'Current AFF Balance',
+ amount: 'Conversion Amount',
+ minAmount: 'Minimum conversion: 0.10',
+ hint: 'After submission, it will be automatically transferred to your account balance.',
+ submit: 'Confirm Conversion',
+ success: 'Conversion successful, transferred to account balance',
+ failed: 'Submission failed',
+ invalidAmount: 'Please enter a valid conversion amount',
+ },
+ withdrawals: 'Conversion History',
+ noWithdrawals: 'No conversion history',
+ withdrawalStatus: {
+ pending: 'Pending Review',
+ approved: 'Approved',
+ rejected: 'Rejected',
+ },
+ leaderboard: {
+ title: 'AFF Leaderboard',
+ loadFailed: 'Failed to load leaderboard',
+ empty: 'No leaderboard data yet',
+ you: 'You',
+ },
+ // Instance creation page promo code input
+ promoCode: 'Promo Code',
+ promoCodeOptional: 'Promo Code (Optional)',
+ promoCodePlaceholder: 'Enter promo code (optional)',
+ promoCodeInputPlaceholder: 'Enter discount code',
+ promoCodeHostedDisabled: 'Promo codes not available for hosted nodes',
+ promoCodeValid: 'Promo code valid, {rate} discount applied',
+ promoCodeInvalid: 'Invalid promo code',
+ verifying: 'Verifying...',
+ originalPrice: 'Original Price',
+ discountAmount: 'Discount',
+ promoDiscount: 'Promo Discount',
+ finalPrice: 'Final Price',
+ usingPromoCode: 'You are using a promo code',
+ promoCodeBenefit: 'You enjoy {discount} discount, while providing about {commission}% commission to the sharer',
+ commissionEstimate: 'Estimated ¥{amount} commission for the sharer',
+ // Admin review
+ adminTitle: 'AFF Conversion Review',
+ adminDescription: 'Review user AFF balance conversion requests',
+ user: 'User',
+ userBalance: 'User AFF Balance',
+ requestAmount: 'Request Amount',
+ requestTime: 'Request Time',
+ approve: 'Approve',
+ reject: 'Reject',
+ rejectReason: 'Rejection Reason',
+ rejectReasonPlaceholder: 'Enter rejection reason',
+ approveSuccess: 'Approved, transferred to user balance',
+ approveFailed: 'Review failed',
+ rejectSuccess: 'Rejected',
+ rejectFailed: 'Rejection failed',
+ noRequests: 'No pending conversion requests',
+ filterStatus: 'Status Filter',
+ all: 'All',
+ },
+
+ popupAnnouncement: {
+ title: 'Site Notice',
+ subtitle: 'Please review this latest notice',
+ promoLabel: 'New server promo',
+ buyNow: 'Buy {name} now',
+ viewImage: 'View full image',
+ promoPlans: 'Available plans',
+ soldOut: 'Sold out',
+ dismissToday: 'Hide today',
+ dismissForever: 'Never show again',
+ },
+
+ // Language
+ language: {
+ zh: '中文',
+ en: 'English',
+ },
+
+ // Auth
+ auth: {
+ login: 'Login',
+ loginTo: 'Login',
+ logout: 'Logout',
+ register: 'Register',
+ registerTo: 'Register',
+ username: 'Username',
+ usernamePlaceholder: 'Enter username',
+ usernameOrEmail: 'Username or Email',
+ usernameOrEmailPlaceholder: 'Enter username or email',
+ password: 'Password',
+ passwordPlaceholder: 'Enter password',
+ confirmPassword: 'Confirm Password',
+ confirmPasswordPlaceholder: 'Enter password again',
+ email: 'Email',
+ emailPlaceholder: 'Enter email',
+ rememberMe: 'Remember me',
+ forgotPasswordLink: 'Forgot password',
+ contactEmail: 'Contact email',
+ noAccount: "Don't have an account?",
+ hasAccount: 'Already have an account?',
+ loginSuccess: 'Login successful',
+ logoutSuccess: 'Logged out',
+ registerSuccess: 'Registration successful',
+ invalidCredentials: 'Invalid username or password',
+ sessionExpired: 'Session expired, please login again',
+ continue: 'Continue',
+ loggingIn: 'Logging in...',
+ registering: 'Registering...',
+ orUse: 'or use',
+ oauthBindHint: 'You need to bind your account in settings first to use quick login',
+ enterUsernamePassword: 'Please enter username and password',
+ enterUsernameOrEmailPassword: 'Please enter your account and password',
+ twoFactorCode: 'Two-Factor Code',
+ twoFactorCodePlaceholder: 'Enter 6-digit code',
+ twoFactorHint: 'Enter the code from your authenticator app',
+ twoFactorOptional: 'optional',
+ twoFactorOptionalHint: 'Enter the code if you have enabled two-factor authentication',
+ recoveryCode: 'Recovery Code',
+ recoveryCodePlaceholder: 'Enter recovery code',
+ recoveryCodeHint: 'Enter the recovery code saved when setting up 2FA (one-time use)',
+ useRecoveryCode: "Can't access authenticator? Use recovery code",
+ useTotpCode: 'Use authenticator code',
+ enterRecoveryCode: 'Please enter recovery code',
+ enterTotpCode: 'Please enter verification code',
+ rememberPassword: 'Remember your password?',
+ verificationCode: 'Verification Code',
+ verificationCodePlaceholder: 'Enter 6-digit code',
+ invalidCode: 'Please enter a 6-digit code',
+ forgotPassword: {
+ title: 'Forgot Password',
+ subtitle: 'Reset your password via email verification code',
+ sendCode: 'Send Verification Code',
+ codeSent: 'Verification code sent, please check your email',
+ codeHint: 'Please enter the 6-digit code sent to your email',
+ resetPassword: 'Reset Password',
+ resetSuccess: 'Password reset successful! New password has been sent to your email, please check.',
+ twoFactorDisabled: 'Your two-factor authentication (2FA) has been automatically disabled. We recommend re-enabling it for account security.'
+ },
+ oauthNotBound: 'Please bind your {provider} account in settings first to use quick login',
+ oauthUserNotFound: 'User not found',
+ oauthAccountBanned: 'Account has been disabled',
+ oauthProviderDisabled: 'This login method has been disabled',
+ oauthTokenError: 'Failed to get authorization, please try again',
+ oauthError: 'OAuth login failed, please try again',
+ loginFailed: 'Login failed',
+ createAccount: 'Register',
+ creatingAccount: 'Creating...',
+ backToLogin: 'Back to Login',
+ registerSuccessRedirect: 'Registration successful, redirecting...',
+ inviteCode: 'Invite Code',
+ inviteCodePlaceholder: 'Enter invite code',
+ usernameHint: 'Start with a letter, 3-32 characters',
+ passwordHint: 'At least 8 characters with uppercase, lowercase and number',
+ fillAllRequired: 'Please fill in all required fields',
+ invalidEmail: 'Please enter a valid email address',
+ emailContainsIllegal: 'Email contains illegal characters',
+ passwordMismatch: 'Passwords do not match',
+ passwordTooShort: 'Password must be at least 8 characters',
+ passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
+ passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
+ passwordNeedsNumber: 'Password must contain at least one number',
+ turnstileRequired: 'Please complete the verification',
+ turnstileFailed: 'Verification failed, please try again',
+ // Email verification
+ emailCode: 'Email Verification Code',
+ emailCodePlaceholder: 'Enter 6-digit code',
+ emailCodeRequired: 'Please enter email verification code',
+ registrationClosedTitle: 'Registration is currently closed',
+ registrationClosedMessage: 'Sorry, registration is temporarily unavailable. Please contact the administrator if you need an account.',
+ registrationClosedShort: 'Registration is currently closed',
+ sendCode: 'Send Code',
+ sendingCode: 'Sending...',
+ codeSentHint: 'Verification code sent to your email, valid for 10 minutes',
+ invalidEmailCode: 'Invalid or expired verification code',
+ allowedEmailDomains: 'Only these email domains are allowed',
+ emailUsernamePlaceholder: 'username',
+ confirmEmail: 'Confirm Email Address',
+ confirmEmailMessage: 'Verification code will be sent to the following email. Please confirm the address is correct:',
+ confirmAndSend: 'Confirm & Send',
+ // Terms of Service
+ tos: {
+ title: 'Terms of Service',
+ agreePrefix: 'I have read and agree to the',
+ termsLink: 'Terms of Service',
+ mustAgree: 'Please read and agree to the Terms of Service',
+ understood: 'I Understand',
+ loadFailed: 'Failed to load Terms of Service',
+ },
+ },
+
+ // User Menu
+ userMenu: {
+ profile: 'Profile',
+ myInstances: 'My Instances',
+ logout: 'Logout',
+ },
+
+ // Quota
+ quota: {
+ hosts: 'Hosts',
+ instances: 'Instances',
+ friends: 'Friends',
+ packages: 'Packages',
+ },
+
+ // Instance
+ instance: {
+ title: 'Instances',
+ create: 'Create Instance',
+ name: 'Instance Name',
+ image: 'Image',
+ package: 'Package',
+ host: 'Host',
+ ip: 'IP Address',
+ port: 'Port',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ bandwidth: 'Bandwidth',
+ expireAt: 'Expires At',
+ expiredLabel: 'Expired',
+ freeInstanceLabel: 'Free Instance',
+ config: 'Config',
+ user: 'User',
+ details: 'Details',
+ // Resource quotas (for order summary)
+ ports: 'Ports',
+ snapshots: 'Snapshots',
+ backups: 'Backups',
+ hostAnnouncement: 'Host Announcement',
+ badgeModal: {
+ open: 'View or change instance badge',
+ kicker: 'Instance Badge',
+ title: 'Instance Badge',
+ subtitle: 'Inspect the current badge and quickly switch badges for {name}',
+ detailsTab: 'Badge Details',
+ ownedTab: 'My Badges',
+ noBadgeSeries: 'No Badge Applied',
+ noBadgeTitle: 'No badge is applied to this instance',
+ noBadgeSummary: 'This instance is currently using the default icon.',
+ noBadgeDescription: 'Switch to "My Badges" to quickly apply one of your owned badges to this instance.',
+ statusLabel: 'Current Status',
+ statusApplied: 'Applied to this instance',
+ statusNotApplied: 'No instance badge applied',
+ ownedCountLabel: 'Owned Copies',
+ openOwnedTab: 'Choose From My Badges',
+ manageUnavailable: 'This instance is not in your manageable instance list. You can only view badge details here.',
+ emptyOwnedTitle: 'No badges available for this instance yet',
+ emptyOwnedHint: 'Get badges from Entertainment first, then you can switch this instance badge here.',
+ applyCurrent: 'Apply to Current Instance',
+ replaceCurrent: 'Replace Current Badge',
+ moveCurrent: 'Move to Current Instance',
+ moveFromAvatar: 'Move From Avatar to Instance',
+ appliedHere: 'On Current Instance',
+ currentHint: 'This badge is already applied to the current instance.',
+ replaceHint: 'Applying this will replace the badge currently used by this instance.',
+ moveFromAvatarHint: 'Applying this will remove it from your current avatar first.',
+ moveFromInstanceHint: 'Applying this will remove it from instance "{name}" first.',
+ removeCurrent: 'Remove Current Instance Badge',
+ updateSuccess: 'Instance badge updated',
+ removeSuccess: 'Current instance badge removed',
+ },
+ errorBanner: {
+ title: 'Instance Error',
+ description: 'This instance is in an error state. You can destroy it directly (paid instance refunds will not incur any fees)',
+ destroyNow: 'Destroy Now',
+ confirmDestroy: 'Are you sure you want to destroy this abnormal instance? Remaining value of paid instances will be refunded to your wallet (no fees charged).',
+ },
+ actions: {
+ start: 'Start',
+ stop: 'Stop',
+ restart: 'Restart',
+ delete: 'Delete',
+ console: 'Console',
+ snapshot: 'Snapshot',
+ backup: 'Backup',
+ rename: 'Rename',
+ clone: 'Clone',
+ suspend: 'Suspend',
+ unsuspend: 'Unsuspend',
+ },
+ renameModal: {
+ title: 'Rename Instance',
+ name: 'Instance Name',
+ namePlaceholder: 'Enter new instance name',
+ cancel: 'Cancel',
+ confirm: 'Confirm',
+ renaming: 'Renaming...',
+ success: 'Instance renamed',
+ failed: 'Rename failed',
+ },
+ statusLabel: 'Status',
+ modeLabel: 'Mode',
+ quotaLabel: 'Quota',
+ trafficLabel: 'Traffic',
+ status: {
+ running: 'Running',
+ stopped: 'Stopped',
+ suspended: 'Suspended',
+ starting: 'Starting',
+ stopping: 'Stopping',
+ restarting: 'Restarting',
+ creating: 'Creating',
+ error: 'Error',
+ deleted: 'Deleted',
+ },
+ statusFilter: {
+ all: 'All Status',
+ },
+ createdAt: 'Created At',
+ manageDesc: 'Manage your container instances',
+ userInstances: 'Instances of user "{name}"',
+ clearFilter: 'Clear filter',
+ searchPlaceholder: 'Search instance name, IP...',
+ totalCount: '{count} instances',
+ noInstances: 'No instances',
+ noMatchingInstances: 'No matching instances found',
+ tryOtherKeywords: 'Try other keywords',
+ createFirstInstance: 'Create your first container instance to get started',
+ listLayout: 'List',
+ cardLayout: 'Cards',
+ order: {
+ label: 'Adjust order',
+ top: 'Move to top',
+ up: 'Move up',
+ down: 'Move down',
+ bottom: 'Move to bottom',
+ updateSuccess: 'Instance order updated',
+ updateFailed: 'Failed to save instance order',
+ },
+ confirmDelete: 'Are you sure to delete instance "{name}"? This action cannot be undone.',
+ createPage: {
+ title: 'Create Instance',
+ description: 'Select a package and configure your container instance',
+ instanceName: 'Instance Name',
+ instanceNamePlaceholder: 'my-instance',
+ selectPackage: 'Please select a package',
+ selectSshKey: 'Please select an SSH key',
+ creating: 'Creating...',
+ createSuccess: 'Instance is being created. Please check the instance list later',
+ loadFailed: 'Failed to load data',
+ loadHostsFailed: 'Failed to load available hosts',
+ loadImagesFailed: 'Failed to load available images',
+ missingSshKey: 'Missing SSH Key',
+ missingSshKeyDesc: 'An SSH key is required to create an instance. Please go to',
+ profileSettings: 'Profile Settings',
+ addSshKey: 'to add an SSH public key.',
+ quotaInsufficient: 'Quota Insufficient',
+ quotaCpu: 'CPU: Used {used}%/{limit}%, need {need}%',
+ quotaMemory: 'Memory: Used {used}/{limit} MB, need {need} MB',
+ quotaDisk: 'Disk: Used {used}/{limit} MB, need {need} MB',
+ quotaInstance: 'Instances: Reached limit {used}/{limit}',
+ packageNoHosts: 'Package has no bound hosts, please contact administrator',
+ sharedPackageNotFound: 'Shared package not found or expired, selected another available package for you',
+ quotaInfo: {
+ prefix: 'This package',
+ you: 'You',
+ maxInstances: '',
+ count: 'Count',
+ instances: ' instances',
+ unlimited: 'Unlimited',
+ cpu: 'CPU',
+ memory: 'Memory',
+ remaining: 'Package Remaining Quota',
+ },
+ resourceLimit: {
+ title: 'Insufficient resource quota, cannot create instance',
+ noInstances: 'No remaining instances (0), cannot create new instance',
+ insufficientMemory: 'Insufficient remaining memory (<128MB), cannot create instance',
+ insufficientCpu: 'Insufficient remaining CPU (<15%), cannot create instance',
+ },
+ ownPaidPackageWarning: 'This is your own paid package, you cannot create instances for yourself',
+ destroyTrafficNotice: 'Destroy limit: this instance can only be destroyed while its current-month traffic usage is below 5 GB.',
+ firstPaidInstanceNotice: 'Destroy limit: this instance can only be destroyed while its current-month traffic usage is below 5 GB.',
+ // Order Summary
+ orderSummary: 'Order Summary',
+ packageName: 'Package',
+ planName: 'Plan',
+ billingCycle: 'Billing Cycle',
+ months: 'months',
+ month: 'mo',
+ resourceConfig: 'Resource Configuration',
+ planFee: 'Plan Fee',
+ // Package Source
+ source: {
+ official: 'Official',
+ market: 'Marketplace',
+ friends: 'Friends',
+ },
+ fun: {
+ selectRegion: 'Select Region',
+ packageCount: '{count} packages',
+ selectPackage: 'Select Package',
+ selectPlan: 'Select Plan',
+ planDesc: 'Choose a paid plan that suits your needs',
+ customPlanHint: 'Can\'t find a suitable plan? Submit a ticket to request custom configuration',
+ noPlans: 'No plans available for this package',
+ planSoldOut: 'Sold Out',
+ noPackages: 'No packages available',
+ selectHost: 'Select Host',
+ hostAutoSelected: 'The first available host is selected by default',
+ selectSystem: 'Select System',
+ noImages: 'No images available',
+ selectSshKey: 'Select SSH Key',
+ },
+ // Hosted Disclaimer
+ hostedDisclaimer: {
+ title: 'Hosted Node Notice',
+ content: 'This is a user-hosted node. Support is handled by the host owner (UID:{uid}). only provides platform services with no quality guarantees. If the host owner becomes unavailable, their hosting balance will be refunded to the affected users\' panel balance.',
+ },
+ zoneNotice: {
+ badge: 'Zone',
+ content: 'This is a zone owned by UID:{uid}. After-sales support is handled by {username}.',
+ },
+ },
+ startingInstance: '{name} is starting',
+ stoppedInstance: '{name} has been stopped',
+ restartingInstance: '{name} is restarting',
+ deletedInstance: '{name} has been deleted',
+ actionFailed: 'Action failed',
+ verificationRequiredHint: 'This operation requires verification, please go to instance detail page',
+ totalRecords: '{count} records',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ batch: {
+ selectedCount: '{count} selected',
+ currentPageOnly: 'Batch actions apply to selected instances on the current page only',
+ clear: 'Clear Selection',
+ start: 'Batch Start',
+ stop: 'Batch Stop',
+ restart: 'Batch Restart',
+ sync: 'Batch Sync',
+ renew: 'Batch Renew',
+ autoRenewOn: 'Enable Auto Renew',
+ autoRenewOff: 'Disable Auto Renew',
+ destroy: 'Batch Destroy',
+ noEligibleAction: 'None of the selected instances can perform this action',
+ partialResult: 'Completed {success}, failed {failed}, skipped {skipped}',
+ successResult: 'Successfully processed {count} instance(s)',
+ actionFailed: 'Batch action failed',
+ previewFailed: 'Failed to load batch preview',
+ renewTitle: 'Batch Renew',
+ renewDescription: 'Renew selected paid instances together. Only instances supporting the chosen period will be processed.',
+ selectedMonths: 'Renewal Period',
+ eligibleCount: 'Eligible Items',
+ totalAmount: 'Total Amount',
+ renewEmpty: 'No renewable items in the current selection',
+ eligibleList: 'Eligible Instances ({count})',
+ skippedList: 'Skipped Instances ({count})',
+ hosted: 'Hosted Instance',
+ unsupportedPeriod: 'The selected period is not available for this instance',
+ unknownReason: 'Unavailable right now',
+ destroyTitle: 'Batch Destroy',
+ destroyDescription: 'Destroy the selected instances together. Only eligible instances will be processed.',
+ destroyEmpty: 'No destroyable items in the current selection',
+ refundTotal: 'Estimated Refund',
+ feeTotal: 'Estimated Fee',
+ feeWaived: 'Fee Waived',
+ confirmHint: 'Type DESTROY to confirm batch destruction',
+ confirmPlaceholder: 'Enter DESTROY to confirm',
+ },
+ batchReason: {
+ notFoundOrForbidden: 'Instance does not exist or you do not have permission',
+ freeNoRenew: 'Free instances do not require renewal',
+ billingUnavailable: 'Billing information is unavailable for this instance',
+ noRenewOptions: 'No renewal options are currently available',
+ renewWindow: 'This instance can only be renewed within {days} days before expiration',
+ renewFailed: 'Renewal failed',
+ freeNoAutoRenew: 'Free instances do not support auto-renew',
+ autoRenewAlreadyOn: 'Auto-renew is already enabled',
+ autoRenewAlreadyOff: 'Auto-renew is already disabled',
+ autoRenewFailed: 'Failed to update auto-renew setting',
+ deleted: 'Instance has already been deleted',
+ creating: 'Instance is still being created and cannot be destroyed',
+ suspended: 'Instance is suspended and cannot be destroyed until it is unsuspended',
+ destroyTrafficLimit: 'This instance cannot be destroyed because current monthly traffic usage has reached or exceeded 5G',
+ destroyFailed: 'Failed to destroy instance',
+ },
+ // Mobile card
+ mobileCard: {
+ ipAddress: 'IP Address',
+ config: 'Config',
+ disk: 'Disk',
+ traffic: 'Traffic',
+ host: 'Host',
+ user: 'User',
+ unlimited: 'Unlimited',
+ cpuCore: '% core',
+ quota: 'Quota',
+ ports: 'Ports',
+ snapshots: 'Snaps',
+ backups: 'Backups',
+ sites: 'Sites',
+ },
+ // Instance create components
+ selector: {
+ // Region selection
+ selectRegion: 'Select Region',
+ allRegions: 'All',
+ noRegions: 'No regions available',
+ packageCount: '{count} packages',
+ // Package selection
+ selectPackage: 'Select Package',
+ selectPlan: 'Select Plan',
+ planDesc: 'Choose a paid plan that suits your needs',
+ customPlanHint: 'Can\'t find a suitable plan? Submit a ticket to request custom configuration',
+ noPlans: 'No plans available for this package',
+ planSoldOut: 'Sold Out',
+ noPackages: 'No packages available',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ cores: 'cores',
+ networkMode: {
+ nat: 'NAT',
+ natDesc: 'Access via port mapping',
+ nat_ipv6: 'NAT + IPv6',
+ nat_ipv6Desc: 'NAT + Public IPv6',
+ },
+ docker: 'Nestable',
+ privileged: 'Privileged',
+ configureResources: 'Configure Resources',
+ adjustBasedOnPackage: 'Adjust freely based on package limits',
+ cpuAllowance: 'allowance',
+ accountQuota: 'Account quota',
+ packageLimit: 'Package limit',
+ selectHost: 'Select Host',
+ hostOptional: 'Optional, system will auto-assign if not selected',
+ hostAutoSelected: 'The first available host is selected by default',
+ hostTraffic: 'Monthly traffic on this host',
+ autoAssign: 'Auto Assign',
+ autoAssignDesc: 'System selects optimal node based on load',
+ available: 'Available',
+ selectSystem: 'Select System',
+ showSyncedImages: 'Showing images allowed on the selected host',
+ noImages: 'No images available',
+ noImagesOnHost: 'No images available on selected host',
+ contactAdmin: 'Please contact the host owner or administrator to adjust the image policy',
+ selectSshKey: 'Select SSH Key',
+ noSshKeys: 'No SSH keys',
+ addSshKeyHint: 'Please add an SSH public key in settings first',
+ hostInsufficient: 'Host resources insufficient',
+ hostInsufficientDesc: 'Current configuration requires {cpu}% CPU and {memory}GB memory, but no host in this package can meet the requirements.',
+ hostInsufficientSuggest: 'Try reducing instance configuration or wait for resources to be released.',
+ viewProbe: 'View Probe Monitor',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ pageInfo: 'Page {current}/{total}, {count} total',
+ // Plan resource quotas
+ ports: 'Ports',
+ snapshots: 'Snapshots',
+ backups: 'Backups',
+ sites: 'Sites',
+ bandwidth: 'Bandwidth',
+ },
+ // Paid subscription card
+ subscription: {
+ premium: 'Premium',
+ expiresAt: 'Expires At',
+ expiresIn: 'Expires In',
+ expired: 'Expired',
+ days: 'days',
+ billingCycle: 'Billing Cycle',
+ renewPrice: 'Renew Price',
+ month: 'mo',
+ monthly: 'Monthly',
+ quarterly: 'Quarterly',
+ semiAnnual: 'Semi-Annual',
+ annual: 'Annual',
+ months: 'months',
+ perMonth: '/mo',
+ perQuarter: '/qtr',
+ perHalfYear: '/6mo',
+ perYear: '/yr',
+ renew: 'Renew',
+ renewSuccess: 'Renewal successful',
+ applyAffShort: 'Promo Code',
+ applyAffTitle: 'Bind AFF Promo Code',
+ applyAffInstance: 'Instance',
+ applyAffCurrentRenewPrice: 'Current renew price',
+ applyAffCodeLabel: 'AFF promo code',
+ applyAffCodePlaceholder: 'Enter AFF promo code',
+ applyAffEffectHint: 'After binding, the discount only affects future renewal prices.',
+ applyAffNoRefundHint: 'No current-cycle price difference will be refunded or changed.',
+ applyAffOwnCodeHint: 'You can only bind another user\'s code, not your own code.',
+ applyAffSubmit: 'Confirm Binding',
+ applyAffSubmitting: 'Binding...',
+ applyAffSuccess: 'Promo code bound successfully. Future renewals will use the discount.',
+ // Auto renew
+ autoRenew: 'Auto Renew',
+ autoRenewOn: 'Auto-renew enabled',
+ autoRenewOff: 'Auto-renew not enabled',
+ autoRenewEnabled: 'Auto-renew enabled',
+ autoRenewDisabled: 'Auto-renew disabled',
+ enableAutoRenew: 'Enable Auto Renew',
+ disableAutoRenew: 'Disable Auto Renew',
+ autoRenewHint: 'Will automatically deduct from balance 24 hours before expiration',
+ autoRenewDesc: 'Once enabled, the instance will automatically renew per {cycle} cycle, costing ¥{price} each time',
+ currentStatus: 'Current Status',
+ },
+ // Instance destroy
+ destroy: {
+ button: 'Destroy',
+ title: 'Destroy Instance',
+ warning: 'This action will permanently delete the instance and all its data, including snapshots, backups, port mappings, etc. This cannot be undone.',
+ warningFree: 'This action will permanently delete the instance and all its data. This cannot be undone.',
+ rulesTitle: 'Destroy Rules',
+ rulesDesc: 'Learn about the rules and limitations of the destroy feature',
+ ruleFirstFree: 'First destroy is fee-free',
+ ruleFirstFreeDesc: 'Your first destroy operation will be exempt from fees with full refund',
+ ruleFeeRate: '{rate}% fee for subsequent destroys',
+ ruleFeeRateDesc: 'Second and subsequent destroys charge {rate}% fee',
+ ruleTrafficThreshold: 'Paid instances require monthly traffic cycle usage below 5G',
+ ruleTrafficThresholdDesc: 'If usage in the current monthly traffic cycle reaches or exceeds 5G, this paid instance cannot be destroyed',
+ ruleFreeInstance: 'Free instances can be destroyed directly',
+ ruleFreeInstanceDesc: 'Free instance destroy has no refund and does not count towards destroy quota',
+ // Preview info
+ instanceInfo: 'Instance Info',
+ instanceName: 'Instance Name',
+ hostName: 'Node',
+ planName: 'Current Plan',
+ refundInfo: 'Refund Info',
+ remainingDays: 'Remaining Days',
+ remainingValue: 'Remaining Value',
+ maxRefundable: 'Refund Cap',
+ feeRate: 'Fee Rate',
+ feeAmount: 'Fee',
+ refundAmount: 'Refund Amount',
+ firstTimeFree: 'First time fee-free',
+ freeInstanceNoRefund: 'Free instance no refund',
+ days: 'days',
+ // Confirm
+ confirmTitle: 'Confirm Destroy',
+ confirmHint: 'Please enter the instance name {name} to confirm destroy',
+ confirmPlaceholder: 'Enter instance name to confirm',
+ confirmButton: 'Confirm Destroy',
+ destroying: 'Destroying...',
+ cancel: 'Cancel',
+ // Status
+ success: 'Instance destroyed',
+ successWithRefund: 'Instance destroyed, refunded ¥{amount}',
+ failed: 'Destroy failed',
+ loadFailed: 'Failed to load destroy info',
+ // Cannot destroy reasons
+ cannotDestroy: 'Cannot Destroy',
+ },
+ // Instance detail page
+ detail: {
+ invalidId: 'Invalid instance ID',
+ notExist: 'Instance does not exist',
+ loadFailed: 'Failed to load instance',
+ tabs: {
+ info: 'Info',
+ network: 'Network',
+ traffic: 'Traffic',
+ quota: 'Quota',
+ snapshots: 'Snapshots',
+ backups: 'Backups',
+ config: 'Config',
+ logs: 'Logs',
+ },
+ task: {
+ start: 'Starting...',
+ stop: 'Stopping...',
+ restart: 'Restarting...',
+ rebuild: 'Rebuilding...',
+ recreate: 'Recreating...',
+ clone: 'Cloning...',
+ change_host: 'Changing host...',
+ },
+ actions: {
+ starting: 'Instance is starting',
+ stopped: 'Instance has been stopped',
+ restarting: 'Instance is restarting',
+ deleted: 'Instance has been deleted',
+ confirmDelete: 'Are you sure to delete instance "{name}"?\n\nThis will permanently delete the instance and all data. This action cannot be undone.',
+ actionFailed: 'Action failed',
+ taskQueued: 'Task submitted, please wait...',
+ taskInProgress: 'Instance has another task in progress, please wait',
+ rebuildSuccess: 'System rebuilt successfully, new password generated',
+ recreateSuccess: 'Instance recreated successfully, new password generated',
+ selectImageAndKey: 'Please select an image and SSH key',
+ clone: 'Clone Instance',
+ cloning: 'Cloning...',
+ cloneSuccess: 'Instance cloned successfully',
+ cloneFailed: 'Instance clone failed',
+ confirmClone: 'Are you sure you want to clone instance "{name}"?',
+ cloneNotice: 'The clone operation will create a new instance copy. The new instance will inherit all configurations from the source instance, but will be assigned new port mappings. The cloning process may take a few minutes, please be patient.',
+ stopRequired: 'Please stop the instance first',
+ stopRequiredHint: 'This operation requires the instance to be stopped',
+ suspend: 'Suspend Instance',
+ unsuspend: 'Unsuspend Instance',
+ suspending: 'Suspending...',
+ unsuspending: 'Unsuspending...',
+ suspendSuccess: 'Instance has been suspended',
+ unsuspendSuccess: 'Instance has been unsuspended',
+ syncStatus: 'Sync',
+ help: 'Help',
+ syncStatusChanged: 'Status synced: {from} → {to}',
+ syncStatusNoChange: 'Status updated, network addresses synced',
+ syncIpv4Changed: 'Internal IP updated: {from} → {to}',
+ syncProxySitesUpdated: 'Updated {count} proxy site(s) configuration',
+ confirmSuspend: 'Are you sure you want to suspend instance "{name}"?',
+ confirmSuspendNotice: 'After suspension, the instance owner will not be able to perform any operations on this instance until it is manually unsuspended.',
+ suspendReason: 'Suspension Reason',
+ suspendReasonPlaceholder: 'Enter suspension reason, will be sent to instance owner via notification...',
+ confirmUnsuspend: 'Are you sure you want to unsuspend instance "{name}"?',
+ },
+ rebuild: {
+ title: 'Rebuild',
+ noHostInfo: 'Unable to get host information',
+ noImages: 'No images are currently available on this host. Please contact the host owner or administrator to adjust the image policy.',
+ loadImagesFailed: 'Failed to load image list',
+ loadKeysFailed: 'Failed to load SSH keys',
+ },
+ recreate: {
+ title: 'Recreate Instance',
+ },
+ port: {
+ fillPrivatePort: 'Please fill in the private port',
+ added: 'Port mapping added',
+ addedBoth: 'TCP and UDP port mappings added',
+ batchAdded: '{count} port mappings added',
+ stillConflict: 'Some ports still have conflicts, please re-select',
+ deleted: 'Port mapping deleted',
+ deleteFailed: 'Delete failed',
+ confirmDelete: 'Are you sure to delete this port mapping?',
+ confirmBatchDelete: 'Are you sure to delete {count} port mappings?',
+ batchDeleted: '{count} port mappings deleted',
+ batchDeletePartial: 'Deleted {success}, {fail} failed',
+ },
+ password: {
+ loadFailed: 'Failed to load password',
+ },
+ quota: {
+ saved: 'Quota updated',
+ saveFailed: 'Save failed',
+ portExceedUsed: 'Port quota cannot be less than current usage: {used} ports used, input {input}',
+ snapshotExceedUsed: 'Snapshot quota cannot be less than current usage: {used} snapshots used, input {input}',
+ backupExceedUsed: 'Backup quota cannot be less than current usage: {used} backups used, input {input}',
+ portOutOfRange: 'Port quota must be between 1 and 1000',
+ snapshotOutOfRange: 'Snapshot quota must be between 1 and 1000',
+ backupOutOfRange: 'Backup quota must be between 1 and 1000',
+ },
+ copy: {
+ success: 'Copied to clipboard',
+ failed: 'Copy failed',
+ },
+ // Info tab
+ info: {
+ title: 'Basic Info',
+ instanceId: 'Instance ID',
+ image: 'Image',
+ host: 'Host',
+ networkMode: 'Network Mode',
+ instanceMode: 'Instance Mode',
+ nat: 'NAT',
+ ipv6: 'IPv6',
+ sshPort: 'SSH Port',
+ sshHelpTitle: 'SSH Connection Guide',
+ sshHelpIpv4: 'For IPv4 connection, go to the "Network" tab to add a port mapping for port 22, then connect using the public IP and the mapped port.',
+ sshHelpIpv6: 'For IPv6 connection, you can directly connect using the public IPv6 address and port 22 without configuring port mapping.',
+ rootPassword: 'Root Password',
+ createdAt: 'Created At',
+ expiresAt: 'Expires At',
+ suspended: 'Instance Suspended',
+ suspendedAt: 'Suspended At',
+ suspendReasonLabel: 'Reason',
+ suspendReasonExpired: 'Instance has expired, please renew to unsuspend',
+ suspendReasonDefault: 'No reason provided',
+ suspendTip: 'While suspended, the instance cannot be started, restarted, rebuilt, etc. To request unsuspension or if you have questions, please submit a ticket.',
+ copy: 'Copy',
+ show: 'Show',
+ hide: 'Hide',
+ resourceUsage: 'Resource Usage',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ inbound: 'Inbound',
+ outbound: 'Outbound',
+ ingressLimit: 'Ingress',
+ egressLimit: 'Egress',
+ hostUid: 'Host UID',
+ hostOwnerTitle: 'Host Owner Info',
+ hostOwnerEmail: 'Email',
+ hostOwnerHostCount: 'Hosted Nodes',
+ hostOwnerInstanceCount: 'Total Instances',
+ hostOwnerRegisteredDays: 'Registered',
+ includesCache: 'incl. cache',
+ cannotEditConfig: 'Instance status does not allow config changes',
+ redeem: 'Redeem',
+ redeemTitle: 'Redeem Resources',
+ },
+ // Cloud-init initialization status
+ cloudInit: {
+ initializing: 'Initializing',
+ retry: 'Retry',
+ retryUnknown: 'Retry Detection',
+ retryStalled: 'Keep Checking',
+ short: 'Init',
+ shortUnknown: 'Pending',
+ shortStalled: 'Slow',
+ clickToRetry: 'System initializing, click to retry',
+ clickToRetryUnknown: 'Cloud-init status is currently unknown, click to retry detection',
+ clickToRetryStalled: 'Initialization is taking longer than usual, click to keep checking or mark it complete manually',
+ statusUnknown: 'Status Unknown',
+ stalled: 'Initialization Slow',
+ manualComplete: 'Mark Complete',
+ manualShort: 'Done',
+ manualCompleteSuccess: 'Cloud-init has been marked complete manually',
+ },
+ // Network tab
+ network: {
+ title: 'Network Addresses',
+ privateIpv4: 'Private IPv4',
+ publicIpv4: 'Public IPv4',
+ publicIpv6: 'Public IPv6',
+ portMappings: 'Port Mappings',
+ publicIp: 'Public IP',
+ add: 'Add',
+ noQuota: 'Please allocate port quota first',
+ quotaFull: 'Port quota is full',
+ addPortMapping: 'Add port mapping',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ perPage: 'Per page',
+ filterBoth: 'Both',
+ selectAll: 'Select all on page',
+ selectedCount: '{count} selected',
+ batchDelete: 'Delete selected',
+ noFilterResults: 'No results match the current filter',
+ noPortQuota: 'No port quota allocated',
+ allocateQuotaHint: 'Please allocate port quota in the "Quota" tab first',
+ noPortMappings: 'No port mappings, click "Add" to create',
+ ipv6OnlyPortMappingHint: 'IPv6 Only instances do not support port mapping. Please use the instance IPv6 address directly.',
+ additionalIpv6: 'Additional IPv6 Addresses',
+ addIpv6: 'Add IPv6',
+ noAdditionalIpv6: 'No additional IPv6 addresses',
+ ipAdded: 'IPv6 address added successfully',
+ ipAddFailed: 'Failed to add IPv6 address',
+ ipDeleted: 'IPv6 address deleted',
+ ipDeleteFailed: 'Failed to delete IPv6 address',
+ confirmDeleteIp: 'Are you sure you want to delete this IPv6 address?',
+ // IPv6 management additions
+ primaryIpv6: 'Primary IPv6',
+ extraIpv6: 'Extra IPv6',
+ ipv6Subnets: 'IPv6 Subnets',
+ addSubnet: 'Allocate Subnet',
+ customIpv6: 'Custom IPv6',
+ randomIpv6: 'Random Assign',
+ setCustom: 'Set Custom',
+ custom: 'Custom',
+ primary: 'Primary',
+ addIpv6Modal: {
+ title: 'Add IPv6 Address',
+ randomHint: 'System will randomly assign an address from the host IPv6 subnet',
+ customHint: 'Enter your own IPv6 address (must be within the host subnet range)',
+ addressLabel: 'IPv6 Address',
+ addressPlaceholder: 'e.g. 2001:db8::1',
+ invalidAddress: 'Invalid IPv6 address format',
+ adding: 'Adding...',
+ },
+ subnetModal: {
+ title: 'Allocate IPv6 Subnet',
+ hint: 'Select the subnet size to allocate, the system will automatically assign from the available pool',
+ prefix112: '/112 (65,536 IPs)',
+ prefix120: '/120 (256 IPs)',
+ prefix124: '/124 (16 IPs)',
+ allocating: 'Allocating...',
+ allocate: 'Allocate',
+ },
+ noSubnets: 'No IPv6 subnets allocated',
+ subnetAllocated: 'IPv6 subnet allocated successfully',
+ subnetAllocateFailed: 'Failed to allocate IPv6 subnet',
+ subnetDeleted: 'IPv6 subnet deleted',
+ subnetDeleteFailed: 'Failed to delete IPv6 subnet',
+ confirmDeleteSubnet: 'Are you sure you want to delete this IPv6 subnet?',
+ customIpv6Set: 'Custom IPv6 address set successfully',
+ customIpv6Failed: 'Failed to set custom IPv6 address',
+ ipv6NotInSubnet: 'IPv6 address must be within host subnet range',
+ ipv6AlreadyExists: 'IPv6 address is already in use',
+ instanceMustRunning: 'Instance must be running to manage IPv6',
+ loading: 'Loading...',
+ // Reassign IPv6
+ reassignIpv6: 'Reassign',
+ reassignIpv6Confirm: 'Are you sure you want to reassign IPv6 address?',
+ reassignIpv6ConfirmHint: 'You need to rebuild the system for the new IPv6 to take effect',
+ reassignIpv6Success: 'IPv6 reassigned, please rebuild the system to apply',
+ reassignIpv6Failed: 'Failed to reassign IPv6',
+ reassignIpv6Loading: 'Reassigning...',
+ reassignIpv6StopRequired: 'Instance must be stopped to reassign IPv6',
+ reassignIpv6Cooldown: 'Only once per day, please wait {hours} hours',
+ reassignIpv6CooldownShort: 'In {hours}h',
+ reassignIpv6NotSupported: 'This instance does not support IPv6 reassignment',
+ },
+ // Quota tab
+ quotaTab: {
+ title: 'Instance Quota Settings',
+ portLimit: 'NAT Port Limit',
+ snapshotLimit: 'Snapshot Limit',
+ backupLimit: 'Backup Limit',
+ placeholder: 'Leave empty or enter 0 to auto-fill remaining quota',
+ currentUsage: 'Current usage',
+ quotaLimit: 'Quota limit',
+ full: 'Full',
+ remaining: 'Remaining',
+ defaultRemaining: 'Default account remaining',
+ unit: '',
+ portMappings: 'port mappings',
+ snapshots: 'snapshots',
+ backups: 'backups',
+ save: 'Save Quota Settings',
+ saving: 'Saving...',
+ },
+ },
+ // Config edit modal
+ configEdit: {
+ title: 'Edit Configuration',
+ hint: 'CPU and memory changes take effect immediately without restart. Disk can only be increased.',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ traffic: 'Traffic',
+ trafficHint: 'Leave empty for unlimited, unit is GB',
+ cores: 'cores',
+ resourceType: 'Resource',
+ current: 'current',
+ modifyTo: 'Modify to',
+ diskOnlyIncrease: 'increase only',
+ quotaInsufficient: 'Quota Insufficient',
+ quotaError: {
+ cpu: 'CPU quota insufficient: available {available}%, need {need}%',
+ memory: 'Memory quota insufficient: available {available}, need {need}',
+ disk: 'Disk quota insufficient: available {available}, need {need}',
+ },
+ success: 'Configuration updated successfully',
+ failed: 'Failed to update configuration',
+ packageRequired: 'Instance must be bound to a package to edit configuration',
+ loadPackageFailed: 'Failed to load package information, please try again later',
+ },
+ // Sites management
+ sites: {
+ title: 'Sites',
+ addSite: 'Add Site',
+ editSite: 'Edit Site',
+ addFirstSite: 'Add First Site',
+ empty: 'No proxy sites yet. Click the button above to add one.',
+ caddyNotEnabled: 'Caddy not enabled on host',
+ caddyNotEnabledHint: 'The host where this instance is located has not enabled the Caddy reverse proxy service. Website features are unavailable.',
+ domain: 'Domain',
+ domainRequired: 'Please enter domain',
+ domainHint: 'Enter the domain to bind, wildcards not supported',
+ wildcardNotAllowed: 'Wildcard domains not supported (e.g. *.example.com)',
+ targetPort: 'Target Port',
+ portHint: 'Web service port running inside the instance (e.g. 80, 3000, 8080)',
+ statusActive: 'Active',
+ statusPending: 'Pending DNS',
+ statusError: 'Error',
+ refresh: 'Refresh Config',
+ loadFailed: 'Failed to load sites',
+ addSuccess: 'Site added successfully',
+ addFailed: 'Failed to add site',
+ deleteConfirm: 'Are you sure to delete site {domain}?',
+ deleteSuccess: 'Site deleted',
+ deleteFailed: 'Failed to delete site',
+ updateSuccess: 'Site updated',
+ updateFailed: 'Failed to update site',
+ refreshSuccess: 'Configuration refreshed',
+ refreshFailed: 'Failed to refresh configuration',
+ addedSuccess: 'Domain added successfully',
+ dnsHintDesc: 'Please add the following DNS record at your DNS provider:',
+ dnsType: 'Type',
+ dnsHost: 'Host',
+ dnsValue: 'Value',
+ sslAutoHint: 'Caddy will automatically apply for SSL certificate after DNS propagation.',
+ quotaInfo: '{used} / {limit} sites used',
+ quotaFull: 'Limit reached',
+ disabled: 'Disabled',
+ enableSite: 'Enable site',
+ disableSite: 'Disable site',
+ toggleFailed: 'Failed to toggle status',
+ enableHttps: 'Enable HTTPS',
+ httpsHint: 'Auto-apply Let\'s Encrypt certificate, HTTP will redirect to HTTPS',
+ httpsEnabled: 'HTTPS enabled',
+ httpOnly: 'HTTP only',
+ checkCert: 'Check Certificate',
+ certCheckFailed: 'Failed to check certificate status',
+ checkDns: 'Check DNS',
+ dnsActivated: 'DNS verified, site activated',
+ dnsResolved: 'DNS resolved correctly',
+ dnsCheckFailed: 'DNS check failed',
+ dnsHintWithCheck: 'After DNS configuration, click "Check DNS" to activate the site',
+ remark: 'Remark',
+ remarkPlaceholder: 'Optional, e.g. Blog, API Service',
+ cert: {
+ title: 'Certificate Status',
+ valid: 'Certificate Valid',
+ disabled: 'HTTPS Not Enabled',
+ pending: 'Pending Activation',
+ certPending: 'Certificate Pending',
+ dnsError: 'DNS Not Resolved',
+ connectionRefused: 'Connection Refused',
+ timeout: 'Connection Timeout',
+ error: 'Check Failed',
+ issuer: 'Issuer',
+ validFrom: 'Valid From',
+ validTo: 'Valid To',
+ daysRemaining: 'Days Remaining',
+ days: 'days',
+ },
+ },
+ },
+
+ // Port mapping modal
+ portModal: {
+ title: 'Add Port Mapping',
+ protocol: 'Protocol',
+ bothHint: 'Will create both TCP and UDP mappings, using 2 quota slots',
+ privatePort: 'Private Port',
+ privatePortRequired: '*',
+ privatePortPlaceholder: 'Container internal port, e.g. 80, 22, 3306',
+ privatePortPlaceholderRange: 'e.g. 80 or 80-85',
+ publicPort: 'Public Port',
+ publicPortOptional: '(optional)',
+ publicPortPlaceholder: 'Leave empty for auto-assign',
+ publicPortPlaceholderRange: 'e.g. 20000 or 20000-20005',
+ publicPortHint: 'Leave empty to auto-assign an available port from the pool',
+ publicPortHintWithRange: 'Available range: {start}-{end}, leave empty to auto-assign',
+ remark: 'Remark',
+ remarkOptional: '(optional)',
+ remarkPlaceholder: 'e.g. Web Server, Database, SSH',
+ cancel: 'Cancel',
+ adding: 'Adding...',
+ add: 'Add',
+ // Range input support
+ rangeHint: 'Supports range input, e.g. 80-85',
+ invalidPortFormat: 'Invalid port format, enter a single port or port range (e.g. 80-85)',
+ ipv6OnlySshPortHint: 'Port 22 does not need a mapping. Use the public IPv6 for SSH; the front door is already lit.',
+ rangeMismatch: 'Private port count ({private}) does not match public port count ({public})',
+ publicPortOutOfRange: 'Public port is out of allowed range ({start}-{end})',
+ quotaPreview: 'Will create {count} mappings, using {quota} quota slots',
+ quotaRemaining: '{remain} remaining',
+ quotaInsufficient: 'Quota insufficient, need {need}, only {remain} remaining',
+ },
+
+ // Port conflict resolution modal
+ portConflict: {
+ title: 'Some Ports Are Occupied',
+ subtitle: '{count} port conflicts',
+ description: 'The following ports are already in use by other instances. You can modify to new ports or use system suggestions.',
+ originalPort: 'Original',
+ newPort: 'New Port',
+ occupied: 'Occupied',
+ suggested: 'Suggested',
+ rangeHint: 'Available range: {start}-{end}',
+ useSuggested: 'Use All Suggestions',
+ cancel: 'Cancel',
+ confirm: 'Confirm Changes',
+ },
+
+ // Rebuild modal
+ rebuildModal: {
+ title: 'Rebuild System',
+ dangerWarning: 'Dangerous Operation',
+ warningList: {
+ dataLoss: 'Rebuilding will erase all data in the instance',
+ snapshotLoss: 'All snapshots will be permanently deleted',
+ irreversible: 'This action cannot be undone!',
+ },
+ preserveInfo: 'Port mappings will be preserved during rebuild.',
+ manualStartHint: 'The instance needs to be started manually after the rebuild is complete.',
+ selectImage: 'Select New Image',
+ imageHint: 'Only images currently allowed on this host are available',
+ selectSshKey: 'Select SSH Key',
+ noSshKey: 'No available keys',
+ addSshKeyHint: 'Please add an SSH key in settings first',
+ passwordHint: 'A new root password will be generated after rebuild, viewable on the instance detail page.',
+ cancel: 'Cancel',
+ rebuilding: 'Rebuilding...',
+ confirmRebuild: 'Confirm Rebuild',
+ },
+
+ // Recreate instance modal
+ recreateModal: {
+ title: 'Recreate Instance',
+ dangerWarning: 'Dangerous Operation',
+ warningList: {
+ dataLoss: 'Recreating will erase all data in the instance',
+ snapshotLoss: 'All snapshots will be permanently deleted',
+ portMappingLoss: 'All port mappings will be deleted',
+ backupLoss: 'All backups and backup policies will be deleted',
+ proxySiteLoss: 'All proxy sites and snapshot policies will be deleted',
+ irreversible: 'This action cannot be undone!',
+ },
+ differenceHint: 'Unlike rebuild: no need to stop the instance first, a new instance will replace the old one.',
+ preserveInfo: 'Only billing status and quotas will be preserved.',
+ selectImage: 'Select New Image',
+ selectSshKey: 'Select SSH Key',
+ noSshKey: 'No available keys',
+ addSshKeyHint: 'Please add an SSH key in settings first',
+ cancel: 'Cancel',
+ recreating: 'Recreating...',
+ confirmRecreate: 'Confirm Recreate',
+ },
+
+ // Snapshot management
+ snapshot: {
+ title: 'Snapshots',
+ autoPolicy: 'Auto Snapshot Enabled',
+ autoPolicyEnabled: 'Auto Snapshot Enabled',
+ currentPolicy: 'Current Policy',
+ disableAutoPolicy: 'Disable',
+ minutes: 'minutes',
+ manual: 'Manual',
+ autoSettings: 'Auto Snapshot Settings',
+ create: 'Create',
+ noQuota: 'Please allocate snapshot quota first',
+ quotaFull: 'Snapshot quota is full',
+ createSnapshot: 'Create Snapshot',
+ noSnapshots: 'No snapshots',
+ noQuotaAllocated: 'No snapshot quota allocated',
+ allocateQuotaHint: 'Please allocate snapshot quota in the "Quota" tab first',
+ statefulSnapshot: 'Stateful',
+ restore: 'Restore',
+ stopInstanceFirst: 'Please stop the instance first',
+ delete: 'Delete',
+ createModal: {
+ title: 'Create Snapshot',
+ name: 'Name',
+ nameRequired: '*',
+ namePlaceholder: 'snapshot-01',
+ description: 'Description',
+ descriptionPlaceholder: 'Optional description',
+ stateful: 'Save memory state (stateful snapshot)',
+ cancel: 'Cancel',
+ creating: 'Creating...',
+ create: 'Create',
+ },
+ policyModal: {
+ title: 'Auto Snapshot Settings',
+ enable: 'Enable auto snapshots',
+ interval: 'Snapshot Interval',
+ intervalOptions: {
+ min10: 'Every 10 minutes',
+ hour1: 'Every 1 hour',
+ hour6: 'Every 6 hours',
+ hour24: 'Every 24 hours',
+ day3: 'Every 3 days',
+ },
+ quotaFromPackage: 'Quota inherited from package, current limit is {limit}. Oldest auto snapshot will be deleted when quota is full.',
+ cancel: 'Cancel',
+ saving: 'Saving...',
+ save: 'Save',
+ },
+ messages: {
+ createSuccess: 'Snapshot created',
+ createFailed: 'Create failed',
+ deleteConfirm: 'Are you sure to delete snapshot "{name}"? This action cannot be undone.',
+ deleteSuccess: 'Snapshot deleted',
+ deleteFailed: 'Delete failed',
+ restoreConfirm: 'Are you sure to restore instance to snapshot "{name}"? Current data will be overwritten.',
+ restoreSuccess: 'Snapshot restored',
+ restoreFailed: 'Restore failed',
+ stopInstanceFirst: 'Please stop the instance before restoring snapshot',
+ policySaved: 'Auto snapshot policy updated',
+ policyDisabled: 'Auto snapshot disabled',
+ policySaveFailed: 'Save failed',
+ },
+ },
+
+ // Backup management
+ backup: {
+ title: 'Backups',
+ autoPolicy: 'Auto Backup Enabled',
+ autoPolicyEnabled: 'Auto Backup Enabled',
+ currentPolicy: 'Current Policy',
+ disableAutoPolicy: 'Disable',
+ minutes: 'minutes',
+ manual: 'Manual',
+ autoSettings: 'Auto Backup Settings',
+ create: 'Create',
+ noQuota: 'Please allocate backup quota first',
+ quotaFull: 'Backup quota is full',
+ createBackup: 'Create Backup',
+ noBackups: 'No backups',
+ noQuotaAllocated: 'No backup quota allocated',
+ allocateQuotaHint: 'Please allocate backup quota in the "Quota" tab first',
+ status: {
+ creating: 'Creating',
+ ready: 'Ready',
+ error: 'Error',
+ },
+ export: 'Export',
+ preparing: 'Preparing...',
+ clickToDownload: 'Click to download',
+ downloading: 'Downloading...',
+ retry: 'Retry',
+ delete: 'Delete',
+ createModal: {
+ title: 'Create Backup',
+ name: 'Name',
+ nameRequired: '*',
+ namePlaceholder: 'backup-01',
+ description: 'Description',
+ descriptionPlaceholder: 'Optional description',
+ expiresIn: 'Expires In (optional)',
+ neverExpire: 'Never expire',
+ days7: '7 days',
+ days14: '14 days',
+ days30: '30 days',
+ days90: '90 days',
+ year1: '1 year',
+ createHint: 'Backup creation may take a few minutes, please wait.',
+ cancel: 'Cancel',
+ creating: 'Creating...',
+ create: 'Create',
+ },
+ policyModal: {
+ title: 'Auto Backup Settings',
+ enable: 'Enable auto backups',
+ interval: 'Backup Interval',
+ intervalOptions: {
+ hour1: 'Every 1 hour',
+ hour6: 'Every 6 hours',
+ hour24: 'Every 24 hours',
+ day3: 'Every 3 days',
+ },
+ quotaFromPackage: 'Quota inherited from package, current limit is {limit}. Oldest auto backup will be deleted when quota is full.',
+ cancel: 'Cancel',
+ saving: 'Saving...',
+ save: 'Save',
+ },
+ restore: 'Restore',
+ restoring: 'Restoring...',
+ rollback: 'Rollback',
+ restoreModal: {
+ title: '⚠️ Dangerous Operation - Restore Backup',
+ warning: 'This will overwrite the current instance!',
+ warningDetail: 'The restore operation will stop the current instance and replace it with the backup content. If restore fails, you can rollback to the original instance.',
+ dataLossWarning: 'The following data will be permanently deleted:',
+ dataLossItems: {
+ backups: 'All other backups of this instance',
+ snapshots: 'All snapshots of this instance',
+ },
+ nameChangeNotice: 'After restore, instance name will change to: {name} | restored:{backup}',
+ backupName: 'Backup Name',
+ instanceName: 'Target Instance',
+ cancel: 'Cancel',
+ confirm: 'Confirm Restore',
+ },
+ messages: {
+ createSuccess: 'Backup is being created...',
+ createFailed: 'Create failed',
+ deleteConfirm: 'Are you sure to delete backup "{name}"? This action cannot be undone.',
+ deleteSuccess: 'Backup deleted',
+ deleteFailed: 'Delete failed',
+ exportFailed: 'Export preparation failed',
+ downloadStarted: 'Download started',
+ downloadFailed: 'Download failed',
+ policySaved: 'Auto backup policy updated',
+ policyDisabled: 'Auto backup disabled',
+ policySaveFailed: 'Save failed',
+ restoreStarted: 'Restoring backup "{name}", please wait...',
+ restoreInProgress: 'A restore task is already in progress',
+ restoreCompleted: 'Backup restored successfully!',
+ restoreFailed: 'Restore failed',
+ rollbackCompleted: 'Rollback successful, original instance restored',
+ rollbackFailed: 'Rollback failed',
+ uploadStarted: 'Upload task created',
+ uploadInProgress: 'An upload task is already in progress',
+ uploadCompleted: 'Backup uploaded successfully!',
+ uploadFailed: 'Upload failed',
+ uploadCancelled: 'Upload task cancelled',
+ },
+ // Upload to remote storage
+ upload: 'Upload',
+ uploadRemote: 'Upload to Cloud',
+ uploadModal: {
+ title: 'Upload Backup to Remote Storage',
+ selectStorage: 'Select Storage',
+ useDefault: 'Use Default Storage',
+ noStorage: 'No storage configurations',
+ noStorageHint: 'Please configure remote storage in Settings first',
+ goToSettings: 'Go to Settings',
+ cancel: 'Cancel',
+ upload: 'Start Upload',
+ uploading: 'Uploading...',
+ },
+ uploadStatus: {
+ pending: 'Queued',
+ processing: 'Uploading',
+ completed: 'Completed',
+ failed: 'Failed',
+ queuePosition: 'Queue position: #{position}',
+ },
+ },
+
+ // Dashboard
+ dashboard: {
+ title: 'Dashboard',
+ welcome: 'Welcome back',
+ totalInstances: 'Total',
+ runningInstances: 'Running',
+ stoppedInstances: 'Stopped',
+ creatingInstances: 'Creating',
+ userBalance: 'Balance',
+ balance: 'Balance',
+ memberLevel: 'Membership',
+ memberLevelBasic: 'Basic',
+ uptimeProbe: 'Self-hosted Node Monitor',
+ statusPage: 'Status',
+ rechargeNow: 'Top Up',
+ walletDetails: 'View Wallet',
+ walletHint: 'Go to wallet to recharge',
+ instanceStatusOverview: 'Instance Status',
+ accountOverview: 'Account Overview',
+ runningHealth: 'Running Health',
+ instanceOverviewSummary: '{running}/{total} instances running, {percent}% online',
+ containerInstances: 'Containers',
+ vmInstances: 'Virtual Machines',
+ totalRecharge: 'Total Recharge',
+ totalConsume: 'Total Spend',
+ userPoints: 'Points',
+ frozenBalance: 'Frozen Balance',
+ frozenBalanceHint: 'Some balance is frozen. Check wallet details.',
+ accountReadyHint: 'Account is ready for instance creation and renewals',
+ instanceListSummary: 'Showing recent {count} of {total} instances',
+ vipProgressTitle: 'Membership Progress',
+ vipProgressToNext: '{current} → {next}',
+ vipProgressMaxed: 'You are at the highest configured membership level',
+ vipProgressNoRule: 'No next level rule configured',
+ vipProgressUnavailable: 'Membership progress is unavailable',
+ vipProgressAllHint: 'Reach {level} by meeting all conditions below',
+ vipProgressAnyHint: 'Reach {level} by meeting any condition below',
+ vipProgressSingleMetricHint: 'Reach {level} by meeting the {metric} threshold',
+ vipProgressStableHint: 'Keep your current benefits; the benefits hall can use this level later',
+ vipMetricTotalRecharge: 'Total Recharge',
+ vipMetricTotalConsume: 'Total Spend',
+ vipMetricTotalHostingIncome: 'Hosting Income',
+ vipMetricInstanceCount: 'Hosted Instances',
+ vipProgressConditionMet: 'Completed',
+ vipProgressCurrent: 'Current',
+ vipProgressTarget: 'Target',
+ vipProgressRemaining: 'Remaining',
+ vipProgressRemainingMoney: '{amount} remaining',
+ vipProgressRemainingCount: '{count} remaining',
+ resourceUsage: 'Resource Usage',
+ resourceOverview: 'Here is your resource usage overview',
+ newInstance: 'New Instance',
+ quotaUsage: 'Quota Usage',
+ increaseQuota: 'Increase Quota',
+ pinnedArticles: 'Pinned Help Articles',
+ viewAllHelp: 'View All Help',
+ myInstances: 'My Instances',
+ viewAll: 'View All',
+ viewAllInstances: 'View all {count} instances',
+ viewAllInstancesWithCount: 'View all {count} instances',
+ memoryMetric: 'Memory',
+ diskMetric: 'Disk',
+ noPublicIp: 'No public IP',
+ unknownHost: 'Unassigned host',
+ noInstances: 'No instances created yet',
+ createFirst: 'Create your first instance',
+ quickActions: 'Quick Actions',
+ createInstance: 'Create Instance',
+ newContainer: 'New container',
+ instanceList: 'Instance List',
+ manageInstances: 'Manage instances',
+ profileSettings: 'Profile',
+ accountSecurity: 'Account security',
+ helpDocs: 'Help',
+ userGuide: 'User guide',
+ greeting: {
+ morning: 'Good morning',
+ afternoon: 'Good afternoon',
+ evening: 'Good evening',
+ basicUser: '{username}',
+ memberUser: 'dear {level} {username}',
+ full: '{greeting}, {member}',
+ },
+ },
+
+ // Profile
+ profile: {
+ title: 'Profile',
+ basicInfo: 'Basic Info',
+ security: 'Security',
+ changePassword: 'Change Password',
+ currentPassword: 'Current Password',
+ newPassword: 'New Password',
+ confirmNewPassword: 'Confirm New Password',
+ twoFactor: 'Two-Factor Auth',
+ enableTwoFactor: 'Enable 2FA',
+ disableTwoFactor: 'Disable 2FA',
+ // Account section
+ account: {
+ title: 'Account',
+ username: 'Username',
+ uid: 'UID',
+ role: 'Role',
+ email: 'Email',
+ notSet: 'Not set',
+ admin: 'Admin',
+ user: 'User',
+ changeEmail: 'Change',
+ bindEmail: 'Bind',
+ emailDialog: {
+ titleChange: 'Change Email',
+ titleBind: 'Bind Email',
+ stepCurrent: 'Verify current email',
+ stepCurrentSkipped: 'No current email bound',
+ stepCurrentHint: 'Verify your current email first to confirm this action is initiated by you.',
+ stepNew: 'Verify new email',
+ stepNewHint: 'Enter your new email address and complete the verification code step.',
+ noCurrentEmailHint: 'No email is currently bound to this account. You can go directly to verifying the new email.',
+ verifyCurrentTitle: 'Current email verification',
+ verifyCurrentDesc: 'A verification code will be sent to {email}. You can continue only after it is verified.',
+ currentCode: 'Current email code',
+ currentCodePlaceholder: 'Enter the 6-digit code',
+ sendCurrentCode: 'Send code',
+ resendCurrentCode: 'Resend',
+ currentCodeSent: 'Current email verification code sent',
+ currentCodeRequired: 'Please enter the current email verification code',
+ verifyCurrentAction: 'Verify current email',
+ currentVerifiedSuccess: 'Current email verified',
+ currentVerificationExpired: 'Current email verification expired. Please verify your current email again.',
+ verifyNewTitle: 'New email verification',
+ verifyNewDesc: 'Verify the new email address. Once complete, it will become your new login email.',
+ bindEmailDesc: 'Verify the new email address to bind it to your account.',
+ newEmail: 'New email',
+ newEmailPlaceholder: 'Enter the new email address',
+ newEmailRequired: 'Please enter the new email address',
+ newEmailInvalid: 'Please enter a valid email address',
+ newEmailSame: 'The new email must be different from the current email',
+ newCode: 'New email code',
+ newCodePlaceholder: 'Enter the 6-digit code sent to the new email',
+ newCodeRequired: 'Please enter the new email verification code',
+ sendNewCode: 'Send code',
+ resendNewCode: 'Resend',
+ newCodeSent: 'New email verification code sent',
+ resendIn: 'Resend in {seconds}s',
+ confirmAction: 'Confirm',
+ updateSuccess: 'Email updated',
+ verifying: 'Verifying...',
+ submitting: 'Submitting...'
+ }
+ },
+ // Avatar
+ avatar: {
+ title: 'Avatar Style',
+ saveSuccess: 'Avatar style updated',
+ saveFailed: 'Failed to update avatar style',
+ styles: {
+ adventurer: 'Adventurer',
+ adventurerNeutral: 'Adventurer Neutral',
+ avataaars: 'Avataaars',
+ avataaarsNeutral: 'Avataaars Neutral',
+ bigEars: 'Big Ears',
+ bigEarsNeutral: 'Big Ears Neutral',
+ bigSmile: 'Big Smile',
+ bottts: 'Bottts',
+ botttsNeutral: 'Bottts Neutral',
+ croodles: 'Croodles',
+ croodlesNeutral: 'Croodles Neutral',
+ dylan: 'Dylan',
+ funEmoji: 'Fun Emoji',
+ glass: 'Glass',
+ icons: 'Icons',
+ identicon: 'Identicon',
+ initials: 'Initials',
+ lorelei: 'Lorelei',
+ loreleiNeutral: 'Lorelei Neutral',
+ micah: 'Micah',
+ miniavs: 'Miniavs',
+ notionists: 'Notionists',
+ notionistsNeutral: 'Notionists Neutral',
+ openPeeps: 'Open Peeps',
+ personas: 'Personas',
+ pixelArt: 'Pixel Art',
+ pixelArtNeutral: 'Pixel Art Neutral',
+ rings: 'Rings',
+ shapes: 'Shapes',
+ thumbs: 'Thumbs',
+ },
+ },
+ // Resource quota
+ resourceQuota: {
+ title: 'Resource Quota',
+ hosts: 'Hosts',
+ instances: 'Instances',
+ friends: 'Friends',
+ unit: '',
+ },
+ // Billing (BillingSection component)
+ billing: {
+ title: 'Account Balance',
+ currentBalance: 'Current Balance',
+ recharge: 'Recharge',
+ totalRecharge: 'Total Recharge',
+ totalConsume: 'Total Consume',
+ balanceLogs: 'Balance Logs',
+ rechargeRecords: 'Recharge Records',
+ noLogs: 'No balance logs',
+ noRecords: 'No recharge records',
+ loadLogsFailed: 'Failed to load balance logs',
+ loadProvidersFailed: 'Failed to load payment providers',
+ loadRecordsFailed: 'Failed to load recharge records',
+ selectProvider: 'Please select payment method',
+ invalidAmount: 'Invalid amount',
+ orderCreated: 'Order created',
+ redirecting: 'Redirecting to payment page...',
+ orderNo: 'Order No',
+ createOrderFailed: 'Failed to create order',
+ paymentMethod: 'Payment Method',
+ noProviders: 'No payment providers available',
+ amount: 'Recharge Amount',
+ amountRange: 'Amount Range',
+ feeNote: 'Fee',
+ pay: 'Pay Now',
+ logTypes: {
+ recharge: 'Recharge',
+ consume: 'Consume',
+ refund: 'Refund',
+ adminAdjust: 'Admin Adjust',
+ gift: 'Gift',
+ },
+ status: {
+ pending: 'Pending',
+ paid: 'Paid',
+ completed: 'Completed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+ refunded: 'Refunded',
+ },
+ },
+ // User billing (legacy)
+ userBilling: {
+ title: 'Balance & Recharge',
+ balance: 'Current Balance',
+ frozen: 'Frozen',
+ recharge: 'Recharge',
+ rechargeTitle: 'Account Recharge',
+ selectAmount: 'Select Amount',
+ customAmount: 'Custom Amount',
+ selectProvider: 'Select Payment Method',
+ noProviders: 'No payment providers available',
+ confirmRecharge: 'Confirm Recharge',
+ fee: 'Fee',
+ actual: 'Actual Credit',
+ balanceLogs: 'Balance Logs',
+ viewLogs: 'View Logs',
+ rechargeRecords: 'Recharge Records',
+ viewRecords: 'View Records',
+ noLogs: 'No balance logs',
+ noRecords: 'No recharge records',
+ amountRequired: 'Please enter recharge amount',
+ providerRequired: 'Please select payment method',
+ recharging: 'Recharging...',
+ rechargeSuccess: 'Recharge successful',
+ rechargeFailed: 'Recharge failed',
+ loadFailed: 'Load failed',
+ },
+ // Increase quota
+ increaseQuota: {
+ title: 'Increase Quota',
+ description: 'You can increase quota when usage reaches 50%',
+ type: 'Quota Type',
+ selectType: 'Select quota type to increase',
+ hosts: 'Hosts',
+ instances: 'Instances',
+ friends: 'Friends',
+ amount: 'Increase Amount',
+ hostsAmount: 'Can increase 5 each time',
+ instancesAmount: 'Can increase 50 each time',
+ friendsAmount: 'Can increase 10 each time',
+ submit: 'Submit',
+ submitting: 'Submitting...',
+ success: 'Quota increased successfully',
+ failed: 'Failed to increase quota',
+ notEligible: 'Usage rate is below 50%, cannot increase quota',
+ usageTooLow: 'Current usage: {percent}%, need to reach 50% to increase quota',
+ selectTypeFirst: 'Please select quota type first',
+ invalidAmount: 'Invalid increase amount',
+ hostsInvalid: 'Hosts can only increase 5 each time',
+ instancesInvalid: 'Instances can only increase 50 each time',
+ friendsInvalid: 'Friends can only increase 10 each time',
+ },
+ // Password section
+ password: {
+ title: 'Change Password',
+ current: 'Current Password',
+ currentPlaceholder: 'Enter current password',
+ new: 'New Password',
+ newPlaceholder: 'At least 6 characters',
+ confirm: 'Confirm New Password',
+ confirmPlaceholder: 'Enter new password again',
+ mismatch: 'Passwords do not match',
+ tooShort: 'Password must be at least 6 characters',
+ updated: 'Password updated',
+ updateFailed: 'Update failed',
+ updating: 'Updating...',
+ update: 'Update Password',
+ },
+ // Two-factor auth
+ twoFactorAuth: {
+ title: 'Two-Factor Authentication (2FA)',
+ status: 'Status',
+ enabled: 'Enabled',
+ notEnabled: 'Not enabled',
+ enable: 'Enable 2FA',
+ disable: 'Disable 2FA',
+ loading: 'Loading...',
+ description: 'After enabling 2FA, you will need to enter a verification code from your authenticator app when logging in.',
+ setup: 'Set up Two-Factor Authentication',
+ scanQrCode: 'Scan the QR code with Google Authenticator, Microsoft Authenticator, or another TOTP app',
+ manualEntry: 'Or enter the key manually',
+ saveRecoveryCodes: 'Please save the following recovery codes for account recovery when you cannot access your authenticator',
+ enterCode: 'Enter the 6-digit code from your authenticator',
+ codePlaceholder: '000000',
+ verifying: 'Verifying...',
+ confirmEnable: 'Confirm Enable',
+ cancel: 'Cancel',
+ disableTitle: 'Disable Two-Factor Authentication',
+ disableDesc: 'After disabling, you will no longer need a verification code to log in.',
+ password: 'Current Password',
+ passwordPlaceholder: 'Enter password',
+ verificationCode: 'Verification Code',
+ processing: 'Processing...',
+ confirmDisable: 'Confirm Disable',
+ recoveryCodesStatus: 'Recovery Codes Status',
+ regenerate: 'Regenerate',
+ remaining: 'Remaining',
+ used: 'Used',
+ lowCodesWarning: 'Recovery codes are running low, consider regenerating',
+ regenerateTitle: 'Regenerate Recovery Codes',
+ regenerateDesc: 'After regenerating, all old recovery codes will be invalidated.',
+ newCodesGenerated: 'New recovery codes generated, please save them',
+ generating: 'Generating...',
+ confirmGenerate: 'Confirm Generate',
+ done: 'Done',
+ enabledSuccess: 'Two-factor authentication enabled',
+ disabledSuccess: 'Two-factor authentication disabled',
+ codesRegenerated: 'Recovery codes regenerated, please save them',
+ getStatusFailed: 'Failed to get status',
+ initFailed: 'Initialization failed',
+ verifyFailed: 'Verification failed',
+ disableFailed: 'Disable failed',
+ regenerateFailed: 'Regeneration failed',
+ enterCodeError: 'Please enter a 6-digit code',
+ fillPasswordAndCode: 'Please fill in password and verification code',
+ },
+ // Sessions
+ sessions: {
+ title: 'Login Sessions',
+ logoutAll: 'Logout All Devices',
+ processing: 'Processing...',
+ loading: 'Loading...',
+ noSessions: 'No active sessions',
+ current: 'Current',
+ ip: 'IP',
+ lastActive: 'Last active',
+ revoke: 'Revoke',
+ revoked: 'Session revoked',
+ confirmLogout: 'Are you sure you want to logout?',
+ confirmLogoutAll: 'Are you sure you want to logout all devices? You will need to login again.',
+ loadFailed: 'Failed to load sessions',
+ revokeFailed: 'Failed to revoke session',
+ revokeAllFailed: 'Failed to revoke all sessions',
+ unknownDevice: 'Unknown device',
+ unknownBrowser: 'Unknown browser',
+ justNow: 'Just now',
+ minutesAgo: '{n} minutes ago',
+ hoursAgo: '{n} hours ago',
+ daysAgo: '{n} days ago',
+ },
+ // OAuth
+ oauth: {
+ title: 'Linked Accounts',
+ description: 'Link accounts for quick login',
+ bound: 'Linked',
+ notBound: 'Not linked',
+ bind: 'Link',
+ unbind: 'Unlink',
+ noProviders: 'No third-party login methods available',
+ bindSuccess: '{provider} account linked successfully',
+ unbindSuccess: '{provider} account unlinked',
+ unbindFailed: 'Unlink failed',
+ confirmUnbind: 'Are you sure to unlink {provider} account? You will not be able to login with this method after unlinking.',
+ errors: {
+ notLoggedIn: 'Please login first before linking',
+ alreadyBoundOther: 'This account is already linked to another user',
+ invalidSession: 'Session expired, please login again',
+ bindFailed: 'Link failed',
+ tokenError: 'Authorization failed, please try again',
+ providerDisabled: 'This login method has been disabled',
+ oauthError: 'Authentication failed, please try again',
+ missingCode: 'Authorization info missing, please try again',
+ },
+ },
+ // SSH Keys
+ sshKeys: {
+ title: 'SSH Public Keys',
+ description: 'Used for SSH connection to instances',
+ add: 'Add',
+ generate: 'Generate',
+ name: 'Name',
+ namePlaceholder: 'My laptop',
+ publicKey: 'Public Key',
+ publicKeyPlaceholder: 'ssh-ed25519 AAAA... or ssh-rsa AAAA...',
+ save: 'Save',
+ cancel: 'Cancel',
+ noKeys: 'No public keys',
+ addSuccess: 'Public key added',
+ addFailed: 'Add failed',
+ deleteSuccess: 'Public key deleted',
+ deleteFailed: 'Delete failed',
+ confirmDelete: 'Are you sure to delete this public key?',
+ invalidName: 'Invalid key name format',
+ generateSuccess: 'Key generated successfully',
+ generateFailed: 'Generation failed',
+ privateKeyTitle: 'Save Your Private Key',
+ privateKeyWarning: 'Please save your private key now',
+ privateKeyWarningDesc: 'We do not store your private key. Once you close this dialog, you will not be able to view it again. Please save it to a secure location.',
+ privateKeyContent: 'Private Key',
+ download: 'Download',
+ noPrivateKey: 'No private key to download',
+ copyFailed: 'Copy failed',
+ downloadFailed: 'Download failed, please copy manually',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ pageInfo: 'Page {current}/{total}, {count} total',
+ },
+ // Notifications
+ notifications: {
+ title: 'Notification Channels',
+ description: 'Receive notifications for instances, snapshots, and other events',
+ add: 'Add',
+ type: 'Type',
+ name: 'Name',
+ namePlaceholder: 'My notification',
+ save: 'Add',
+ saving: 'Adding...',
+ cancel: 'Cancel',
+ noChannels: 'No notification channels',
+ addSuccess: 'Notification channel added',
+ addFailed: 'Add failed',
+ deleteSuccess: 'Notification channel deleted',
+ deleteFailed: 'Delete failed',
+ confirmDelete: 'Are you sure to delete this notification channel?',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ enable: 'Enable',
+ disable: 'Disable',
+ toggleFailed: 'Operation failed',
+ test: 'Test',
+ testSuccess: 'Test notification sent',
+ testFailed: 'Test failed',
+ history: 'History',
+ historyTitle: 'Notification History',
+ disabledSuffix: '(disabled)',
+ statsTotal: 'Total: {count}',
+ statsSent: 'Sent: {count}',
+ statsFailed: 'Failed: {count}',
+ filterAll: 'All',
+ filterSent: 'Sent',
+ filterFailed: 'Failed',
+ loadingLogs: 'Loading...',
+ noLogs: 'No notification records',
+ statusSent: 'Sent',
+ statusFailed: 'Failed',
+ statusPending: 'Pending',
+ errorPrefix: 'Error: {error}',
+ eventTypes: {
+ snapshot_created: 'Snapshot created',
+ snapshot_restored: 'Snapshot restored',
+ snapshot_deleted: 'Snapshot deleted',
+ backup_created: 'Backup created',
+ backup_failed: 'Backup failed',
+ backup_deleted: 'Backup deleted',
+ backup_restored: 'Backup restored',
+ backup_uploaded: 'Backup uploaded',
+ instance_created: 'Instance created',
+ instance_started: 'Instance started',
+ instance_stopped: 'Instance stopped',
+ instance_deleted: 'Instance deleted',
+ auto_snapshot: 'Auto snapshot',
+ auto_backup: 'Auto backup',
+ traffic_warning: 'Traffic warning',
+ traffic_throttled: 'Traffic throttled',
+ test: 'Test notification',
+ },
+ telegram: {
+ botToken: 'Bot Token',
+ botTokenPlaceholder: '123456:ABC-...',
+ chatId: 'Chat ID',
+ chatIdPlaceholder: '-100123456789',
+ },
+ discord: {
+ webhookUrl: 'Webhook URL',
+ webhookUrlPlaceholder: 'https://discord.com/api/webhooks/...',
+ },
+ webhook: {
+ url: 'URL',
+ urlPlaceholder: 'https://example.com/webhook',
+ secret: 'Secret (optional)',
+ secretPlaceholder: 'For signature verification',
+ },
+ },
+ telegramBinding: {
+ title: 'Telegram Binding',
+ description: 'Use Telegram for private group admission and related access checks.',
+ refresh: 'Refresh',
+ refreshing: 'Refreshing',
+ unavailableTitle: 'Not enabled',
+ unavailableDescription: 'Telegram binding has not been enabled or fully configured by the admin.',
+ boundTitle: 'Bound to {name}',
+ telegramId: 'Telegram ID: {id}',
+ boundAt: 'Bound at: {date}',
+ joinHint: 'To request private group access, message {bot} with',
+ unlink: 'Unlink',
+ unlinking: 'Unlinking',
+ unboundTitle: 'Telegram not bound',
+ unboundDescription: 'Generate a link to open {bot}. Tap Start inside Telegram to finish binding.',
+ generate: 'Generate binding link',
+ generating: 'Generating',
+ openTelegram: 'Open Telegram',
+ copyLink: 'Copy link',
+ linkHint: 'The link is valid for 10 minutes. Come back and refresh after binding.',
+ expiresAt: 'Expires at: {date}',
+ generated: 'Telegram binding link generated',
+ generateFailed: 'Failed to generate binding link: {error}',
+ copied: 'Binding link copied',
+ copyFailed: 'Copy failed. Please copy the link manually.',
+ confirmUnlink: 'Unlink Telegram binding?',
+ unlinked: 'Telegram binding removed',
+ unlinkFailed: 'Failed to unlink Telegram: {error}',
+ },
+ // Remote Storage
+ storage: {
+ title: 'Remote Storage',
+ description: 'Configure WebDAV/FTP/SFTP storage for backup uploads',
+ add: 'Add',
+ name: 'Name',
+ namePlaceholder: 'My NAS',
+ type: 'Type',
+ host: 'Host',
+ port: 'Port',
+ username: 'Username',
+ password: 'Password',
+ passwordUnchanged: 'Leave empty to keep unchanged',
+ basePath: 'Base Path',
+ setAsDefault: 'Set as default',
+ default: 'Default',
+ setDefault: 'Set Default',
+ test: 'Test',
+ noConfigs: 'No storage configurations',
+ nameHostRequired: 'Name and host are required',
+ createSuccess: 'Storage configuration created',
+ updateSuccess: 'Storage configuration updated',
+ deleteSuccess: 'Storage configuration deleted',
+ saveFailed: 'Save failed',
+ deleteFailed: 'Delete failed',
+ hasActiveTasks: 'Cannot delete: there are upload tasks in progress using this storage',
+ confirmDelete: 'Are you sure to delete this storage configuration?',
+ testSuccess: 'Connection test successful',
+ testFailed: 'Connection test failed',
+ setDefaultSuccess: 'Set as default',
+ setDefaultFailed: 'Set default failed',
+ },
+ // Login history
+ loginHistory: {
+ title: 'Login History',
+ description: 'View your account login records',
+ empty: 'No login records',
+ },
+ },
+
+ // Admin
+ admin: {
+ statistics: {
+ title: 'Statistics',
+ description: 'User, instance, and billing data',
+ timezone: '{timezone}',
+ refresh: 'Refresh',
+ reload: 'Reload',
+ noData: 'No statistics available',
+ loadFailed: 'Failed to load statistics: {message}',
+ unknownError: 'Unknown error',
+ tooltip: '{label} · {value}',
+ tabs: {
+ users: 'Users',
+ instances: 'Instances',
+ billing: 'Billing',
+ },
+ periods: {
+ daily: 'Daily',
+ monthly: 'Monthly',
+ },
+ billingMetrics: {
+ recharge: 'Recharge',
+ consume: 'Spend',
+ aff: 'Referral',
+ destroyFee: 'Destroy fee',
+ },
+ ranges: {
+ last30Days: 'Last 30 days',
+ last12Months: 'Last 12 months',
+ },
+ cards: {
+ totalUsers: 'Total users',
+ recentDailyNewUsers: 'New in 30 days',
+ recentMonthlyNewUsers: 'New in 12 months',
+ averageNewUsers: 'Average new',
+ totalInstances: 'Total instances',
+ availableInstances: 'Available instances',
+ recentDailyCreatedInstances: 'Created in 30 days',
+ recentMonthlyCreatedInstances: 'Created in 12 months',
+ totalRecharge: 'Total recharge',
+ totalConsume: 'Total spend',
+ totalAff: 'Total referral',
+ totalDestroyFee: 'Total destroy fees',
+ },
+ captions: {
+ currentTotal: 'Current total',
+ dailyAggregate: 'Daily aggregate',
+ monthlyAggregate: 'Monthly aggregate',
+ dailyAverage: 'Daily average',
+ monthlyAverage: 'Monthly average',
+ nonDeletedInstances: 'Non-deleted instances',
+ notDeletedOrSuspended: 'Not deleted / not suspended',
+ completedOrders: 'Completed orders',
+ totalScope: 'Hosted and official total',
+ affCommission: 'Referral new purchase / renewal',
+ userDestroyFee: 'User instance destroy fees',
+ },
+ sections: {
+ newUsers: 'New users',
+ createdInstances: 'Created instances',
+ paidFreeInstances: 'Paid / free instances',
+ paidFreeDescription: 'Share of current non-deleted instances',
+ metricTrend: '{metric} trend',
+ billingScope: '{range}, hosted and official combined',
+ },
+ labels: {
+ paidInstances: 'Paid instances',
+ freeInstances: 'Free instances',
+ },
+ },
+ hosting: {
+ title: 'Hosting',
+ description: 'View qualified hosting owners and hosting operation data',
+ loadFailed: 'Failed to load hosting data: {message}',
+ unknownError: 'Unknown error',
+ tabs: {
+ owners: 'Hosting Owners',
+ zones: 'Zone Owners',
+ hostingVipLevels: 'Hosting VIP Levels',
+ },
+ cards: {
+ owners: 'Hosting Owners',
+ ownersCaption: 'Users who meet hosting criteria',
+ hosts: 'Hosts',
+ hostsCaption: 'Hosts added by owners',
+ instances: 'Instances',
+ instancesCaption: 'Non-deleted instances on owner hosts',
+ totalIncome: 'Historical Hosting Income',
+ totalIncomeCaption: 'Accumulated income logs',
+ },
+ owners: {
+ title: 'Hosting Owner Users',
+ description: 'Users with hosts, listed public packages, and non-zero historical hosting balance.',
+ searchPlaceholder: 'Search username, ID, or email...',
+ empty: 'No hosting owners',
+ emptyHint: 'Users must meet the host, listed package, and historical hosting balance criteria.',
+ user: 'User',
+ vipLevel: 'VIP Level',
+ hostingBalance: 'Hosting Balance',
+ frozenBalance: 'Frozen Balance',
+ totalIncome: 'Historical Income',
+ hostCount: 'Hosts',
+ packageCount: 'Listed Packages',
+ instanceCount: 'Instances',
+ createdAt: 'Registered At',
+ noEmail: 'No email',
+ },
+ zones: {
+ title: 'Zone Owners',
+ description: 'Zone owners appear as separate tabs on the instance creation page, and their packages are removed from the marketplace tab.',
+ createTitle: 'Add Zone Owner',
+ createDescription: 'Enter a zone name, select a hosting owner, and paste a hosted logo URL.',
+ name: 'Zone Name',
+ namePlaceholder: 'e.g. Tokyoo Zone',
+ owner: 'Hosting Owner',
+ ownerSearchPlaceholder: 'Search hosting owners...',
+ selectOwner: 'Select a hosting owner',
+ ownerHint: 'Owners that already have a zone are hidden from the selectable list.',
+ logo: 'Logo URL',
+ logoPlaceholder: 'https://example.com/logo.png',
+ logoHint: 'Use an http or https image URL. The system does not store image files.',
+ logoPreview: 'Logo preview',
+ noLogo: 'Logo',
+ previewName: 'Zone preview',
+ previewHint: 'Shown as a round logo on the instance creation page.',
+ create: 'Add Zone',
+ formRequired: 'Please enter a zone name, select a hosting owner, and enter a logo URL',
+ logoInvalid: 'Please enter a valid http or https logo image URL',
+ createSuccess: 'Zone owner added',
+ createFailed: 'Failed to add zone owner',
+ loadFailed: 'Failed to load zone owners: {message}',
+ deleteConfirm: 'Delete zone "{name}"? Its packages will appear in the marketplace tab again.',
+ deleteSuccess: 'Zone owner deleted',
+ deleteFailed: 'Failed to delete zone owner',
+ empty: 'No zone owners',
+ emptyHint: 'Added zones appear between Official and Marketplace on the instance creation page.',
+ zone: 'Zone',
+ },
+ },
+ vipRules: {
+ userTitle: 'User VIP Levels',
+ userDescription: 'Choose either cumulative recharge or cumulative spending as the global user membership metric, up to VIP10.',
+ hostingTitle: 'Hosting VIP Levels',
+ hostingDescription: 'Dynamically calculate hosting membership by cumulative hosting income and current hosted instance count, up to VIP10.',
+ level: 'Level',
+ badgeBgColor: 'Badge Background',
+ badgeTextColor: 'Badge Text',
+ enabled: 'Enabled',
+ mode: 'Condition Mode',
+ modeAny: 'Any condition',
+ modeAll: 'All conditions',
+ userMetricTitle: 'User VIP Metric',
+ userMetricHint: 'User VIP levels use one global metric across the site. After switching, each level only saves and calculates the threshold for the selected metric.',
+ metricRecharge: 'By Recharge',
+ metricConsume: 'By Spending',
+ minRecharge: 'Cumulative recharge (CNY)',
+ minConsume: 'Cumulative spending (CNY)',
+ minHostingIncome: 'Cumulative hosting income (CNY)',
+ minHostingInstances: 'Current hosted instances',
+ noLimit: 'No limit',
+ save: 'Save Rules',
+ saveSuccess: 'VIP level rules saved',
+ saveFailed: 'Failed to save VIP level rules',
+ loadFailed: 'Failed to load VIP level rules: {message}',
+ unknownError: 'Unknown error',
+ conditionRequired: 'VIP{level} requires at least one condition',
+ moneyThresholdInvalid: 'VIP{level} money thresholds must be numbers greater than 0',
+ instanceThresholdInvalid: 'VIP{level} instance threshold must be a positive integer',
+ colorInvalid: 'VIP{level} badge colors must use #RRGGBB format',
+ },
+ vipBenefits: {
+ title: 'Membership Benefits Hall',
+ description: 'Configure claimable benefits for enabled user VIP levels. Balance and points are delivered automatically; instance rewards create pending delivery records.',
+ save: 'Save Benefits',
+ saveSuccess: 'Membership benefits saved',
+ saveFailed: 'Failed to save membership benefits',
+ loadFailed: 'Failed to load membership benefits',
+ loadPlansFailed: 'Failed to load package plans',
+ noEnabledLevels: 'No user VIP levels are enabled yet. Enable levels in User VIP Levels first.',
+ levelTitle: 'VIP{level} Benefits',
+ levelHint: 'Users can claim these rewards after reaching this level.',
+ addReward: 'Add Reward',
+ noRewardsForLevel: 'No rewards configured for this level.',
+ rewardDefaultTitle: 'Benefit',
+ rewardTitle: 'Reward Title',
+ rewardType: 'Reward Type',
+ rewardDescription: 'Reward Description',
+ claimLimit: 'Claim Limit',
+ sortOrder: 'Sort Order',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ types: {
+ balance: 'Balance',
+ points: 'Points',
+ instance: 'Instance',
+ },
+ balanceTitle: 'Bonus Balance',
+ balanceDesc: 'Credits the user account balance for renewals or new instances.',
+ balanceAmount: 'Bonus amount (CNY)',
+ pointsTitle: 'Bonus Points',
+ pointsDesc: 'Credits the user points account for benefits and point spending.',
+ pointsAmount: 'Points',
+ instanceTitle: 'Package Instance',
+ instanceDesc: 'Select a package plan. Claims become pending delivery records and can be automated later.',
+ package: 'Package',
+ plan: 'Plan',
+ selectPackage: 'Select package',
+ selectPlan: 'Select plan',
+ instanceDays: 'Gift days',
+ instanceQuantity: 'Quantity',
+ balancePreview: 'Balance {amount}',
+ pointsPreview: 'Points {amount}',
+ instancePreview: 'Package instance',
+ instancePreviewWithPlan: '{plan} · {quantity} instance(s) · {days} day(s)',
+ titleRequired: 'VIP{level} reward title is required',
+ claimLimitInvalid: 'VIP{level} claim limit must be a positive integer',
+ amountInvalid: 'VIP{level} balance or points reward amount must be greater than 0',
+ balanceInvalid: 'VIP{level} bonus balance must be greater than 0',
+ pointsInvalid: 'VIP{level} bonus points must be a positive integer',
+ instancePlanRequired: 'VIP{level} package instance benefit requires a package and plan',
+ instanceDaysInvalid: 'VIP{level} gift days must be a positive integer',
+ instanceQuantityInvalid: 'VIP{level} instance quantity must be a positive integer',
+ },
+ // Mail management
+ mail: {
+ title: 'Mail Management',
+ description: 'Manage mail sources, plans and subscriptions',
+ tabs: {
+ sources: 'Sources',
+ plans: 'Plans',
+ subscriptions: 'Subscriptions',
+ domains: 'Domains',
+ },
+ createSource: 'Add Source',
+ editSource: 'Edit Source',
+ sourceCreated: 'Source created',
+ sourceUpdated: 'Source updated',
+ sourceDeleted: 'Source deleted',
+ confirmDeleteSource: 'Are you sure to delete source {name}? Make sure no plans are linked.',
+ noSources: 'No sources, please add one first',
+ createPlan: 'Add Plan',
+ editPlan: 'Edit Plan',
+ planCreated: 'Plan created',
+ planUpdated: 'Plan updated',
+ planDeleted: 'Plan deleted',
+ confirmDeletePlan: 'Are you sure to delete plan {name}?',
+ noPlans: 'No plans, please add one first',
+ noSubscriptions: 'No subscriptions',
+ noDomains: 'No domains',
+ searchSubscriptions: 'Search by username, email or ID...',
+ searchDomains: 'Search by domain, username, email or ID...',
+ fillRequired: 'Please fill in required fields',
+ createSourceFirst: 'Please create a source first',
+ region: 'Region',
+ apiEndpoint: 'API Endpoint',
+ apiKey: 'API Key',
+ smtpHost: 'SMTP Host',
+ smtpPort: 'SMTP Port',
+ webmailUrl: 'Webmail URL',
+ sourcePlaceholder: 'e.g. US Datacenter',
+ source: 'Source',
+ domainLimit: 'Domain Limit',
+ diskLimit: 'Disk Limit',
+ diskLimitGb: 'Disk Limit (GB)',
+ price: 'Price',
+ billingCycle: 'Billing Cycle',
+ planPlaceholder: 'e.g. Basic',
+ user: 'User',
+ plan: 'Plan',
+ expiresAt: 'Expires At',
+ domain: 'Domain',
+ accounts: 'Accounts',
+ plans: 'Plans',
+ unsub: {
+ button: 'Unsubscribe',
+ title: 'Cancel Subscription',
+ refundType: 'Refund Type',
+ refundNone: 'No Refund',
+ refundNoneDesc: 'Cancel subscription without any refund',
+ refundFull: 'Full Refund',
+ refundFullDesc: 'Refund full plan price {amount}',
+ refundRemaining: 'Remaining Value Refund',
+ refundRemainingDesc: 'Refund based on remaining subscription time',
+ reason: 'Refund Reason',
+ reasonPlaceholder: 'Enter refund reason, will be recorded in balance log...',
+ reasonRequired: 'Reason is required when refunding',
+ confirm: 'Confirm Cancel',
+ success: 'Subscription cancelled',
+ successWithRefund: 'Subscription cancelled, refunded ¥{amount}',
+ },
+ },
+ // Admin create instance
+ instanceCreate: {
+ title: 'Create Instance',
+ description: 'Create instance for user as a gift, no payment required',
+ targetUser: 'Target User',
+ usernamePlaceholder: 'Enter username',
+ usernameHint: 'Instance will be created under this user account',
+ userHint: 'Instance will be created under this user account',
+ checkUser: 'Check',
+ checking: 'Checking...',
+ userFound: 'User found (ID: {id})',
+ userNotFound: 'User not found',
+ userNotFoundHint: 'Please check if the username is correct',
+ noSshKey: 'This user has no SSH key set',
+ noSshKeyHint: 'Please ask the user to add an SSH key in their profile settings first',
+ orderSummary: 'Gift Summary',
+ freeGift: 'Free Gift',
+ freeGiftHint: 'This instance is created by admin for free, not counted in billing',
+ createFor: 'Create for',
+ create: 'Create Instance',
+ creating: 'Creating...',
+ submit: 'Create Instance',
+ success: 'Instance created successfully',
+ selectUser: 'Please select a target user first',
+ createSuccess: 'Instance created, will appear in user {username}\'s instance list',
+ createFailed: 'Failed to create instance',
+ selectUserFirst: 'Please check and confirm the target user first',
+ packageScope: {
+ official: 'Official Packages',
+ hosted: 'Hosted Packages',
+ },
+ // Paid instance related
+ instanceType: 'Instance Type',
+ freeInstance: 'Free Instance',
+ paidInstance: 'Paid Instance',
+ selectPlan: 'Select Plan',
+ noPlanHint: 'No available plans for this package',
+ chargeFirstMonth: 'Charge First Month',
+ chargeFirstMonthHint: 'Deduct first month fee from user balance',
+ noChargeFirstMonthHint: 'First month free, regular billing from next month',
+ planPrice: 'Monthly Price',
+ setupFee: 'Setup Fee',
+ totalCharge: 'Total Charge',
+ freeFirstMonth: 'First Month Free',
+ userBalance: 'User Balance',
+ insufficientBalance: 'Insufficient user balance',
+ paidSummary: 'Paid Summary',
+ paidInstanceHint: 'Create paid instance, billed by plan',
+ },
+ // Broadcast announcement
+ broadcast: {
+ title: 'Site-wide Announcement',
+ description: 'Send notification to all active users',
+ messageTitle: 'Announcement Title',
+ titlePlaceholder: 'Enter announcement title',
+ titleRequired: 'Please enter announcement title',
+ titleTooLong: 'Title exceeds 200 characters',
+ messageContent: 'Announcement Content',
+ contentPlaceholder: 'Enter announcement content',
+ contentRequired: 'Please enter announcement content',
+ contentTooLong: 'Content exceeds 5000 characters',
+ send: 'Send Announcement',
+ sendSuccess: 'Successfully sent to {count} users',
+ sendFailed: 'Failed to send',
+ hint: 'Announcement will be sent to all users with "Active" status.',
+ // History
+ history: 'History',
+ noHistory: 'No sending records',
+ recipients: 'Sent to {count} recipients',
+ sender: 'Sender',
+ types: {
+ system_broadcast: 'Site Broadcast',
+ host_broadcast: 'Host Notification',
+ admin_message: 'Admin Message',
+ host_message: 'Host Owner Message',
+ },
+ },
+ // System settings
+ system: {
+ title: 'System Settings',
+ description: 'Configure system initial parameters and defaults',
+ tabs: {
+ system: 'System Settings',
+ popupAnnouncement: 'Popup Notice',
+ telegram: 'Telegram Settings',
+ },
+ sections: {
+ access: {
+ title: 'Access & Registration',
+ description: 'Manage registration, invite generation, default quotas, and instance transfer rules',
+ },
+ hosting: {
+ title: 'Hosting & Sites',
+ description: 'Manage hosting access, hosting notices, and free-site gift rules',
+ },
+ brand: {
+ title: 'Brand & Appearance',
+ description: 'Manage system name, logo, avatar service, and footer contact links',
+ },
+ security: {
+ title: 'Security Verification',
+ description: 'Manage Turnstile verification and registration email domain allowlists',
+ },
+ mail: {
+ title: 'Mail Service',
+ description: 'Manage SMTP delivery settings and test emails',
+ },
+ tickets: {
+ title: 'Tickets & Attachments',
+ description: 'Manage ticket access and ticket image storage settings',
+ },
+ },
+ popupAnnouncement: {
+ title: 'Popup Notice',
+ description: 'Configure the site-wide notice shown when users visit the website.',
+ content: 'Notice content',
+ placeholder: 'Enter popup notice content here. Leave it empty and save to delete the notice and stop broadcasting.',
+ hint: 'Users can hide it for today or never show it again. When the notice is updated, it will be shown as a new notice.',
+ promoTitle: 'Image Promo Popup',
+ promoDescription: 'Configure a new server promotion popup with an image and a package purchase action.',
+ promoImageUrl: 'Image URL',
+ promoImagePlaceholder: 'https://example.com/promo.jpg',
+ promoImageHint: 'Use a clear banner image. The popup shows it in full while preserving its aspect ratio.',
+ promoPackage: 'Target Package',
+ promoPackagePlaceholder: 'Select a package to promote',
+ promoPackageLoading: 'Loading packages...',
+ promoPackageEmpty: 'No promotable packages',
+ promoPackageHint: 'The promo popup is shown only when both image URL and target package are configured.',
+ promoPreview: 'Frontend preview',
+ promoPreviewEmpty: 'Enter an image URL to preview it',
+ promoNoPackage: 'No package selected',
+ promoPackageFallback: 'Target package',
+ },
+ defaultQuota: 'Default User Quota',
+ defaultQuotaDesc: 'New users will automatically receive the following quota limits',
+ quotaHost: 'Default Host Quota',
+ quotaHostDesc: 'Default number of hosts for new users',
+ quotaFriend: 'Default Friend Quota',
+ quotaFriendDesc: 'Default number of friends for new users (0 = not authorized)',
+ quotaPackage: 'Default Package Quota',
+ quotaPackageDesc: 'Default number of packages for new users (0 = not authorized)',
+ registration: 'Registration',
+ registrationDesc: 'Configure user registration options',
+ registrationEnabled: 'Registration Access',
+ registrationEnabledDesc: 'When disabled, new users cannot register, while existing users can still sign in normally',
+ registrationOpen: 'Registration Open',
+ registrationClosed: 'Registration Closed',
+ requireInviteCode: 'Require Invite Code',
+ requireInviteCodeDesc: 'When enabled, users must enter an invite code to register',
+ openRegistration: 'Open',
+ inviteOnly: 'Invite Only',
+ hostingFeature: {
+ title: 'Hosting Feature',
+ description: 'Control whether host and hosting earnings entry points are shown to new users. Users who have already created a host always keep access.',
+ enable: 'Show host entry points',
+ enableDesc: 'When disabled, users who have never created a host will not see host and earnings entry points. Existing host owners keep access.',
+ marketEntry: 'Show hosted package purchase entry',
+ marketEntryDesc: 'Controls whether hosted zones and hosted package purchase entry points are shown on the create instance page. When disabled, users can only select official packages there.',
+ marketEntryVisible: 'Entry visible',
+ marketEntryHidden: 'Entry hidden',
+ notice: 'Hosting Notice',
+ noticePlaceholder: 'Enter the hosting notice here. Leave empty to hide it on the frontend.',
+ noticeHint: 'Shown on the hosting earnings page. Line breaks are supported. Leave empty to hide it.',
+ visibleToAll: 'Visible to all users',
+ hiddenForNewUsers: 'Hidden for new users',
+ },
+ brand: {
+ title: 'Brand Settings',
+ description: 'Configure the system name, subtitle, and logo. Leave empty to use the default brand.',
+ name: 'System Name',
+ nameDesc: 'Shown in the sidebar, top bar, auth pages, and SEO metadata.',
+ subtitle: 'Site Subtitle',
+ subtitlePlaceholder: 'Incus-powered NAT VPS platform',
+ subtitleDesc: 'Shown in the public header/footer, default browser title, and default SEO description.',
+ logo: 'Logo URL',
+ logoDesc: 'Supports http(s) image URLs or absolute site paths. Leave empty to use the default logo.',
+ },
+ ticket: {
+ title: 'Ticket Settings',
+ description: 'Control whether regular users can create support tickets.',
+ enable: 'Enable Tickets',
+ enableDesc: 'When disabled, the user-side ticket entry is hidden and regular users cannot create tickets through the API.',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ },
+ freeSite: {
+ title: 'Freebie Site',
+ description: 'Control whether recharge and affiliate features are shown on the user wallet page.',
+ enable: 'Enable Freebie Site',
+ enableDesc: 'When enabled, the user wallet hides recharge buttons, recharge records, and affiliate plans. Recharge order creation and repayment are blocked by the API.',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ registerGift: 'Registration Gift',
+ registerGiftDesc: 'When enabled, new users receive balance and points after successful registration.',
+ giftEnabled: 'Giving',
+ giftDisabled: 'Off',
+ giftBalance: 'Gift Balance',
+ giftBalanceDesc: 'Added to the account balance after registration, in yuan.',
+ giftPoints: 'Gift Points',
+ giftPointsDesc: 'Added to the entertainment points account after registration.',
+ giftRequiresFreeSite: 'Enable Freebie Site before configuring registration gifts.'
+ },
+ unitCount: '',
+ reset: 'Reset',
+ save: 'Save Config',
+ saving: 'Saving...',
+ loadFailed: 'Failed to load config',
+ saveSuccess: 'Config saved',
+ saveFailed: 'Save failed',
+ notes: 'Notes',
+ note1: 'Changing default quotas only affects newly registered users, not existing users.',
+ note2: 'To modify existing user quotas, go to "User Management" page.',
+ note3: 'Host quota limits the number of hosts a user can create (0 = feature not authorized).',
+ note4: 'Friend quota limits the number of friends a user can add (0 = feature not authorized).',
+ note5: 'Package quota limits the number of packages a user can create (0 = feature not authorized).',
+ // Turnstile config
+ turnstile: {
+ title: 'Cloudflare Turnstile',
+ description: 'Configure bot protection for login, registration and sensitive operations',
+ enable: 'Enable Turnstile',
+ enableDesc: 'When enabled, users must complete verification for login, registration and sensitive operations',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ siteKey: 'Site Key',
+ siteKeyPlaceholder: 'Enter Cloudflare Turnstile Site Key',
+ siteKeyDesc: 'Site key used by frontend',
+ secretKey: 'Secret Key',
+ secretKeyPlaceholder: 'Enter Cloudflare Turnstile Secret Key',
+ secretKeyDesc: 'Secret key used by backend for verification (keep it safe)',
+ helpText: 'Get your keys from Cloudflare dashboard:',
+ },
+ avatar: {
+ title: 'Avatar Service',
+ description: 'Configure user avatar generation service, using DiceBear official API by default',
+ apiBase: 'API Base URL',
+ apiBaseDesc: 'DiceBear avatar API base URL, can self-host for better performance',
+ helpText: 'Learn how to self-host DiceBear:',
+ },
+ // SMTP Email configuration
+ smtp: {
+ title: 'SMTP Email Service',
+ description: 'Configure SMTP server to enable email verification. Users will need to verify their email when registering',
+ enable: 'Enable Email Verification',
+ enableDesc: 'When enabled, users must verify their email with a verification code during registration',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ host: 'SMTP Server',
+ hostPlaceholder: 'smtp.example.com',
+ port: 'SMTP Port',
+ secure: 'Use SSL/TLS',
+ secureHint: 'Usually required for port 465, not for port 587',
+ username: 'Username',
+ usernamePlaceholder: 'Email account or username',
+ password: 'Password',
+ passwordPlaceholder: 'App password or authorization code',
+ fromEmail: 'From Email',
+ fromEmailPlaceholder: "noreply{'@'}example.com",
+ fromName: 'From Name',
+ testConnection: 'Test Connection',
+ testing: 'Testing...',
+ testSuccess: 'SMTP connection test successful',
+ testFailed: 'SMTP connection test failed',
+ sendTestEmail: 'Send Test Email',
+ sendTestEmailDesc: 'Send a test email to the specified address to verify email sending functionality',
+ testEmailPlaceholder: 'Enter recipient email address',
+ sending: 'Sending...',
+ send: 'Send',
+ invalidEmail: 'Please enter a valid email address',
+ testEmailSent: 'Test email sent to {email}',
+ testEmailFailed: 'Failed to send test email',
+ helpText: 'Make sure the SMTP server address, port, username and password are correct. Most email providers require an app password instead of your login password.',
+ },
+ // Email domain whitelist configuration
+ emailDomain: {
+ title: 'Email Domain Whitelist',
+ description: 'Restrict registration to specific email domains to improve user quality',
+ enable: 'Enable Email Domain Whitelist',
+ enableDesc: 'When enabled, only email addresses from whitelisted domains can register',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ allowedDomains: 'Allowed Email Domains',
+ allowedDomainsPlaceholder: 'gmail.com,outlook.com,icloud.com\nOne domain per line or comma-separated\nLeave empty to use default whitelist',
+ allowedDomainsDesc: 'Enter allowed email domains, comma-separated or one per line. Leave empty to use the default whitelist (includes Gmail, Outlook, iCloud, Yahoo, Proton, and other major email services).',
+ helpText: 'Default whitelist includes: Gmail, Outlook/Hotmail, iCloud, Yahoo, Zoho, Proton, Fastmail, Tuta, Posteo, Disroot, Riseup, and other major email services.',
+ },
+ // Transfer settings
+ transfer: {
+ title: 'Transfer Settings',
+ description: 'Configure instance transfer settings',
+ feeLabel: 'Transfer Fee',
+ feeUnit: 'per transfer',
+ feeDesc: 'Fee charged when initiating a transfer (0 means free). Automatically refunded if the recipient rejects the transfer',
+ feeRangeError: 'Transfer fee must be between 0 and {max} yuan, with up to 2 decimal places',
+ },
+ footerLinks: {
+ title: 'Footer Contact Links',
+ description: 'Configure the email button shown at the bottom of the sidebar',
+ email: 'Contact Email',
+ emailPlaceholder: "support{'@'}example.com or mailto:support{'@'}example.com",
+ emailDesc: 'Leave empty to hide the email button. You can enter either an email address or a full mailto: link.',
+ telegram: 'Telegram Group Link',
+ telegramPlaceholder: 'https://t.me/your_group',
+ telegramDesc: 'Leave empty to hide the Telegram button.',
+ },
+ ticketImages: {
+ title: 'Ticket Image Storage',
+ description: 'Upload ticket images to Lsky. The panel only forwards uploads and stores metadata, without writing files to local disk.',
+ baseUrl: 'Lsky Base URL',
+ baseUrlPlaceholder: 'https://img.example.com',
+ baseUrlDesc: 'Lsky site root URL. Do not append /api/v1/upload.',
+ token: 'Lsky Token',
+ tokenPlaceholder: 'Enter the Lsky API token',
+ tokenDesc: 'Used by backend only and never exposed to the frontend',
+ apiVersion: 'API Version',
+ apiVersionDesc: 'Choose the upload API version that matches your Lsky deployment',
+ targetId: 'Strategy/Storage ID',
+ targetIdPlaceholder: 'Use strategy_id for v1, storage_id for v2',
+ targetIdDesc: 'Optional. Leave empty to use Lsky defaults.',
+ },
+ },
+ // User management
+ users: {
+ title: 'User Management',
+ description: 'Manage platform users and invite codes',
+ create: 'Create User',
+ generateInvite: 'Generate Invite',
+ userInfo: 'User',
+ role: 'Role',
+ status: 'Status',
+ quotaUsage: 'Quota Usage',
+ allInstances: 'All Instances',
+ instances: 'instances',
+ registeredAt: 'Registered',
+ admin: 'Admin',
+ user: 'User',
+ active: 'Active',
+ banned: 'Banned',
+ searchPlaceholder: 'Search username, ID, or email...',
+ searchRange: 'Search Scope',
+ searchFieldUsername: 'Username',
+ searchFieldId: 'ID',
+ searchFieldEmail: 'Email',
+ exactMatch: 'Exact Match',
+ noUsers: 'No users',
+ noMatchingUsers: 'No matching users found',
+ noEmail: 'No email set',
+ resourceQuota: 'Resource Quota',
+ viewInstances: 'View Instances',
+ quota: 'Quota',
+ ban: 'Ban',
+ unban: 'Unban',
+ promoteAdmin: 'Make Admin',
+ demoteAdmin: 'Remove Admin',
+ confirmBan: 'Are you sure to ban user "{name}"?',
+ confirmUnban: 'Are you sure to unban user "{name}"?',
+ confirmPromoteAdmin: 'Make user "{name}" an admin? They need to sign in again for it to take effect.',
+ confirmDemoteAdmin: 'Remove admin privileges from user "{name}"? Their current sessions will be revoked.',
+ userBanned: 'User banned',
+ userUnbanned: 'User unbanned',
+ userPromotedAdmin: 'User is now an admin',
+ userDemotedAdmin: 'Admin privileges removed',
+ onlyActiveCanBeAdmin: 'Only active users can be made admins',
+ loadFailed: 'Failed to load users',
+ userVipLevels: 'User VIP Levels',
+ vipBenefits: 'Membership Benefits',
+ // Invite codes
+ invites: 'Invite Codes',
+ inviteCode: 'Invite Code',
+ inviteStatus: 'Status',
+ createdBy: 'Created By',
+ usedBy: 'Used By',
+ createdAt: 'Created At',
+ usedExpireAt: 'Used/Expire At',
+ noInvites: 'No invite codes',
+ noMatchingInvites: 'No matching invite codes',
+ inviteFilterAll: 'All',
+ inviteFilterUsed: 'Used',
+ inviteFilterUnused: 'Unused',
+ inviteUsed: 'Used',
+ inviteExpired: 'Expired',
+ inviteUnused: 'Unused',
+ permanent: 'Permanent',
+ deleteInvite: 'Delete',
+ confirmDeleteInvite: 'Are you sure to delete invite code {code}?',
+ inviteDeleted: 'Invite code deleted',
+ deleteFailed: 'Delete failed',
+ // Generate invite
+ generateInviteTitle: 'Generate Invite Code',
+ inviteCount: 'Quantity',
+ countUnit: '',
+ expireDays: 'Validity',
+ day1: '1 day',
+ day3: '3 days',
+ day7: '7 days',
+ day14: '14 days',
+ day30: '30 days',
+ permanentValid: 'Permanent',
+ expireHint: 'Set the expiration time for the invite code',
+ generate: 'Generate',
+ generating: 'Generating...',
+ generateFailed: 'Failed to generate invite code',
+ // Invite result
+ inviteGenerated: 'Invite Code Generated',
+ validUntil: 'Valid until',
+ copyCode: 'Copy Code',
+ copyAllCodes: 'Copy All',
+ copyLink: 'Copy Link',
+ copied: 'Copied!',
+ // Quota edit
+ editQuota: 'Edit Quota',
+ instanceLimit: 'Instance Limit',
+ instanceLimitHint: 'Maximum number of instances the user can create',
+ hostLimit: 'Host Limit',
+ hostLimitHint: 'Maximum number of hosts the user can own',
+ friendLimit: 'Friend Limit',
+ friendLimitHint: 'Maximum number of friends the user can add',
+ packageLimitHint: 'Maximum number of packages the user can create',
+ packageLimit: 'Package limit',
+ notAuthorized: 'Not authorized',
+ cpuAllowance: 'CPU Allowance',
+ cpuAllowanceHint: 'Allowance ÷ 100 ≈ available cores, min 10, step 5',
+ cpuCores: '~{n} cores',
+ memoryLimit: 'Memory (MB)',
+ diskLimit: 'Disk (MB)',
+ portLimit: 'NAT Port Limit',
+ portLimitHint: 'Total port quota limit for all instances',
+ snapshotLimit: 'Snapshot Limit',
+ snapshotLimitHint: 'Total snapshot quota limit for all instances',
+ backupLimit: 'Backup Limit',
+ backupLimitHint: 'Total backup quota limit for all instances',
+ trafficLimit: 'Monthly Traffic Limit',
+ trafficLimitPlaceholder: 'Leave empty for unlimited',
+ trafficLimitHint: 'In GB, leave empty for unlimited',
+ quotaUpdated: 'Quota updated',
+ totalRecords: '{count} records',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ // Reset password
+ resetPassword: 'Reset Password',
+ resetPasswordConfirm: 'Are you sure to reset password for user "{name}"?',
+ resetPasswordHint: 'A new password will be generated automatically. All user sessions will be revoked and re-login with new password is required.',
+ confirmResetPassword: 'Confirm Reset',
+ resetting: 'Resetting...',
+ passwordResetSuccess: 'Password Reset Successfully',
+ newPasswordFor: 'New password for "{name}":',
+ copyPassword: 'Copy Password',
+ copyPasswordHint: 'Please save this password securely. It cannot be viewed again after closing this dialog.',
+ // Disable 2FA
+ disable2FA: 'Disable 2FA',
+ disable2FAConfirm: 'Are you sure to disable two-factor authentication for user "{name}"?',
+ disable2FAWarning: 'After disabling, the user will no longer need a verification code to log in, which may reduce account security.',
+ twoFADisabled: 'Two-factor authentication disabled',
+ // Unbind GitHub
+ unbindGitHub: 'Unbind GitHub',
+ unbindGitHubConfirm: 'Are you sure to unbind GitHub OAuth for user "{name}"?',
+ unbindGitHubWarning: 'After unbinding, the user will not be able to use GitHub quick login.',
+ githubUnbound: 'GitHub OAuth unbound successfully',
+ // User activity and login records
+ userActivity: 'User Activity',
+ registeredNew: 'New',
+ registeredDays: 'Registered {days}d',
+ viewLoginRecords: 'View login records',
+ noLoginRecord: 'No login record',
+ loginRecords: 'Login Records',
+ noLoginRecords: 'No login records',
+ loadLoginRecordsFailed: 'Failed to load login records',
+ // Send message
+ sendMessage: 'Send Message',
+ sendMessageTo: 'Send Message to {username}',
+ messageTitle: 'Message Title',
+ messageTitlePlaceholder: 'Enter message title',
+ messageTitleRequired: 'Please enter message title',
+ messageContent: 'Message Content',
+ messageContentPlaceholder: 'Enter message content',
+ messageContentRequired: 'Please enter message content',
+ messageSent: 'Message sent',
+ messageSendFailed: 'Failed to send message',
+ // User balance
+ balance: 'Balance',
+ viewBalance: 'View Balance Details',
+ consumed: 'Consumed',
+ totalConsumed: 'Total amount consumed',
+ balanceDetails: 'Balance Details',
+ balanceOverview: 'Account Overview',
+ balanceLogs: 'Balance Logs',
+ rechargeRecords: 'Recharge Records',
+ currentBalance: 'Current Balance',
+ totalRecharge: 'Total Recharged',
+ totalConsume: 'Total Consumed',
+ noBalanceLogs: 'No balance changes',
+ noRechargeRecords: 'No recharge records',
+ loadBalanceFailed: 'Failed to load balance info',
+ loadBalanceLogsFailed: 'Failed to load balance logs',
+ loadRechargeRecordsFailed: 'Failed to load recharge records',
+ balanceType: {
+ recharge: 'Recharge',
+ consume: 'Consume',
+ refund: 'Refund',
+ admin_adjust: 'Admin Adjust',
+ gift: 'Gift',
+ transfer_fee: 'Transfer Fee',
+ transfer_refund: 'Fee Refund',
+ },
+ // Adjust balance
+ adjustBalance: 'Adjust Balance',
+ adjustBalanceFor: 'Adjust Balance for {username}',
+ adjustType: 'Adjustment Type',
+ addBalance: 'Add Balance',
+ deductBalance: 'Deduct Balance',
+ amount: 'Amount',
+ amountPlaceholder: 'Enter amount',
+ adjustReason: 'Reason',
+ adjustReasonPlaceholder: 'Enter reason for adjustment (required)',
+ invalidAmount: 'Please enter a valid amount',
+ reasonRequired: 'Please enter a reason',
+ balanceAdjusted: 'Balance adjusted successfully',
+ balanceAdjustFailed: 'Failed to adjust balance',
+ // Points
+ points: 'Points',
+ earned: 'Total',
+ totalEarnedPoints: 'Total earned points',
+ spent: 'Spent',
+ spentPoints: 'Spent points',
+ adjustPoints: 'Adjust Points',
+ currentPoints: 'Current Points',
+ pointsAmount: 'Amount',
+ pointsAmountHint: 'Positive to add, negative to deduct',
+ pointsAmountPlaceholder: 'e.g. 100 or -50',
+ pointsReasonPlaceholder: 'Enter reason for adjustment (required)',
+ invalidPointsAmount: 'Please enter a valid points amount',
+ pointsAdjusted: 'Points adjusted successfully',
+ pointsAdjustFailed: 'Failed to adjust points',
+ // Hosting balance
+ hostingBalance: 'Hosting Balance',
+ hostingBalanceDetails: 'Hosting Balance Details',
+ hostingBalanceOverview: 'Overview',
+ hostingBalanceLogs: 'Logs',
+ adjustHostingBalance: 'Adjust Hosting Balance',
+ viewHostingBalance: 'View Hosting Balance',
+ frozenHostingBalance: 'Frozen Hosting Balance',
+ availableBalance: 'Available Balance',
+ frozenBalance: 'Frozen Balance',
+ frozen: 'Frozen',
+ available: 'Available',
+ operation: 'Operation',
+ operationAdd: 'Add',
+ operationDeduct: 'Deduct',
+ hostingReasonPlaceholder: 'Enter reason for adjustment (required)',
+ hostingBalanceAdjusted: 'Hosting balance adjusted successfully',
+ hostingBalanceAdjustFailed: 'Failed to adjust hosting balance',
+ loadHostingLogsFailed: 'Failed to load hosting balance logs',
+ logTime: 'Time',
+ logType: 'Type',
+ logAmount: 'Amount',
+ logStatus: 'Status',
+ logDescription: 'Description',
+ hostingLogType: {
+ income: 'Income',
+ deduction: 'Deduction',
+ unfreeze: 'Unfreeze',
+ withdraw: 'Withdraw',
+ admin_adjust: 'Admin Adjust',
+ },
+ // Linked accounts detection
+ linkedAccounts: 'Linked Accounts',
+ detectDays: 'Detection Range',
+ daysUnit: 'days',
+ startDetect: 'Start Detection',
+ detecting: 'Detecting...',
+ detectingHint: 'Analyzing user data, please wait...',
+ clickToDetect: 'Click the button above to start detecting linked accounts',
+ loadLinkedAccountsFailed: 'Failed to load linked accounts detection',
+ detectTime: 'Detection Time',
+ detectDuration: 'Duration',
+ detectRange: 'Detection Range',
+ ipGroupCount: 'IP linked groups',
+ emailGroupCount: 'email similar groups',
+ usernameGroupCount: 'username similar groups',
+ ipLinkedGroups: 'IP Linked Groups',
+ emailSimilarGroups: 'Email Similar Groups',
+ usernameSimilarGroups: 'Username Similar Groups',
+ usersCount: 'users',
+ loginsCount: 'logins',
+ lastLoginAt: 'Last login',
+ noLinkedAccounts: 'No linked accounts detected, your users are clean!',
+ },
+ // Host management
+ hosts: {
+ title: 'Host Management',
+ description: 'Manage hosts and node groups',
+ create: 'Add Host',
+ address: 'Address',
+ status: 'Status',
+ online: 'Online',
+ offline: 'Offline',
+ maintenance: 'Maintenance',
+ hostsTab: 'Hosts',
+ searchPlaceholder: 'Search hosts...',
+ noHosts: 'No hosts',
+ name: 'Name',
+ resources: 'Resources',
+ instances: 'Instances',
+ actions: 'Actions',
+ cpu: 'CPU',
+ cpuQuota: 'CPU Quota',
+ memory: 'Memory',
+ memoryQuota: 'Memory Quota',
+ disk: 'Disk',
+ diskUsage: 'Disk Usage',
+ cores: 'cores',
+ allowanceLimit: 'Allowance limit',
+ memoryLimit: 'Memory limit',
+ instanceType: 'Type',
+ typeContainer: 'Container',
+ typeVm: 'VM',
+ typeBoth: 'Both',
+ edit: 'Edit',
+ test: 'Test',
+ delete: 'Delete',
+ testSuccess: 'Connection successful',
+ testFailed: 'Connection failed',
+ confirmDelete: 'Are you sure to delete host "{name}"?',
+ hostDeleted: 'Host deleted',
+ deleteFailed: 'Delete failed',
+ // Add/Edit host
+ addHost: 'Add Host',
+ editHost: 'Edit Host',
+ hostName: 'Name',
+ hostNameHint: 'Only letters, numbers, - and _ allowed',
+ hostNameRequired: 'Please enter the node name',
+ hostDesc: 'Description',
+ apiUrl: 'API URL',
+ ipAddress: 'Server Address',
+ ipAddressHint: 'Supports bare IPv4, bare IPv6, or a domain name',
+ ipAddressRequired: 'Please enter the server address',
+ apiPort: 'API Port',
+ apiPortHint: 'Default 8443',
+ tokenPrompt: 'If prompted for a secure communication Token during installation, please copy and paste the following content:',
+ copyToken: 'Copy Token',
+ country: 'Country or Region',
+ certPath: 'Certificate Path',
+ keyPath: 'Key Path',
+ natPublicIp: 'NIC IP',
+ natPublicIpPlaceholder: 'Enter server NIC IP',
+ natConfig: 'NAT Config',
+ natPublicIpv4: 'Public IPv4',
+ natPublicIpv4Placeholder: 'Enter the public IPv4 shown to users',
+ natPublicIpv4Desc: 'The public IPv4 address users see for IPv4 port mappings.',
+ natPublicIpv6: 'Public IPv6',
+ natPublicIpv6Placeholder: 'e.g. 2600:1900:41a0:5bb::',
+ natPublicIpv6Desc: 'The public IPv6 address shown to users. It does not have to be the actual bind address.',
+ natBindIpv4: 'Bind IPv4',
+ natBindIpv4Placeholder: 'Leave empty for auto-detect, e.g. 0.0.0.0 or 10.170.0.3',
+ natBindIpv4Desc: 'The address used when binding IPv4 ports. Leave empty to let the system choose.',
+ natBindIpv6: 'Bind IPv6',
+ natBindIpv6Placeholder: 'Leave empty for auto-detect, e.g. 2600:1900:41a0:5bb::',
+ natBindIpv6Desc: 'The address used when binding IPv6 ports. Leave empty to let the system choose.',
+ natPublicIpv6Invalid: 'Public IPv6 address format is invalid',
+ natBindIpv6Invalid: 'Bind IPv6 address format is invalid',
+ portRangeStart: 'Port Range Start',
+ portRangeEnd: 'Port Range End',
+ portRangeEndMustBeGreater: 'Port range end must be greater than or equal to start',
+ cpuAllowanceMax: 'Total CPU Time Quota',
+ memoryMax: 'Max Memory',
+ instanceTypeLabel: 'Instance Type',
+ networkModeLabel: 'Network Mode',
+ networkModeNat: 'IPv4 NAT',
+ networkModeNatIpv6: 'IPv4 NAT & IPv6',
+ networkModeNatIpv6Nat: 'IPv4 NAT & IPv6 NAT',
+ networkModeIpv6Only: 'IPv6 Only',
+ networkModeIpv6Nat: 'IPv6 NAT',
+ autoAssign: 'Auto Assign',
+ hostAdded: 'Host added',
+ hostUpdated: 'Host updated',
+ typeChangeWarning: 'Host type change warning',
+ addFailed: 'Add failed',
+ updateFailed: 'Update failed',
+ // Init config
+ initConfig: 'Init Config',
+ // Storage config
+ storageConfig: 'Storage Config',
+ storageDriver: 'Storage Driver',
+ storageDriverZfs: 'ZFS (Recommended)',
+ storageDriverLvm: 'LVM',
+ storageType: 'Storage Type',
+ storageTypeLoop: 'Loop File',
+ storageTypeDisk: 'Physical Disk',
+ storagePath: 'Device Path',
+ storagePathHint: 'e.g. /dev/sdb',
+ storageSize: 'Storage Size',
+ // Network config
+ networkConfig: 'Network Config',
+ networkOption: 'Network Option',
+ networkOptionHint: 'Select the network egress mode for container instances',
+ independentIpv6: 'Independent IPv6',
+ ipv6Mode: 'IPv6 Mode',
+ ipv6Routed: 'Routed',
+ ipv6Nat: 'NAT',
+ ipv6Disabled: 'Disabled',
+ ipv6Subnet: 'IPv6 Subnet',
+ ipv6SubnetHint: 'IPv6 subnet allocated to containers',
+ ipv6SubnetRequired: 'Please enter IPv6 subnet',
+ ipv6SubnetInvalid: 'Invalid IPv6 subnet format, CIDR prefix required (e.g. /48)',
+ ipv6Gateway: 'IPv6 Gateway',
+ ipv6ParentInterface: 'IPv6 Parent Interface',
+ ipv6ParentInterfaceHint: 'Physical NIC name on the host for IPv6 routed mode (e.g. eth0)',
+ ipv6ParentInterfaceRequired: 'Please enter IPv6 parent interface',
+ enableApi: 'Enable API',
+ // Sysctl config
+ sysctlConfig: 'Kernel Parameters',
+ sysctlConfigHint: 'Custom sysctl config, leave empty for defaults',
+ resetSysctl: 'Reset to Default',
+ enableBBR: 'Enable BBR',
+ bbrEnabled: 'BBR Enabled',
+ // Install script
+ installScript: 'Install Script',
+ runOnHost: 'Run the following command on the host with root privileges:',
+ copyCommand: 'Copy Command',
+ step1RunScript: 'Run Install Script',
+ step2Verify: 'Verify & Connect',
+ verifyHint: 'After the script completes, click the button below to verify the connection',
+ verifyAndConnect: 'Verify & Connect',
+ verifying: 'Verifying...',
+ verifySuccess: 'Successfully connected!',
+ verifyFailed: 'Verification failed',
+ reinstall: 'Reinstall',
+ reinstallScript: 'Reinstall Script',
+ reinstallFailed: 'Failed to generate install command',
+ waitingInstall: 'Waiting for installation...',
+ installSuccess: 'Installation successful!',
+ tokenExpired: 'Token expired, please recreate the host',
+ // Detail page
+ tabInfo: 'Info',
+ tabConfig: 'Config',
+ tabInstances: 'Instances',
+ tabStorage: 'Storage',
+ tabImages: 'Images',
+ tabOps: 'Ops',
+ tabCreate: 'Create',
+ basicInfo: 'Basic Info',
+ config: 'Config',
+ resourceLimits: 'Resource Limits',
+ transferControl: 'Transfer Control',
+ transferEnabled: 'Allow Transfer',
+ transferEnabledHint: 'When disabled, instances on this host cannot initiate transfer requests',
+ notificationSettings: 'Notification Settings',
+ notificationSettingsHint: 'These notifications are sent only through your enabled Telegram, Discord, or Webhook channels. Email is not used here.',
+ notifyPurchase: 'Purchase Notification',
+ notifyPurchaseHint: 'Notify you when a user purchases a paid instance on this host.',
+ notifyRenew: 'Renewal Notification',
+ notifyRenewHint: 'Notify you when a user renews a paid instance on this host.',
+ notifyDestroy: 'Destroy Notification',
+ notifyDestroyHint: 'Notify you when a user destroys an instance on this host, including refund and fee amounts.',
+ extraConfig: 'Extra Config',
+ trafficConfig: 'Traffic Config',
+ trafficResetDay: 'Traffic Reset Day',
+ trafficResetDayHint: 'Day of month to reset instance traffic (1-28)',
+ enableResourcePool: 'Enable Resource Pool',
+ enableResourcePoolHint: 'When enabled, instances on this host can participate in check-in/lottery resource applications',
+ announcement: 'Announcement',
+ announcementPlaceholder: 'Enter announcement content here, will be displayed on all instances under this host',
+ announcementHint: 'Leave empty to hide announcement, supports line breaks',
+ probeUrl: 'Probe URL',
+ probeUrlPlaceholder: 'Enter node probe monitoring page URL',
+ probeUrlHint: 'You can enter probe, script test results or other links for this node. Once configured, users can click the icon to view when selecting hosts',
+ portRange: 'Port Range',
+ portsUsed: 'Ports Used',
+ recalculateResources: 'Align Used',
+ recalculateResourcesTip: 'Recalculate resource usage and align quota to used amount',
+ recalculateSuccess: 'Resources recalculated, quota aligned',
+ recalculateNoChanges: 'Resources are correct, no changes needed',
+ recalculateFailed: 'Resource recalculation failed',
+ ops: {
+ title: 'Host Operations Center',
+ description: 'Run non-destructive inventory, baseline sync, and network repair first for legacy host onboarding and state reconciliation.',
+ discover: 'Inventory',
+ baselineSync: 'Baseline Sync',
+ networkRepair: 'Network Repair',
+ refresh: 'Refresh Result',
+ managed: 'Managed Instances',
+ orphaned: 'Unmanaged Legacy Instances',
+ missing: 'Database Missing Instances',
+ summary: 'Summary',
+ totalIncus: 'Host Instances',
+ totalDb: 'Database Instances',
+ managedCount: 'Managed',
+ orphanedCount: 'Unmanaged',
+ missingCount: 'Missing',
+ runSuccess: 'Operation completed successfully',
+ runFailed: 'Operation failed',
+ lastRunAt: 'Last run at',
+ empty: 'No result yet. Run an operation above first.',
+ sectionInventory: 'Host Instance Inventory',
+ sectionRepair: 'Safe Repair Actions',
+ sectionReport: 'Execution Result',
+ resultInventory: 'Inventory Result',
+ resultBaseline: 'Baseline Sync Result',
+ resultNetwork: 'Network Repair Result',
+ resultPreview: 'Instance Precheck Result',
+ resultInstanceSync: 'Single Instance Sync Result',
+ resultInstanceRestart: 'Single Instance Restart Result',
+ resultDanger: 'Dangerous Action Result',
+ instanceName: 'Instance Name',
+ instanceType: 'Type',
+ incusStatus: 'Host Status',
+ dbStatus: 'DB Status',
+ dbInstance: 'DB Instance',
+ changes: 'Changes',
+ synced: 'Synced',
+ failed: 'Failed',
+ total: 'Total',
+ ipv4: 'IPv4',
+ ipv6: 'IPv6',
+ details: 'Details',
+ noManaged: 'No managed instances found',
+ noOrphaned: 'No unmanaged legacy instances found',
+ noMissing: 'No database-missing instances found',
+ baselineHint: 'Sync host resource usage and batch backfill status/IP for running instances.',
+ networkHint: 'Batch reconcile status, IPv4 and IPv6 records for non-deleted instances.',
+ discoverHint: 'Read all current Incus containers / KVMs from the host and reconcile with database records.',
+ selectInstanceHint: 'Select one managed instance to perform single-instance sync, restart, or dangerous actions.',
+ instancePanel: 'Single Instance Panel',
+ loadPreview: 'Load Precheck',
+ syncInstance: 'Sync Instance',
+ safeRestart: 'Safe Restart',
+ forceRestart: 'Force Restart',
+ dangerZone: 'Danger Zone',
+ dangerHint: 'These actions may wipe or replace the instance system data. Use them only when inventory, sync, and restart can no longer solve the issue.',
+ rebuild: 'Rebuild Current Instance',
+ recreate: 'Recreate Replacement Instance',
+ imageAlias: 'Image Alias',
+ imageAliasPlaceholder: 'e.g. ubuntu/22.04 or debian/12',
+ selectImage: 'Image Selection',
+ imagePlaceholder: 'Please select a target image',
+ loadingImages: 'Loading available images...',
+ noImagesAvailable: 'No available images on this host',
+ sshKeyId: 'SSH Key ID',
+ selectSshKey: 'SSH Key',
+ sshKeyPlaceholder: 'Do not specify an SSH key',
+ loadingSshKeys: 'Loading SSH keys...',
+ noSshKeysAvailable: 'This instance user has no SSH keys. Leaving this empty will use backend default handling.',
+ sshKeyOptionalHint: 'Optional. If empty, the instance user default key strategy is used.',
+ customInitCommandIds: 'Custom Init Command IDs',
+ selectInitCommands: 'Init Commands',
+ loadingInitCommands: 'Loading init commands...',
+ noInitCommandsAvailable: 'No init commands are available for the selected image',
+ optionalField: 'Optional, leave empty if not needed',
+ confirmDangerTitle: 'Confirm Before Execution',
+ riskCheckbox: 'I understand this action may cause irreversible loss of instance system or data, and I confirm this is intentional.',
+ confirmTextHint: 'Enter the real instance name to confirm, e.g. u2-g65uoeo1',
+ ownerUserId: 'User ID',
+ fullInstanceName: 'Full Instance Name',
+ selectedOnlyHint: 'Single-instance actions only affect the currently selected instance. Other instances are not affected.',
+ localizedNone: 'No action needed',
+ dangerActionType: 'Action Type',
+ dangerConfirm1Title: 'First Dangerous Action Confirmation',
+ dangerConfirm1Hint: 'Please confirm again that you are about to execute an irreversible dangerous action.',
+ dangerConfirm1Btn: 'Confirm and Continue',
+ dangerConfirm2Title: 'Second Dangerous Action Confirmation',
+ dangerConfirm2Hint: 'This is the final confirmation. A task will be created immediately after execution.',
+ dangerConfirm2Btn: 'Confirm Execution',
+ dangerConfirmStep: 'Confirmation step {step} / {total}',
+ executeDangerAction: 'Execute Dangerous Action',
+ suggestedAction: 'Suggested Action',
+ activeTask: 'Active Task',
+ latestInstanceAction: 'Latest Single-Instance Action',
+ },
+ statusOnline: 'Online',
+ statusOffline: 'Offline',
+ statusMaintenance: 'Maintenance',
+ invalidId: 'Invalid host ID',
+ loadFailed: 'Load failed',
+ noInstances: 'No instances on this host',
+ search: 'Search',
+ instanceSearchPlaceholder: 'Search by instance ID, name, username, email, IP address...',
+ imagesOnHost: 'Images on host {name}',
+ noImagesOnHost: 'No images on this host',
+ selectImagesToSync: 'Select images to sync to host {name}',
+ allImagesSynced: 'All images are synced to this host',
+ imagePolicy: {
+ title: 'Image Policy',
+ description: 'Configure which images can be selected for provisioning and rebuild on host "{name}"',
+ defaultMode: 'Use panel defaults',
+ defaultDesc: 'Do not restrict this host separately. Provisioning and rebuild will load all available images for the host architecture and instance type.',
+ restrictedMode: 'Restrict to selected images',
+ restrictedDesc: 'Only the checked images will appear in the provisioning and rebuild lists for this host.',
+ selectableImages: 'Selectable images',
+ defaultHint: 'This host is currently using the default image policy.',
+ selectedCount: '{count} images selected',
+ searchPlaceholder: 'Search by image name, alias, or distro...',
+ emptySelection: 'Restricted mode requires at least one selected image. Switch back to default mode if you do not want to restrict this host.',
+ noImages: 'No visible images match this host architecture and instance type.',
+ loadFailed: 'Failed to load image policy',
+ saveSuccess: 'Image policy saved',
+ saveFailed: 'Failed to save image policy',
+ },
+ addHostDesc: 'Add a new host node',
+ allocated: 'Allocated',
+ includesPageCache: 'includes page cache',
+ syncTime: 'Sync Time',
+ deleteHost: 'Delete Host',
+ deleteWarning: 'This action cannot be undone! Please ensure there are no instances on this host before deleting.',
+ deleteConfirmHint: 'Please enter the host name "{name}" to confirm deletion:',
+ enterHostName: 'Host Name',
+ confirmDeleteBtn: 'Confirm Delete',
+ deleteNameMismatch: 'Name does not match',
+ hasInstances: 'This host still has {count} instance(s), please delete or migrate them first',
+ checkFailed: 'Check failed',
+ // Batch extend
+ batchExtend: 'Gift Time',
+ extendHint: '{count} paid instance(s) on this host will be extended',
+ extendDaysLabel: 'Days to extend',
+ extendDaysPlaceholder: 'Enter a number between 1-365',
+ extendDaysInvalid: 'Please enter a valid number of days (1-365)',
+ confirmExtendBtn: 'Confirm',
+ extendSuccess: 'Successfully extended {count} paid instance(s) by {days} days',
+ extendFailed: 'Batch extend failed',
+ // Batch delete instances
+ selectedCount: '{count} instance(s) selected',
+ noInstanceSelected: 'Select instances to use batch actions',
+ batchDelete: 'Batch Delete',
+ batchDeleteTitle: 'Batch Delete Instances',
+ batchDeleteWarning: 'This action cannot be undone! Instance data will be permanently deleted.',
+ batchDeleteConfirm: 'Are you sure to delete these {count} instance(s)?',
+ confirmBatchDelete: 'Confirm Delete',
+ databaseOnlyDelete: 'Database Only',
+ batchDeleteSuccess: 'Successfully deleted {count} instance(s)',
+ batchDeletePartial: 'Successfully deleted {success} instance(s), {failed} failed',
+ batchDeleteFailed: 'Batch delete failed',
+ batchDeleteRefundWarning: 'Deleting paid instances will automatically refund the remaining value to the user and deduct from your hosting balance.',
+ batchDeleteRefundTotal: 'Total Refund Amount',
+ instanceName: 'Instance Name',
+ instanceUser: 'Owner',
+ refundAmount: 'Refund Amount',
+ // Batch sync instance status
+ batchSyncStatus: 'Sync Status',
+ batchSyncSuccess: 'Successfully synced {synced} instance(s), {changed} status updated',
+ batchSyncWithIpv4: 'Successfully synced {synced} instance(s), {changed} status updated, {ipv4Changed} internal IP updated',
+ batchSyncPartial: 'Synced {synced}, updated {changed}, failed {failed}',
+ batchSyncNoChange: 'Successfully synced {synced} instance(s), no status change',
+ batchSyncFailed: 'Sync status failed',
+ // Batch suspend instances
+ batchSuspend: 'Batch Suspend',
+ batchSuspendTitle: 'Batch Suspend Instances',
+ batchSuspendWarning: 'After suspension, instance owners will not be able to perform any operations until unsuspended.',
+ batchSuspendConfirm: 'Are you sure you want to suspend these {count} instances?',
+ confirmBatchSuspend: 'Confirm Suspend',
+ batchSuspendSuccess: 'Successfully suspended {count} instance(s)',
+ batchSuspendPartial: 'Successfully suspended {success} instance(s), {failed} failed',
+ batchSuspendFailed: 'Batch suspend failed',
+ batchUnsuspend: 'Batch Unsuspend',
+ batchUnsuspendSuccess: 'Successfully unsuspended {count} instances',
+ batchUnsuspendPartial: 'Unsuspended {success} instances, {failed} failed',
+ batchUnsuspendNone: 'No suspended instances found in the selection',
+ batchUnsuspendFailed: 'Batch unsuspend failed',
+ suspendReason: 'Suspension Reason (Optional)',
+ suspendReasonPlaceholder: 'Enter suspension reason, instance owners will be notified via inbox...',
+ deleteReason: 'Deletion Reason (Optional)',
+ deleteReasonPlaceholder: 'Enter the reason for deletion, users will be notified via inbox and notification channels...',
+ deleteReasonHint: 'If a reason is provided, the instance owner will receive a notification',
+ // Instance traffic
+ trafficUsage: 'Traffic Usage',
+ trafficUnlimited: 'Unlimited',
+ resetTraffic: 'Reset Traffic',
+ trafficResetSuccess: 'Traffic reset successfully',
+ trafficResetFailed: 'Failed to reset traffic',
+ resetTrafficTitle: 'Reset Instance Traffic',
+ resetTrafficWarning: 'If the instance has exceeded its quota, resetting traffic will not automatically restore the bandwidth throttling (1Mbps). Please manually restore the bandwidth rate for this instance.',
+ resetTrafficDesc: 'Are you sure you want to reset traffic for instance "{instance}"?',
+ // Host traffic statistics
+ trafficStats: 'Traffic Statistics',
+ monthlyUsed: 'Monthly Used',
+ hostTotalLimit: 'Host Total Limit',
+ // Host status
+ agentStatusTitle: 'Host Status',
+ agentStatusDesc: 'Agent reports live host resources and runtime status every {seconds} seconds',
+ agentStatusRefreshSuccess: 'Host status refreshed',
+ agentStatusLoadFailed: 'Failed to load host status',
+ agentNotInstalled: 'Not installed',
+ agentDisabled: 'Disabled',
+ agentOnline: 'Online',
+ agentOffline: 'Offline',
+ agentUnknown: 'Unknown',
+ agentVersionLatest: 'Latest',
+ agentVersionOutdated: 'Update available',
+ agentVersionUnknown: 'Version unknown',
+ agentLatestVersion: 'Latest version: {version}',
+ agentUpgradeClickHint: 'Click to request upgrade to {version}; Agent will run it on next heartbeat',
+ agentUpgradeRequestSuccess: 'Upgrade requested; Agent will run it on the next heartbeat in about {seconds}s',
+ agentUpgradeRequestFailed: 'Failed to request Agent upgrade',
+ agentAlreadyLatest: 'Agent is already latest',
+ agentInstallCommand: 'Install/Reinstall Agent',
+ agentInstallCommandTitle: 'Agent Installation Command',
+ agentInstallCommandHint: 'Run the full command on the host, or paste it or its ait_ token into the Agent menu.',
+ agentInstallCommandConfirm: 'Generating a new Agent installation command rotates this host Agent credentials. The old Agent will stop reporting until it is reinstalled. Continue?',
+ agentInstallTokenExpiresAt: 'Expires at {time}',
+ agentInstallCommandSuccess: 'Agent installation command generated',
+ agentInstallCommandFailed: 'Failed to generate Agent installation command',
+ agentInstallCommandCopied: 'Agent installation command copied',
+ agentNoRecordHint: 'No Agent heartbeat has been recorded for this host. It will report automatically after Agent install or reinstall.',
+ agentId: 'Agent ID',
+ agentLastSeen: 'Last Heartbeat',
+ agentHeartbeatIp: 'Heartbeat IP',
+ agentReportedAt: 'Reported At',
+ agentIncus: 'Incus Check',
+ agentIncusAvailable: 'Available',
+ agentIncusUnavailable: 'Unavailable',
+ agentCpuTotal: 'CPU Cores',
+ agentMemoryTotal: 'Total Memory',
+ agentUptime: 'Host Uptime',
+ agentSocket: 'Incus Socket',
+ agentCpuUsage: 'CPU Usage',
+ agentCpuCores: 'cores',
+ agentMemoryUsage: 'Memory Usage',
+ agentSwapUsage: 'SWAP Usage',
+ agentDiskUsage: 'Disk Usage',
+ agentLoadAverage: 'Load Average',
+ agentLoadAverageHint: '1 / 5 / 15 minutes',
+ agentProcessCount: 'Process Count',
+ // Storage pool management
+ storage: {
+ title: 'Storage Pools',
+ subtitle: 'Manage Incus storage pools on this host',
+ create: 'Add Storage Pool',
+ createTitle: 'Create Storage Pool',
+ empty: 'No storage pools',
+ loadFailed: 'Failed to load storage pools',
+ createSuccess: 'Storage pool created',
+ createFailed: 'Failed to create storage pool',
+ deleteSuccess: 'Storage pool deleted',
+ deleteFailed: 'Failed to delete storage pool',
+ deleteConfirm: 'Are you sure to delete storage pool "{name}"? This action cannot be undone.',
+ updateSuccess: 'Storage pool updated',
+ updateFailed: 'Failed to update storage pool',
+ editTitle: 'Edit Storage Pool',
+ currentSize: 'Current Size',
+ newSize: 'New Size',
+ newSizeHint: 'Only expansion is supported, not shrinking',
+ nameRequired: 'Please enter storage pool name',
+ sourceRequired: 'Please enter storage source (device path)',
+ sizeRequired: 'Please enter storage size',
+ pathRequired: 'Please enter directory path',
+ poolName: 'Pool Name',
+ driver: 'Driver Type',
+ description: 'Description',
+ source: 'Source',
+ size: 'Size',
+ usedBy: 'Used by',
+ volumes: 'volumes',
+ // Driver descriptions
+ zfsDesc: 'Recommended: Full features (snapshots, clones, compression, quotas), excellent performance',
+ lvmDesc: 'Linux standard, very stable, thin provisioning recommended',
+ btrfsDesc: 'Similar to ZFS features, suitable for single disk or RAID1/10',
+ dirDesc: 'Directory storage, lowest performance, for testing only',
+ // Common
+ useLoop: 'Use loop file',
+ loopSizeHint: 'Will create image file in /var/lib/incus/',
+ // ZFS
+ zfsSourceHint: 'Physical disk or partition path, e.g. /dev/disk/by-id/nvme-xxx',
+ zfsPoolName: 'ZFS Pool Name',
+ zfsPoolNameHint: 'Optional, defaults to storage pool name',
+ // LVM
+ lvmSourceHint: 'Physical disk path, e.g. /dev/sdb',
+ lvmVgName: 'Volume Group Name',
+ lvmUseThinpool: 'Enable Thin Provisioning',
+ lvmThinpoolHint: 'Strongly recommended, otherwise snapshot performance is poor',
+ // Btrfs
+ btrfsSourceHint: 'Physical disk path, e.g. /dev/sdb',
+ // DIR
+ dirPath: 'Directory Path',
+ dirPathHint: 'Specify an existing directory, e.g. /mnt/data/incus-storage',
+ // Storage purpose
+ purpose: 'Storage Purpose',
+ forInstances: 'For instance system disk',
+ forInstancesHint: 'Used as default storage when creating instances',
+ forVolumes: 'For instance storage volumes',
+ forVolumesHint: 'Can be manually mounted to instances',
+ purposeSystemDisk: 'System',
+ purposeStorageDisk: 'Storage',
+ // Mode switch
+ modeCreate: 'Create New Pool',
+ modeExisting: 'Link Existing Pool',
+ modeImport: 'Import Existing Storage',
+ modeCreateHint: 'Create a new storage pool on the host, requires driver type, source and other parameters',
+ modeExistingHint: 'Link an existing storage pool on the host, just enter the pool name',
+ modeImportHint: 'Import storage that exists at OS level but Incus does not know about (e.g. manually created ZFS pool, LVM VG)',
+ linkSuccess: 'Storage pool linked successfully',
+ importSuccess: 'Storage pool imported successfully',
+ // Import storage pool
+ importZfsSource: 'ZFS Pool Name',
+ importLvmSource: 'LVM Volume Group Name',
+ importBtrfsSource: 'Btrfs Device or Subvolume Path',
+ importDirSource: 'Directory Path',
+ importZfsHint: 'Existing ZFS pool name (check with zpool list)',
+ importLvmHint: 'Existing LVM volume group name (check with vgs)',
+ importBtrfsHint: 'Device or subvolume path formatted as Btrfs',
+ importDirHint: 'Existing directory path, e.g. /mnt/storage',
+ importSourceRequired: 'Btrfs and DIR types require storage source path',
+ },
+ },
+ // Image management
+ images: {
+ title: 'Image Management',
+ description: 'Manage system images for users to select when creating instances',
+ create: 'Add Image',
+ edit: 'Edit Image',
+ imagesTab: 'Image Library',
+ syncTab: 'Sync Status',
+ noImages: 'No images added yet',
+ noImagesForArchitecture: 'No images for the selected architecture',
+ addImage: 'Add Image',
+ syncToHost: 'Sync Image to Host',
+ delete: 'Delete',
+ confirmDelete: 'Are you sure to delete image "{name}"?',
+ imageDeleted: 'Image deleted',
+ deleteFailed: 'Delete failed',
+ deleteSuccess: 'Image deleted',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ active: 'Active',
+ show: 'Show',
+ hide: 'Hide',
+ shown: 'Image shown',
+ hidden: 'Image hidden',
+ statusVisible: 'Visible',
+ statusHidden: 'Hidden',
+ createSuccess: 'Image created',
+ updateSuccess: 'Image updated',
+ saveFailed: 'Save failed',
+ loadFailed: 'Failed to load images',
+ validation: {
+ requiredFields: 'Please fill in name, remote alias and icon',
+ },
+ fields: {
+ icon: 'Icon',
+ name: 'Name',
+ remoteAlias: 'Remote Alias',
+ osType: 'OS Type',
+ architecture: 'Architecture',
+ instanceType: 'Instance Type',
+ sortOrder: 'Sort Order',
+ status: 'Status',
+ hidden: 'Hide this image from users',
+ },
+ placeholder: {
+ name: 'e.g. Ubuntu 24.04 LTS',
+ remoteAlias: 'e.g. ubuntu/noble/cloud',
+ icon: 'Select icon',
+ },
+ hint: {
+ remoteAlias: 'Incus remote image alias, e.g. ubuntu/noble/cloud',
+ sortOrder: 'Lower values appear first',
+ instanceType: 'Specify whether this image is for containers, VMs, or both',
+ },
+ // Instance types
+ typeContainer: 'Container',
+ typeVm: 'Virtual Machine',
+ typeBoth: 'Both',
+ // Sync status
+ syncStatusDesc: 'View sync status of images on each host, click cells to sync or delete',
+ refresh: 'Refresh',
+ addToLibraryFirst: 'Please add images to the library first',
+ noSystemImages: 'No system images available',
+ image: 'Image',
+ statusReady: 'Ready',
+ statusSyncing: 'Syncing',
+ statusPending: 'Pending',
+ statusError: 'Error',
+ statusNotSynced: 'Not Synced',
+ retry: 'Retry',
+ sync: 'Sync',
+ confirmDeleteFromHost: 'Are you sure to delete this image from the host?',
+ deletedFromHost: 'Image deleted from host',
+ imageInUse: 'This image is being used by {count} instance(s) and cannot be deleted',
+ syncStarted: 'Sync task started',
+ syncFailed: 'Sync failed',
+ loadStatusFailed: 'Failed to load image status',
+ loadHostsFailed: 'Failed to load hosts',
+ // Form
+ imageName: 'Name',
+ osType: 'OS Type',
+ remoteAlias: 'Remote Alias',
+ imageDesc: 'Description',
+ sortOrder: 'Sort Order',
+ activeStatus: 'Active Status',
+ selectHosts: 'Select Sync Hosts',
+ imageAdded: 'Image added',
+ imageCreated: 'Image created',
+ imageUpdated: 'Image updated',
+ // Image form modal
+ selectDistro: 'Select Distribution',
+ selectVersion: 'Select Version',
+ configureInfo: 'Configure Info',
+ selectDistroHint: 'Select the Linux distribution to add',
+ versionsCount: '{count} versions',
+ imageVariant: 'Image Variant',
+ cloudVariantDesc: 'Cloud optimized, supports cloud-init',
+ defaultVariantDesc: 'Standard version',
+ displayName: 'Display Name',
+ descPlaceholder: 'Optional description...',
+ enableImage: 'Enable this image',
+ syncHosts: 'Sync Hosts',
+ selectAll: 'Select All',
+ deselectAll: 'Deselect All',
+ editSyncHint: 'Deselected hosts will have the image removed on save, newly selected hosts will start syncing',
+ createSyncHint: 'Selected hosts will start syncing after save',
+ // Sync modal
+ selectTargetHosts: 'Select Target Hosts',
+ syncWarning: 'Image sync may take several minutes depending on image size and network speed',
+ startSync: 'Start Sync',
+ },
+ // Package management
+ packages: {
+ title: 'Package Management',
+ description: 'Configure instance specification templates',
+ create: 'Create Package',
+ noPackages: 'No packages',
+ createFirst: 'Create your first package',
+ noDesc: 'No description',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ status: 'Status',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ cores: '~{n} cores',
+ networkNat: 'NAT',
+ networkIpv6: 'IPv6',
+ networkDual: 'Dual Stack',
+ nested: 'Nested Virtualization',
+ privileged: 'Privileged Container',
+ allNodes: 'All Nodes',
+ enable: 'Enable',
+ disable: 'Disable',
+ edit: 'Edit',
+ delete: 'Delete',
+ confirmDelete: 'Are you sure to delete package "{name}"?',
+ packageDeleted: 'Package deleted',
+ packageEnabled: 'Package enabled',
+ packageDisabled: 'Package disabled',
+ deleteFailed: 'Delete failed',
+ operationFailed: 'Operation failed',
+ // Form
+ createPackage: 'Create Package',
+ editPackage: 'Edit Package',
+ basicInfo: 'Basic Info',
+ name: 'Name',
+ namePlaceholder: 'Starter',
+ descLabel: 'Description',
+ descPlaceholder: 'Suitable for lightweight applications',
+ resourceLimits: 'Resource Limits',
+ maxCpuAllowance: 'Max CPU Allowance',
+ cpuAllowanceHint: 'Allowance ÷ 100 ≈ available cores, min 10, step 5',
+ maxMemory: 'Max Memory (MB)',
+ maxDisk: 'Max Disk (MB)',
+ networkAndScheduling: 'Network & Scheduling',
+ networkMode: 'Network Mode',
+ allNodesNoLimit: 'All Nodes (no limit)',
+ boundHosts: 'Bound Hosts',
+ mustBindHost: 'Must bind at least one host',
+ noHostsBound: 'No hosts bound',
+ noHostsAvailable: 'No hosts available',
+ advancedOptions: 'Advanced Options',
+ nestedLabel: 'Nested Virtualization',
+ nestedHint: 'Allow running Docker/VM inside container',
+ privilegedLabel: 'Privileged Container',
+ privilegedHint: 'Grant higher system privileges (security risk)',
+ enablePackage: 'Enable Package',
+ enablePackageHint: 'Users cannot select this package when disabled',
+ enterName: 'Please enter package name',
+ packageCreated: 'Package created',
+ packageUpdated: 'Package updated',
+ trafficLimit: 'Monthly Traffic Limit',
+ trafficLimitPlaceholder: 'Leave empty for unlimited',
+ trafficLimitHint: 'Unit: GB, leave empty for unlimited',
+ syncTraffic: 'Sync Traffic Limits',
+ confirmSyncTraffic: 'Sync all package traffic limits to their instances?',
+ syncTrafficSuccess: 'Synced traffic limits for {count} instances',
+ syncTrafficFailed: 'Sync failed',
+ // Package list
+ traffic: 'Monthly Traffic',
+ unlimited: 'Unlimited',
+ unlimitedPlaceholder: 'Leave empty for unlimited',
+ active: 'Active',
+ inactive: 'Archived',
+ archivedHint: 'Archived packages will not appear in the package selection when creating instances',
+ activeLabel: 'Active',
+ saveFailed: 'Save failed',
+ loadFailed: 'Failed to load package',
+ instanceMaxLimit: 'Instance max limit',
+ },
+ // Help management
+ helpManage: {
+ title: 'Help Documentation',
+ description: 'Create and manage help documents',
+ create: 'New Article',
+ noArticles: 'No articles',
+ createFirst: 'Create your first article',
+ edit: 'Edit',
+ delete: 'Delete',
+ confirmDelete: 'Are you sure to delete article "{title}"?',
+ articleDeleted: 'Article deleted',
+ deleteFailed: 'Delete failed',
+ // Tabs
+ articlesTab: 'Articles',
+ categoriesTab: 'Category Management',
+ // Categories
+ addCategory: 'Add Category',
+ noCategories: 'No categories, click "Add Category" to create',
+ categoryId: 'Category ID',
+ categoryIdHint: 'Only lowercase letters, numbers and hyphens allowed',
+ categoryName: 'Category Name',
+ categoryColor: 'Color',
+ categoryAdded: 'Category added',
+ categoryUpdated: 'Category updated',
+ categoryDeleted: 'Category deleted',
+ categoryInUse: 'This category has documents, cannot delete',
+ confirmDeleteCategory: 'Are you sure to delete category "{name}"?',
+ duplicateCategoryId: 'This category ID already exists',
+ invalidCategoryId: 'Category ID can only contain lowercase letters, numbers and hyphens',
+ fillCategoryIdAndName: 'Please fill in category ID and name',
+ // Articles table
+ articleTitleCol: 'Title',
+ categoryCol: 'Category',
+ statusCol: 'Status',
+ updatedAtCol: 'Updated At',
+ actionsCol: 'Actions',
+ totalArticles: '{count} articles',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ hide: 'Hide',
+ publish: 'Publish',
+ articleHidden: 'Article hidden',
+ articlePublished: 'Article published',
+ // Form
+ createArticle: 'New Article',
+ editArticle: 'Edit Article',
+ articleTitle: 'Title',
+ titlePlaceholder: 'Document title',
+ urlSlug: 'URL Slug',
+ urlSlugPlaceholder: 'getting-started',
+ urlSlugHint: 'Only lowercase letters, numbers and hyphens allowed',
+ category: 'Category',
+ sortOrder: 'Sort Order',
+ content: 'Content',
+ contentMarkdown: 'Content (Markdown)',
+ preview: 'Preview',
+ activeStatus: 'Publish Status',
+ published: 'Published',
+ draft: 'Draft',
+ pinned: 'Pinned',
+ notPinned: 'Not Pinned',
+ articleCreated: 'Article created',
+ articleUpdated: 'Article updated',
+ saveFailed: 'Save failed',
+ loadFailed: 'Load failed',
+ operationFailed: 'Operation failed',
+ fillRequired: 'Please fill in title, URL slug and content',
+ invalidSlug: 'URL slug can only contain lowercase letters, numbers and hyphens',
+ noTitle: 'No title',
+ // Markdown help
+ showMarkdownHelp: 'Show syntax help',
+ hideMarkdownHelp: 'Hide syntax help',
+ markdownHeading1: 'Heading 1',
+ markdownHeading2: 'Heading 2',
+ markdownBold: 'Bold text',
+ markdownItalic: 'Italic text',
+ markdownLink: 'Hyperlink',
+ markdownImage: 'Image',
+ markdownUnorderedList: 'Unordered list',
+ markdownOrderedList: 'Ordered list',
+ markdownInlineCode: 'Inline code',
+ markdownCodeBlock: 'Code block',
+ markdownQuote: 'Quote block',
+ markdownHr: 'Horizontal rule',
+ customAlerts: 'Custom Alerts:',
+ alertInfo: 'Info alert',
+ alertSuccess: 'Success alert',
+ alertWarning: 'Warning alert',
+ alertDanger: 'Danger alert',
+ alertNote: 'Note alert',
+ },
+ // OAuth config
+ oauth: {
+ title: 'OAuth Config',
+ description: 'Configure third-party login methods',
+ noProviders: 'No OAuth providers',
+ provider: 'Provider',
+ clientId: 'Client ID',
+ clientSecret: 'Client Secret',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ enable: 'Enable',
+ disable: 'Disable',
+ edit: 'Edit',
+ save: 'Save',
+ saving: 'Saving...',
+ saveSuccess: 'Config saved',
+ saveFailed: 'Save failed',
+ notConfigured: 'Not configured. Go to',
+ developerConsole: 'Developer Console',
+ createOAuthApp: 'to create an OAuth app.',
+ callbackUrl: 'Callback URL',
+ usageGuide: 'Usage Guide',
+ step1: '1. Create an OAuth app in the developer console of the corresponding platform to get Client ID and Client Secret.',
+ step2: '2. Fill in the callback URL shown above in the OAuth app configuration.',
+ step3: '3. Enter the Client ID and Client Secret on this page and enable it.',
+ step4: '4. Users can bind OAuth accounts in their profile settings, then use quick login.',
+ warning: 'Note: Users must register an account and bind it first, cannot directly register with OAuth.',
+ configure: 'Configure',
+ enterClientId: 'Enter Client ID',
+ enterClientSecret: 'Enter Client Secret',
+ leaveEmptyUnchanged: '(leave empty to keep unchanged)',
+ enableLogin: 'Enable this login method',
+ enableLoginHint: 'Users can use this method for quick login when enabled',
+ },
+ // Payment Providers Management
+ paymentProviders: {
+ title: 'Payment Providers',
+ description: 'Configure and manage payment providers',
+ add: 'Add Provider',
+ create: 'Add Provider',
+ noProviders: 'No payment providers',
+ empty: 'No payment providers configured yet',
+ createFirst: 'Add the first payment provider',
+ providerName: 'Provider Name',
+ providerType: 'Provider Type',
+ displayName: 'Display Name',
+ description_label: 'Description',
+ status: 'Status',
+ statusActive: 'Active',
+ statusDisabled: 'Disabled',
+ statusTesting: 'Testing',
+ feeRate: 'Fee Rate',
+ feeFixed: 'Fixed Fee',
+ minAmount: 'Min Amount',
+ maxAmount: 'Max Amount',
+ sortOrder: 'Sort Order',
+ methods: 'Payment Methods',
+ paymentMethods: 'Supported Payment Methods',
+ configLabel: 'Config',
+ configPlaceholder: 'JSON format provider config',
+ edit: 'Edit',
+ delete: 'Delete',
+ name: 'Name',
+ namePlaceholder: 'Enter provider name',
+ nameRequired: 'Please enter provider name',
+ type: 'Type',
+ notImplemented: 'Pending implementation',
+ providerTypes: {
+ yipay: 'Yipay',
+ heleket: 'Heleket',
+ stripe: 'Stripe',
+ alipayDirect: 'Alipay Direct',
+ wechatDirect: 'WeChat Direct',
+ manual: 'Manual Recharge',
+ },
+ deleteConfirm: 'Confirm Delete',
+ confirmDelete: 'Are you sure to delete payment provider "{name}"?',
+ deleteWarning: 'Are you sure to delete payment provider "{name}"? This action cannot be undone.',
+ createSuccess: 'Payment provider created successfully',
+ updateSuccess: 'Payment provider updated successfully',
+ deleteSuccess: 'Payment provider deleted',
+ statusUpdated: 'Status updated',
+ statusUpdateSuccess: 'Status updated successfully',
+ statusUpdateFailed: 'Failed to update status',
+ saveFailed: 'Save failed',
+ createFailed: 'Failed to create payment provider',
+ updateFailed: 'Failed to update payment provider',
+ deleteFailed: 'Delete failed',
+ loadFailed: 'Load failed',
+ createProvider: 'Add Payment Provider',
+ editProvider: 'Edit Payment Provider',
+ displayNamePlaceholder: 'Name shown to users',
+ descPlaceholder: 'Optional provider description',
+ feeRateHint: 'e.g. 0.02 means 2% fee',
+ types: {
+ alipay: 'Alipay',
+ wechat: 'WeChat Pay',
+ stripe: 'Stripe',
+ paypal: 'PayPal',
+ usdt: 'USDT',
+ manual: 'Manual',
+ },
+ config: {
+ sdkVersion: 'SDK Version',
+ apiurl: 'API URL',
+ pid: 'Merchant ID',
+ key: 'Merchant Key',
+ platformPublicKey: 'Platform Public Key',
+ merchantPrivateKey: 'Merchant Private Key',
+ heleketMerchantUuid: 'Merchant UUID',
+ heleketApiKey: 'API Key',
+ heleketInvoiceCurrency: 'Invoice Fiat Currency',
+ heleketLifetime: 'Invoice Lifetime (Seconds)',
+ heleketApiUrlHint: 'Uses the official Heleket endpoint by default. Switch to a proxy or private gateway if needed.',
+ heleketCurrencyHint: 'The fiat currency used when creating Heleket invoices. CNY is the common choice.',
+ heleketLifetimeHint: 'Invoice lifetime used for Heleket. Defaults to 3600 seconds and is mirrored to the local order expiry.',
+ yipayVersionV1: 'V1 (MD5 Signature) - Legacy',
+ yipayVersionV2: 'V2 (RSA Signature) - New',
+ yipayVersionV1Hint: 'V1 uses MD5 signing and is intended for legacy Yipay deployments.',
+ yipayVersionV2Hint: 'V2 uses RSA signing and is intended for newer Rainbow Yipay deployments.',
+ yipayApiUrlPlaceholder: 'e.g. https://pay.example.com/',
+ yipayApiUrlHint: 'Payment API URL, ending with /.',
+ yipayPidPlaceholder: 'Merchant ID',
+ yipayKeyPlaceholder: 'Merchant Key',
+ yipayKeyHint: 'The merchant key issued by the Yipay platform.',
+ platformPublicKeyPlaceholder: 'Platform Public Key (RSA)',
+ platformPublicKeyHint: 'The public key provided by the platform for verifying returned signatures.',
+ merchantPrivateKeyPlaceholder: 'Merchant Private Key (RSA)',
+ merchantPrivateKeyHint: 'Your generated RSA private key used to sign requests.',
+ yipayMethodsHint: 'Choose at least one payment method supported by this provider.',
+ yipayMethodFeeHint: 'Fees are added to the payable amount based on the selected payment method. The recharge principal is still credited in full.',
+ yipayFeeFieldHint: 'Set Yipay fees per payment method above.',
+ heleketMethods: 'Common Currency Display',
+ heleketMethodsPlaceholder: 'USDT@TRON\nUSDT@BSC\nBTC\nETH',
+ heleketMethodsHint: 'Used only as an admin-side display/reference list. It does not restrict the final currency or network chosen on the Heleket payment page.',
+ instructions: 'Recharge Instructions',
+ instructionsPlaceholder: 'Enter instructions shown to users when recharging...',
+ },
+ },
+ // Billing Management (legacy key name)
+ billingManage: {
+ title: 'Billing Management',
+ description: 'Manage paid instances and billing records',
+ tabOverview: 'Overview',
+ tabInstances: 'Paid Instances',
+ tabRecords: 'Billing Records',
+ totalIncome: 'Total Income',
+ monthIncome: 'Monthly Income',
+ todayIncome: 'Today Income',
+ totalRefund: 'Total Refund',
+ paidInstances: 'Paid Instances',
+ activeInstances: 'Active Instances',
+ searchPlaceholder: 'Search instances...',
+ instanceId: 'Instance ID',
+ instanceName: 'Name',
+ user: 'User',
+ plan: 'Plan',
+ expiresAt: 'Expires At',
+ status: 'Status',
+ actions: 'Actions',
+ noInstances: 'No paid instances',
+ suspend: 'Suspend',
+ unsuspend: 'Unsuspend',
+ extend: 'Extend',
+ refund: 'Refund',
+ confirmSuspend: 'Are you sure to suspend instance "{name}"?',
+ confirmUnsuspend: 'Are you sure to unsuspend instance "{name}"?',
+ suspendSuccess: 'Instance suspended',
+ unsuspendSuccess: 'Instance unsuspended',
+ extendTitle: 'Extend Instance',
+ extendDays: 'Extend Days',
+ extendReason: 'Extend Reason',
+ freeExtend: 'Free Extend',
+ freeExtendHint: 'No deduction from user balance when checked',
+ extendSuccess: 'Instance extended',
+ extendFailed: 'Extend failed',
+ refundTitle: 'Refund Instance',
+ refundAmount: 'Refund Amount',
+ refundReason: 'Refund Reason',
+ refundSuccess: 'Refund successful',
+ refundFailed: 'Refund failed',
+ recordId: 'Record ID',
+ recordType: 'Type',
+ amount: 'Amount',
+ period: 'Period',
+ remark: 'Remark',
+ createdAt: 'Time',
+ noRecords: 'No billing records',
+ userBalance: 'User Balance',
+ adjustBalance: 'Adjust Balance',
+ giftBalance: 'Gift Balance',
+ adjustTitle: 'Adjust User Balance',
+ giftTitle: 'Gift Balance',
+ adjustAmount: 'Adjust Amount',
+ giftAmount: 'Gift Amount',
+ adjustRemark: 'Adjust Reason',
+ giftRemark: 'Gift Remark',
+ adjustHint: 'Positive to add, negative to deduct',
+ adjustSuccess: 'Balance adjusted successfully',
+ giftSuccess: 'Balance gifted successfully',
+ operationFailed: 'Operation failed',
+ },
+ // Billing Management (new key name, matching component)
+ billing: {
+ title: 'Billing Management',
+ description: 'Manage paid instances and billing records',
+ loadFailed: 'Failed to load billing data',
+ loadInstancesFailed: 'Failed to load instances',
+ loadRecordsFailed: 'Failed to load billing records',
+ // Tab labels
+ tabs: {
+ overview: 'Overview',
+ instances: 'Paid Instances',
+ records: 'Billing Records',
+ rechargeRecords: 'Recharge Records',
+ affConversions: 'AFF Conversions',
+ paymentProviders: 'Payment Providers',
+ },
+ // Overview statistics
+ totalRevenue: 'Total Revenue',
+ thisMonthRevenue: 'This Month',
+ todayRevenue: 'Today',
+ totalRefunds: 'Total Refunds',
+ paidInstances: 'Paid Instances',
+ activeInstances: 'Active Instances',
+ suspendedInstances: 'Suspended Instances',
+ expiringInstances: 'Expiring Soon',
+ netRevenueLabel: 'Net Revenue',
+ thisMonthVsLastMonth: 'This Month / Last Month',
+ hostedRevenueShare: 'Hosted Revenue Share',
+ revenueBreakdownTitle: 'Revenue Mix',
+ directRevenue: 'Direct Revenue',
+ hostedRevenue: 'Hosted Revenue',
+ instanceHealthTitle: 'Instance Health',
+ affOverviewTitle: 'AFF Overview',
+ rechargeLabel: 'Recharge',
+ revenueLabel: 'Revenue',
+ overviewPeriods: {
+ total: 'Overview',
+ thisMonth: 'This Month',
+ today: 'Today',
+ },
+ // Instance list
+ allStatus: 'All Status',
+ allHosts: 'All Hosts',
+ showExpiring: 'Show expiring only',
+ showDateColumns: 'Show date columns',
+ searchPlaceholder: 'Search user/host/instance/plan/package...',
+ user: 'User',
+ host: 'Host',
+ plan: 'Plan',
+ package: 'Package',
+ price: 'Price',
+ expiresAt: 'Expires At',
+ purchaseDate: 'Purchase Date',
+ remainingDays: 'Remaining Days',
+ expired: 'Expired',
+ days: 'days',
+ instanceType: 'Type',
+ instanceName: 'Instance Name',
+ instanceStatus: 'Status',
+ noInstances: 'No paid instances',
+ viewInstance: 'View',
+ autoRenew: 'Auto Renew',
+ // Hosting type
+ hostingType: 'Hosting',
+ direct: 'Direct',
+ hosted: 'Hosted',
+ cycle: 'Cycle',
+ cycleMonths: '{months} month(s)',
+ perPage: 'Per page',
+ totalCount: '{count} items',
+ // Actions
+ suspend: 'Suspend',
+ unsuspend: 'Unsuspend',
+ extend: 'Extend',
+ refund: 'Refund',
+ deleteRefund: 'Delete & Refund',
+ // Action modals
+ suspendTitle: 'Suspend Instance',
+ unsuspendTitle: 'Unsuspend Instance',
+ extendTitle: 'Extend Instance',
+ refundTitle: 'Refund',
+ deleteRefundTitle: 'Delete & Refund',
+ targetInstance: 'Target Instance',
+ reason: 'Reason',
+ reasonPlaceholder: 'Enter reason...',
+ extendDays: 'Extend Days',
+ freeExtend: 'Free extend (no charge)',
+ refundAmount: 'Refund Amount',
+ refundReasonPlaceholder: 'Enter refund reason...',
+ refundReasonRequired: 'Please enter refund reason',
+ // Delete and refund related
+ deleteRefundWarning: 'Warning: This operation will permanently delete the instance and all its data (snapshots, backups, port mappings, etc.). This action cannot be undone!',
+ refundTypeLabel: 'Refund Type',
+ refundTypeRemaining: 'Refund by remaining value (calculated by remaining days)',
+ refundTypeFull: 'Full refund (refund all consumed amount)',
+ deleteRefundReasonPlaceholder: 'Enter delete reason...',
+ deleteRefundReasonRequired: 'Please enter delete reason',
+ deleteRefundSuccess: 'Instance deleted and refunded',
+ databaseOnlyDelete: 'Database Only Delete',
+ deleteRefundDatabaseOnlySuccess: 'Instance deleted from database',
+ // Apply discount
+ applyDiscount: 'Apply Discount',
+ applyDiscountTitle: 'Apply Renewal Discount',
+ applyDiscountHint: 'After entering the AFF code, this instance will automatically receive a 5% discount on future renewals, and the code creator will also receive renewal commissions.',
+ affCodeLabel: 'AFF Code',
+ affCodePlaceholder: 'Enter AFF code...',
+ affCodeRequired: 'Please enter AFF code',
+ applyDiscountSuccess: 'Renewal discount applied successfully',
+ // Update price
+ updatePrice: 'Update Price',
+ owner: 'Owner',
+ currentPrice: 'Current Price',
+ newPrice: 'New Price',
+ enterNewPrice: 'Enter new price',
+ priceHint: 'This is the price per billing cycle, changes will affect renewal fees',
+ settleBalance: 'Settle balance (charge/refund based on remaining days)',
+ needPay: 'Need to pay',
+ willRefund: 'Will refund',
+ priceDiffHint: 'Calculated based on {days} remaining days, will automatically process user balance',
+ noSettleHint: 'Without balance settlement, only future renewal price will change, current cycle unaffected',
+ affDiscount: 'AFF Discount',
+ actualRenewPrice: 'Actual Renew Price',
+ affAppliedHint: 'This instance has a promo code applied, {discount}% discount on renewal. Price difference is calculated based on discounted price.',
+ newActualPrice: 'New Actual Price',
+ // Upgrade plan
+ upgradePlan: 'Upgrade',
+ upgradePlanTitle: 'Upgrade Plan',
+ currentPlan: 'Current Plan',
+ planName: 'Plan Name',
+ monthlyPrice: 'Monthly Price',
+ memoryLabel: 'Memory',
+ diskLabel: 'Disk',
+ selectNewPlan: 'Select New Plan',
+ noAvailablePlans: 'No plans available for upgrade (new plan monthly price must be higher)',
+ priceDifference: 'Price Difference',
+ priceDifferenceHint: 'Calculated based on remaining days, will be deducted from user balance',
+ userBalance: 'User Balance',
+ insufficientBalance: 'Insufficient user balance, cannot complete upgrade',
+ confirmUpgrade: 'Confirm Upgrade',
+ monthly: 'Monthly',
+ quarterly: 'Quarterly',
+ semiAnnual: 'Semi-Annual',
+ yearly: 'Yearly',
+ month: 'month',
+ months: 'months',
+ // Action results
+ suspendSuccess: 'Instance suspended',
+ unsuspendSuccess: 'Instance unsuspended',
+ extendSuccess: 'Instance extended',
+ refundSuccess: 'Refund successful',
+ batchUpdatePrice: 'Batch Update Price',
+ batchSelectedCount: '{count} selected',
+ selectAllCurrentPage: 'Select current page instances',
+ selectInstance: 'Select instance',
+ batchPriceNoSelection: 'Select instances first',
+ batchSelected: 'Selected',
+ batchPreviewChanged: 'Will update',
+ batchPreviewFailed: 'Failed',
+ batchPriceHint: 'Applies one cycle price to all selected instances. Preview calculates each instance by its cycle, remaining days, and AFF discount.',
+ batchTotalCharge: 'Total charge',
+ batchTotalRefund: 'Total refund',
+ batchNetAmount: 'Net impact',
+ batchPreviewBlocked: 'This preview has failed items or insufficient user balance and cannot be submitted.',
+ batchUserImpact: 'User balance impact',
+ batchPreviewDetails: 'Preview details',
+ batchPreviewWaiting: 'Preview will be generated after entering a price.',
+ batchPriceStatusReady: 'Ready',
+ batchPriceStatusUnchanged: 'No change',
+ batchPriceStatusFailed: 'Failed',
+ result: 'Result',
+ // Billing records
+ recordType: 'Type',
+ recordTypes: {
+ newPurchase: 'Purchase',
+ renew: 'Renewal',
+ upgrade: 'Upgrade',
+ downgrade: 'Downgrade',
+ refund: 'Refund',
+ transfer_fee: 'Transfer Fee',
+ },
+ amount: 'Amount',
+ instance: 'Instance',
+ remark: 'Remark',
+ time: 'Time',
+ noRecords: 'No billing records',
+ // Recharge statistics
+ totalRecharge: 'Total Recharge',
+ thisMonthRecharge: 'This Month Recharge',
+ todayRecharge: 'Today Recharge',
+ orders: 'orders',
+ // AFF commission statistics
+ totalAffCommission: 'Total AFF Commission',
+ thisMonthAff: 'This Month AFF',
+ affConverted: 'Converted Amount',
+ affPendingConvert: 'Pending Convert',
+ // Recharge records
+ loadRechargeRecordsFailed: 'Failed to load recharge records',
+ noRechargeRecords: 'No recharge records',
+ rechargeOrderNo: 'Order No.',
+ creditAmount: 'Credit Amount',
+ actualAmount: 'Credited',
+ estimatedAmount: 'Estimated Credit',
+ payChannel: 'Pay Channel',
+ paymentDetails: 'Payment Details',
+ paymentUuid: 'UUID:',
+ paymentTxid: 'TxID:',
+ rechargeStatusLabel: 'Status',
+ tradeNo: 'Trade No.',
+ sync: 'Sync',
+ syncSuccess: 'Sync successful, recharge completed',
+ rechargeStatus: {
+ pending: 'Pending',
+ completed: 'Completed',
+ cancelled: 'Cancelled',
+ failed: 'Failed',
+ },
+ },
+ },
+
+ // Error pages
+ error: {
+ notFound: 'Page Not Found',
+ notFoundDesc: 'Sorry, the page you visited does not exist',
+ backHome: 'Back to Home',
+ serverError: 'Server Error',
+ networkError: 'Network Error',
+ },
+
+ // Logs
+ logs: {
+ title: 'System Logs',
+ module: 'Module',
+ allModules: 'All Modules',
+ search: 'Search',
+ searchPlaceholder: 'Search username, action or content...',
+ reset: 'Reset',
+ time: 'Time',
+ user: 'User',
+ action: 'Action',
+ content: 'Content',
+ result: 'Result',
+ system: 'System',
+ loading: 'Loading...',
+ noLogs: 'No logs',
+ loadFailed: 'Failed to load logs',
+ loadModulesFailed: 'Failed to load modules',
+ totalRecords: '{total} records, page {page} / {totalPages}',
+ success: 'Success',
+ failed: 'Failed',
+ expand: 'Expand',
+ collapse: 'Collapse',
+ },
+
+ // Help
+ help: {
+ title: 'Help Center',
+ search: 'Search help...',
+ description: 'View guides and FAQs',
+ backToHelp: 'Back to Help Center',
+ updatedAt: 'Updated on {date}',
+ all: 'All',
+ noArticles: 'No help articles',
+ articleNotFound: 'Article not found or has been deleted',
+ totalArticles: '{count} articles',
+ categories: {
+ general: 'General',
+ gettingStarted: 'Getting Started',
+ instances: 'Instances',
+ networking: 'Networking',
+ billing: 'Billing',
+ faq: 'FAQ',
+ },
+ },
+
+ // Log modules translation
+ logModules: {
+ security: 'Security',
+ instance: 'Instance',
+ snapshot: 'Snapshot',
+ backup: 'Backup',
+ image: 'Image',
+ host: 'Host',
+ package: 'Package',
+ user: 'User',
+ personal: 'Personal',
+ ssh_key: 'SSH Key',
+ notification: 'Notification',
+ system: 'System',
+ auth: 'Auth',
+ storage: 'Storage',
+ // Legacy Chinese module names compatibility
+ '登录操作': 'Login',
+ '安全事件': 'Security',
+ '实例操作': 'Instance',
+ '快照操作': 'Snapshot',
+ '备份操作': 'Backup',
+ '镜像操作': 'Image',
+ '节点操作': 'Host',
+ '节点组操作': 'Node Group',
+ '套餐操作': 'Package',
+ '用户管理': 'User',
+ '个人设置': 'Personal',
+ '通知设置': 'Notification',
+ '系统配置': 'System',
+ '认证操作': 'Auth',
+ '远程存储': 'Storage',
+ },
+
+ // Log actions translation
+ logActions: {
+ // Security events
+ 'login_success': 'Login Success',
+ 'login_failed': 'Login Failed',
+ 'logout': 'Logout',
+ 'register_success': 'Register Success',
+ 'rate_limit_exceeded': 'Rate Limit Exceeded',
+ 'invalid_invite_code': 'Invalid Invite Code',
+ 'suspicious_activity': 'Suspicious Activity',
+ 'permission_denied': 'Permission Denied',
+ 'unauthorized_access': 'Unauthorized Access',
+ 'admin_action': 'Admin Action',
+ // Instance operations
+ 'instance.create': 'Create Instance',
+ 'instance.delete': 'Delete Instance',
+ 'instance.start': 'Start Instance',
+ 'instance.stop': 'Stop Instance',
+ 'instance.restart': 'Restart Instance',
+ 'instance.rebuild': 'Rebuild System',
+ 'instance.recreate': 'Recreate Instance',
+ 'instance.change_host': 'Change Host',
+ 'instance.cloud_init_manual_complete': 'Manually Complete Init Check',
+ 'instance.update_quota': 'Update Instance Quota',
+ 'instance.rename': 'Rename Instance',
+ // Port mapping
+ 'port.add': 'Add Port Mapping',
+ 'port.delete': 'Delete Port Mapping',
+ // Snapshot operations
+ 'snapshot.create': 'Create Snapshot',
+ 'snapshot.delete': 'Delete Snapshot',
+ 'snapshot.restore': 'Restore Snapshot',
+ // Backup operations
+ 'backup.create': 'Create Backup',
+ 'backup.delete': 'Delete Backup',
+ 'backup.export': 'Export Backup',
+ // Image operations
+ 'image.sync': 'Sync Image',
+ 'image.delete': 'Delete Image',
+ 'image.create': 'Create Image',
+ 'image.update': 'Update Image',
+ // Host operations
+ 'host.create': 'Add Host',
+ 'host.update': 'Update Host',
+ 'host.delete': 'Delete Host',
+ 'host.test': 'Test Host',
+ // Node group operations
+ // Package operations
+ 'package.create': 'Create Package',
+ 'package.update': 'Update Package',
+ 'package.delete': 'Delete Package',
+ // User management
+ 'user.ban': 'Ban User',
+ 'user.unban': 'Unban User',
+ 'user.delete': 'Delete User',
+ 'user.update_quota': 'Update User Quota',
+ 'user.revoke_sessions': 'Revoke User Sessions',
+ // Personal settings
+ 'profile.update': 'Update Profile',
+ 'password.change': 'Change Password',
+ // SSH key
+ 'ssh_key.add': 'Add SSH Key',
+ 'ssh_key.delete': 'Delete SSH Key',
+ // Notification settings
+ 'notification.add': 'Add Notification Channel',
+ 'notification.delete': 'Delete Notification Channel',
+ // Security settings
+ '2fa.setup': 'Setup 2FA',
+ '2fa.enable': 'Enable 2FA',
+ '2fa.disable': 'Disable 2FA',
+ '2fa.recovery_reset': 'Reset Recovery Codes',
+ // Auth operations
+ 'session.revoke': 'Revoke Session',
+ 'session.revoke_all': 'Revoke All Sessions',
+ // System config
+ 'system.config_update': 'Update System Config',
+ // Help articles
+ 'help.create': 'Create Help Article',
+ 'help.update': 'Update Help Article',
+ 'help.delete': 'Delete Help Article',
+ // Backup upload operations
+ 'backup.upload': 'Upload Backup',
+ 'backup.upload.queue': 'Queue Backup Upload',
+ 'backup.upload.cancel': 'Cancel Backup Upload',
+ // Backup restore operations
+ 'backup.restore': 'Restore Backup',
+ 'backup.restore.rollback': 'Rollback Restore',
+ 'backup.restore.cancel': 'Cancel Restore',
+ // Storage config operations
+ 'storage.create': 'Create Storage Config',
+ 'storage.update': 'Update Storage Config',
+ 'storage.delete': 'Delete Storage Config',
+ // AFF Referral Program
+ 'aff.create_code': 'Create Promo Code',
+ 'aff.convert_request': 'Request AFF Conversion',
+ 'aff.approve_convert': 'Approve AFF Conversion',
+ 'aff.reject_convert': 'Reject AFF Conversion',
+ },
+
+ // Log results translation
+ logResults: {
+ success: 'Success',
+ failed: 'Failed',
+ warning: 'Warning',
+ },
+
+ // API error codes translation
+ errors: {
+ // Common errors
+ INVALID_ID: 'Invalid ID',
+ NOT_FOUND: 'Resource not found',
+ UNAUTHORIZED: 'Unauthorized',
+ FORBIDDEN: 'Access denied',
+ ADMIN_REQUIRED: 'Admin privileges required',
+ // User errors
+ USER_NOT_FOUND: 'User not found',
+ USER_EXISTS: 'Username already exists',
+ CANNOT_MODIFY_SELF: 'Cannot modify your own status',
+ CANNOT_DELETE_SELF: 'Cannot delete yourself',
+ CANNOT_BAN_ADMIN: 'Cannot ban admin account',
+ CANNOT_DELETE_ADMIN: 'Cannot delete admin account',
+ USER_HAS_INSTANCES: 'User has instances, please delete them first',
+ // Auth errors
+ INVALID_CREDENTIALS: 'Invalid username or password',
+ ACCOUNT_BANNED: 'Account has been disabled',
+ TOO_MANY_ATTEMPTS: 'Too many login attempts, please try again later',
+ REGISTRATION_DISABLED: 'Registration is currently closed',
+ INVALID_INVITE_CODE: 'Invalid or used invite code',
+ INVITE_CODE_EXPIRED: 'Invite code has expired',
+ INVALID_2FA_CODE: 'Invalid verification code or recovery code',
+ TWO_FA_REQUIRED: 'Two-factor authentication required',
+ TWO_FA_ALREADY_ENABLED: '2FA is already enabled, please disable it first',
+ TWO_FA_NOT_ENABLED: '2FA is not enabled',
+ REFRESH_TOKEN_MISSING: 'Refresh token missing',
+ REFRESH_TOKEN_INVALID: 'Refresh token invalid or expired',
+ SESSION_NOT_FOUND: 'Session not found',
+ // Validation errors
+ INVALID_EMAIL: 'Please enter a valid email address',
+ EMAIL_CONTAINS_ILLEGAL: 'Email contains illegal characters',
+ USERNAME_CONTAINS_ILLEGAL: 'Username contains illegal characters',
+ PASSWORD_TOO_WEAK: 'Password is too weak',
+ INVALID_SSH_KEY: 'Invalid SSH public key format',
+ SSH_KEY_EXISTS: 'This SSH key has already been added',
+ INVALID_NAME: 'Invalid name format',
+ // Instance errors
+ INSTANCE_NOT_FOUND: 'Instance not found',
+ INSTANCE_ALREADY_RUNNING: 'Instance is already running',
+ INSTANCE_ALREADY_STOPPED: 'Instance is already stopped',
+ INSTANCE_STATUS_INVALID: 'Instance status does not allow this operation',
+ INSTANCE_SUSPENDED: 'Instance is suspended and cannot perform this operation',
+ INSTANCE_NOT_SUSPENDED: 'Instance is not in suspended status',
+ INSTANCE_SUSPENDED_EXPIRED: 'Instance is suspended due to expiration, please renew to unsuspend',
+ INSTANCE_DESTROY_TRAFFIC_LIMIT_EXCEEDED: 'Destroy is unavailable in the current monthly traffic cycle because usage has reached 5G',
+ // Host errors
+ HOST_NOT_FOUND: 'Host not found',
+ HOST_OFFLINE: 'Host is offline',
+ HOST_HAS_INSTANCES: 'Host has instances, please delete them first',
+ HOST_ALREADY_OFFICIAL: 'Host is already official',
+ HOST_TAKEOVER_PACKAGE_BINDING_CONFLICT: 'Takeover blocked because some packages would lose all bound hosts',
+ NO_AVAILABLE_HOSTS: 'No available hosts',
+ // Image errors
+ IMAGE_NOT_FOUND: 'Image not found',
+ IMAGE_SYNCED_ON_HOSTS: 'Image is synced on hosts, please delete from hosts first',
+ IMAGE_TYPE_MISMATCH: 'Selected image is not compatible with package instance type',
+ // Package errors
+ PACKAGE_NOT_FOUND: 'Package not found',
+ PACKAGE_IN_USE: 'Package is in use by instances',
+ // Quota errors
+ QUOTA_EXCEEDED: 'Quota exceeded',
+ QUOTA_CPU_EXCEEDED: 'CPU quota exceeded',
+ QUOTA_MEMORY_EXCEEDED: 'Memory quota exceeded',
+ QUOTA_DISK_EXCEEDED: 'Disk quota exceeded',
+ QUOTA_INSTANCE_EXCEEDED: 'Instance quota exceeded',
+ QUOTA_PORT_EXCEEDED: 'Port quota exceeded',
+ QUOTA_SNAPSHOT_EXCEEDED: 'Snapshot quota exceeded',
+ QUOTA_BACKUP_EXCEEDED: 'Backup quota exceeded',
+ QUOTA_NOT_ALLOCATED: 'Quota not allocated',
+ // Snapshot errors
+ SNAPSHOT_NOT_FOUND: 'Snapshot not found',
+ SNAPSHOT_RESTORE_REQUIRES_STOP: 'Please stop the instance before restoring snapshot',
+ // Backup errors
+ BACKUP_NOT_FOUND: 'Backup not found',
+ BACKUP_NOT_READY: 'Backup is not ready for export',
+ EXPORT_TASK_NOT_FOUND: 'Export task not found or expired',
+ EXPORT_TASK_EXPIRED: 'Export task has expired',
+ // Port mapping errors
+ PORT_IN_USE: 'Port is already in use',
+ PORT_OUT_OF_RANGE: 'Port is out of allowed range',
+ PORT_MAPPING_NOT_FOUND: 'Port mapping not found',
+ // Node group errors
+ NODE_GROUP_NOT_FOUND: 'Node group not found',
+ NODE_GROUP_HAS_HOSTS: 'Node group has hosts, please remove them first',
+ NODE_GROUP_HAS_PACKAGES: 'Node group is used by packages',
+ // Notification errors
+ NOTIFICATION_CHANNEL_NOT_FOUND: 'Notification channel not found',
+ // Help errors
+ ARTICLE_NOT_FOUND: 'Article not found',
+ SLUG_EXISTS: 'Slug already exists',
+ // Invite code errors
+ INVITE_CODE_USED: 'Invite code has been used',
+ INVITE_CODE_NOT_FOUND: 'Invite code not found',
+ // OAuth errors
+ OAUTH_PROVIDER_DISABLED: 'This login method has been disabled',
+ OAUTH_ALREADY_BOUND: 'This account is already bound to another user',
+ OAUTH_NOT_BOUND: 'Account not bound',
+ OAUTH_TOKEN_ERROR: 'Failed to get authorization',
+ // SSH Key errors
+ SSH_KEY_NOT_FOUND: 'SSH key not found',
+ SSH_KEY_REQUIRED: 'SSH key is required, please add one in settings first',
+ SSH_KEY_NOT_OWNED: 'SSH key not found or does not belong to this user',
+ // Package errors (additional)
+ PACKAGE_UNAVAILABLE: 'Package is unavailable or discontinued',
+ CANNOT_CREATE_OWN_PAID_PACKAGE: 'Cannot create instance with your own paid package',
+ // Host resource errors
+ HOST_UNAVAILABLE: 'Selected host is unavailable or has insufficient resources',
+ HOST_NO_ONLINE: 'No online hosts available, please test connection in node management',
+ HOST_RESOURCES_NOT_SYNCED: 'Host resources not synced, please click "Test Connection" in node management',
+ HOST_NODE_GROUP_NO_HOSTS: 'No available hosts in the required node group',
+ HOST_RESOURCES_INSUFFICIENT: 'All hosts have insufficient resources, please try later or choose another package',
+ HOST_NAME_EXISTS: 'Host name already exists',
+ HOST_ADDRESS_EXISTS: 'Host connection address already exists',
+ HOST_ADDRESS_UNRESOLVABLE: 'The panel could not resolve this host connection address',
+ HOST_CERT_NOT_CONFIGURED: 'Please configure certificate path first',
+ HOST_INVALID_CPU_MAX: 'CPU max allowance cannot be negative',
+ HOST_INVALID_MEMORY_MAX: 'Memory max cannot be negative or less than 256MB',
+ HOST_INVALID_IPV6_MODE: 'IPv6 mode must be 1 (route), 2 (NAT), or 3 (disabled)',
+ HOST_IPV6_ROUTE_REQUIRES_CONFIG: 'IPv6 route mode requires subnet and parent interface configuration',
+ HOST_CPU_BELOW_USED: 'CPU max allowance cannot be less than current usage by instances',
+ HOST_MEMORY_BELOW_USED: 'Memory max cannot be less than current usage by instances',
+ // Instance operation errors
+ INSTANCE_STOP_REQUIRED: 'Please stop the instance first',
+ INSTANCE_IMAGE_REQUIRED: 'Please specify an image',
+ INSTANCE_IMAGE_UNAVAILABLE: 'Selected image is not available on this host',
+ INSTANCE_REBUILD_FAILED: 'Instance rebuild failed',
+ INSTANCE_IPV6_NOT_SUPPORTED: 'Only NAT + IPv6 mode instances support IPv6 reassignment',
+ INSTANCE_IPV6_REASSIGN_FAILED: 'Failed to reassign IPv6 address',
+ INSTANCE_NO_IPV4: 'VM instance requires IPv4 address for port mapping',
+ HOST_NO_IPV6_SUBNET: 'Host has no IPv6 subnet configured',
+ IPV6_POOL_EXHAUSTED: 'IPv6 address pool exhausted, please try again later',
+ IPV6_REASSIGN_COOLDOWN: 'IPv6 can only be reassigned once per day, please try again later',
+ // Port mapping errors (additional)
+ PORT_MAPPING_NAT_ONLY: 'Port mapping is only supported in NAT or dual-stack mode',
+ PORT_NO_AVAILABLE: 'No available ports, please contact administrator',
+ PORT_CONFLICT: 'Port is already in use, please use another port',
+ PORT_MAPPING_INVALID_ID: 'Invalid instance or port mapping ID',
+ // Image errors (additional)
+ IMAGE_CREATE_FAILED: 'Image creation failed',
+ IMAGE_UPDATE_FAILED: 'Image update failed',
+ IMAGE_INVALID_HOST_ID: 'Invalid image or host ID',
+ // Backup errors (additional)
+ BACKUP_CREATE_FAILED: 'Backup creation failed',
+ BACKUP_DELETE_FAILED: 'Backup deletion failed',
+ BACKUP_EXPORT_FAILED: 'Backup export failed',
+ BACKUP_QUOTA_NOT_SET: 'Please set backup quota for this instance first',
+ BACKUP_EXPORT_STATUS_INVALID: 'Export task status is invalid',
+ // Config errors
+ CONFIG_INVALID_KEY: 'Invalid configuration key',
+ CONFIG_INVALID_VALUE: 'Configuration value must be a non-negative integer',
+ // Snapshot errors (additional)
+ SNAPSHOT_QUOTA_NOT_SET: 'Please set snapshot quota for this instance first',
+ // Package errors (additional)
+ PACKAGE_HAS_INSTANCES: 'Package is in use by instances and cannot be deleted',
+ // Resource limit errors
+ RESOURCE_CPU_EXCEEDS_PACKAGE: 'CPU configuration exceeds package limit',
+ RESOURCE_MEMORY_EXCEEDS_PACKAGE: 'Memory configuration exceeds package limit',
+ RESOURCE_DISK_EXCEEDS_PACKAGE: 'Disk configuration exceeds package limit',
+ // User quota errors (detailed)
+ QUOTA_CPU_INSUFFICIENT: 'CPU quota insufficient',
+ QUOTA_MEMORY_INSUFFICIENT: 'Memory quota insufficient',
+ QUOTA_DISK_INSUFFICIENT: 'Disk quota insufficient',
+ QUOTA_INSTANCE_LIMIT_REACHED: 'Instance limit reached',
+ QUOTA_HOST_LIMIT_REACHED: 'Host limit reached',
+ QUOTA_FRIEND_LIMIT_REACHED: 'Your friend limit reached',
+ QUOTA_PORT_BELOW_USED: 'Port quota cannot be less than current usage',
+ QUOTA_SNAPSHOT_BELOW_USED: 'Snapshot quota cannot be less than current usage',
+ QUOTA_BACKUP_BELOW_USED: 'Backup quota cannot be less than current usage',
+ QUOTA_PORT_TOTAL_EXCEEDED: 'Total port quota exceeds user limit',
+ QUOTA_SNAPSHOT_TOTAL_EXCEEDED: 'Total snapshot quota exceeds user limit',
+ QUOTA_BACKUP_TOTAL_EXCEEDED: 'Total backup quota exceeds user limit',
+ // Image errors (additional)
+ IMAGE_NOT_SYNCED: 'Image not synced to selected host',
+ IMAGE_SYNCING_CANNOT_DELETE: 'Image is syncing on hosts and cannot be deleted',
+ IMAGE_NO_HOSTS: 'No available hosts, please add hosts first',
+ // Port errors (additional)
+ PORT_RANGE_INVALID: 'Port must be within allowed range',
+ // Snapshot policy errors
+ SNAPSHOT_MANUAL_FULL: 'Manual snapshots have reached instance quota, cannot enable auto snapshot',
+ SNAPSHOT_RETENTION_EXCEEDS: 'Retention count exceeds available quota',
+ // Backup policy errors
+ BACKUP_MANUAL_FULL: 'Manual backups have reached instance quota, cannot enable auto backup',
+ BACKUP_RETENTION_EXCEEDS: 'Retention count exceeds available quota',
+ // OAuth errors (additional)
+ OAUTH_NOT_ENABLED: 'This OAuth provider is not enabled',
+ // Storage pool errors
+ STORAGE_POOL_NOT_CONFIGURED: 'No system disk storage pool configured on host, cannot create instance',
+ // Friends system errors
+ CANNOT_ADD_SELF: 'Cannot add yourself as a friend',
+ ALREADY_FRIENDS: 'You are already friends',
+ FRIEND_REQUEST_PENDING: 'Friend request is already pending',
+ FRIEND_REQUEST_NOT_FOUND: 'Friend request not found',
+ FRIEND_REQUEST_NOT_PENDING: 'Request has already been processed',
+ FRIENDSHIP_NOT_FOUND: 'Friendship not found',
+ TARGET_FRIEND_QUOTA_FULL: 'Target user friend quota is full, cannot add',
+ // Package share errors
+ CANNOT_SHARE_TO_SELF: 'Cannot share to yourself',
+ PACKAGE_ALREADY_SHARED: 'Package is already shared to this user',
+ SHARE_NOT_FOUND: 'Share record not found',
+ NOT_FRIENDS: 'Target user is not your friend',
+ SHARE_QUOTA_CPU_EXCEEDED: 'Shared package CPU quota exceeded',
+ SHARE_QUOTA_MEMORY_EXCEEDED: 'Shared package memory quota exceeded',
+ SHARE_QUOTA_INSTANCES_EXCEEDED: 'Shared package instance limit exceeded',
+ // Email verification errors
+ EMAIL_VERIFICATION_DISABLED: 'Email verification is not enabled',
+ EMAIL_CODE_REQUIRED: 'Email verification code is required',
+ INVALID_EMAIL_CODE: 'Invalid or expired verification code',
+ TOO_MANY_VERIFICATION_REQUESTS: 'Too many verification requests, please try again later',
+ EMAIL_SEND_FAILED: 'Failed to send verification email, please try again later',
+ EMAIL_ALREADY_REGISTERED: 'This email address is already in use',
+ EMAIL_DOMAIN_NOT_ALLOWED: 'This email domain is not allowed for registration, please use another email',
+ // Transfer errors
+ TRANSFER_NOT_FOUND: 'Transfer request not found',
+ TRANSFER_TO_SELF: 'Cannot transfer to yourself',
+ TRANSFER_TO_BANNED: 'Cannot transfer to a banned user',
+ TRANSFER_ALREADY_PENDING: 'This instance already has a pending transfer request',
+ TRANSFER_NOT_PENDING: 'Transfer request is not pending',
+ TRANSFER_INVALID_STATUS: 'Instance status does not allow transfer',
+ TRANSFER_QUOTA_NOT_FOUND: 'Target user quota not found',
+ TRANSFER_QUOTA_INSUFFICIENT: 'Target user quota insufficient',
+ TRANSFER_INSTANCE_LOCKED: 'Instance is locked due to pending transfer',
+ TRANSFER_HOST_DISABLED: 'Transfer is disabled for this host',
+ TRANSFER_INSUFFICIENT_BALANCE: 'Insufficient balance for transfer fee',
+ // Transfer process errors
+ INSTANCE_MUST_BE_STOPPED: 'Instance must be stopped before transfer',
+ INCUS_RENAME_FAILED: 'Failed to rename instance on host',
+ DATABASE_ERROR: 'Database operation failed, please try again',
+ // Sensitive operation verification errors
+ VERIFICATION_REQUIRED: 'This operation requires verification',
+ INVALID_CODE: 'Verification code is invalid or expired',
+ NO_NOTIFICATION_CHANNEL: 'No notification channel configured',
+ EMAIL_NOT_CONFIGURED: 'Email not configured, cannot send verification code',
+ SEND_FAILED: 'Failed to send verification code',
+ // Checkin errors
+ CHECKIN_NO_INSTANCE: 'You need at least one instance to check in',
+ CHECKIN_ALREADY_TODAY: 'You have already checked in today',
+ REDEEM_ALREADY_TODAY: 'You have already redeemed a code today',
+ REDEEM_CODE_NOT_FOUND: 'Redeem code not found',
+ REDEEM_CODE_USED: 'Redeem code has already been used',
+ REDEEM_CODE_EXPIRED: 'Redeem code has expired',
+ REDEEM_CODE_SELF_ONLY: 'This redeem code can only be used by its owner',
+ REDEEM_CODE_DISABLED: 'This redeem code has been disabled',
+ REDEEM_CODE_INVALID_FORMAT: 'Invalid code format, only h- prefix codes are supported',
+ REDEEM_CODE_EXHAUSTED: 'This redeem code has reached its maximum usage limit',
+ REDEEM_CODE_ALREADY_USED_BY_USER: 'You have already used this redeem code',
+ REDEEM_CODE_HOST_MISMATCH: 'This redeem code can only be used on instances from the same host',
+ REDEEM_CODE_BATCH_LIMIT: 'You have already used another redeem code from this batch',
+ REDEEM_EXCEEDS_PACKAGE_QUOTA: 'Redeeming would exceed instance package quota',
+ REDEEM_ALREADY_AT_LIMIT: 'Instance resource is already at package limit',
+ CHECKIN_CODE_PAID_INSTANCE: 'Check-in redeem codes can only be used on free instances',
+ PAID_INSTANCE_DELETION_NOT_ALLOWED: 'Paid instances cannot be deleted',
+ // Balance errors
+ INSUFFICIENT_BALANCE: 'Insufficient balance, please top up first',
+ INTERNAL_ERROR: 'Internal server error',
+ },
+
+ // Traffic Statistics
+ traffic: {
+ title: 'Traffic Statistics',
+ monthlyUsage: 'Monthly Traffic',
+ used: 'Used',
+ unlimited: 'Unlimited',
+ total: 'Total',
+ history30Days: 'Last 30 Days Traffic',
+ historyPeriod: 'Period Traffic',
+ noData: 'No traffic data',
+ noHistoryData: 'No history data',
+ throttledHint: 'Throttled to 1Mbps',
+ resetHint: 'Resets on the 1st of each month',
+ periodResetHint: 'Resets on the {date}th of each month',
+ status: {
+ normal: 'Normal',
+ warning: 'Warning',
+ limited: 'Limited',
+ },
+ download: 'Download',
+ upload: 'Upload',
+ limit: 'Limit',
+ extraQuota: 'Extra Quota',
+ resetDate: 'Reset Date',
+ nextReset: 'Next Reset',
+ },
+
+ // Instance Transfer
+ transfer: {
+ title: 'Instance Transfer',
+ sentTab: 'Sent',
+ receivedTab: 'Received',
+ pendingCount: 'Pending',
+ noTransfers: 'No transfer records',
+ noPendingTransfers: 'No pending transfers',
+ searchPlaceholder: 'Search by instance name, username, or remark...',
+ // Status
+ status: {
+ pending: 'Pending',
+ processing: 'Processing',
+ accepted: 'Accepted',
+ rejected: 'Rejected',
+ cancelled: 'Cancelled',
+ },
+ // Actions
+ actions: {
+ transfer: 'Transfer',
+ accept: 'Accept',
+ reject: 'Reject',
+ cancel: 'Cancel',
+ push: 'Push',
+ },
+ // Completion times
+ completedAt: 'Completed at',
+ rejectedAt: 'Rejected at',
+ cancelledAt: 'Cancelled at',
+ // Transfer Modal
+ modal: {
+ title: 'Transfer Instance',
+ targetUser: 'Target Username',
+ targetUserPlaceholder: 'Enter target username',
+ searchUser: 'Search User',
+ userNotFound: 'User not found',
+ userBanned: 'This user is banned',
+ cannotTransferToSelf: 'Cannot transfer to yourself',
+ remark: 'Remark (optional)',
+ remarkPlaceholder: 'Enter transfer remark',
+ quotaCheck: 'Quota Check',
+ instance: 'Instance',
+ quotaSufficient: 'Quota sufficient',
+ quotaInsufficient: 'Quota insufficient',
+ canTransfer: 'User status is normal, transfer operation can be performed',
+ confirmTransfer: 'Confirm Transfer',
+ transferring: 'Transferring...',
+ deleteWarning: 'Note: If the recipient accepts the transfer, the system will automatically delete the instance\'s port mappings, proxy sites, snapshots, backups and other associated resources.',
+ feeLabel: 'Transfer Fee',
+ balanceLabel: 'Current Balance',
+ insufficientBalance: 'Insufficient balance, please top up first',
+ feeRefundHint: 'Fee will be charged upon confirmation. Automatically refunded if the recipient rejects',
+ },
+ // Reject Modal
+ rejectModal: {
+ title: 'Reject Transfer',
+ reason: 'Reason (optional)',
+ reasonPlaceholder: 'Enter rejection reason',
+ },
+ // Config Detail Modal
+ configModal: {
+ title: 'Configuration at Transfer',
+ instanceName: 'Instance Name',
+ hostInfo: 'Host Information',
+ networkMode: 'Network Mode',
+ portMappings: 'Port Mappings',
+ snapshots: 'Snapshots',
+ backups: 'Backups',
+ package: 'Package',
+ },
+ hasRemark: 'Has remark',
+ // Detail
+ detail: {
+ fromUser: 'From',
+ toUser: 'To',
+ instance: 'Instance',
+ snapshot: 'Configuration at Transfer',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ ports: 'Port Mappings',
+ snapshots: 'Snapshots',
+ backups: 'Backups',
+ host: 'Host',
+ package: 'Package',
+ remark: 'Remark',
+ rejectReason: 'Rejection Reason',
+ createdAt: 'Created At',
+ acceptedAt: 'Accepted At',
+ rejectedAt: 'Rejected At',
+ cancelledAt: 'Cancelled At',
+ },
+ // Messages
+ messages: {
+ transferSuccess: 'Transfer request sent',
+ transferComplete: 'Transfer completed',
+ acceptSuccess: 'Transfer accepted',
+ rejectSuccess: 'Transfer rejected',
+ cancelSuccess: 'Transfer cancelled',
+ pushSuccess: 'Instance pushed successfully',
+ instanceLocked: 'This instance is being transferred and cannot be operated',
+ },
+ // Errors
+ errors: {
+ TRANSFER_NOT_FOUND: 'Transfer request not found',
+ TRANSFER_TO_SELF: 'Cannot transfer to yourself',
+ TRANSFER_TO_BANNED: 'Cannot transfer to a banned user',
+ TRANSFER_ALREADY_PENDING: 'This instance already has a pending transfer',
+ TRANSFER_NOT_PENDING: 'Transfer request is not pending',
+ TRANSFER_INVALID_STATUS: 'Instance status does not allow transfer',
+ TRANSFER_QUOTA_NOT_FOUND: 'Target user quota not found',
+ TRANSFER_QUOTA_INSUFFICIENT: 'Target user quota insufficient',
+ TRANSFER_INSTANCE_LOCKED: 'Instance is locked due to pending transfer',
+ TRANSFER_HOST_DISABLED: 'Transfer is disabled for this host',
+ TRANSFER_INSUFFICIENT_BALANCE: 'Insufficient balance for transfer fee',
+ PUSH_NOT_HOST_OWNER: 'Only host owner can push transfer directly',
+ },
+ },
+
+ // Friends System
+ friends: {
+ title: 'Friends',
+ description: 'Manage your friends list. Friends can share resources with each other',
+ friendsList: 'Friends List',
+ pendingRequests: 'Pending Requests',
+ historyRequests: 'History',
+ addFriend: 'Add Friend',
+ username: 'Username',
+ usernamePlaceholder: 'Enter username',
+ usernameHint: 'Enter the username of the friend you want to add',
+ remark: 'Remark',
+ remarkPlaceholder: 'Enter remark (optional)',
+ remarkHint: 'e.g., how you met, purpose, etc.',
+ sendRequest: 'Send Request',
+ accept: 'Accept',
+ reject: 'Reject',
+ noFriends: 'No friends yet',
+ noFriendsHint: 'Add friends to share hosts, packages, and images',
+ noSearchResult: 'No matching friends found',
+ noHistorySearchResult: 'No matching history records found',
+ noPendingRequests: 'No pending friend requests',
+ noHistoryRecords: 'No history records',
+ noHistoryRecordsHint: 'Processed friend requests will appear here',
+ addedOn: 'Added on',
+ requestedOn: 'Requested on',
+ sentOn: 'Sent on',
+ sentTo: 'Sent to {username}',
+ processedOn: 'Processed on',
+ confirmRemove: 'Are you sure you want to remove {name} from your friends?',
+ requestSent: 'Friend request sent',
+ requestAccepted: 'Friend request accepted',
+ requestRejected: 'Friend request rejected',
+ friendRemoved: 'Friend removed',
+ statusAccepted: 'Accepted',
+ statusRejected: 'Rejected',
+ filterAll: 'All',
+ filterAccepted: 'Accepted',
+ filterRejected: 'Rejected',
+ hosts: 'Hosts',
+ instances: 'Instances',
+ // Invite codes
+ invites: 'Invite Codes',
+ generateInvite: 'Generate Invite',
+ generateInviteTitle: 'Generate Invite Code',
+ inviteCode: 'Invite Code',
+ inviteStatus: 'Status',
+ inviteUsed: 'Used',
+ inviteExpired: 'Expired',
+ inviteUnused: 'Unused',
+ usedBy: 'Used By',
+ createdAt: 'Created At',
+ expiresAt: 'Expires At',
+ permanent: 'Permanent',
+ noInvites: 'No invite codes',
+ noInvitesHint: 'Generate invite codes for others to register',
+ inviteCount: 'Quantity',
+ inviteCountHint: 'Can generate 1-10 invite codes at once',
+ expireDays: 'Expire Days',
+ expireDaysPlaceholder: '0 means never expire',
+ expireDaysHint: 'Set expiration time for invite codes, 0 means never expire',
+ generate: 'Generate',
+ confirmDeleteInvite: 'Are you sure to delete invite code {code}?',
+ inviteDeleted: 'Invite code deleted',
+ inviteGenerated: 'Invite Code Generated',
+ copyCode: 'Copy Code',
+ copyLink: 'Copy Link',
+ inviteCodeCopied: 'Invite code copied',
+ inviteLinkCopied: 'Invite link copied',
+ close: 'Close',
+ deleteInvite: 'Delete',
+ userNotFound: 'User not found',
+ cannotAddSelf: 'Cannot add yourself as a friend',
+ alreadyFriend: 'You are already friends',
+ requestAlreadyPending: 'Friend request is already pending',
+ requestNotFound: 'Request not found or already processed',
+ friendshipNotFound: 'Friendship not found',
+ // Package sharing
+ selectFriendHint: 'Select a friend',
+ selectFriendDesc: 'Click a friend card on the left to manage package sharing',
+ sharedPackages: 'Shared Packages',
+ availablePackages: 'Available Packages',
+ noSharedPackages: 'No packages shared yet',
+ addShare: 'Add Share',
+ addFirstShare: 'Share your first package',
+ removeShare: 'Remove Share',
+ editQuota: 'Edit Quota',
+ quotaMultiplier: 'Quota Multiplier',
+ quotaMultiplierHint: 'e.g. 1, 1.5, 2x, set the resource ratio available to friend',
+ maxInstances: 'Max Instances',
+ maxInstancesHint: 'Limit the number of instances friend can create',
+ noLimit: 'No limit',
+ currentUsage: 'Current Usage',
+ shareAdded: 'Package shared',
+ shareRemoved: 'Share removed',
+ quotaUpdated: 'Quota updated',
+ confirmRemoveShare: 'Are you sure you want to remove the share of {package}?',
+ addShareTitle: 'Share Package',
+ shareToFriend: 'Share to this friend',
+ selectPackage: 'Select Package',
+ selectPackagePlaceholder: 'Select a package to share',
+ confirmShare: 'Confirm Share',
+ editQuotaTitle: 'Edit Quota Limits',
+ sharedTo: 'Shared to',
+ removeFriend: 'Remove Friend',
+ noPackagesToShare: 'You have no packages to share',
+ createPackageFirst: 'Please create a package in "My Packages" first',
+ noPackageSearchResult: 'No matching packages found',
+ },
+
+ // Package labels
+ package: {
+ shared: 'Friend',
+ globalShared: 'Available',
+ friendPrefix: 'Friend:',
+ myPackage: 'My Package',
+ soldOut: 'Sold Out',
+ },
+
+ // User Resources Management
+ resources: {
+ hosts: {
+ title: 'My Hosts',
+ description: 'Manage your hosts that can be used by you and your friends',
+ create: 'Add Host',
+ createDesc: 'Add a new Incus host',
+ ubuntuOnlyHint: 'Currently supports Ubuntu 22.04+ and Debian 11+ only.',
+ installHintTitle: 'After submitting, the system will generate an install command with the panel URL and Token embedded. Copy and run it on the host to complete installation.',
+ installHintIpv6: 'Tip: For IPv6 modes (NAT+IPv6, IPv6 Only), run the install script on the host first. It will auto-generate IPv6 subnet info for you to fill in below.',
+ ipv6OptionalHint: 'Not sure? Leave blank for now. Run the install script on the host first — it will detect and display your IPv6 subnet. Then come back and edit the node to fill in.',
+ storagePoolAfterConnectHint: 'After the node connects successfully, remember to create a storage pool from the Storage tab on the node detail page.',
+ noHosts: 'No hosts',
+ noHostsHint: 'Add hosts to create instances on them',
+ calibrateAll: 'Sync All Usage',
+ noOnlineHosts: 'No online hosts to sync',
+ calibrateAllDone: 'Synced {total} hosts, {changed} with differences corrected',
+ calibrateAllNoChange: 'Synced {total} hosts, no differences',
+ nameHint: 'Node name is prefixed with PEER + your user ID, followed by custom suffix',
+ nameSuffixRequired: 'Please enter the node name suffix',
+ // Admin only: Host scope toggle
+ mine: 'My Hosts',
+ hosted: 'Hosted',
+ owner: 'Owner',
+ filterByUserId: 'User ID',
+ takeoverOfficial: 'Take Over',
+ takeoverOfficialLoading: 'Taking over...',
+ takeoverOfficialConfirm: 'Take over host "{name}" as official? This transfers the current host and any safely transferable packages. Existing instances stay with their current users.',
+ takeoverOfficialSuccess: 'Host {name} taken over. {packages} packages transferred, {instances} instances kept.',
+ takeoverOfficialDetached: '{count} packages were still bound to other hosted nodes, so the current host binding was removed: {names}',
+ takeoverOfficialBlocked: 'Takeover blocked: {count} packages would lose all bound hosts after removing the current host. Resolve these packages first: {names}',
+ },
+ packages: {
+ title: 'My Packages',
+ description: 'Manage your package configurations that can be used by you and your friends',
+ create: 'Create Package',
+ noPackages: 'No packages',
+ noPackagesHint: 'Create packages to use when creating instances',
+ share: 'Share Package',
+ viewShares: 'View Shares',
+ selectFriend: 'Select Friend',
+ selectFriendPlaceholder: 'Please select a friend to share with',
+ noFriends: 'No friends yet, please add friends first',
+ shareSuccess: 'Package shared successfully',
+ shareFailed: 'Failed to share package',
+ confirmUnshare: 'Are you sure you want to unshare?',
+ unshareSuccess: 'Unshared successfully',
+ unshareFailed: 'Failed to unshare',
+ sharesList: 'Shares List',
+ sharesCount: '{count} people',
+ noShares: 'No shares yet',
+ noSharesHint: 'Share to let friends use this package',
+ sharedAt: 'Shared at',
+ unshare: 'Unshare',
+ searchFriend: 'Search friends...',
+ noAvailableFriends: 'All friends have been shared this package',
+ selectToShare: 'Select a friend to share',
+ // Quota limits
+ quotaSettings: 'Quota Limits',
+ quotaMultiplier: 'Resource Quota Multiplier',
+ quotaMultiplierHint: 'Limit CPU/Memory as a fraction of package quota',
+ maxInstances: 'Max Instances',
+ maxInstancesHint: 'Limit the maximum number of instances',
+ noLimit: 'No limit',
+ instanceUnit: '{n} instance | {n} instances',
+ quotaDisplay: 'Quota: {multiplier} · Instances: {instances}',
+ usageDisplay: 'Used: {cpu}% CPU / {memory} MB Memory / {instances} instances',
+ updateQuota: 'Update Quota',
+ updateQuotaSuccess: 'Quota updated',
+ updateQuotaFailed: 'Failed to update quota',
+ // Enhanced modal additions
+ shareToFriend: 'Share Package to Friend',
+ noFriendsHint: 'Add friends first to share packages',
+ allFriendsShared: 'All Shared',
+ confirmShare: 'Confirm Share',
+ editQuota: 'Edit Quota',
+ editQuotaFor: 'Editing quota for {username}',
+ quotaUpdated: 'Quota updated',
+ quotaUpdateFailed: 'Failed to update quota',
+ usageStatus: 'Usage Status',
+ instanceCount: 'Instance Count',
+ packageInstanceCount: 'Package Instances',
+ networkModeColumn: 'Network Mode',
+ instanceTypeColumn: 'Instance Type',
+ trafficMultiplierColumn: 'Traffic Multiplier',
+ hostColumn: 'Host',
+ instanceColumn: 'Instances',
+ publicBadge: 'Public',
+ currentUsage: 'Current: {cpu}% CPU / {memory} MB Memory / {instances} instances',
+ currentUsageInfo: 'Currently using {cpu}% CPU, {memory} MB memory, {instances} instances',
+ addShare: 'Add Share',
+ globalShared: 'Global Shared',
+ shared: 'Shared',
+ searchPlaceholder: 'Search name, host, description...',
+ noSearchResults: 'No matching packages found',
+ clearSearch: 'Clear search',
+ // Notification channel settings
+ notifyChannel: 'Notification Channel',
+ notifyChannelTitle: 'Resource Release Notification Channel',
+ notifyChannelDesc: 'System will send notifications via this channel when users delete instances or when quota is released',
+ // Share link
+ copyShareLink: 'Copy Share Link',
+ shareLinkCopied: 'Share link copied, users can access this link to directly create instances',
+ // Admin only: Package scope toggle
+ mine: 'My Packages',
+ hosted: 'Hosted',
+ owner: 'Owner',
+ filterByUserId: 'User ID',
+ },
+ // Package plans management
+ plans: {
+ title: 'Plan Management',
+ manage: 'Manage Plans',
+ noPlans: 'No plans',
+ noPlansHint: 'Create a plan to allow users to purchase paid instances for this package',
+ add: 'Add Plan',
+ create: 'Create Plan',
+ edit: 'Edit Plan',
+ name: 'Plan Name',
+ namePlaceholder: 'e.g., Basic, Pro, Enterprise',
+ description: 'Plan Description',
+ descriptionPlaceholder: 'Optional, describe the plan features',
+ resourceConfig: 'Resource Configuration',
+ portLimit: 'Ports',
+ snapshotLimit: 'Snapshots',
+ backupLimit: 'Backups',
+ siteLimit: 'Sites',
+ swapSize: 'SWAP Size',
+ trafficLimit: 'Traffic Limit',
+ trafficSpeed: 'Bandwidth Limit',
+ unlimitedHint: 'Leave empty for unlimited',
+ billingConfig: 'Billing Configuration',
+ price: 'Price',
+ billingCycle: 'Billing Cycle',
+ setupFee: 'Setup Fee',
+ slaGuarantee: 'SLA Guarantee',
+ stock: 'Stock',
+ isActive: 'Enable Plan',
+ status: 'Plan Status',
+ statusActive: 'Available',
+ statusActiveHint: 'Visible and available for new orders',
+ statusSoldOut: 'Sold Out',
+ statusSoldOutHint: 'Still visible, but blocked for new orders and plan changes',
+ statusInactive: 'Inactive',
+ statusInactiveHint: 'Hidden from order entry points and cannot be selected',
+ sortOrder: 'Sort Order',
+ daily: 'Daily',
+ weekly: 'Weekly',
+ monthly: 'Monthly',
+ quarterly: 'Quarterly',
+ semiAnnual: 'Semi-Annual',
+ yearly: 'Yearly',
+ days: 'days',
+ months: 'months',
+ createSuccess: 'Plan created successfully',
+ updateSuccess: 'Plan updated successfully',
+ saveFailed: 'Failed to save plan',
+ priceRangeHint: 'Max ¥{max}',
+ priceRangeError: 'Plan price must be between 0 and {max} yuan, with up to 2 decimal places',
+ confirmDelete: 'Are you sure you want to delete the plan "{name}"?',
+ deleteSuccess: 'Plan deleted',
+ deleteFailed: 'Failed to delete plan',
+ },
+ images: {
+ title: 'My Images',
+ description: 'Manage your image configurations that can be used by you and your friends',
+ create: 'Add Image',
+ noImages: 'No images',
+ noImagesHint: 'Add images to use when creating instances',
+ },
+ },
+
+ // Instance config tab
+ instanceConfig: {
+ title: 'Advanced Configuration',
+ sections: {
+ swap: 'SWAP',
+ storageIO: 'Storage I/O Limits',
+ networkLimits: 'Network Limits',
+ processScheduling: 'Process & Scheduling',
+ bootSettings: 'Boot Settings',
+ },
+ swap: {
+ size: 'Size',
+ enabled: 'Enabled',
+ disabled: 'Closed',
+ enableButton: 'Enable SWAP',
+ disableButton: 'Disable SWAP',
+ toggleHint: 'SWAP can be enabled or disabled at any time and the current state is kept after rebuild/recreate.',
+ runningRequired: 'The VM must be running before SWAP can be enabled.',
+ vmHint: 'VMs enable SWAP persistently through a guest swapfile.',
+ containerHint: 'Containers apply SWAP through the Incus memory swap limit.',
+ enableConfirmTitle: 'Enable SWAP',
+ enableConfirmText: 'Enable {size} of SWAP for this instance?',
+ disableConfirmTitle: 'Disable SWAP',
+ disableConfirmText: 'Disable SWAP for this instance?',
+ enableSuccess: 'SWAP enabled',
+ enableFailed: 'Failed to enable SWAP',
+ disableSuccess: 'SWAP disabled',
+ disableFailed: 'Failed to disable SWAP',
+ },
+ changeHost: {
+ title: 'Change Host',
+ description: 'Recreate this instance on another host in the same package while keeping the instance ID and billing data.',
+ currentHost: 'Current host',
+ availableCount: '{count} available host(s)',
+ button: 'Change host',
+ loadFailed: 'Failed to load hosts',
+ submitFailed: 'Failed to submit host change',
+ taskQueued: 'Host change task submitted, please wait...',
+ modalTitle: 'Select Target Host',
+ modalSubtitle: 'Only hosts in the same package with enough CPU and memory can be selected.',
+ warning: 'This recreates the system disk. Old instance data, snapshots, backups, port mappings, and proxy sites will be cleared.',
+ selectSshKey: 'SSH key',
+ noSshKey: 'No SSH key available',
+ confirm: 'Confirm Change',
+ current: 'Current',
+ available: 'Available',
+ memory: 'Memory',
+ reasons: {
+ current_host: 'Current host',
+ host_offline: 'Offline',
+ host_type_mismatch: 'Type mismatch',
+ cpu_full: 'Full',
+ memory_full: 'Full',
+ resource_unconfigured: 'Quota missing',
+ image_unavailable: 'Image unavailable',
+ },
+ },
+ overridden: 'Overridden',
+ resetToDefault: 'Reset to package default',
+ saveSuccess: 'Configuration saved',
+ saveFailed: 'Failed to save configuration',
+ boostProcesses: {
+ button: 'Boost',
+ title: 'Boost Process Limit',
+ confirm: 'Are you sure you want to boost the process limit for this {type} instance? The process limit will be increased to {limit}.',
+ hint: 'This operation only increases the process limit and does not affect other configurations',
+ success: 'Process limit has been boosted to {limit}',
+ failed: 'Failed to boost process limit',
+ },
+ },
+
+ // Package form page
+ packageForm: {
+ createTitle: 'Create Package',
+ editTitle: 'Edit Package',
+ description: 'Configure resource limits and advanced options for the package',
+ sections: {
+ basicInfo: 'Basic Info',
+ resourceLimits: 'Resource Limits',
+ storageIO: 'Storage I/O Limits',
+ networkLimits: 'Network Limits',
+ processScheduling: 'Process & Scheduling',
+ bootSettings: 'Boot Settings',
+ prerequisite: 'Prerequisite Package',
+ visibility: 'Visibility',
+ instancePermissions: 'Instance Operation Permissions',
+ advancedOptions: 'Advanced Options',
+ instanceQuota: 'Instance Quota',
+ },
+ fields: {
+ networkMode: 'Network Mode',
+ instanceType: 'Instance Type',
+ packageCreationMode: 'Package Usage',
+ ioLimitMode: 'IO Limit Mode',
+ limitsRead: 'Read Rate Limit',
+ limitsWrite: 'Write Rate Limit',
+ limitsReadIops: 'Read IOPS Limit',
+ limitsWriteIops: 'Write IOPS Limit',
+ limitsIngress: 'Ingress Bandwidth Limit',
+ limitsEgress: 'Egress Bandwidth Limit',
+ limitsProcesses: 'Max Processes',
+ limitsCpuPriority: 'CPU Priority',
+ bootAutostart: 'Auto-start with Host',
+ bootAutostartPriority: 'Startup Priority',
+ bootAutostartDelay: 'Startup Delay',
+ bootHostShutdownTimeout: 'Shutdown Timeout',
+ portLimit: 'Port Mapping Limit',
+ snapshotLimit: 'Snapshot Limit',
+ backupLimit: 'Backup Limit',
+ siteLimit: 'Site Limit',
+ hostStoragePools: 'Host System Disk Pool',
+ hostTrafficMultiplier: 'Host Traffic Multiplier',
+ requiredPackage: 'Prerequisite Package',
+ publicAccess: 'Public Package',
+ globalMaxInstances: 'Max Instances',
+ allowInstanceDeletion: 'Allow users to delete instances',
+ },
+ hostSelector: {
+ official: 'Official Hosts',
+ searchPlaceholder: 'Search host name, region, URL, owner...',
+ selectedCount: '{count} host(s) selected',
+ noSearchResults: 'No matching hosts found',
+ detailUnavailable: 'This host is not loaded in the current view; the binding will still be preserved when saved',
+ },
+ creationModes: {
+ free: {
+ title: 'Free Instance Package',
+ description: 'Instances use the package resource, quota, and bandwidth limits directly',
+ },
+ paid: {
+ title: 'Paid Instance Package',
+ description: 'Create plans afterward to set resources, quotas, traffic, and price',
+ },
+ },
+ ioMode: {
+ throughput: 'Throughput Limit',
+ iops: 'IOPS Limit',
+ },
+ hints: {
+ ioLimitMode: 'Incus only supports one IO limit mode at a time, please select one',
+ cpuPriority: '0 is lowest priority, 10 is highest priority',
+ bootPriority: 'Lower values start first',
+ startupDelay: 'Seconds to wait before starting instance (5-600)',
+ shutdownTimeout: 'Seconds to wait for instance shutdown when host shuts down (30-600)',
+ instanceQuota: 'Limit the number of resources a user can create on instances using this package',
+ portLimit: 'Maximum port mappings per instance',
+ snapshotLimit: 'Maximum snapshots per instance (0 = no quota)',
+ backupLimit: 'Maximum backups per instance (0 = no quota)',
+ siteLimit: 'Maximum proxy sites per instance (0 = no quota)',
+ hostStoragePools: 'Choose the default system disk storage pool for each bound host. Leave empty to use automatic selection.',
+ hostTrafficMultiplier: 'Instance monthly traffic = package or plan traffic multiplied by this value. Default is 1.',
+ requiredPackage: 'When selected, users must already have an instance from this package before they can create an instance from this package.',
+ noSystemStoragePools: 'This host currently has no storage pool available for instance system disks',
+ instanceType: 'Containers are lightweight and fast, VMs provide full isolation',
+ publicAccess: 'When enabled, the package will be visible to all users who can use it to create instances; when disabled, the package will be hidden/archived',
+ globalMaxInstances: 'Limit the maximum number of instances users can create. Required integer from 1 to 5.',
+ allowInstanceDeletion: 'When disabled, instances created with this package will not allow users to delete them',
+ freePackageCreationMode: 'Free instances do not need a plan. They inherit the settings on this page directly. If you need an entry requirement or want to use the paid instance flow, choose a paid instance package, then add a plan with the price set to 0.',
+ paidPackageCreationMode: 'Paid instances use the resources, quotas, traffic, and price from their plan. This page hides the fields that will be overridden by plans and saves package defaults instead.',
+ },
+ units: {
+ seconds: 'seconds',
+ },
+ placeholders: {
+ unlimited: 'Leave empty for unlimited',
+ autoStoragePool: 'Auto select (not specified)',
+ noPrerequisite: 'No prerequisite package',
+ },
+ validation: {
+ cpuPriorityRange: 'CPU priority must be between 0 and 10',
+ bootPriorityRange: 'Boot priority must be between 0 and 100',
+ startupDelayRange: 'Startup delay must be between 5 and 600 seconds',
+ shutdownTimeoutRange: 'Shutdown timeout must be between 30 and 600 seconds',
+ portLimitMin: 'Port limit must be at least 1',
+ globalMaxInstancesRange: 'Public package max instances must be an integer from 1 to 5',
+ },
+ typeHelp: {
+ containerFast: 'Fast startup, seconds to ready',
+ containerLight: 'Low overhead, suitable for most Linux apps',
+ containerDocker: 'Docker support (requires nesting)',
+ vmIsolation: 'Full isolation, independent kernel',
+ vmKernel: 'Custom kernel and kernel modules support',
+ vmWindows: 'Can run Windows and other non-Linux OS',
+ },
+ },
+
+ // Sensitive operation verification
+ sensitiveVerification: {
+ title: 'Sensitive Operation Verification',
+ description: 'This operation requires verification to ensure account security',
+ operationLabel: 'Operation Type',
+ requestHint: 'Click the button below to receive a verification code via your notification channel',
+ sendCode: 'Send Code',
+ sendingCode: 'Sending...',
+ resendCode: 'Resend',
+ resendIn: 'Resend in {seconds}s',
+ codeSent: 'Code sent',
+ codeSentTo: 'Code sent to {channel}',
+ enterCode: 'Enter verification code',
+ codePlaceholder: '000000',
+ verify: 'Verify',
+ verifying: 'Verifying...',
+ verifySuccess: 'Verification successful, please retry the operation',
+ verifyFailed: 'Verification failed',
+ codeExpired: 'Code expired, please resend',
+ invalidCode: 'Invalid code, please enter 6 digits',
+ operationTypes: {
+ change_password: 'Change Password',
+ disable_2fa: 'Disable Two-Factor Authentication',
+ change_email: 'Change Email Address',
+ delete_account: 'Delete Account',
+ delete_instance: 'Delete Instance',
+ reinstall_instance: 'Reinstall Instance',
+ recreate_instance: 'Recreate Instance',
+ transfer_instance: 'Transfer Instance',
+ delete_snapshot: 'Delete Snapshot',
+ delete_backup: 'Delete Backup',
+ },
+ channels: {
+ email: 'Email',
+ telegram: 'Telegram',
+ discord: 'Discord',
+ webhook: 'Webhook',
+ },
+ },
+
+ // Host Caddy management
+ host: {
+ caddy: {
+ title: 'Caddy Proxy',
+ description: 'Provide domain reverse proxy service for your instances through Caddy, with automatic SSL certificate.',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ notInstalled: 'Caddy is not installed yet. Click the button below to generate installation command.',
+ generateCommand: 'Generate Command',
+ installCommand: 'Installation Command',
+ commandLabel: 'Run the following command on your host',
+ confirmInstalled: 'Confirm Installed',
+ viewCommand: 'View Command',
+ resetCredentials: 'Reset Credentials',
+ resetConfirm: 'After resetting credentials, you need to re-run the installation command on the host. Continue?',
+ resetSuccess: 'Credentials reset. Please re-run the installation command on the host.',
+ resetFailed: 'Failed to reset credentials',
+ testConnection: 'Test Connection',
+ apiPort: 'API Port',
+ username: 'Username',
+ password: 'Password',
+ publicIp: 'Public IP',
+ sitesCount: 'Sites Count',
+ loadFailed: 'Failed to load Caddy status',
+ generateFailed: 'Failed to generate command',
+ confirmSuccess: 'Caddy installation confirmed',
+ confirmFailed: 'Confirmation failed',
+ testSuccess: 'Connection successful',
+ testFailed: 'Connection failed',
+ installHint: 'Installation Steps',
+ step1: 'Copy the command above and run it as root on your host',
+ step2: 'Wait for installation to complete, you should see "Caddy Reverse Proxy Ready"',
+ step3: 'Return to this page and click "Confirm Installed" button',
+ // Sites list
+ sitesList: 'Proxy Sites',
+ sitesTotalCount: '{count} sites in total',
+ loadSitesFailed: 'Failed to load sites',
+ noSites: 'No proxy sites',
+ instance: 'Instance',
+ targetPort: 'Target Port',
+ siteActive: 'Active',
+ sitePending: 'Pending',
+ siteError: 'Error',
+ siteDisabled: 'Disabled',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ pageInfo: 'Page {current}/{total}, {count} total',
+ },
+ // Host create instance
+ createInstance: {
+ title: 'Create Instance',
+ selfMode: 'Create for Myself',
+ giftMode: 'Gift to User',
+ selfModeHint: 'Use your own SSH key and init commands to create a free instance on this host.',
+ giftModeHint: 'Create an instance for another user on this host. Paid plans can only be gifted for free days.',
+ adminModeHint: 'Admins can create a free instance for a specified user on this host, or gift the first period of a paid instance.',
+ selectPackage: 'Select Package',
+ noPackages: 'No packages available for this host',
+ noPackagesHint: 'Please create and bind packages to this host first',
+ instanceName: 'Instance Name',
+ giftDaysLabel: 'Free Gift Days',
+ giftDaysHint: 'Only free gifted days are supported. No deduction will be made from the recipient balance.',
+ giftDaysRange: 'Range: 1-365 days',
+ giftDuration: 'Gift Duration',
+ giftDurationValue: '{days} days free',
+ giftOnlyFreeHint: 'After the free period ends, renewal will follow the selected plan price.',
+ creating: 'Creating...',
+ success: 'Instance created successfully',
+ giftSuccess: 'Instance has been created for {username}',
+ userInactive: 'This user is not active',
+ cannotGiftToSelf: 'Use the self-create mode if you want to create an instance for yourself',
+ },
+ // Host owner notifies instance users
+ notify: {
+ title: 'Send Notification',
+ sendToUsers: 'Notify Users',
+ hint: 'An inbox message will be sent immediately to all unique users with instances on this host',
+ hintSelected: 'An inbox message will be sent immediately to users of the {count} selected instance(s), with duplicate users removed',
+ deliveryHint: 'If email is enabled, a single recipient is emailed immediately. Multiple recipients are queued and sent at a rate of one email per minute.',
+ messageTitle: 'Message Title',
+ titlePlaceholder: 'Enter message title',
+ titleRequired: 'Please enter message title',
+ messageContent: 'Message Content',
+ contentPlaceholder: 'Enter message content',
+ contentRequired: 'Please enter message content',
+ sendEmail: 'Also send email notification',
+ sendEmailHint: 'Emails are only sent to users who have an email address set. Bulk emails are queued automatically to avoid burst sending.',
+ send: 'Send Notification',
+ sendSuccess: 'Successfully sent to {count} users',
+ sendSuccessBase: 'Inbox message sent to {count} users',
+ emailDirectSuccess: '{count} email sent immediately',
+ emailQueuedSuccess: '{count} email queued',
+ emailSkipped: '{count} user(s) skipped because no email address is set',
+ emailFailed: '{count} email(s) failed to send or queue',
+ sendFailed: 'Failed to send',
+ // Send to single instance user
+ sendToUser: 'Send Message',
+ sendToUserTitle: 'Send Message to {username}',
+ sendToUserHint: 'Message will be sent to the owner of instance "{instance}"',
+ sendToUserSuccess: 'Message sent',
+ },
+ // Renewal Price
+ price: {
+ editPrice: 'Edit Renewal Price',
+ modalTitle: 'Edit Renewal Price',
+ hint: 'Modify the renewal price for instance "{instance}". The new price will take effect at next renewal and will not affect the current month.',
+ currentPrice: 'Current Price',
+ newPrice: 'New Price',
+ placeholder: 'Enter new renewal price',
+ effectHint: 'New price takes effect at next renewal, no impact on current month',
+ minPriceError: 'Price cannot be negative',
+ samePriceError: 'New price is the same as current price',
+ updateSuccess: 'Renewal price updated, user notified',
+ updateFailed: 'Update failed',
+ },
+ // Batch Configuration
+ batchConfig: {
+ title: 'Batch Config',
+ button: 'Batch Config',
+ targetAll: 'Apply to: All {count} instances on host',
+ targetSelected: 'Apply to: {count} selected instances',
+ enableFieldHint: 'Check the box to enable modification for the field',
+ // Sections
+ section: {
+ resources: 'Resources',
+ quota: 'Quota Limits',
+ permissions: 'Container Permissions',
+ advanced: 'Advanced',
+ io: 'Storage I/O Limits',
+ network: 'Network Limits',
+ process: 'Process & Scheduling',
+ boot: 'Boot Settings',
+ },
+ // Fields
+ cpu: 'CPU Cores',
+ memory: 'Memory',
+ disk: 'Disk',
+ traffic: 'Traffic',
+ swapEnabled: 'SWAP Toggle',
+ swapSize: 'SWAP Size',
+ portLimit: 'Port Limit',
+ snapshotLimit: 'Snapshot Limit',
+ backupLimit: 'Backup Limit',
+ siteLimit: 'Site Limit',
+ nested: 'Nested Virtualization',
+ privileged: 'Privileged Container',
+ limitsRead: 'Read Limit',
+ limitsWrite: 'Write Limit',
+ limitsIngress: 'Ingress Limit',
+ limitsEgress: 'Egress Limit',
+ limitsProcesses: 'Max Processes',
+ limitsCpuPriority: 'CPU Priority',
+ bootPriority: 'Boot Priority',
+ bootAutostart: 'Auto Start',
+ bootDelay: 'Boot Delay',
+ shutdownTimeout: 'Shutdown Timeout',
+ // Placeholders
+ placeholder: {
+ cpu: 'e.g. 1, 2, 4',
+ memory: 'e.g. 512, 1024, 2048',
+ disk: 'e.g. 10, 20, 50',
+ traffic: 'e.g. 100, 500, 1000',
+ swapSize: 'e.g. 512, 1024, 2048',
+ limit: 'e.g. 5, 10, 20',
+ ioLimit: 'e.g. 100MB',
+ priority: 'e.g. 5',
+ processLimit: 'e.g. 500, 1000',
+ bootPriority: 'e.g. 0, 1, 2',
+ },
+ // Status
+ processing: 'Applying batch configuration...',
+ processed: 'Processed {current} / {total}',
+ // Results
+ success: 'Success',
+ failed: 'Failed',
+ successAll: 'Batch config applied successfully to {count} instances',
+ partial: 'Partial success: {success} succeeded, {failed} failed',
+ allFailed: 'All Failed',
+ submitFailed: 'Submit failed',
+ retrySuccess: 'Retry succeeded for {count} instances',
+ retryPartial: 'Retry partial: {success} succeeded, {failed} failed',
+ retryFailed: 'Retry Failed',
+ failedDetails: 'Failed Details',
+ instanceName: 'Instance Name',
+ incusId: 'Incus ID',
+ errorReason: 'Error Reason',
+ copyIncusIds: 'Copy Failed Incus IDs',
+ copiedIncusIds: 'Copied {count} Incus IDs',
+ // Actions
+ submit: 'Apply Config ({count})',
+ close: 'Close',
+ noFieldsEnabled: 'Please enable at least one config field',
+ noChanges: 'No configuration changes',
+ },
+ // Batch migrate instances
+ migrate: {
+ title: 'Migrate Instances to Another Node',
+ button: 'Migrate',
+ selectedCount: '{count} instance(s) selected',
+ targetNode: 'Target Node',
+ selectTarget: 'Select target node',
+ targetImage: 'Target OS',
+ selectImage: 'Select target OS',
+ selectImageRequired: 'Please select target OS',
+ loadImagesFailed: 'Failed to load OS list',
+ noImageAvailable: 'No OS images available on target node',
+ imageHint: 'Migration will rebuild instances with this OS instead of their old image',
+ instances: 'instances',
+ warning: 'Migration Notes',
+ warningCloudInit: 'cloud-init will be re-executed',
+ warningImage: 'Instances will be rebuilt with the selected OS',
+ warningIp: 'Instances will get new IP addresses',
+ warningNotify: 'Users will be notified after migration',
+ confirm: 'Confirm Migration',
+ migrating: 'Migrating...',
+ resultSummary: '{success} succeeded, {failed} failed',
+ failedInstances: 'Failed Instances',
+ loadHostsFailed: 'Failed to load host list',
+ selectTargetRequired: 'Please select target node',
+ noInstancesSelected: 'Please select instances to migrate first',
+ failed: 'Migration failed',
+ // Paid instance plan selection
+ targetPlan: 'Target Plan',
+ selectPlan: 'Select target plan',
+ selectPlanRequired: 'Please select target plan',
+ loadPlansFailed: 'Failed to load plan list',
+ noPlanAvailable: 'No plans available on target node',
+ planHint: 'Paid instances will use the new plan\'s renewal price, keeping original expiry date and coupon',
+ },
+ // Gift Days
+ giftDays: {
+ title: 'Gift Days',
+ button: 'Gift Days',
+ hint: 'Extend expiry date for selected paid instances for free, no charges.',
+ confirm: 'Will gift days to {count} paid instance(s)',
+ daysLabel: 'Days to Gift',
+ daysRange: 'Range: 1-365 days',
+ confirmButton: 'Confirm Gift',
+ success: 'Successfully gifted {days} days to {count} instance(s)',
+ partial: 'Success: {success}, Failed: {failed}',
+ failed: 'Gift failed',
+ skipped: 'Skipped {count} free instance(s)',
+ noPaidInstances: 'Please select paid instances',
+ },
+ },
+
+ // Inbox (Notifications)
+ inbox: {
+ title: 'Notification Center',
+ description: 'View all your system notifications',
+ notifications: 'Notifications',
+ unread: 'Unread',
+ all: 'All',
+ allCategories: 'All Types',
+ markAllRead: 'Mark all read',
+ markRead: 'Mark as read',
+ clearRead: 'Clear read',
+ noMessages: 'No notifications',
+ noUnread: 'No unread notifications',
+ noCategoryMessages: 'No messages in this category',
+ viewAll: 'View all',
+ justNow: 'Just now',
+ minutesAgo: '{n} min ago',
+ hoursAgo: '{n} hr ago',
+ daysAgo: '{n} days ago',
+ deleteConfirm: 'Delete this notification?',
+ clearConfirm: 'Clear all read notifications?',
+ deleted: 'Deleted',
+ cleared: 'Cleared',
+ markedRead: 'Marked as read',
+ markedAllRead: 'All marked as read',
+ currentPageFiltered: 'Filtered on this page: {count} items',
+ // Message categories
+ categories: {
+ instance: 'Instance',
+ snapshot: 'Snapshot',
+ backup: 'Backup',
+ social: 'Friends',
+ transfer: 'Transfer',
+ package: 'Package',
+ security: 'Security',
+ quota: 'Quota',
+ ticket: 'Ticket',
+ system: 'System',
+ },
+ },
+
+ // Quota Release
+ quotaRelease: {
+ title: 'Release Quota',
+ packageQuota: 'Package Quota',
+ noHosts: 'No hosts available',
+ noHostsHint: 'No hosts are bound to this package',
+ selectHosts: 'Select Hosts',
+ selectAll: 'Select All',
+ deselectAll: 'Deselect All',
+ available: 'Available',
+ quotaToAdd: 'Quota to Add',
+ preview: 'Will add {cpu}% CPU and {memory} RAM to each of {count} host(s)',
+ notificationChannel: 'Notification Channel',
+ noNotification: 'No notification',
+ notificationHint: 'System will send a notification via this channel after releasing quota',
+ selectChannel: 'Select notification channel',
+ bindChannelHint: 'Only disabled channels are shown for release quota notifications',
+ unbindChannel: 'Unbind',
+ sendNotification: 'Send Notification',
+ noDisabledChannel: 'No available notification channel',
+ noDisabledChannelHint: 'Please add a channel in "Profile - Notifications" and disable it first',
+ channelEnabledWarning: 'This channel is enabled and will also receive system event notifications',
+ noGlobalChannel: 'No global notification channel available',
+ noGlobalChannelHint: 'Please ask the admin to create a global notification channel in system settings',
+ confirm: 'Release Quota',
+ success: 'Successfully released quota to {count} host(s)',
+ failed: 'Failed to release quota',
+ loadFailed: 'Failed to load host information',
+ selectHost: 'Please select at least one host',
+ enterQuota: 'Please enter the quota to add',
+ channelUpdated: 'Notification channel updated',
+ channelUpdateFailed: 'Failed to update notification channel',
+ },
+
+ // Web Terminal
+ terminal: {
+ title: 'Terminal',
+ connecting: 'Connecting...',
+ connected: 'Connected',
+ disconnected: 'Disconnected',
+ failed: 'Connection Failed',
+ connectionFailed: 'Connection Failed',
+ reconnect: 'Reconnect',
+ requiresRunning: 'Instance must be running',
+ clear: 'Clear',
+ fullscreen: 'Fullscreen',
+ exitFullscreen: 'Exit Fullscreen',
+ fontSize: 'Font Size',
+ fontSizeIncrease: 'Increase Font',
+ fontSizeDecrease: 'Decrease Font',
+ connectionError: 'Connection Error',
+ reconnecting: 'Reconnecting...',
+ close: 'Close Terminal',
+ disconnect: 'Disconnect',
+ modeExec: 'Shell',
+ modeConsole: 'Console',
+ modeBootConsole: 'Boot Console',
+ modeSwitching: 'Switching to Shell',
+ modeUnknown: 'Unknown Mode',
+ consoleFallback: 'Fell back to console mode',
+ consoleFallbackHint: 'This virtual machine could not enter shell mode, so the terminal is using the serial console. If the experience is poor, check qemu-guest-agent, cloud-init, and serial login configuration.',
+ switchingToShell: 'Switching to Shell...',
+ switchingToShellHint: 'The shell transport is ready, and the terminal is switching from boot console to interactive shell.',
+ shellReadyNotice: 'Shell Attached',
+ shellReadyHint: 'Boot console output is complete, and the terminal is now attached to the interactive shell.',
+ consoleOnlyHint: 'The terminal is still on the console. The shell is not ready yet or is reconnecting.',
+ instanceInfo: 'Instance: {name}',
+ statusConnecting: 'Connecting',
+ statusConnected: 'Connected',
+ statusDisconnected: 'Disconnected',
+ statusError: 'Error',
+ escToClose: 'Press Esc to close',
+ pressCtrlShiftFToSearch: 'Press Ctrl+Shift+F to search',
+ searchPlaceholder: 'Search...',
+ searchNext: 'Next',
+ searchPrevious: 'Previous',
+ restore: 'Restore Terminal',
+ exportLog: 'Export Log',
+ // Multi-tab
+ tab: 'Tab',
+ newTab: 'New Tab',
+ closeTab: 'Close Tab',
+ maxTabsReached: 'Maximum tabs reached',
+ // Context menu
+ contextMenu: {
+ copy: 'Copy',
+ paste: 'Paste',
+ selectAll: 'Select All',
+ },
+ // Cloud-init status
+ cloudInitChecking: 'Checking instance initialization status...',
+ cloudInitInProgress: 'Instance is initializing',
+ cloudInitInProgressHint: 'Instance is running Cloud-init initialization, which may take 10-60 seconds. Please click "Retry" to try connecting again.',
+ cloudInitUnknown: 'Cloud-init Status Unknown',
+ cloudInitUnknownHint: 'The Cloud-init status inside this KVM instance cannot be detected reliably right now. You can continue connecting or mark it as completed manually.',
+ cloudInitRetry: 'Retry',
+ cloudInitSkip: 'Skip and Connect',
+ cloudInitManualComplete: 'Mark Complete',
+ cloudInitManualCompleteSuccess: 'This instance has been marked as cloud-init completed manually',
+ // Mobile hints
+ mobileKeyboardHint: 'Please switch to English keyboard for best experience',
+ // Help
+ help: 'Help',
+ helpTitle: 'Terminal Help',
+ helpShortcuts: 'Keyboard Shortcuts',
+ helpShortcutSearch: 'Search',
+ helpShortcutCopy: 'Copy selection',
+ helpShortcutPaste: 'Paste',
+ helpShortcutFontIncrease: 'Increase font',
+ helpShortcutFontDecrease: 'Decrease font',
+ helpShortcutFontReset: 'Reset font',
+ helpShortcutExport: 'Export log',
+ helpShortcutNewTab: 'New tab',
+ helpShortcutCloseTab: 'Close tab',
+ helpMouseOps: 'Mouse Operations',
+ helpMouseSelect: 'Drag to select text',
+ helpMouseCopy: 'Right-click to copy/paste',
+ helpMouseScroll: 'Scroll wheel to view history',
+ helpTouchOps: 'Touch Operations',
+ helpTouchPinchZoom: 'Pinch to zoom font size',
+ helpTouchSwipeScroll: 'Swipe to scroll history',
+ // Settings panel
+ settings: 'Settings',
+ settingBell: 'Terminal Alert Sound',
+ settingBellDesc: 'Play sound when program sends alert (e.g., command complete, error warning)',
+ settingAutoCopy: 'Auto Copy on Select',
+ settingAutoCopyDesc: 'Automatically copy selected text to clipboard',
+ settingLinkPreview: 'Link Preview',
+ settingLinkPreviewDesc: 'Show URL when hovering over links',
+ settingTouch: 'Touch Optimization',
+ settingTouchDesc: 'Enable mobile touch gestures (pinch to zoom, etc.)',
+ settingTheme: 'Terminal Theme',
+ settingThemeDesc: 'Choose the color scheme for terminal',
+ themeDark: 'Dark',
+ themeLight: 'Light',
+ themeHighContrast: 'High Contrast',
+ currentStatus: 'Current Status',
+ latency: 'Latency',
+ savedCommands: {
+ cloud: 'Cloud Sync',
+ title: 'Saved Commands',
+ subtitle: 'Store common commands and send them into the active terminal.',
+ add: 'Add',
+ synced: 'Synced',
+ encrypted: 'Encrypted',
+ count: '{count} saved',
+ collapse: 'Collapse saved commands',
+ expand: 'Expand saved commands',
+ open: 'Open saved commands',
+ short: 'Cmd',
+ new: 'New Command',
+ edit: 'Edit Command',
+ name: 'Name',
+ namePlaceholder: 'Example: Update package index',
+ command: 'Command',
+ commandPlaceholder: 'Enter the terminal command to save in the cloud',
+ description: 'Description',
+ descriptionPlaceholder: 'Optional note describing what this command does',
+ loadFailed: 'Failed to load saved commands',
+ createSuccess: 'Saved command created',
+ updateSuccess: 'Saved command updated',
+ saveFailed: 'Failed to save command',
+ deleteSuccess: 'Saved command deleted',
+ deleteFailed: 'Failed to delete command',
+ deleteConfirm: 'Delete "{name}"?',
+ emptyTitle: 'No saved commands yet',
+ emptyDescription: 'Use the add button to keep your common terminal commands here.',
+ selected: 'Selected: {name}',
+ notSelected: 'Select a command to execute or delete it',
+ execute: 'Run',
+ runHint: 'The command is sent straight into the active terminal and executed immediately.',
+ disconnectedHint: 'Connect the terminal before running a saved command',
+ shellRequiredHint: 'Wait for the interactive shell before running a saved command',
+ },
+ // Mobile accessory bar
+ paste: 'Paste',
+ hideKeyboard: 'Hide Keyboard',
+ },
+
+ // Terminal management page
+ terminalPage: {
+ newConnection: 'New Connection',
+ selectInstance: 'Select Instance',
+ runningCount: '{count} running instances',
+ selectedInstance: 'Selected Instance',
+ selectionHint: 'Pick a running instance and the terminal will connect directly into it.',
+ directShell: 'Console-first access',
+ directShellHint: 'The terminal attaches to the boot console first, then switches to the interactive shell as soon as it is ready.',
+ host: 'Host',
+ package: 'Package',
+ instanceId: 'Instance ID',
+ statusRunning: 'Running',
+ noConnections: 'No terminal connections',
+ noRunningInstances: 'No running instances',
+ noRunningInstancesHint: 'Start an instance first, or check its status from the instance detail page.',
+ connect: 'Connect',
+ description: 'Manage terminal connections for all instances',
+ searchInstances: 'Search by name or image...',
+ noMatchingInstances: 'No matching instances',
+ noMatchingInstancesHint: 'Try another keyword or clear the search to see all running instances.',
+ },
+
+ // Ticket System
+ tickets: {
+ title: 'Ticket Center',
+ myTickets: 'My Tickets',
+ hostTickets: 'Received Tickets',
+ createTicket: 'Create Ticket',
+ newTicket: 'New Ticket',
+ ticketDetails: 'Ticket Details',
+ noTickets: 'No Tickets',
+ noTicketsHint: 'You have not created any tickets yet',
+ noHostTickets: 'No Received Tickets',
+ noHostTicketsHint: 'Your hosts have not received any tickets',
+ noUserTickets: 'No User Tickets',
+ noUserTicketsHint: 'There are no user tickets sent directly to admins without an instance',
+ noOfficialTickets: 'No Official Tickets',
+ noOfficialTicketsHint: 'There are no tickets from official hosts',
+ noHostedTickets: 'No Hosted Tickets',
+ noHostedTicketsHint: 'There are no tickets from hosted nodes',
+ // Ticket status
+ status: {
+ open: 'Open',
+ in_progress: 'In Progress',
+ resolved: 'Resolved',
+ closed: 'Closed',
+ },
+ // Ticket priority
+ priority: {
+ low: 'Low',
+ normal: 'Normal',
+ high: 'High',
+ urgent: 'Urgent',
+ },
+ // Ticket category
+ category: {
+ general: 'General Inquiry',
+ billing: 'Billing Issue',
+ technical: 'Technical Support',
+ abuse: 'Abuse Report',
+ },
+ // Form
+ subject: 'Subject',
+ subjectPlaceholder: 'Brief description of the issue',
+ content: 'Content',
+ contentPlaceholder: 'Please enter at least 10 characters to describe your issue, or upload image attachments',
+ selectInstance: 'Select Instance',
+ selectInstanceHint: 'Select instance (optional)',
+ noInstancesHint: 'You have no instances, you can submit ticket directly',
+ hostedInstanceHint: 'For tickets related to hosted instances, please select the corresponding instance. The ticket will be sent to the node operator.',
+ hostedInstanceHintTitle: 'Hosted Instance Tips',
+ selectCategory: 'Select Category',
+ selectPriority: 'Select Priority',
+ // Actions
+ reply: 'Reply',
+ replyPlaceholder: 'Enter your reply...',
+ close: 'Close Ticket',
+ reopen: 'Reopen',
+ updateStatus: 'Update Status',
+ markResolved: 'Mark as Resolved',
+ markInProgress: 'Mark as In Progress',
+ // Confirmation dialogs
+ confirmClose: 'Close this ticket?',
+ confirmCloseHint: 'You will not be able to reply after closing',
+ // Messages
+ createSuccess: 'Ticket created successfully',
+ replySuccess: 'Reply sent successfully',
+ closeSuccess: 'Ticket closed',
+ deleteMessage: 'Delete message',
+ confirmDeleteMessage: 'Are you sure you want to delete this message? It will be invisible to everyone.',
+ deleteMessageSuccess: 'Message deleted',
+ statusUpdated: 'Status updated',
+ // Other
+ host: 'Host',
+ instance: 'Instance',
+ from: 'From',
+ assignedTo: 'Assigned To',
+ createdAt: 'Created At',
+ lastReply: 'Last Reply',
+ messages: 'Messages',
+ viewDetails: 'View Details',
+ pendingCount: 'Pending',
+ allHosts: 'All Hosts',
+ filterByHost: 'Filter by Host',
+ filterByStatus: 'Filter by Status',
+ sourceFilter: {
+ all: 'All Tickets',
+ user: 'User Tickets',
+ official: 'Official Tickets',
+ hosted: 'Hosted Tickets',
+ },
+ activeStatus: 'Active',
+ allStatus: 'All',
+ ownerReply: 'Support Reply',
+ userReply: 'User Reply',
+ ticketClosed: 'Ticket is closed, cannot reply',
+ mustSelectInstance: 'Please select an instance first',
+ noInstancesAvailable: 'No instances available',
+ instanceDetails: 'Instance Details',
+ instanceStatus: 'Status',
+ instanceId: 'Instance ID',
+ incusId: 'Incus ID',
+ packageName: 'Package',
+ cores: 'Cores',
+ memory: 'Memory',
+ disk: 'Disk',
+ image: 'Image',
+ loadMoreMessages: 'Load More Messages',
+ remaining: 'remaining',
+ needsReply: 'Needs Reply',
+ searchPlaceholder: 'Search ticket ID, subject, username...',
+ perPage: 'Per page',
+ totalCount: 'Total {count}',
+ images: {
+ label: 'Image Attachments',
+ hint: 'Supports JPG, PNG, WebP, GIF and AVIF. Up to {count} images, {size}MB each.',
+ add: 'Add Images',
+ remove: 'Remove Image',
+ selected: '{count}/{max} images selected',
+ maxReached: 'You can upload up to {count} images',
+ invalidType: 'Only JPG, PNG, WebP, GIF and AVIF images are supported',
+ fileTooLarge: 'Each image must be no larger than {size}MB',
+ loadFailed: 'Failed to load image',
+ zoomIn: 'Zoom in',
+ zoomOut: 'Zoom out',
+ resetZoom: 'Reset zoom',
+ },
+ },
+
+ // Checkin system
+ checkin: {
+ title: 'Daily Check-in',
+ checkinTab: 'Check-in',
+ redeemTab: 'Redeem',
+ recordsTab: 'Records',
+ // Checkin status
+ notCheckedIn: 'Not checked in today',
+ alreadyCheckedIn: 'Already checked in today',
+ checkinButton: 'Check-in',
+ checkinSuccess: 'Check-in successful',
+ noInstance: 'You need at least one instance to check in',
+ clickToOpen: 'Click the gift box to claim your reward',
+ opening: 'Opening',
+ revealing: 'Revealing reward',
+ congratulations: 'Congratulations! You got',
+ // Redeem code
+ redeemCode: 'Redeem Code',
+ codeExpired: 'Expired',
+ codeUsed: 'Used',
+ expiresIn: 'Expires in',
+ copyCode: 'Copy Code',
+ codeCopied: 'Code copied',
+ // Resource types
+ resourceType: 'Resource Type',
+ resourceValue: 'Resource Value',
+ cpu: 'CPU',
+ memory: 'Memory',
+ disk: 'Disk',
+ traffic: 'Traffic',
+ points: 'Points',
+ // Redeem
+ redeemTitle: 'Redeem Code',
+ inputCode: 'Enter redeem code',
+ selectInstance: 'Select Instance',
+ selectInstanceHint: 'Select a free instance to apply resources',
+ redeemButton: 'Redeem',
+ redeemSuccess: 'Redeem successful',
+ cappedFromPackageLimit: 'capped at package limit',
+ redeemHint: 'Check-in codes can only be used on free instances',
+ alreadyRedeemed: 'Check-in code already redeemed today',
+ noInstancesForRedeem: 'No free instances available',
+ // Records
+ checkinRecords: 'Check-in Records',
+ redeemRecords: 'Redeem Records',
+ noRecords: 'No records',
+ showingRecent: 'Showing {count} of {total}',
+ usedBy: 'Used by',
+ usedFor: 'Used for',
+ self: 'Self',
+ others: 'Others',
+ unused: 'Unused',
+ // Share rules
+ selfOnlyMode: 'Self-only',
+ selfOnlyHint: 'Your codes have been used by others for 2 consecutive days. From today, only you can use your codes until you use one yourself',
+ // Rules
+ rulesTitle: 'Check-in Rules',
+ rulesCheckin: 'Check-in',
+ rulesCheckin1: 'Once daily, resets at midnight',
+ rulesCheckin2: 'Requires at least 1 instance',
+ rulesCheckin3: 'Randomly receive CPU / Memory / Disk / Traffic rewards',
+ rulesRedeem: 'Redemption',
+ rulesRedeem1: 'Redeem codes expire in 3 hours',
+ rulesRedeem2: 'Limit 1 redemption per person per day',
+ rulesRedeem3: 'Resources cannot exceed package limits',
+ rulesShare: 'Sharing',
+ rulesShare1: 'Redeem codes can be shared with friends',
+ rulesShare2: 'Used by others for 2 consecutive days will restrict to self-only until you use one yourself',
+ // Units
+ percent: '%',
+ mb: 'MB',
+ gb: 'GB',
+ // Resource pool system
+ tabCheckin: 'Check-in',
+ tabRedeem: 'Redeem',
+ tabPool: 'Resource Pool',
+ tabLogs: 'Logs',
+ clickToCheckin: 'Click to check in and claim rewards',
+ noInstances: 'You need at least one instance to check in',
+ bonusPoints: 'Bonus {points} points',
+ savedToPool: 'Added to resource pool',
+ redeemSystemCode: 'System Redeem Code',
+ enterCode: 'Enter redeem code',
+ systemCodePlaceholder: 'Enter h- prefix system code',
+ systemCodeHint: 'System codes (h- prefix) require selecting a target instance, resources will be applied directly to that instance',
+ redeem: 'Redeem',
+ redeemToInstanceSuccess: '{type} +{value}{unit} applied to {instance}',
+ applyToInstance: 'Apply to Instance',
+ amount: 'Amount',
+ targetInstance: 'Target Instance',
+ kvmCpuHint: 'CPU must be a multiple of 100 when applied to KVM instances',
+ kvmHint: 'KVM limits: CPU must be multiple of 100, memory must be multiple of 128MB, disk must be multiple of 1GB, and memory/disk changes require stopped instance. LXC instances have no such limits.',
+ apply: 'Apply',
+ applySuccess: '{type} +{value}{unit} applied to {instance}',
+ insufficientPool: 'Insufficient resource pool balance',
+ enterAmount: 'Please enter amount',
+ allActions: 'All actions',
+ allResources: 'All resources',
+ noLogs: 'No records',
+ action: 'Action',
+ instance: 'Instance',
+ time: 'Time',
+ actionCheckin: 'Check-in',
+ actionRedeem: 'Redeem',
+ actionAdminGrant: 'Admin grant',
+ actionSystemGrant: 'System reward',
+ actionLottery: 'Lottery',
+ actionApply: 'Apply',
+ actionSystemRedeem: 'System Code',
+ },
+
+ // Redeem codes management
+ redeemCodes: {
+ title: 'Redeem Codes',
+ create: 'Create Code',
+ createTitle: 'Create Redeem Code',
+ createBatch: 'Batch Create',
+ codeType: 'Resource Type',
+ codeValue: 'Resource Value',
+ resourceType: 'Resource Type',
+ resourceValue: 'Resource Value',
+ maxUses: 'Max Uses',
+ usedCount: 'Used',
+ expiresAt: 'Expires At',
+ expiresAtHint: 'Leave empty for no expiration',
+ neverExpires: 'Never expires',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ enable: 'Enable',
+ disable: 'Disable',
+ remark: 'Remark',
+ remarkPlaceholder: 'Optional remark',
+ batchCount: 'Quantity',
+ batchCountHint: 'When greater than 1, generates single-use codes in batch',
+ createSuccess: 'Redeem code created',
+ batchCreateSuccess: 'Successfully created {count} redeem codes',
+ createFailed: 'Failed to create',
+ deleteConfirm: 'Confirm Delete',
+ deleteSuccess: 'Successfully deleted {count} code(s)',
+ deleteFailed: 'Failed to delete',
+ confirmDelete: 'Confirm Delete',
+ confirmDeleteMessage: 'Are you sure you want to delete {count} selected code(s)? This action cannot be undone.',
+ deleteSelected: 'Delete Selected ({count})',
+ updateSuccess: 'Updated successfully',
+ copyCode: 'Copy Code',
+ copyAll: 'Copy All',
+ copyCodes: 'Copy All Codes',
+ codesCopied: 'Codes copied',
+ exhausted: 'Exhausted',
+ expired: 'Expired',
+ active: 'Active',
+ paused: 'Paused',
+ usages: 'Usage History',
+ usageRecords: 'Usage Records',
+ noUsages: 'No usage records',
+ user: 'User',
+ instance: 'Instance',
+ usedAt: 'Used At',
+ selectType: 'Select resource type',
+ selectValue: 'Please select a value',
+ empty: 'No redeem codes',
+ emptyList: 'No redeem codes yet. Click the button above to create.',
+ filterAll: 'All',
+ filterEnabled: 'Enabled',
+ filterDisabled: 'Disabled',
+ batchDelete: 'Batch Delete',
+ batchDeleteConfirm: 'Are you sure you want to delete {count} selected codes?',
+ usesHint: 'Set to 1 for single-use code',
+ batchHint: 'Batch create generates single-use codes only',
+ batchResult: 'Batch Create Result',
+ code: 'Code',
+ type: 'Type',
+ usage: 'Usage',
+ status: 'Status',
+ actions: 'Actions',
+ loadFailed: 'Failed to load',
+ batchId: 'Batch ID',
+ batchLimitHint: 'Each user can only use one code from the same batch',
+ batch: 'Batch',
+ valueRange: 'Range: {min} - {max}',
+ valueOutOfRange: 'Value must be between {min} and {max}',
+ valueMustBeInteger: 'Value must be an integer',
+ },
+
+ // Extensions
+ extensions: {
+ title: 'Scripts',
+ description: 'Manage extensions',
+ initCommands: {
+ title: 'Custom Init Commands',
+ description: 'Create command templates to run during instance creation/rebuild',
+ add: 'Add Command',
+ addFirst: 'Add First Command',
+ edit: 'Edit Command',
+ view: 'View Details',
+ viewDetail: 'Command Details',
+ empty: 'No custom commands',
+ emptyHint: 'Click the button above to create your first init command template',
+ name: 'Name',
+ namePlaceholder: 'Enter command name',
+ command: 'Commands',
+ commandPlaceholder: 'Enter shell commands, one per line\nExample:\napt update\napt install -y nginx',
+ commandHint: 'One command per line, executed in order during instance initialization',
+ distros: 'Compatible Distros',
+ distrosHint: 'Select the Linux distributions this command is compatible with',
+ remark: 'Description',
+ remarkPlaceholder: 'Optional description',
+ createdAt: 'Created At',
+ actions: 'Actions',
+ status: 'Status',
+ statusEnabled: 'Enabled',
+ statusDisabled: 'Disabled',
+ enabled: 'Command enabled',
+ disabled: 'Command disabled',
+ clickToEnable: 'Click to enable',
+ clickToDisable: 'Click to disable',
+ toggleFailed: 'Failed to toggle status',
+ confirmDelete: 'Are you sure you want to delete command "{name}"? This action cannot be undone.',
+ createSuccess: 'Command created',
+ updateSuccess: 'Command updated',
+ deleteSuccess: 'Command deleted',
+ createFailed: 'Failed to create',
+ updateFailed: 'Failed to update',
+ deleteFailed: 'Failed to delete',
+ loadFailed: 'Failed to load commands',
+ loadDetailFailed: 'Failed to load command details',
+ noContent: 'No content',
+ lineCount: '{count} lines',
+ // Modal
+ addTitle: 'Add Init Command',
+ editTitle: 'Edit Init Command',
+ modalDesc: 'Commands will be executed during instance initialization',
+ // Selector
+ selectTitle: 'Init Commands',
+ optional: '(optional)',
+ noAvailable: 'No init commands available',
+ goToManage: 'Go to Extensions to create',
+ selectHint: 'Selected commands will run after instance initialization completes',
+ selectedCount: '{count} selected',
+ showAll: 'Show all {count}',
+ collapse: 'Collapse',
+ // Distro names
+ distroAll: 'All Distros',
+ distroNames: {
+ all: 'All Distros',
+ ubuntu: 'Ubuntu',
+ debian: 'Debian',
+ rhel: 'RHEL/CentOS/Fedora',
+ alpine: 'Alpine',
+ arch: 'Arch Linux',
+ suse: 'openSUSE/SLES',
+ },
+ },
+ },
+
+ // Billing
+ billing: {
+ // Common
+ balance: 'Balance',
+ frozen: 'Frozen',
+ totalRecharge: 'Total Recharge',
+ totalConsume: 'Total Consumption',
+ yuan: 'CNY',
+ months: 'months',
+ month: 'mo',
+ days: 'days',
+ freeInstance: 'Free Instance',
+ paidInstance: 'Paid Instance',
+ traffic: 'Monthly Traffic',
+ trafficBidirectional: 'Bidirectional',
+ soldOut: 'Sold Out',
+ save: 'Save',
+ setupFee: 'Setup Fee',
+ cycle: {
+ monthly: 'Monthly',
+ quarterly: 'Quarterly',
+ semiAnnual: 'Semi-Annual',
+ annual: 'Annual',
+ months: 'months',
+ },
+
+ // Instance billing info
+ billingInfo: 'Billing Info',
+ currentPlan: 'Current Plan',
+ expiresAt: 'Expires At',
+ neverExpires: 'Never Expires',
+ autoRenew: 'Auto Renew',
+ autoRenewOn: 'Auto On',
+ autoRenewOff: 'Auto Off',
+ autoRenewEnabled: 'Auto-renew enabled',
+ autoRenewDisabled: 'Auto-renew disabled',
+ autoRenewing: 'Auto-renewing',
+ enableAutoRenew: 'Enable Auto-renew',
+ disableAutoRenew: 'Disable Auto-renew',
+ autoRenewHint: 'Will automatically deduct from balance 24 hours before expiration',
+ autoRenewDesc: 'Once enabled, the instance will automatically renew per {cycle} cycle, costing ¥{price} each time',
+ currentStatus: 'Current Status',
+
+ // Renewal
+ renew: 'Renew',
+ renewInstance: 'Renew Instance',
+ renewTitle: 'Renew Instance',
+ selectRenewPeriod: 'Select Renewal Period',
+ renewMonths: '{months} months',
+ renewPrice: 'Renewal Amount',
+ originalPrice: 'Original Price',
+ affDiscount: 'Promo Code Discount',
+ actualPrice: 'Actual Price',
+ newExpiresAt: 'New Expiration',
+ currentBalance: 'Current Balance',
+ balanceAfterRenew: 'Balance After Renewal',
+ insufficientBalance: 'Insufficient Balance',
+ goRecharge: 'Go to Recharge',
+ renewing: 'Renewing...',
+ renewSuccess: 'Renewal Successful',
+ renewFailed: 'Renewal Failed',
+ freeInstanceNoRenew: 'Free instances do not need renewal',
+ hostingRenewTooEarly: 'Hosted instances can only be renewed within 7 days before expiration (currently {days} days remaining)',
+
+ // Plan change
+ changePlan: 'Upgrade',
+ changePlanTitle: 'Upgrade Instance Plan',
+ upgrade: 'Upgrade',
+ selectNewPlan: 'Select New Plan',
+ currentPlanLabel: 'Current Plan',
+ newPlanLabel: 'New Plan',
+ remainingDays: 'Remaining Days',
+ remainingValue: 'Remaining Value',
+ newPlanCost: 'New Plan Cost',
+ priceDiff: 'Price Difference',
+ needPay: 'Amount to Pay',
+ noPriceChange: 'No Price Difference',
+ isUpgrade: 'Upgrade',
+ planNotActive: 'Plan is inactive',
+ planNoStock: 'Plan is sold out',
+ changePlanInProgress: 'Upgrading plan...',
+ changePlanSuccess: 'Plan upgraded successfully',
+ changePlanFailed: 'Failed to upgrade plan',
+ needRestart: 'Please restart instance to apply new configuration',
+ newConfig: 'New Configuration',
+ freeInstanceNoChange: 'Free instances do not support upgrade',
+ samePlan: 'Cannot change to the same plan',
+ // Change rules
+ viewRules: 'View Rules',
+ hideRules: 'Hide Rules',
+ changePlanRulesTitle: 'Plan Upgrade Rules',
+ changePlanRule1: 'Price difference calculated by daily rate; pay difference on upgrade',
+ changePlanRule2: 'Remaining days must be ≥ 15 to upgrade plan',
+ changePlanRule3: 'Expiration date remains unchanged',
+ changePlanRule4: 'Promo code discounts continue to apply',
+ // Highest plan
+ alreadyHighestPlan: 'Already on the highest plan',
+ contactForCustomPlan: 'Submit a ticket for custom higher configurations',
+ // KVM/LXC restart hints
+ kvmRestartHint: 'KVM instances require a restart to apply new configuration; instances using reinstall scripts may need to expand partitions manually',
+ lxcInstantHint: 'LXC instance configuration takes effect immediately after upgrade',
+ kvmRestartRequired: 'KVM instance plan has been changed, please restart the instance to apply new configuration',
+ // Cannot change reasons
+ cannotChange: 'Cannot Upgrade Now',
+ cannotChangeRemainingDays: 'Less than {days} days remaining, cannot upgrade plan',
+ cannotChangeInstanceStatus: 'Current instance status does not allow plan upgrade, only running or stopped instances can be modified',
+ cannotChangeUnknown: 'Cannot upgrade plan at this time',
+ // Calculation details
+ oldDailyPrice: 'Current Daily Rate',
+ newDailyPrice: 'New Daily Rate',
+ day: 'day',
+ newPlanCostOriginal: 'New Plan Remaining Cost',
+ newPlanCostFinal: 'New Plan Remaining Cost (After Discount)',
+ discountAmount: 'Promo Discount',
+
+ // Billing records
+ records: 'Records',
+ billingRecords: 'Billing Records',
+ recordType: 'Type',
+ recordAmount: 'Amount',
+ recordPeriod: 'Period',
+ recordRemark: 'Remark',
+ recordTypes: {
+ purchase: 'Purchase',
+ renewal: 'Renewal',
+ upgrade: 'Upgrade',
+ downgrade: 'Downgrade',
+ admin_extension: 'Admin Extension',
+ },
+
+ // Recharge
+ recharge: 'Recharge',
+ rechargeTitle: 'Account Recharge',
+ rechargeAmount: 'Recharge Amount',
+ actualAmount: 'Actual Amount',
+ fee: 'Fee',
+ minAmount: 'Minimum Amount',
+ maxAmount: 'Maximum Amount',
+ selectPaymentMethod: 'Select Payment Method',
+ noPaymentProviders: 'No payment methods available',
+ createOrder: 'Create Order',
+ creatingOrder: 'Creating order...',
+ orderCreated: 'Order Created',
+ orderNo: 'Order No.',
+ orderStatus: 'Order Status',
+ orderExpiredAt: 'Payment Deadline',
+ cancelOrder: 'Cancel Order',
+ orderCancelled: 'Order Cancelled',
+ orderStatus_pending: 'Pending',
+ orderStatus_paid: 'Paid',
+ orderStatus_completed: 'Completed',
+ orderStatus_failed: 'Failed',
+ orderStatus_cancelled: 'Cancelled',
+ orderStatus_expired: 'Expired',
+ orderStatus_refunded: 'Refunded',
+
+ // Recharge records
+ rechargeRecords: 'Recharge Records',
+ noRecords: 'No Records',
+ viewDetails: 'View Details',
+
+ // Balance logs
+ balanceLogs: 'Balance History',
+ balanceLogTypes: {
+ recharge: 'Recharge',
+ purchase: 'Purchase',
+ renewal: 'Renewal',
+ upgrade: 'Upgrade',
+ downgrade: 'Downgrade',
+ refund: 'Refund',
+ admin_adjust: 'Admin Adjustment',
+ },
+
+ // Package plans
+ plan: 'Plan',
+ planName: 'Plan Name',
+ planPrice: 'Price',
+ planBillingCycle: 'Billing Cycle',
+ planConfig: 'Configuration',
+ planStock: 'Stock',
+ planSoldOut: 'Sold Out',
+ planInactive: 'Inactive',
+ billingCycleMonthly: 'Monthly',
+ billingCycleQuarterly: 'Quarterly',
+ billingCycleYearly: 'Yearly',
+ perMonth: '/mo',
+ perQuarter: '/qtr',
+ perYear: '/yr',
+ },
+
+ // Hosting Access
+ hosting: {
+ accessDenied: {
+ title: 'Hosting Requirements Not Met',
+ description: 'You need to meet the following condition to use hosting features:',
+ condition: 'Own at least 1 instance',
+ currentInstances: 'Currently own {count} instances',
+ hint: 'Includes both free and paid instances, from official or hosted nodes.',
+ featureHiddenCondition: 'Hosting is currently unavailable to new users',
+ featureHiddenCurrent: 'You have not created any host yet, so this entry is currently hidden by the system.',
+ featureHiddenHint: 'Contact an administrator to reopen hosting access, or try again when the feature is enabled again.',
+ },
+ },
+
+ // Hosting Wallet
+ hostingWallet: {
+ title: 'Hosting Earnings',
+ description: 'View your node hosting earnings and withdrawal records',
+ hostingMember: 'Hosting Member',
+ notice: {
+ title: 'Hosting Notice',
+ },
+ balance: {
+ available: 'Available Balance',
+ frozen: 'Frozen',
+ frozenNote: 'Income unfreezes after 30 days',
+ totalIncome: 'Total Income',
+ },
+ stats: {
+ myHostsCount: 'Hosted Nodes',
+ instancesOnMyHosts: 'Node Instances',
+ uniqueCustomersCount: 'Customers',
+ monthIncome: 'This Month',
+ },
+ withdraw: {
+ button: 'Request Withdrawal',
+ minAmountNote: 'Withdrawal available when balance reaches {amount}',
+ },
+ tabs: {
+ overview: 'Overview',
+ logs: 'Transactions',
+ withdrawals: 'Withdrawals',
+ blocks: 'Blacklist',
+ },
+ overview: {
+ title: 'Withdrawal Info',
+ howItWorks: 'How It Works',
+ step1Title: 'User Purchases',
+ step1Desc: 'Users buy/renew instances on your nodes',
+ step2Title: 'Income Frozen',
+ step2Desc: 'Earnings credited to balance, frozen for 30 days',
+ step3Title: 'Withdraw',
+ step3Desc: 'Withdraw after freeze period ends',
+ minAmountTitle: 'Minimum Amount',
+ minAmount: 'Minimum withdrawal {amount}',
+ feeTitle: 'Withdrawal Fee',
+ feeDesc: 'Panel balance withdrawal fee {rate}%',
+ feeDescNew: 'Panel balance withdrawal fee 5%, specified method fee 10%',
+ manualTitle: 'Manual Withdrawal',
+ manualDesc: 'For other methods (10% fee), please submit a ticket',
+ withdrawMethodTitle: 'Withdrawal Method',
+ withdrawMethodDesc: 'Self-serve withdraw to balance, or submit a ticket for other withdrawal methods',
+ },
+ logs: {
+ noRecords: 'No transaction records',
+ emptyHint: 'When users purchase instances on your nodes, earnings will appear here',
+ searchPlaceholder: 'Search by username/email/instance...',
+ filterAll: 'All Types',
+ freePlan: 'Free',
+ columns: {
+ type: 'Type',
+ amount: 'Amount',
+ status: 'Status',
+ buyer: 'Buyer',
+ instance: 'Instance',
+ host: 'Host',
+ package: 'Package',
+ plan: 'Plan',
+ remark: 'Remark',
+ time: 'Time',
+ },
+ types: {
+ income: 'Income',
+ unfreeze: 'Unfreeze',
+ withdraw: 'Withdrawal',
+ deduction: 'Deduction',
+ },
+ actionTypes: {
+ purchase: 'Purchase',
+ renew: 'Renew',
+ upgrade: 'Upgrade',
+ destroy: 'Destroy',
+ unfreeze: 'Unfreeze',
+ withdraw: 'Withdrawal',
+ admin_adjust: 'Admin Adjust',
+ },
+ status: {
+ frozen: 'Frozen',
+ unfrozen: 'Unfrozen',
+ },
+ unknownInstance: 'Unknown Instance',
+ unknownUser: 'Unknown User',
+ unknownHost: 'Unknown Host',
+ },
+ perPage: '/page',
+ prevPage: 'Previous',
+ nextPage: 'Next',
+ withdrawals: {
+ noRecords: 'No withdrawal records',
+ emptyTitle: 'No Withdrawals Yet',
+ emptyHint: 'You can request a withdrawal once your available balance reaches the minimum amount',
+ startEarning: 'Withdraw Now',
+ columns: {
+ amount: 'Amount',
+ actualAmount: 'Actual Amount',
+ target: 'Method',
+ status: 'Status',
+ time: 'Request Time',
+ },
+ target: {
+ balance: 'Panel Balance',
+ },
+ status: {
+ pending: 'Pending',
+ approved: 'Approved',
+ rejected: 'Rejected',
+ completed: 'Completed',
+ },
+ },
+ blocks: {
+ title: 'User Blacklist',
+ description: 'Blocked users cannot see your hosted packages or plans in the instance creation flow, and cannot create new instances from your packages.',
+ total: '{count} users',
+ searchLabel: 'Search users',
+ searchPlaceholder: 'Enter UID, username, or email, at least 2 characters',
+ noSearchResults: 'No matching users found',
+ blockedUsers: 'Blocked Users',
+ effectHint: 'These users cannot create new instances from your hosted packages. Existing instances and renewals are unaffected.',
+ emptyTitle: 'No blocked users',
+ emptyHint: 'Search above to add one.',
+ block: 'Block',
+ unblock: 'Unblock',
+ blockSuccess: 'User added to blacklist',
+ unblockSuccess: 'User removed from blacklist',
+ },
+ modal: {
+ title: 'Request Withdrawal',
+ amount: 'Withdrawal Amount',
+ availableNote: 'Available: {amount}',
+ targetBalance: 'To panel balance ({rate}% fee)',
+ manualWithdrawNote: 'For manual withdrawal to specified method (10% fee), please submit a ticket to request hosting balance withdrawal',
+ summary: {
+ amount: 'Withdrawal Amount',
+ fee: 'Fee',
+ actual: 'Actual Amount',
+ },
+ cancel: 'Cancel',
+ confirm: 'Confirm Withdrawal',
+ submitting: 'Submitting...',
+ },
+ errors: {
+ minAmount: 'Minimum withdrawal is {amount}',
+ exceedBalance: 'Amount exceeds available balance',
+ },
+ },
+
+ vipBenefits: {
+ commonMember: 'Member',
+ overviewLabel: 'VIP Benefits Hall',
+ overviewTitle: 'Current Level {level}',
+ overviewDesc: 'At {level}, you can claim every unlocked reward below. Claim lower-level rewards before higher-level rewards.',
+ noVipDesc: 'Upgrade to VIP to unlock level rewards. All rewards are shown from low to high level.',
+ availableSummary: 'Unlocked Reward Summary',
+ availableSummaryDesc: 'A compact total of every reward unlocked by your current level.',
+ remainingSummary: 'Remaining',
+ noAvailableReward: 'No unlocked rewards yet',
+ empty: 'No VIP benefits configured yet.',
+ levelRewards: 'VIP{level} Rewards',
+ levelUnlocked: 'This level is unlocked',
+ levelLocked: 'Unlock after upgrade',
+ rewardCount: '{count} reward(s)',
+ rewardValue: 'Reward',
+ claimProgress: 'Claims',
+ claim: 'Claim',
+ claimAll: 'Claim All Remaining',
+ claimingAll: 'Claiming...',
+ claimed: 'Claimed',
+ pendingDelivery: 'Pending',
+ upgradeRequired: 'Upgrade Required',
+ claimLowerFirst: 'Claim VIP{level} First',
+ claimSuccess: 'Reward claimed',
+ noClaimableReward: 'No claimable rewards',
+ rewardReceived: 'VIP benefit reward',
+ pointsUnit: 'points',
+ instanceReward: 'Package instance',
+ instanceValue: '{plan} · {quantity} instance(s) · {days} day(s)',
+ feedback: {
+ delivered: 'Delivered',
+ pending: 'Pending delivery',
+ },
+ types: {
+ balance: 'Balance',
+ points: 'Points',
+ instance: 'Instance',
+ },
+ summary: {
+ unlocked: 'Unlocked',
+ claimable: 'Claimable',
+ claimed: 'Claimed',
+ pending: 'Pending',
+ },
+ status: {
+ claimable: 'Claimable',
+ claimed: 'Claimed',
+ locked: 'Locked',
+ blocked: 'Claim VIP{level} first',
+ pending: 'Pending',
+ },
+ },
+
+ // Benefits System
+ entertainment: {
+ title: 'Benefits',
+ description: 'Claim membership benefits and manage points and badges',
+ currentPoints: 'Current Points',
+ convertPoints: 'Convert Points',
+ noPointsToConvert: 'No points to convert',
+ convertSuccess: 'Successfully converted {points} points',
+ convertFailed: 'Conversion failed',
+ pointsUnit: 'pts',
+ tabs: {
+ lottery: 'Lottery',
+ records: 'Lottery Records',
+ points: 'Points History',
+ },
+ mainTabs: {
+ vipBenefits: 'VIP Benefits',
+ lottery: 'Lottery',
+ badge: 'Badges',
+ blindbox: 'Blind Box',
+ checkin: 'Check-in',
+ },
+ comingSoon: 'Coming Soon',
+ blindbox: {
+ title: 'Blind Box',
+ },
+ checkinSection: {
+ title: 'Daily Check-in',
+ },
+ // Lottery
+ spin: 'Spin',
+ spinCost: 'Cost: {points} points',
+ spinFailed: 'Lottery failed',
+ selectLottery: 'Please select a lottery',
+ notEnoughPoints: 'Not enough points',
+ noActiveLotteries: 'No active lottery available',
+ prizeList: 'Prize List',
+ probability: 'Probability',
+ remaining: 'Remaining',
+ // Multi Draw
+ multiDraw: 'Multi Draw x10',
+ multiDrawAgain: 'Draw Again',
+ multiDrawFailed: 'Multi draw failed',
+ multiDrawResults: 'Multi Draw Results',
+ multiDrawStopped: 'Draw stopped early',
+ notEnoughPointsForMulti: 'Not enough points. Required: {required}, Current: {current}',
+ totalDraws: 'Total Draws',
+ totalPointsSpent: 'Points Spent',
+ badgeUnit: 'badge(s)',
+ instanceUnit: '',
+ multiDrawBadgesTitle: 'Badges Won in This 10x Draw',
+ multiDrawBadgesSubtitle: 'You obtained {count} badge rewards in this 10x draw. Review them before the full result list.',
+ continueToMultiResults: 'Continue to 10x Results',
+ // Prize types
+ prizeTypes: {
+ nothing: 'Better Luck Next Time',
+ points: 'Points',
+ balance: 'Balance',
+ badge: 'Random Badge',
+ instance: 'Instance',
+ cpu: 'CPU Resource',
+ memory: 'Memory Resource',
+ disk: 'Disk Resource',
+ traffic: 'Traffic Resource',
+ },
+ // Result
+ congratulations: 'Congratulations!',
+ betterLuckNextTime: 'Better Luck Next Time',
+ wonPoints: 'Won {points} points',
+ wonBalance: 'Won ¥{amount} balance',
+ wonBadge: 'Won badge: {badge}',
+ wonInstance: 'Please submit a ticket to claim your instance prize',
+ wonCpu: 'Won {value}% CPU, added to resource pool',
+ wonMemory: 'Won {value}MB memory, added to resource pool',
+ wonDisk: 'Won {value}MB disk, added to resource pool',
+ wonTraffic: 'Won {value}GB traffic, added to resource pool',
+ // Records
+ lotteryName: 'Lottery Name',
+ prize: 'Prize',
+ prizeType: 'Prize Type',
+ value: 'Value',
+ time: 'Time',
+ noRecords: 'No lottery records',
+ loadRecordsFailed: 'Failed to load lottery records',
+ loadLotteriesFailed: 'Failed to load lotteries',
+ // Points history
+ pointsLogType: 'Type',
+ pointsChange: 'Change',
+ pointsAfter: 'Balance After',
+ remark: 'Remark',
+ noPointsLogs: 'No points history',
+ loadPointsLogsFailed: 'Failed to load points history',
+ pointsLogTypes: {
+ convert: 'Consumption Conversion',
+ lotteryWin: 'Lottery Win',
+ lotterySpend: 'Lottery Spend',
+ badgeDrawSpend: 'Badge Random Draw Spend',
+ badgeSelectSpend: 'Badge Select Spend',
+ adminAdjust: 'Admin Adjustment',
+ checkin: 'Check-in Reward',
+ },
+ badges: {
+ drawTab: 'Draw',
+ myTab: 'My Badges',
+ randomTitle: 'Random Draw',
+ randomHint: 'Equal odds, guaranteed badge',
+ randomDescription: 'Spend {points} points for an equal-odds draw. Every draw grants one badge copy.',
+ randomButton: 'Random Draw ({points} pts)',
+ randomMultiButton: '10x Random Draw ({points} pts)',
+ selectTitle: 'Select Badge',
+ selectHint: 'Choose a specific badge directly',
+ selectDescription: 'Spend {points} points to directly claim one selected badge.',
+ selectButton: 'Claim Selected Badge ({points} pts)',
+ multiDrawTitle: 'Badges Won in 10x Draw',
+ multiDrawSubtitle: 'You obtained {count} badges in this 10x draw.',
+ myTitle: 'My Badges',
+ summary: 'Available {available}, Applied {applied}',
+ filterAll: 'All Types',
+ ownedCount: 'Owned {count}',
+ empty: 'No badges obtained yet.',
+ statusAvatar: 'Applied to avatar',
+ statusInstance: 'Applied to instance',
+ statusUnused: 'Unused',
+ sourceLabel: 'Source',
+ sourceDraw: 'Random draw',
+ sourceLottery: 'Lottery reward',
+ sourceSelect: 'Direct select',
+ sourceAdminGrant: 'Admin grant',
+ obtainedAt: 'Obtained At',
+ currentInstance: 'Current Instance',
+ applyAvatar: 'Apply to Avatar',
+ applyInstance: 'Apply to Instance Icon',
+ applyInstanceButton: 'Apply to Instance',
+ selectInstance: 'Select an instance',
+ unapply: 'Remove Application',
+ selectRequired: 'Please select a badge first',
+ instanceRequired: 'Please select an instance first',
+ drawSuccess: 'Badge obtained: {badge}',
+ selectSuccess: 'Badge claimed: {badge}',
+ rewardTitleDraw: 'Draw Successful',
+ rewardTitleSelect: 'Claim Successful',
+ rewardSubtitle: 'Your new badge is now in your collection. You can apply it to your avatar or an instance from My Badges.',
+ rewardSeriesLabel: 'Series',
+ rewardRemainingPoints: 'Remaining Points',
+ rewardDrawAgain: 'Draw Again',
+ rewardViewMine: 'View My Badges',
+ applyAvatarSuccess: 'Applied to avatar',
+ applyInstanceSuccess: 'Applied to instance icon',
+ unapplySuccess: 'Application removed',
+ },
+ // Admin
+ admin: {
+ title: 'Entertainment Management',
+ description: 'Manage lotteries, prizes, and user points',
+ tabs: {
+ lotteries: 'Lotteries',
+ records: 'Win Records',
+ users: 'User Points',
+ badges: 'Badges',
+ },
+ createLottery: 'Create Lottery',
+ editLottery: 'Edit Lottery',
+ lotteryName: 'Lottery Name',
+ enterLotteryName: 'Enter lottery name',
+ lotteryDesc: 'Description',
+ enterDescription: 'Enter description (optional)',
+ costPoints: 'Cost Points',
+ startAt: 'Start Date',
+ endAt: 'End Date',
+ isActive: 'Active',
+ enterName: 'Please enter name',
+ invalidCostPoints: 'Cost points must be greater than 0',
+ createSuccess: 'Created successfully',
+ updateSuccess: 'Updated successfully',
+ saveFailed: 'Failed to save',
+ deleteSuccess: 'Deleted successfully',
+ deleteFailed: 'Failed to delete',
+ noLotteries: 'No lotteries',
+ loadLotteriesFailed: 'Failed to load lotteries',
+ prizes: 'Prizes',
+ totalDraws: 'Total Draws',
+ status: 'Status',
+ active: 'Active',
+ inactive: 'Inactive',
+ // Prize management
+ managePrizes: 'Manage Prizes',
+ addPrize: 'Add Prize',
+ prizeName: 'Prize Name',
+ prizeType: 'Prize Type',
+ prizeValue: 'Prize Value',
+ balanceValue: 'Balance (cents)',
+ balanceCents: 'Enter cents, e.g. 100=¥1',
+ cpuPercent: 'Enter CPU percentage',
+ memoryMB: 'Enter memory (MB)',
+ diskMB: 'Enter disk (MB)',
+ trafficGB: 'Enter traffic (GB)',
+ probability: 'Weight',
+ quantity: 'Quantity',
+ unlimited: 'Unlimited',
+ noQuantityForType: 'This prize type cannot have quantity limits',
+ replenish: 'Replenish',
+ remaining: 'Remaining',
+ replenishPlaceholder: 'Enter amount to add',
+ instanceDesc: 'Instance Description',
+ instanceDescPlaceholder: 'e.g. 1 Core/1G RAM/10G SSD',
+ noPrizes: 'No prizes. Click the button above to add one.',
+ enterPrizeName: 'Please enter prize name',
+ invalidProbability: 'Weight must be greater than 0',
+ savePrizesSuccess: 'Prizes saved successfully',
+ savePrizesFailed: 'Failed to save prizes',
+ // Win records
+ user: 'User',
+ searchUser: 'Search username',
+ noRecords: 'No win records',
+ loadRecordsFailed: 'Failed to load win records',
+ // User points
+ currentPoints: 'Current Points',
+ totalEarned: 'Total Earned',
+ totalSpent: 'Total Spent',
+ lastConvertedAt: 'Last Converted',
+ noUsers: 'No user points data',
+ loadUsersFailed: 'Failed to load user points',
+ // Badge catalog
+ badgeCatalog: {
+ loadFailed: 'Failed to load badge catalog',
+ fillSeriesRequired: 'Please fill in series ID, title, name, and description',
+ seriesUpdated: 'Series updated',
+ seriesCreated: 'Series created',
+ saveSeriesFailed: 'Failed to save series',
+ seriesDeleted: 'Series deleted',
+ deleteSeriesFailed: 'Failed to delete series',
+ fillBadgeRequired: 'Please fill in badge ID, name, label, series, and default image URL',
+ badgeUpdated: 'Badge updated',
+ badgeCreated: 'Badge created',
+ saveBadgeFailed: 'Failed to save badge',
+ badgeDeleted: 'Badge deleted',
+ deleteBadgeFailed: 'Failed to delete badge',
+ addSeries: 'Add Series',
+ addBadge: 'Add Badge',
+ series: {
+ title: 'Series',
+ description: 'Controls frontend filter groups and whole-series availability',
+ add: 'Add',
+ all: 'All Series',
+ enabledCount: '{active} / {total} enabled',
+ empty: 'No series',
+ editTitle: 'Edit Series',
+ createTitle: 'Add Series',
+ id: 'Series ID',
+ sort: 'Sort',
+ nameZh: 'Chinese Name',
+ nameEn: 'English Name',
+ titleLabel: 'Title',
+ titlePlaceholder: 'SUPREME Series',
+ descriptionLabel: 'Description',
+ sourceId: 'Source ID',
+ sourceLabel: 'Source Name',
+ enable: 'Enable this series',
+ },
+ badges: {
+ title: 'Badges',
+ currentFilter: 'Current filter: {name}',
+ empty: 'No badges',
+ tableBadge: 'Badge',
+ tableSeries: 'Series',
+ tableAssetUrl: 'Image URL',
+ tableStatus: 'Status',
+ tableUsage: 'Usage',
+ drawable: 'Drawable',
+ notDrawable: 'Not drawable',
+ usage: 'Owned {ownership} / Avatar {avatar} / Instance {instance}',
+ editTitle: 'Edit Badge',
+ createTitle: 'Add Badge',
+ id: 'Badge ID',
+ series: 'Series',
+ name: 'Name',
+ nameEn: 'English Name',
+ fullLabel: 'Full Label',
+ fullLabelPlaceholder: 'Elite',
+ sourceId: 'Source ID',
+ sourceLabel: 'Source Name',
+ assetUrl: 'Default Image URL',
+ assetUrlPlaceholder: '/badges/dark/elite.svg or https://example.com/badge.svg',
+ assetUrlDark: 'Dark Image URL',
+ assetUrlLight: 'Light Image URL',
+ sort: 'Sort',
+ enable: 'Enable this badge',
+ preview: 'Preview',
+ previewName: 'Badge Name',
+ previewLabel: 'Full Label',
+ },
+ },
+ // Notification config
+ notification: {
+ title: 'Win Notification',
+ enabled: 'Enable Notification',
+ type: 'Notification Type',
+ conditions: 'Notification Conditions',
+ notifyBalance: 'Notify on balance win',
+ notifyInstance: 'Notify on instance win',
+ secret: 'Secret Key',
+ secretPlaceholder: 'For Webhook signature verification (optional)',
+ fillTelegram: 'Please fill in Telegram Bot Token and Chat ID',
+ fillDiscord: 'Please fill in Discord Webhook URL',
+ fillWebhook: 'Please fill in Webhook URL',
+ saveSuccess: 'Notification config saved',
+ saveFailed: 'Failed to save notification config',
+ },
+ },
+ },
+
+ // Mail
+ mail: {
+ title: 'Mail',
+ description: 'Manage your professional email service',
+ tabs: {
+ my: 'My Mail',
+ buy: 'Buy Mail',
+ accounts: 'Accounts',
+ dns: 'DNS Config',
+ settings: 'Settings',
+ },
+ noSubscription: 'You have no mail service',
+ buyNowHint: 'Purchase professional mail service now',
+ buyNow: 'Buy Now',
+ subscriptionOverview: 'Subscription Overview',
+ expiresAt: 'Expires At',
+ domainsUsed: 'Domains Used',
+ diskUsed: 'Disk Used',
+ totalSpace: 'Total Space',
+ accounts: 'Accounts',
+ plan: 'Plan',
+ domains: 'domains',
+ month: 'month',
+ year: 'year',
+ renew: 'Renew',
+ myDomains: 'My Domains',
+ addDomain: 'Add Domain',
+ noDomains: 'No domains, click the button above to add',
+ used: 'used',
+ status: {
+ active: 'Active',
+ expired: 'Expired',
+ suspended: 'Suspended',
+ },
+ domainStatus: {
+ pending: 'Pending',
+ verified: 'Verified',
+ suspended: 'Suspended',
+ },
+ selectRegion: 'Select Service Region',
+ nodeStatus: {
+ available: 'Node Status: Available',
+ limited: 'Limited Stock',
+ },
+ plans: 'plans',
+ planDetails: 'Plan Configuration',
+ planTag: 'Pro Plan',
+ allFeaturesIncluded: 'All basic and advanced features included',
+ pureStorage: 'Pure Storage',
+ feature: {
+ domains: 'Support {count} custom domain bindings',
+ domainStorage: '{count} Domain {storage}G Storage',
+ unlimitedAliases: 'Unlimited aliases per domain',
+ unlimitedMailboxes: 'Unlimited mailboxes per domain',
+ emailLimit: '600 emails/hour per domain',
+ emClientPro: 'Free eM Client Pro license',
+ catchAll: 'Catch All support',
+ antispam: 'Built-in anti-spam gateway',
+ protocols: 'Support SMTP/IMAP/POP3 protocols',
+ aliases: 'Unlimited email aliases',
+ },
+ otherOptions: 'Other Options',
+ verify: 'Verify',
+ checkout: {
+ title: 'Order Summary',
+ region: 'Selected Region',
+ serviceStatus: 'Service Status',
+ instant: 'Instant Activation',
+ amount: 'Amount Due',
+ confirm: 'Activate Now',
+ securePayment: 'Secure payment, refund available anytime',
+ balanceRequired: 'Please recharge balance first to purchase',
+ },
+ help: {
+ title: 'Need Help?',
+ desc: 'If you encounter any technical issues during purchase, please submit a ticket to contact support.',
+ },
+ selectPlan: 'Select Plan',
+ storage: 'Storage',
+ unlimitedAccounts: 'Unlimited Accounts',
+ orderConfirm: 'Order Confirmation',
+ billingCycle: 'Billing Cycle',
+ monthly: 'Monthly',
+ yearly: 'Yearly',
+ totalPrice: 'Total',
+ confirmRenew: 'Confirm Renew',
+ confirmPurchase: 'Confirm Purchase',
+ renewSubscription: 'Renew Subscription',
+ renewMonths: 'Renewal Period',
+ monthlyPrice: 'Monthly Price',
+ yearlyPrice: 'Yearly Price',
+ alreadyPurchased: 'You have already purchased email service in this region',
+ alreadyPurchasedDesc: 'Each user can only purchase one email plan per region. To change your plan, please manage it in "My Email".',
+ viewMySubscription: 'View My Subscription',
+ renewDuration: 'Duration',
+ months: 'months',
+ renewSuccess: 'Renewal successful',
+ selectPlanFirst: 'Please select a plan first',
+ purchaseSuccess: 'Purchase successful',
+ domainName: 'Domain',
+ domainPlaceholder: 'e.g. example.com',
+ domainHint: 'Enter your domain, DNS verification required after adding',
+ domainRequired: 'Please enter a domain',
+ domainAdded: 'Domain added, please configure DNS records',
+ accountsDescription: 'Manage email accounts under this domain',
+ createAccount: 'Create Account',
+ verifyFirst: 'Please verify domain DNS first before creating accounts',
+ completeDnsFirst: 'Please complete DNS configuration first to use email service',
+ goDnsConfig: 'Configure DNS',
+ adminAccount: 'Admin Account',
+ adminAccountDesc: 'This account was created automatically when adding the domain, use it to login to Webmail',
+ webmailUrl: 'Login URL',
+ helpDoc: 'View Help Documentation',
+ noAdminAccount: 'Admin account information unavailable',
+ refreshStatus: 'Refresh Status',
+ noAccounts: 'No email accounts',
+ admin: 'Admin',
+ resetPassword: 'Reset Password',
+ deleteAccountConfirm: 'Are you sure to delete account {email}? This cannot be undone.',
+ accountDeleted: 'Account deleted',
+ dnsDescription: 'Add the following records in your domain DNS management panel',
+ recordType: 'Type',
+ hostRecord: 'Host',
+ recordValue: 'Value',
+ emailAddress: 'Email Address',
+ txtVerification: 'TXT Verification',
+ pending: 'Pending',
+ verified: 'Verified',
+ mxRecords: 'MX Records',
+ cnameRecords: 'CNAME Records',
+ spfRecord: 'SPF Record',
+ dkimRecord: 'DKIM Record',
+ optional: 'Optional',
+ required: 'Required',
+ recommended: 'Recommended',
+ dnsHint: {
+ txt: 'Domain verification record. Click "Refresh Status" after adding.',
+ mx: 'Mail exchange record. Number indicates priority (lower = higher priority).',
+ cname: 'Alias record for Webmail and auto-discover functionality.',
+ spf: 'Sender Policy Framework. Prevents spoofing and improves deliverability.',
+ dkim: 'Domain key authentication. Improves email credibility.',
+ dmarc: 'Domain-based Message Authentication. Prevents phishing and fraud.',
+ },
+ domainInfo: 'Domain Info',
+ createdAt: 'Created At',
+ verifiedAt: 'Verified At',
+ dangerZone: 'Danger Zone',
+ deleteDomainWarning: 'Deleting this domain will also delete all accounts and data. This cannot be undone.',
+ deleteDomain: 'Delete Domain',
+ deleteDomainConfirm: 'Are you sure to delete domain {domain}? All data will be lost.',
+ domainDeleted: 'Domain deleted',
+ domainVerified: 'Domain verified successfully',
+ domainNotVerified: 'DNS records not yet active, please try again later',
+ username: 'Username',
+ password: 'Password',
+ displayName: 'Display Name',
+ diskLimit: 'Disk Quota',
+ setAsAdmin: 'Set as Domain Admin',
+ usernamePlaceholder: 'e.g. admin',
+ passwordPlaceholder: 'At least 8 characters',
+ passwordHint: 'At least 8 characters, recommend mixed case and numbers',
+ displayNamePlaceholder: 'e.g. John Doe',
+ accountFieldsRequired: 'Please enter username and password',
+ accountCreated: 'Account created',
+ accountUpdated: 'Account updated',
+ passwordMinLength: 'Password must be at least 8 characters',
+ passwordReset: 'Password reset successfully',
+ editAccount: 'Edit Account',
+ newPassword: 'New Password',
+ newPasswordPlaceholder: 'Enter new password',
+ },
+}
diff --git a/client/src/locales/index.ts b/client/src/locales/index.ts
new file mode 100644
index 0000000..6bc15ca
--- /dev/null
+++ b/client/src/locales/index.ts
@@ -0,0 +1,88 @@
+import { createI18n } from 'vue-i18n'
+import zhCN from './zh-CN'
+import zhTW from './zh-TW'
+import en from './en'
+
+export type MessageSchema = typeof zhCN
+export type Locale = 'zh-CN' | 'zh-TW' | 'en'
+
+// 支援的語言列表
+const supportedLocales: { code: Locale; name: string }[] = [
+ { code: 'zh-CN', name: '简体中文' },
+ { code: 'zh-TW', name: '繁體中文' },
+ { code: 'en', name: 'English' },
+]
+
+// 檢測瀏覽器語言
+function detectBrowserLocale(): Locale {
+ const browserLang = navigator.language || (navigator as { userLanguage?: string }).userLanguage || ''
+
+ // 中文區分簡體/繁體
+ if (browserLang.startsWith('zh')) {
+ // zh-TW, zh-HK, zh-Hant 等使用繁體
+ if (browserLang.includes('TW') || browserLang.includes('HK') || browserLang.includes('Hant')) {
+ return 'zh-TW'
+ }
+ // 其他中文默認簡體
+ return 'zh-CN'
+ }
+
+ // 其他語言默認英文
+ return 'en'
+}
+
+// 取得儲存的語言或檢測瀏覽器語言
+function getInitialLocale(): Locale {
+ const saved = localStorage.getItem('locale') as Locale | null
+ if (saved && supportedLocales.some(l => l.code === saved)) {
+ return saved
+ }
+ return detectBrowserLocale()
+}
+
+// 匯出支援的語言列表
+export { supportedLocales }
+
+const i18n = createI18n({
+ legacy: false, // 使用 Composition API 模式
+ locale: getInitialLocale(),
+ fallbackLocale: 'en',
+ messages: {
+ 'zh-CN': zhCN,
+ 'zh-TW': zhTW,
+ 'en': en,
+ },
+})
+
+// 切換語言並儲存
+export function setLocale(newLocale: Locale): void {
+
+ ; (i18n.global.locale as any).value = newLocale
+ localStorage.setItem('locale', newLocale)
+ // 設定 HTML lang 屬性
+ document.documentElement.lang = newLocale
+}
+
+// 取得目前語言
+export function getLocale(): Locale {
+
+ return (i18n.global.locale as any).value as Locale
+}
+
+// 取得目前語言的簡寫
+// 用於顯示在 UI 上(如語言切換按鈕)
+export function getCurrentLocaleShort(): string {
+ const locale = getLocale()
+ switch (locale) {
+ case 'zh-CN':
+ return '简'
+ case 'zh-TW':
+ return '繁'
+ case 'en':
+ return 'EN'
+ default:
+ return 'EN'
+ }
+}
+
+export default i18n
diff --git a/client/src/locales/zh-CN.ts b/client/src/locales/zh-CN.ts
new file mode 100644
index 0000000..c830862
--- /dev/null
+++ b/client/src/locales/zh-CN.ts
@@ -0,0 +1,7460 @@
+export default {
+ // 通用
+ common: {
+ confirm: '确认',
+ cancel: '取消',
+ save: '保存',
+ saving: '保存中...',
+ send: '发送',
+ sending: '发送中...',
+ syncing: '同步中...',
+ processing: '处理中...',
+ submitting: '提交中...',
+ delete: '删除',
+ noIncudalHint: '请勿在名称或描述中使用字样',
+ edit: '编辑',
+ create: '创建',
+ creating: '创建中...',
+ deleting: '删除中...',
+ deleteSuccess: '删除成功',
+ search: '搜索',
+ loading: '加载中...',
+ noData: '暂无数据',
+ noSearchResults: '搜索无结果',
+ success: '操作成功',
+ error: '操作失败',
+ warning: '警告',
+ info: '提示',
+ yes: '是',
+ no: '否',
+ back: '返回',
+ next: '下一步',
+ previous: '上一步',
+ close: '关闭',
+ reset: '重置',
+ refresh: '刷新',
+ copy: '复制',
+ copied: '已复制',
+ copyFailed: '复制失败',
+ show: '显示',
+ hide: '隐藏',
+ done: '完成',
+ actions: '操作',
+ details: '详情',
+ status: '状态',
+ name: '名称',
+ description: '描述',
+ createdAt: '创建时间',
+ updatedAt: '更新时间',
+ none: '无',
+ notSet: '未设置',
+ turnstileFailed: '人机验证失败,请重试',
+ filter: '筛选',
+ total: '共',
+ items: '条',
+ searchPlaceholder: '搜索...',
+ developing: '开发中',
+ developingHint: '此功能正在开发中,敬请期待...',
+ gotIt: '我知道了',
+ expand: '展开',
+ collapse: '收起',
+ page: '页',
+ pageInfo: '第 {current}/{total} 页,共 {count} 条',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ loadFailed: '加载失败',
+ all: '全部',
+ perPage: '每页',
+ totalCount: '共 {count} 条',
+ day: '天',
+ days: '天',
+ seconds: '秒',
+ month: '月',
+ totalRecords: '共 {count} 条记录',
+ deleted: '已删除',
+ // 国家名称
+ countries: {
+ // 亚洲
+ cn: '中国',
+ hk: '中国香港',
+ mo: '中国澳门',
+ tw: '中国台湾',
+ jp: '日本',
+ kr: '韩国',
+ sg: '新加坡',
+ my: '马来西亚',
+ th: '泰国',
+ vn: '越南',
+ ph: '菲律宾',
+ id: '印度尼西亚',
+ in: '印度',
+ pk: '巴基斯坦',
+ bd: '孟加拉国',
+ kz: '哈萨克斯坦',
+ uz: '乌兹别克斯坦',
+ ae: '阿联酋',
+ sa: '沙特阿拉伯',
+ il: '以色列',
+ tr: '土耳其',
+ // 欧洲
+ gb: '英国',
+ de: '德国',
+ fr: '法国',
+ nl: '荷兰',
+ be: '比利时',
+ lu: '卢森堡',
+ ch: '瑞士',
+ at: '奥地利',
+ it: '意大利',
+ es: '西班牙',
+ pt: '葡萄牙',
+ ie: '爱尔兰',
+ se: '瑞典',
+ no: '挪威',
+ dk: '丹麦',
+ fi: '芬兰',
+ pl: '波兰',
+ cz: '捷克',
+ hu: '匈牙利',
+ ro: '罗马尼亚',
+ bg: '保加利亚',
+ gr: '希腊',
+ ua: '乌克兰',
+ ru: '俄罗斯',
+ // 北美洲
+ us: '美国',
+ ca: '加拿大',
+ mx: '墨西哥',
+ // 南美洲
+ br: '巴西',
+ ar: '阿根廷',
+ cl: '智利',
+ co: '哥伦比亚',
+ pe: '秘鲁',
+ // 大洋洲
+ au: '澳大利亚',
+ nz: '新西兰',
+ // 非洲
+ za: '南非',
+ eg: '埃及',
+ ng: '尼日利亚',
+ ke: '肯尼亚',
+ },
+ // 网络模式(统一定义)
+ networkMode: {
+ nat: 'IPv4 NAT',
+ nat_ipv6: 'IPv4 NAT & IPv6',
+ nat_ipv6_nat: 'IPv4 NAT & IPv6 NAT',
+ ipv6_only: 'IPv6 Only',
+ ipv6_nat: 'IPv6 NAT',
+ },
+ // 实例类型
+ instanceType: {
+ container: 'LXC',
+ vm: 'KVM',
+ },
+ // 启用/禁用状态
+ enabled: '启用',
+ disabled: '禁用',
+ active: '已启用',
+ inactive: '已停用',
+ unlimited: '无限制',
+ },
+
+ validation: {
+ fields: {
+ name: '名称',
+ identifier: '标识符',
+ content: '内容',
+ serverAddress: '服务器地址',
+ ipAddress: 'IP地址',
+ ipOrDomain: 'IP地址或域名',
+ },
+ required: '{field}不能为空',
+ minLength: '{field}长度至少 {min} 个字符',
+ maxLength: '{field}长度不能超过 {max} 个字符',
+ illegalChars: '{field}包含非法字符',
+ safeNameChars: '{field}只能包含中文、字母、数字、连字符、下划线、空格、逗号和圆括号',
+ identifierChars: '{field}只能包含字母、数字、连字符和下划线,且必须以字母开头',
+ invalidFormat: '{field}格式不正确',
+ urlProtocol: '{field}必须以 http:// 或 https:// 开头',
+ hostAddressInvalid: '{field}格式不正确,请输入有效的 IPv4、IPv6 地址或域名',
+ ipAddressInvalid: '{field}格式不正确,请输入有效的 IPv4 或 IPv6 地址',
+ ipv4Invalid: '{field}格式不正确,请输入有效的 IPv4 地址',
+ },
+
+ // 导航
+ nav: {
+ main: '常用',
+ dashboard: '概览',
+ instances: '实例',
+ transfers: '转移',
+ friends: '好友',
+ tickets: '工单',
+ resources: '资源',
+ myHosts: '我的节点',
+ myPackages: '我的套餐',
+ myImages: '我的镜像',
+ logs: '日志',
+ inbox: '通知',
+ settings: '设置',
+ wallet: '钱包',
+ invites: '邀请',
+ help: '帮助',
+ admin: '管理',
+ expand: '扩展',
+ system: '系统',
+ users: '用户',
+ statistics: '统计',
+ hosts: '节点',
+ images: '镜像',
+ packages: '套餐',
+ helpManage: '帮助',
+ oauth: 'OAuth',
+ broadcast: '公告',
+ paymentProviders: '支付',
+ billing: '计费',
+ withdrawals: '提现',
+ aff: '推荐',
+ openMenu: '打开菜单',
+ collapseSidebar: '折叠侧边栏',
+ toggleTheme: '切换主题',
+ toggleLanguage: '切换语言',
+ terminal: '终端',
+ extensions: '扩展',
+ scripts: '脚本',
+ operations: '运维',
+ createInstance: '创建实例',
+ create: '创建',
+ entertainment: '福利',
+ hosting: '托管',
+ hostingWallet: '托管收益',
+ earnings: '收益',
+ mail: '邮箱',
+ instanceDetail: '实例详情',
+ mailDomain: '邮箱域名',
+ myHostCreate: '创建节点',
+ myHostDetail: '节点详情',
+ myPackageCreate: '创建套餐',
+ myPackageEdit: '编辑套餐',
+ telegramSettings: 'Telegram 设置',
+ adminCreateInstance: '管理员创建实例',
+ },
+
+ // 主题
+ theme: {
+ dark: '深色模式',
+ light: '浅色模式',
+ system: '跟随系统',
+ },
+
+ freeSite: {
+ billingCycleLabel: {
+ monthly: '月付?月亮替你付了',
+ quarterly: '季付?四季都免单',
+ semiAnnual: '半年快乐通行证',
+ annual: '年付?钱包装睡中',
+ custom: '{months}个月,快乐代扣空气',
+ free: '免费乱逛许可证',
+ },
+ billingCycleShort: {
+ monthly: '/月,免惊',
+ quarterly: '/季,免单',
+ semiAnnual: '/半年快乐',
+ annual: '/年也白送',
+ custom: '/{months}个月快乐',
+ },
+ copy: {
+ finalPrice: '装模作样价',
+ renewPrice: '续费?仪式感拉满',
+ billingCycle: '快乐档位',
+ needPay: '象征性付款',
+ originalPrice: '原价标本',
+ oldDailyPrice: '旧日租,考古用',
+ newDailyPrice: '新日租,摆设用',
+ remainingValue: '剩余价值:快乐无价',
+ newPlanCost: '新方案成本:空气币',
+ currentBalance: '余额吉祥物',
+ balanceAfterRenew: '续后余额:纹丝不动',
+ walletBalanceTab: '快乐余额',
+ walletLogsTab: '免单流水',
+ walletCurrentBalance: '当前快乐刻度',
+ walletDescription: '白嫖站模式已开启,充值入口去喝茶了,余额负责站岗。',
+ walletLogsDescription: '这里记录余额的小动作。别紧张,主线任务依旧是免费玩。',
+ walletTotalRecharge: '累计免单',
+ walletTotalConsume: '快乐蒸发',
+ walletDestroyedValue: '销毁纪念值',
+ dashboardNewInstance: '快乐实例',
+ dashboardCreateInstance: '召唤快乐机',
+ dashboardCreateFirst: '先召唤一台',
+ dashboardUserBalance: '快乐余额',
+ dashboardBalanceValue: '免费无价',
+ dashboardNewContainer: '钱包坐好,我们直接开整',
+ instanceCreate: '快乐实例',
+ instanceCreateFirst: '先召唤一台',
+ instanceBatchRenewTitle: '批量续快乐',
+ instanceBatchRenewDescription: '白嫖站模式下,续费只是盖个章,机器继续开心营业。',
+ instanceBatchTotalAmount: '空气合计',
+ instanceBatchBalanceAfter: '续后心情',
+ instanceBatchCurrentBalance: '当前吉祥物余额',
+ instanceBatchRenewAction: '确认快乐续杯',
+ moneyJustForShow: '数字巡演',
+ marketPriceFree: '免单起飞',
+ marketPlanCount: '{count} 个快乐档位',
+ marketCreateNow: '立即开薅',
+ marketLoginToOrder: '登录后开薅',
+ marketSelectedPlanTitle: '快乐档位',
+ marketCycleMonthly: '月付?月亮替你付了',
+ marketMonthlyPrice: '月均?快乐不均摊',
+ createOrderSummary: '免单概览',
+ createPromoCode: '神秘暗号',
+ createPromoPlaceholder: '可填可不填,免费列车已发车',
+ createPromoHostedDisabled: '托管节点不吃暗号,直接上车',
+ createPromoValid: '暗号有效,快乐加成 {rate}',
+ createPromoUsing: '暗号灯已亮',
+ createPromoBenefit: '折扣和返利在旁边表演,情绪价值负责鼓掌。',
+ createCommissionEstimate: '预计给分享者投喂 ¥{amount} 的想象返利',
+ createPlanFee: '标价展览品',
+ createMonthlyEquivalent: '折算?快乐拒绝被折算',
+ mailPrice: '邮箱也免单',
+ mailCheckoutTitle: '邮箱免单确认',
+ mailBillingCycle: '快乐周期',
+ mailCheckoutAmount: '象征性结算',
+ mailCheckoutConfirm: '确认领取邮箱',
+ mailBalanceRequired: '余额不余额的,白嫖站讲究先用再说。',
+ },
+ },
+
+ publicSite: {
+ brandTagline: '基于 Incus 的低价 NAT VPS',
+ nav: {
+ home: '首页',
+ overview: '概览',
+ products: '套餐列表',
+ help: '帮助中心',
+ },
+ actions: {
+ signIn: '登录',
+ console: '进入控制台',
+ consoleCompact: '控制台',
+ browseProducts: '查看全部套餐',
+ browseOfficial: '查看直营套餐',
+ browseMarket: '查看托管套餐',
+ viewCatalog: '查看套餐',
+ },
+ footer: {
+ description: '精选全球多节点 LXC / KVM 套餐,配置丰富、方案齐全,持续提供高性价比 NAT VPS 选择。',
+ explore: '浏览',
+ account: '账户',
+ purchaseHint: '未登录也可以先查看套餐详情,登录后再继续购买。',
+ },
+ seo: {
+ keywords: 'Incus,NAT VPS,LXC,KVM,VPS 面板,云服务器,NAT云主机',
+ homeTitle: '基于 Incus 的低价 NAT VPS 平台',
+ homeDescription: '精选全球多节点 LXC / KVM 套餐,配置丰富、方案齐全,持续提供高性价比 NAT VPS 选择。',
+ marketTitle: '套餐列表',
+ marketDescription: '浏览全部公开套餐,按地区、配置、价格与来源筛选 NAT VPS。',
+ marketPackageTitle: '{name} - 套餐列表',
+ marketPackageDescription: '查看 {name} 的 {type} 配置、月流量与套餐信息,确认后即可继续购买。',
+ },
+ portal: {
+ badge: 'INCUS NAT VPS',
+ title: '基于 Incus 的低价 NAT VPS 平台',
+ description: '精选全球多节点 LXC / KVM 套餐,覆盖直营与托管供给,配置档位丰富,持续提供高性价比 NAT VPS 选择。',
+ authPanelDescription: '把稳定供给、更多地区和更丰富价格带放在同一个入口里,让挑套餐这一步更直接。',
+ authPanelFlowLabel: 'ORDER FLOW',
+ authPanelFlowTitle: '先看商品,登录后继续下单',
+ authPanelFlowDescription: '未登录时可以先浏览公开套餐;从分享链接进入也会保留商品上下文,登录后直接续上原本的购买流程。',
+ authPanelTagValue: '高性价比',
+ previewLabel: 'CONSOLE',
+ previewTitle: '开通后即可直接管理',
+ previewDescription: '开机、重装、流量和实例状态等常用操作集中在同一控制台内,日常管理更高效。',
+ controlPoint1: '$ incus launch ubuntu:24.04 nat-vps',
+ controlPoint2: '# 开机、重装、流量、快照等操作统一管理',
+ controlPoint3: '# 直营与托管节点均可在同一控制台购买和管理',
+ packageFallback: '公开套餐,可直接查看配置并继续购买。',
+ stats: {
+ packages: '在售套餐',
+ regions: '可选地区',
+ official: '直营套餐',
+ market: '托管套餐',
+ },
+ officialTitle: '官方直营节点',
+ officialDescription: '价格与配置透明,供应稳定,适合对稳定性与长期使用更看重的用户。',
+ officialPoint1: '节点与套餐风格相对稳定,适合持续使用。',
+ officialPoint2: '配置、价格与可选方案展示清晰,便于快速确认。',
+ officialPoint3: '开通后可直接进入控制台,常用管理操作集中可见。',
+ marketTitle: '托管节点套餐',
+ marketDescription: '地区选择更多,价格区间更丰富,适合需要特定地区或更高性价比的用户。',
+ marketPoint1: '更容易找到低价套餐、特殊地区或不同线路的机器。',
+ marketPoint2: '支持按地区、配置与价格筛选,查找效率更高。',
+ marketPoint3: '选定套餐后可进入详情,登录后直接继续购买。',
+ experienceNoLoginTitle: '全球多节点供给',
+ experienceNoLoginDescription: '直营与托管套餐并行上架,热门地区与更多节点选择可以在同一入口集中浏览。',
+ experienceRoutingTitle: 'LXC / KVM 套餐更全',
+ experienceRoutingDescription: '从轻量容器到完整虚拟机,资源配置与方案档位覆盖更广,便于按需求挑选。',
+ experienceThemeTitle: '多价位高性价比',
+ experienceThemeDescription: '持续补充不同预算与不同定位的 NAT VPS 套餐,方便横向比较后再决定。',
+ catalogLabel: 'PRODUCT LINES',
+ catalogTitle: '直营与托管产品并行提供',
+ catalogDescription: '直营与托管套餐并行提供,适合优先看稳定供给,也适合挑选更多地区与价格带。',
+ emptyPackages: '当前暂无在售套餐',
+ browseLabel: 'PLAN LIST',
+ browseTitle: '按配置与地区筛选合适套餐',
+ browseDescription: '支持按地区、配置与价格筛选套餐,便于新用户选购,也方便老用户补购。',
+ },
+ market: {
+ badge: '公开套餐',
+ title: '浏览全部套餐',
+ description: '按地区、配置、价格与来源筛选套餐,直接找到合适的 NAT VPS。',
+ publicNotice: '当前页面展示全部公开套餐,可按地区、来源与配置筛选。',
+ buyLinkNotice: '你打开的是某个套餐的购买链接。未登录时可先查看套餐详情,登录后可继续购买。',
+ searchPlaceholder: '搜索套餐名称、描述或虚拟化类型',
+ allRegions: '全部地区',
+ official: '官方直营',
+ market: '托管套餐',
+ noPackages: '当前暂无公开套餐',
+ noResults: '没有匹配当前筛选条件的套餐',
+ soldOut: '暂时售罄',
+ inStock: '可开通',
+ free: '免费',
+ fromMonthly: '¥{price}/月起',
+ planCount: '{count} 个方案',
+ featuresTitle: '套餐详情',
+ plansTitle: '可选方案',
+ planCycle: '{months} 个月',
+ selectedPlanTitle: '当前方案',
+ customConfigTitle: '自定义配置',
+ customConfigDescription: '这个套餐没有固定方案。登录后会进入创建页,在允许范围内自定义配置。',
+ createNow: '立即开通',
+ loginToOrder: '登录后开通',
+ loginHint: '登录后会按当前套餐继续进入购买流程。',
+ choosePackage: '从左侧选择一个套餐,右侧会显示详细配置。',
+ summary: {
+ total: '公开套餐',
+ available: '可开通',
+ regions: '覆盖地区',
+ source: '当前来源',
+ },
+ labels: {
+ startingPrice: '起售价',
+ traffic: '月流量',
+ plans: '方案数',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '硬盘',
+ network: '网络模式',
+ hosts: '宿主机数',
+ nesting: '嵌套支持',
+ },
+ },
+ },
+
+ // 钱包
+ wallet: {
+ title: '钱包',
+ description: '管理您的账户余额和充值',
+ tabs: {
+ balance: '账户余额',
+ logs: '余额明细',
+ records: '充值记录',
+ },
+ currentBalance: '当前余额',
+ recharge: '充值',
+ totalRecharge: '累计充值',
+ totalConsume: '累计消费',
+ totalDestroyedValue: '已销毁总价值',
+ noLogs: '暂无余额记录',
+ noRecords: '暂无充值记录',
+ type: '类型',
+ amount: '金额',
+ actualAmount: '实际到账',
+ estimatedAmount: '预计到账',
+ balanceAfter: '余额',
+ instanceOrRemark: '实例/备注',
+ time: '时间',
+ orderNo: '订单号',
+ completedAt: '完成时间',
+ statusLabel: '状态',
+ paymentMethod: '支付方式',
+ paymentMethodType: '选择支付方式',
+ heleketSelectionHint: '将在 Heleket 支付页选择具体的加密货币和网络,本页不会限制最终付款币种。',
+ paymentChannel: '支付渠道',
+ paymentUuid: 'UUID:',
+ paymentTxid: 'TxID:',
+ paymentMethods: {
+ alipay: '支付宝',
+ wxpay: '微信支付',
+ qqpay: 'QQ钱包',
+ bank: '网银支付',
+ jdpay: '京东支付',
+ },
+ noProviders: '暂无可用支付渠道',
+ amountLabel: '充值金额',
+ amountRange: '金额范围',
+ feeNote: '手续费',
+ payableAmount: '应付金额',
+ pay: '立即支付',
+ void: '作废',
+ logTypes: {
+ recharge: '充值',
+ consume: '消费',
+ refund: '退款',
+ adminAdjust: '管理员调整',
+ gift: '赠送',
+ transferFee: '转移手续费',
+ transferRefund: '手续费退还',
+ hostingWithdraw: '托管余额提现',
+ hostingDeduction: '托管实例扣款',
+ },
+ status: {
+ pending: '待支付',
+ paid: '已支付',
+ completed: '已完成',
+ failed: '失败',
+ cancelled: '已取消',
+ refunded: '已退款',
+ },
+ loadLogsFailed: '加载余额明细失败',
+ showLotteryGift: '抽奖赠送',
+ showingLotteryGift: '抽奖赠送',
+ loadProvidersFailed: '加载支付渠道失败',
+ loadRecordsFailed: '加载充值记录失败',
+ selectProvider: '请选择支付方式',
+ invalidAmount: '金额无效',
+ orderCreated: '订单已创建',
+ redirecting: '正在跳转支付页面...',
+ createOrderFailed: '创建订单失败',
+ noPayUrl: '获取支付链接失败',
+ repayFailed: '重新支付失败',
+ orderCancelled: '订单已取消',
+ noRefundNotice: '所有充值都无法原路退款。',
+ rechargeNotice: '我知道不好用的实例可以销毁退至面板余额,可转移实例 PUSH 是免费的,邮箱可自行修改。',
+ cancelFailed: '取消订单失败',
+ orderExpired: '订单已过期',
+ rechargeSuccess: '充值成功!余额已到账',
+ verifyingPayment: '正在验证支付状态...',
+ paymentProcessing: '支付处理中,请稍后刷新',
+ verifyFailed: '验证支付状态失败',
+ amountMismatch: '支付金额与订单金额不匹配,请联系客服',
+ natDisclaimer: '我知道所售实例都是 NAT 属性,不保证 IP 在大陆的连通性,所有充值都无法退款。',
+ },
+
+ // 推荐计划
+ aff: {
+ title: '推荐计划',
+ description: '邀请好友使用您的优惠码,获取返利收益',
+ notActivated: '推荐计划已可用',
+ activateHint: '您可以创建优惠码并分享给朋友,当他们使用您的优惠码购买您购买过的方案时,您将获得返利收益。',
+ goRecharge: '前往充值',
+ affBalance: 'AFF 余额',
+ totalEarnings: '累计收益',
+ balanceHint: 'AFF 余额仅可转化为账户余额用于面板消费,无法直接提现。',
+ convert: '申请转化',
+ myCodes: '我的优惠码',
+ createCode: '创建优惠码',
+ noCodes: '暂无优惠码,创建一个开始推广吧',
+ code: '优惠码',
+ plan: '方案',
+ discount: '折扣',
+ commission: '返利',
+ usedCount: '使用次数',
+ earnings: '收益',
+ status: '状态',
+ enabled: '已启用',
+ disabled: '已禁用',
+ toggle: '切换',
+ selectPlan: '选择方案',
+ selectPlanHint: '选择全局优惠码或方案专有码',
+ alreadyCreated: '已创建',
+ globalCode: '全局优惠码',
+ globalCodeBadge: '全站通用',
+ globalCodeHint: '可用于全站所有付费套餐和方案',
+ orSelectPlan: '或选择方案专有码',
+ discountCommission: '折扣/返利比例',
+ discountCommissionHint: '购买者享 {discount} 折扣,您获 {commission} 返利',
+ fixedRate: '折扣与返利比例',
+ fixedRateHint: '系统固定折扣率和返利率均为 5%',
+ createSuccess: '优惠码创建成功',
+ createFailed: '创建失败',
+ deleteCodeConfirm: '确定删除优惠码 {code} 吗?',
+ deleteCodeSuccess: '优惠码已删除',
+ deleteCodeFailed: '删除失败',
+ toggleSuccess: '状态已切换',
+ toggleFailed: '切换失败',
+ earningsLog: '收益明细',
+ noLogs: '暂无收益记录',
+ logType: {
+ new_purchase: '新购返利',
+ renew: '续费返利',
+ convert: '余额转化',
+ },
+ convertModal: {
+ title: '申请转化',
+ currentBalance: '当前 AFF 余额',
+ amount: '转化金额',
+ minAmount: '最低转化金额:0.10 元',
+ hint: '提交后将自动转入账户余额。',
+ submit: '确认转化',
+ success: '转化成功,已转入账户余额',
+ failed: '提交失败',
+ invalidAmount: '请输入有效的转化金额',
+ },
+ withdrawals: '转化记录',
+ noWithdrawals: '暂无转化记录',
+ withdrawalStatus: {
+ pending: '待审核',
+ approved: '已通过',
+ rejected: '已拒绝',
+ },
+ leaderboard: {
+ title: 'AFF 榜单',
+ loadFailed: '加载榜单失败',
+ empty: '暂无榜单数据',
+ you: '就是你',
+ },
+ // 实例创建页优惠码输入
+ promoCode: '优惠码',
+ promoCodeOptional: '优惠码(可选)',
+ promoCodePlaceholder: '输入优惠码(可选)',
+ promoCodeInputPlaceholder: '输入折扣代码',
+ promoCodeHostedDisabled: '托管节点不支持使用优惠码',
+ promoCodeValid: '优惠码有效,享受 {rate} 折扣',
+ promoCodeInvalid: '优惠码无效',
+ verifying: '验证中...',
+ originalPrice: '原价',
+ discountAmount: '折扣',
+ promoDiscount: '推广折扣',
+ finalPrice: '实付',
+ usingPromoCode: '您正在使用优惠码',
+ promoCodeBenefit: '您享受 {discount} 折扣,同时为分享者带来约 {commission}% 的返利',
+ commissionEstimate: '预计为分享者带来 ¥{amount} 返利',
+ // 管理员审核
+ adminTitle: 'AFF 转化审核',
+ adminDescription: '审核用户的 AFF 余额转化申请',
+ user: '用户',
+ userBalance: '用户 AFF 余额',
+ requestAmount: '申请金额',
+ requestTime: '申请时间',
+ approve: '通过',
+ reject: '拒绝',
+ rejectReason: '拒绝原因',
+ rejectReasonPlaceholder: '请输入拒绝原因',
+ approveSuccess: '已通过,已转入用户余额',
+ approveFailed: '审核失败',
+ rejectSuccess: '已拒绝',
+ rejectFailed: '拒绝失败',
+ noRequests: '暂无待审核的转化申请',
+ filterStatus: '状态筛选',
+ all: '全部',
+ },
+
+ popupAnnouncement: {
+ title: '站点公告',
+ subtitle: '请留意这条最新通知',
+ promoLabel: '新机器推广',
+ buyNow: '立即购买 {name}',
+ viewImage: '查看完整图片',
+ promoPlans: '可选方案',
+ soldOut: '售罄',
+ dismissToday: '今日不见',
+ dismissForever: '再也不见',
+ },
+
+ // 语言
+ language: {
+ zh: '中文',
+ en: 'English',
+ },
+
+ // 认证
+ auth: {
+ login: '登录',
+ loginTo: '登录',
+ logout: '退出登录',
+ register: '注册',
+ registerTo: '注册',
+ username: '用户名',
+ usernamePlaceholder: '输入用户名',
+ usernameOrEmail: '用户名或邮箱',
+ usernameOrEmailPlaceholder: '输入用户名或邮箱',
+ password: '密码',
+ passwordPlaceholder: '输入密码',
+ confirmPassword: '确认密码',
+ confirmPasswordPlaceholder: '再次输入密码',
+ email: '邮箱',
+ emailPlaceholder: '输入邮箱',
+ rememberMe: '记住我',
+ forgotPasswordLink: '忘记密码',
+ contactEmail: '联系邮箱',
+ noAccount: '没有账号?',
+ hasAccount: '已有账号?',
+ loginSuccess: '登录成功',
+ logoutSuccess: '已退出登录',
+ registerSuccess: '注册成功',
+ invalidCredentials: '用户名或密码错误',
+ sessionExpired: '会话已过期,请重新登录',
+ continue: '继续',
+ loggingIn: '登录中...',
+ registering: '注册中...',
+ orUse: '或使用',
+ oauthBindHint: '需先在设置中绑定账号才能使用快捷登录',
+ enterUsernamePassword: '请输入用户名和密码',
+ enterUsernameOrEmailPassword: '请输入账号和密码',
+ twoFactorCode: '双因素验证码',
+ twoFactorCodePlaceholder: '输入6位验证码',
+ twoFactorHint: '请输入验证器应用中显示的验证码',
+ twoFactorOptional: '可选',
+ twoFactorOptionalHint: '如果您启用了双因素认证,请输入验证码',
+ recoveryCode: '恢复码',
+ recoveryCodePlaceholder: '输入恢复码',
+ recoveryCodeHint: '输入设置2FA时保存的恢复码(一次性使用)',
+ useRecoveryCode: '无法访问验证器?使用恢复码',
+ useTotpCode: '使用验证器验证码',
+ enterRecoveryCode: '请输入恢复码',
+ enterTotpCode: '请输入验证码',
+ rememberPassword: '记起密码了?',
+ verificationCode: '验证码',
+ verificationCodePlaceholder: '输入6位验证码',
+ invalidCode: '请输入6位验证码',
+ forgotPassword: {
+ title: '找回密码',
+ subtitle: '通过邮箱验证码重置您的密码',
+ sendCode: '发送验证码',
+ codeSent: '验证码已发送,请查收邮件',
+ codeHint: '请输入发送到您邮箱的6位验证码',
+ resetPassword: '重置密码',
+ resetSuccess: '密码重置成功!新密码已发送到您的邮箱,请查收。',
+ twoFactorDisabled: '您的双因素验证(2FA)已被自动禁用,建议重新启用以确保账户安全。'
+ },
+ oauthNotBound: '请先在个人设置中绑定 {provider} 账号后再使用快捷登录',
+ oauthUserNotFound: '用户不存在',
+ oauthAccountBanned: '账号已被禁用',
+ oauthProviderDisabled: '该登录方式已被禁用',
+ oauthTokenError: '获取授权失败,请重试',
+ oauthError: 'OAuth 登录失败,请重试',
+ loginFailed: '登录失败',
+ createAccount: '注册',
+ creatingAccount: '创建中...',
+ backToLogin: '返回登录',
+ registerSuccessRedirect: '注册成功,正在跳转...',
+ inviteCode: '邀请码',
+ inviteCodePlaceholder: '输入邀请码',
+ usernameHint: '字母开头,3-32 个字符',
+ passwordHint: '至少 8 位,包含大小写字母和数字',
+ fillAllRequired: '请填写所有必填项',
+ invalidEmail: '请输入有效的邮箱地址',
+ emailContainsIllegal: '邮箱包含非法字符',
+ passwordMismatch: '两次密码不一致',
+ passwordTooShort: '密码至少 8 位',
+ passwordNeedsUppercase: '密码需要包含至少一个大写字母',
+ passwordNeedsLowercase: '密码需要包含至少一个小写字母',
+ passwordNeedsNumber: '密码需要包含至少一个数字',
+ turnstileRequired: '请完成人机验证',
+ turnstileFailed: '人机验证失败,请重试',
+ // Email verification
+ emailCode: '邮箱验证码',
+ emailCodePlaceholder: '请输入6位验证码',
+ emailCodeRequired: '请输入邮箱验证码',
+ registrationClosedTitle: '当前已关闭注册',
+ registrationClosedMessage: '抱歉,当前站点暂时关闭注册,如需开通账号请联系管理员。',
+ registrationClosedShort: '当前已关闭注册',
+ sendCode: '发送验证码',
+ sendingCode: '发送中...',
+ codeSentHint: '验证码已发送至您的邮箱,10分钟内有效',
+ invalidEmailCode: '验证码无效或已过期',
+ allowedEmailDomains: '仅支持以下邮箱',
+ emailUsernamePlaceholder: '用户名',
+ confirmEmail: '确认邮箱地址',
+ confirmEmailMessage: '验证码将发送至以下邮箱,请确认地址是否正确:',
+ confirmAndSend: '确认发送',
+ // Terms of Service
+ tos: {
+ title: '服务条款',
+ agreePrefix: '我已阅读并同意',
+ termsLink: '《服务条款》',
+ mustAgree: '请阅读并同意服务条款',
+ understood: '我已了解',
+ loadFailed: '加载服务条款失败',
+ },
+ },
+
+ // 用户菜单
+ userMenu: {
+ profile: '个人设置',
+ myInstances: '我的实例',
+ logout: '退出登录',
+ },
+
+ // 配额
+ quota: {
+ hosts: '宿主机',
+ instances: '实例',
+ friends: '好友',
+ packages: '套餐',
+ },
+
+ // 实例相关
+ instance: {
+ title: '实例管理',
+ create: '创建实例',
+ name: '实例名称',
+ image: '镜像',
+ package: '套餐',
+ host: '节点',
+ ip: 'IP 地址',
+ port: '端口',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '磁盘',
+ bandwidth: '带宽',
+ expireAt: '到期时间',
+ expiredLabel: '已到期',
+ freeInstanceLabel: '免费实例',
+ config: '配置',
+ user: '用户',
+ details: '详情',
+ // 资源配额(订单概览用)
+ ports: '端口',
+ snapshots: '快照',
+ backups: '备份',
+ hostAnnouncement: '节点公告',
+ badgeModal: {
+ open: '查看或修改实例徽章',
+ kicker: '实例徽章',
+ title: '实例徽章',
+ subtitle: '查看当前实例徽章,并快速切换到其他已拥有徽章:{name}',
+ detailsTab: '徽章说明',
+ ownedTab: '我的徽章',
+ noBadgeSeries: '未设置徽章',
+ noBadgeTitle: '当前未设置实例徽章',
+ noBadgeSummary: '这个实例目前仍在使用默认图标。',
+ noBadgeDescription: '你可以切换到“我的徽章”,把已拥有的徽章快速应用到当前实例。',
+ statusLabel: '当前状态',
+ statusApplied: '已应用到当前实例',
+ statusNotApplied: '未设置实例徽章',
+ ownedCountLabel: '已拥有副本',
+ openOwnedTab: '从我的徽章中选择',
+ manageUnavailable: '当前实例不在你的可管理实例列表中,这里只能查看徽章信息,不能直接修改。',
+ emptyOwnedTitle: '你还没有可用的实例徽章',
+ emptyOwnedHint: '前往娱乐中心获取徽章后,就可以在这里快速切换当前实例的徽章。',
+ applyCurrent: '应用到当前实例',
+ replaceCurrent: '替换当前实例徽章',
+ moveCurrent: '转移到当前实例',
+ moveFromAvatar: '从头像改为当前实例',
+ appliedHere: '当前实例中',
+ currentHint: '这个徽章已经应用在当前实例上。',
+ replaceHint: '应用后会替换当前实例已经使用的徽章。',
+ moveFromAvatarHint: '应用后会先从当前头像上移除。',
+ moveFromInstanceHint: '应用后会先从实例“{name}”上移除。',
+ removeCurrent: '移除当前实例徽章',
+ updateSuccess: '实例徽章已更新',
+ removeSuccess: '已移除当前实例徽章',
+ },
+ errorBanner: {
+ title: '实例异常',
+ description: '实例异常,您可直接销毁(付费实例剩余价值退款不扣除手续费)',
+ destroyNow: '立即销毁',
+ confirmDestroy: '确定要销毁此异常实例吗?付费实例的剩余价值将退款至您的钱包(不扣除手续费)。',
+ },
+ actions: {
+ start: '启动',
+ stop: '停止',
+ restart: '重启',
+ delete: '删除',
+ console: '控制台',
+ snapshot: '快照',
+ backup: '备份',
+ rename: '重命名',
+ clone: '复制',
+ suspend: '封停',
+ unsuspend: '解封',
+ },
+ renameModal: {
+ title: '重命名实例',
+ name: '实例名称',
+ namePlaceholder: '输入新的实例名称',
+ cancel: '取消',
+ confirm: '确认',
+ renaming: '重命名中...',
+ success: '实例已重命名',
+ failed: '重命名失败',
+ },
+ statusLabel: '状态',
+ modeLabel: '模式',
+ quotaLabel: '配额',
+ trafficLabel: '流量',
+ status: {
+ running: '运行中',
+ stopped: '已停止',
+ suspended: '已封停',
+ starting: '启动中',
+ stopping: '停止中',
+ restarting: '重启中',
+ creating: '创建中',
+ error: '异常',
+ deleted: '已删除',
+ },
+ statusFilter: {
+ all: '全部状态',
+ },
+ createdAt: '创建时间',
+ manageDesc: '管理您的容器实例',
+ userInstances: '用户 "{name}" 的实例',
+ clearFilter: '清除筛选',
+ searchPlaceholder: '搜索实例名称、IP...',
+ totalCount: '共 {count} 个实例',
+ noInstances: '暂无实例',
+ noMatchingInstances: '未找到匹配的实例',
+ tryOtherKeywords: '尝试使用其他关键词',
+ createFirstInstance: '创建第一个容器实例开始使用',
+ listLayout: '列表',
+ cardLayout: '卡片',
+ order: {
+ label: '调整排序',
+ top: '置顶',
+ up: '上移',
+ down: '下移',
+ bottom: '置底',
+ updateSuccess: '实例顺序已更新',
+ updateFailed: '保存实例顺序失败',
+ },
+ confirmDelete: '确定删除实例 "{name}"?此操作不可恢复。',
+ createPage: {
+ title: '创建实例',
+ description: '选择套餐并配置您的容器实例',
+ instanceName: '实例名称',
+ instanceNamePlaceholder: 'my-instance',
+ selectPackage: '请选择套餐',
+ selectSshKey: '请选择 SSH 密钥',
+ creating: '创建中...',
+ createSuccess: '实例创建中,请稍后在实例列表中查看状态',
+ loadFailed: '加载数据失败',
+ loadHostsFailed: '加载可用宿主机失败',
+ loadImagesFailed: '加载可用镜像失败',
+ missingSshKey: '缺少 SSH 密钥',
+ missingSshKeyDesc: '创建实例需要 SSH 密钥。请先前往',
+ profileSettings: '个人设置',
+ addSshKey: '添加 SSH 公钥。',
+ quotaInsufficient: '配额不足',
+ quotaCpu: 'CPU: 已用 {used}%/{limit}% 额配,本次需要 {need}%',
+ quotaMemory: '内存: 已用 {used}/{limit} MB,本次需要 {need} MB',
+ quotaDisk: '磁盘: 已用 {used}/{limit} MB,本次需要 {need} MB',
+ quotaInstance: '实例: 已达上限 {used}/{limit} 个',
+ packageNoHosts: '套餐未绑定宿主机,请联系管理员',
+ sharedPackageNotFound: '分享的套餐不存在或已失效,已为您选择其他可用套餐',
+ quotaInfo: {
+ prefix: '该套餐',
+ you: '您',
+ maxInstances: '',
+ count: '数量',
+ instances: '个实例',
+ unlimited: '无限制',
+ cpu: 'CPU',
+ memory: '内存',
+ remaining: '该套餐剩余配额',
+ },
+ resourceLimit: {
+ title: '资源配额不足,无法创建实例',
+ noInstances: '剩余实例数量为 0,无法创建新实例',
+ insufficientMemory: '剩余内存不足(<128MB),无法创建实例',
+ insufficientCpu: '剩余 CPU 不足(<15%),无法创建实例',
+ },
+ ownPaidPackageWarning: '这是您自己创建的付费套餐,不能为自己开通实例',
+ destroyTrafficNotice: '销毁限制:实例本月已用流量低于 5 GB 时才可销毁。',
+ firstPaidInstanceNotice: '销毁限制:实例本月已用流量低于 5 GB 时才可销毁。',
+ // 订单概览
+ orderSummary: '订单概览',
+ packageName: '套餐',
+ planName: '方案',
+ billingCycle: '计费周期',
+ months: '个月',
+ month: '月',
+ resourceConfig: '资源配置',
+ planFee: '方案费用',
+ // 套餐来源
+ source: {
+ official: '亲儿子',
+ market: '野生大佬',
+ friends: '好友共享',
+ },
+ fun: {
+ selectRegion: '挑个地盘',
+ packageCount: '{count} 个可薅档位',
+ selectPackage: '挑个饭碗',
+ selectPlan: '挑个姿势',
+ planDesc: '选个顺眼的付费档位',
+ customPlanHint: '没看上的?提交工单让人给你单独开小灶',
+ noPlans: '这个套餐暂时没摆上菜单',
+ planSoldOut: '被薅空了',
+ noPackages: '现在还没啥可薅的',
+ selectHost: '宠幸谁?',
+ hostAutoSelected: '默认先宠幸第一台能干活的',
+ selectSystem: '挑个灵魂',
+ noImages: '暂时没有可投胎的系统',
+ selectSshKey: '交出通行令牌',
+ },
+ // 托管免责提示
+ hostedDisclaimer: {
+ title: '托管节点提示',
+ content: '此为用户托管节点,售后由托管者(UID:{uid})负责。仅提供平台服务,不担保节点质量。若托管者失联,将处理其托管余额退还至受影响用户的面板余额。',
+ },
+ zoneNotice: {
+ badge: '专区',
+ content: '此为 UID:{uid} 的专区,售后由 {username} 处理。',
+ },
+ },
+ startingInstance: '{name} 启动中',
+ stoppedInstance: '{name} 已停止',
+ restartingInstance: '{name} 重启中',
+ deletedInstance: '{name} 已删除',
+ actionFailed: '操作失败',
+ verificationRequiredHint: '此操作需要二次验证,请前往实例详情页面执行',
+ totalRecords: '共 {count} 条记录',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ batch: {
+ selectedCount: '已选 {count} 个实例',
+ currentPageOnly: '批量操作仅作用于当前页选中的实例',
+ clear: '取消选择',
+ start: '批量开机',
+ stop: '批量关机',
+ restart: '批量重启',
+ sync: '批量同步',
+ renew: '批量续费',
+ autoRenewOn: '开启自动续费',
+ autoRenewOff: '关闭自动续费',
+ destroy: '批量销毁',
+ noEligibleAction: '所选实例中没有可执行该操作的实例',
+ partialResult: '已完成 {success} 个,失败 {failed} 个,跳过 {skipped} 个',
+ successResult: '已成功处理 {count} 个实例',
+ actionFailed: '批量操作失败',
+ previewFailed: '加载批量预览失败',
+ renewTitle: '批量续费',
+ renewDescription: '为当前选中的付费实例统一续费,只有支持所选时长的实例会被执行。',
+ selectedMonths: '续费时长',
+ eligibleCount: '可执行数量',
+ totalAmount: '合计金额',
+ renewEmpty: '所选实例中没有可续费项目',
+ eligibleList: '可执行实例({count})',
+ skippedList: '跳过实例({count})',
+ hosted: '托管实例',
+ unsupportedPeriod: '当前所选时长不可续费',
+ unknownReason: '当前无法处理',
+ destroyTitle: '批量销毁',
+ destroyDescription: '销毁当前选中的实例,只有满足条件的实例会被执行。',
+ destroyEmpty: '所选实例中没有可销毁项目',
+ refundTotal: '预计总退款',
+ feeTotal: '预计总手续费',
+ feeWaived: '免手续费',
+ confirmHint: '请输入 DESTROY 以确认批量销毁',
+ confirmPlaceholder: '输入 DESTROY 确认',
+ },
+ batchReason: {
+ notFoundOrForbidden: '实例不存在或无权操作',
+ freeNoRenew: '免费实例无需续费',
+ billingUnavailable: '无法获取实例计费信息',
+ noRenewOptions: '暂无可用续费选项',
+ renewWindow: '仅可在到期前 {days} 天内续费',
+ renewFailed: '续费失败',
+ freeNoAutoRenew: '免费实例不支持自动续费',
+ autoRenewAlreadyOn: '已经开启自动续费',
+ autoRenewAlreadyOff: '已经关闭自动续费',
+ autoRenewFailed: '自动续费设置失败',
+ deleted: '实例已删除',
+ creating: '实例正在创建中,无法销毁',
+ suspended: '实例已被封停,无法销毁,请先联系管理员解封',
+ destroyTrafficLimit: '当前月流量周期无法销毁,已用流量达到或超过 5G',
+ destroyFailed: '销毁实例失败',
+ },
+ // 移动端卡片
+ mobileCard: {
+ ipAddress: 'IP 地址',
+ config: '配置',
+ disk: '硬盘',
+ traffic: '流量',
+ host: '节点',
+ user: '用户',
+ unlimited: '无限制',
+ cpuCore: '%核',
+ quota: '配额',
+ ports: '端口',
+ snapshots: '快照',
+ backups: '备份',
+ sites: '反代',
+ },
+ // 实例创建组件
+ selector: {
+ // 地区选择
+ selectRegion: '选择国家/地区',
+ allRegions: '全部',
+ noRegions: '暂无可用地区',
+ packageCount: '{count} 个套餐',
+ // 套餐选择
+ selectPackage: '选择套餐',
+ selectPlan: '选择方案',
+ planDesc: '选择适合您需求的付费方案',
+ customPlanHint: '找不到合适的方案?您可以提交工单申请定制配置',
+ noPlans: '该套餐暂无可用方案',
+ planSoldOut: '已售罄',
+ noPackages: '暂无可用套餐',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '硬盘',
+ cores: '核',
+ networkMode: {
+ nat: 'NAT',
+ natDesc: '通过端口映射访问',
+ nat_ipv6: 'NAT + IPv6',
+ nat_ipv6Desc: 'NAT + 公网 IPv6',
+ },
+ docker: '可嵌套',
+ privileged: '特权',
+ configureResources: '配置资源',
+ adjustBasedOnPackage: '基于套餐上限自由调节',
+ cpuAllowance: '额配',
+ accountQuota: '账户配额',
+ packageLimit: '套餐限制',
+ selectHost: '选择宿主机',
+ hostOptional: '可选,不选则系统自动分配',
+ hostAutoSelected: '默认选中第一台可用节点',
+ hostTraffic: '该节点下实例月流量',
+ autoAssign: '自动分配',
+ autoAssignDesc: '系统根据负载自动选择最优节点',
+ available: '可用',
+ selectSystem: '选择系统',
+ showSyncedImages: '显示当前节点允许的镜像',
+ noImages: '暂无可用镜像',
+ noImagesOnHost: '所选节点暂无可用镜像',
+ contactAdmin: '请联系节点所有者或管理员调整镜像策略',
+ selectSshKey: '选择 SSH 密钥',
+ noSshKeys: '暂无 SSH 密钥',
+ addSshKeyHint: '请先在设置页面添加 SSH 公钥',
+ hostInsufficient: '宿主机资源不足',
+ hostInsufficientDesc: '当前配置需要 {cpu}% CPU 和 {memory}GB 内存,但套餐内所有宿主机均无法满足。',
+ hostInsufficientSuggest: '建议降低实例配置或等待资源释放。',
+ viewProbe: '查看探针监控',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ pageInfo: '第 {current}/{total} 页,共 {count} 条',
+ // 方案资源配额
+ ports: '端口',
+ snapshots: '快照',
+ backups: '备份',
+ sites: '站点',
+ bandwidth: '带宽',
+ },
+ // 付费订阅卡片
+ subscription: {
+ premium: '付费订阅',
+ expiresAt: '到期日期',
+ expiresIn: '剩余时间',
+ expired: '已过期',
+ days: '天',
+ billingCycle: '计费周期',
+ renewPrice: '续费价格',
+ month: '月',
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ annual: '年付',
+ months: '个月',
+ perMonth: '/月',
+ perQuarter: '/季',
+ perHalfYear: '/半年',
+ perYear: '/年',
+ renew: '续费',
+ renewSuccess: '续费成功',
+ applyAffShort: '优惠码',
+ applyAffTitle: '绑定 AFF 优惠码',
+ applyAffInstance: '实例',
+ applyAffCurrentRenewPrice: '当前续费价格',
+ applyAffCodeLabel: 'AFF 优惠码',
+ applyAffCodePlaceholder: '输入 AFF 优惠码',
+ applyAffEffectHint: '绑定成功后仅影响后续续费价格。',
+ applyAffNoRefundHint: '不会退还当前周期差价,也不会改变当前周期费用。',
+ applyAffOwnCodeHint: '只能绑定他人的优惠码,不能绑定自己的优惠码。',
+ applyAffSubmit: '确认绑定',
+ applyAffSubmitting: '绑定中...',
+ applyAffSuccess: '优惠码绑定成功,后续续费将享受折扣',
+ // 自动续费
+ autoRenew: '自动续费',
+ autoRenewOn: '已开启自动续费',
+ autoRenewOff: '未开启自动续费',
+ autoRenewEnabled: '已开启自动续费',
+ autoRenewDisabled: '已关闭自动续费',
+ enableAutoRenew: '开启自动续费',
+ disableAutoRenew: '关闭自动续费',
+ autoRenewHint: '到期前 24 小时将自动从余额扣款续费',
+ autoRenewDesc: '开启自动续费后,实例将在到期前自动按 {cycle} 周期续费,每次续费 ¥{price}',
+ currentStatus: '当前状态',
+ },
+ // 实例销毁
+ destroy: {
+ button: '销毁',
+ title: '销毁实例',
+ warning: '此操作将永久删除实例及其所有数据,包括快照、备份、端口映射等,且不可恢复。',
+ warningFree: '此操作将永久删除实例及其所有数据,且不可恢复。',
+ rulesTitle: '销毁规则',
+ rulesDesc: '了解销毁功能的使用规则和限制',
+ ruleFirstFree: '首次销毁免手续费',
+ ruleFirstFreeDesc: '您的第一次销毁操作将免除手续费,全额退款',
+ ruleFeeRate: '后续销毁收取 {rate}% 手续费',
+ ruleFeeRateDesc: '第二次及以后销毁收取 {rate}% 手续费',
+ ruleTrafficThreshold: '付费实例当前月流量周期已用流量需低于 5G',
+ ruleTrafficThresholdDesc: '若当前月流量周期已用流量达到或超过 5G,则本次无法销毁付费实例',
+ ruleFreeInstance: '免费实例可直接销毁',
+ ruleFreeInstanceDesc: '免费实例销毁无退款,不计入销毁次数',
+ // 预览信息
+ instanceInfo: '实例信息',
+ instanceName: '实例名称',
+ hostName: '所在节点',
+ planName: '当前方案',
+ refundInfo: '退款信息',
+ remainingDays: '剩余天数',
+ remainingValue: '剩余价值',
+ maxRefundable: '退款上限',
+ feeRate: '手续费率',
+ feeAmount: '手续费',
+ refundAmount: '实际退款',
+ firstTimeFree: '首次免手续费',
+ freeInstanceNoRefund: '免费实例无退款',
+ days: '天',
+ // 确认
+ confirmTitle: '确认销毁',
+ confirmHint: '请输入实例名称 {name} 以确认销毁',
+ confirmPlaceholder: '输入实例名称确认',
+ confirmButton: '确认销毁',
+ destroying: '销毁中...',
+ cancel: '取消',
+ // 状态
+ success: '实例已销毁',
+ successWithRefund: '实例已销毁,已退款 ¥{amount}',
+ failed: '销毁失败',
+ loadFailed: '加载销毁信息失败',
+ // 不可销毁原因
+ cannotDestroy: '无法销毁',
+ },
+ // 实例详情页
+ detail: {
+ invalidId: '无效的实例 ID',
+ notExist: '实例不存在',
+ loadFailed: '加载实例失败',
+ tabs: {
+ info: '信息',
+ network: '网络',
+ traffic: '流量',
+ quota: '配额',
+ snapshots: '快照',
+ backups: '备份',
+ config: '配置',
+ logs: '日志',
+ },
+ task: {
+ start: '正在启动...',
+ stop: '正在停止...',
+ restart: '正在重启...',
+ rebuild: '正在重装...',
+ recreate: '正在重建...',
+ clone: '正在复制...',
+ change_host: '正在改节点...',
+ },
+ actions: {
+ starting: '实例启动中',
+ stopped: '实例已停止',
+ restarting: '实例重启中',
+ deleted: '实例已删除',
+ confirmDelete: '确定删除实例 "{name}"?\n\n此操作将永久删除实例和所有数据,不可恢复。',
+ actionFailed: '操作失败',
+ taskQueued: '操作已提交,请稍候...',
+ taskInProgress: '实例正在执行其他操作,请稍候',
+ rebuildSuccess: '系统重装成功,新密码已生成',
+ recreateSuccess: '实例重建成功,新密码已生成',
+ selectImageAndKey: '请选择镜像和 SSH 密钥',
+ clone: '复制实例',
+ cloning: '复制中...',
+ cloneSuccess: '实例复制成功',
+ cloneFailed: '实例复制失败',
+ confirmClone: '确定要复制实例 "{name}" 吗?',
+ cloneNotice: '复制操作将创建一个新的实例副本,新实例将继承源实例的所有配置,但会分配新的端口映射。复制可能需要几分钟时间,请耐心等待。',
+ stopRequired: '请先停止实例',
+ stopRequiredHint: '此操作需要实例处于停止状态',
+ suspend: '封停实例',
+ unsuspend: '解除封停',
+ suspending: '封停中...',
+ unsuspending: '解封中...',
+ suspendSuccess: '实例已封停',
+ unsuspendSuccess: '实例已解封',
+ syncStatus: '同步',
+ help: '帮助',
+ syncStatusChanged: '状态已同步:{from} → {to}',
+ syncStatusNoChange: '状态已更新,已同步网络地址',
+ syncIpv4Changed: '内网 IP 已更新: {from} → {to}',
+ syncProxySitesUpdated: '已同步更新 {count} 个反代站点配置',
+ confirmSuspend: '确定要封停实例 "{name}" 吗?',
+ confirmSuspendNotice: '封停后,实例所有者将无法对该实例进行任何操作,直到所有者手动解除封禁。',
+ suspendReason: '封停原因',
+ suspendReasonPlaceholder: '请输入封停原因,将通过站内信通知实例所有者...',
+ confirmUnsuspend: '确定要解除实例 "{name}" 的封停状态吗?',
+ },
+ rebuild: {
+ title: '重装',
+ noHostInfo: '无法获取节点信息',
+ noImages: '该节点当前没有可用镜像,请联系节点所有者或管理员调整镜像策略。',
+ loadImagesFailed: '加载镜像列表失败',
+ loadKeysFailed: '加载 SSH 密钥失败',
+ },
+ recreate: {
+ title: '重建实例',
+ },
+ port: {
+ fillPrivatePort: '请填写内部端口',
+ added: '端口映射已添加',
+ addedBoth: 'TCP 和 UDP 端口映射已添加',
+ batchAdded: '已添加 {count} 个端口映射',
+ stillConflict: '部分端口仍有冲突,请重新选择',
+ deleted: '端口映射已删除',
+ deleteFailed: '删除失败',
+ confirmDelete: '确定删除此端口映射?',
+ confirmBatchDelete: '确定删除选中的 {count} 个端口映射?',
+ batchDeleted: '已删除 {count} 个端口映射',
+ batchDeletePartial: '成功删除 {success} 个,{fail} 个删除失败',
+ },
+ password: {
+ loadFailed: '加载密码失败',
+ },
+ quota: {
+ saved: '配额已更新',
+ saveFailed: '保存失败',
+ portExceedUsed: '端口配额不能小于当前已使用量:当前已使用 {used} 个端口,输入 {input}',
+ snapshotExceedUsed: '快照配额不能小于当前已使用量:当前已使用 {used} 个快照,输入 {input}',
+ backupExceedUsed: '备份配额不能小于当前已使用量:当前已使用 {used} 个备份,输入 {input}',
+ portOutOfRange: '端口配额必须在 1-1000 范围内',
+ snapshotOutOfRange: '快照配额必须在 1-1000 范围内',
+ backupOutOfRange: '备份配额必须在 1-1000 范围内',
+ },
+ copy: {
+ success: '已复制到剪贴板',
+ failed: '复制失败',
+ },
+ // 信息标签页
+ info: {
+ title: '基本信息',
+ instanceId: '实例 ID',
+ image: '镜像',
+ host: '节点',
+ networkMode: '网络模式',
+ instanceMode: '实例模式',
+ nat: 'NAT',
+ ipv6: 'IPv6',
+ sshPort: 'SSH 端口',
+ sshHelpTitle: 'SSH 连接说明',
+ sshHelpIpv4: '使用 IPv4 连接时,需要先前往「网络」标签页添加 22 端口的映射,然后使用公网 IP 和映射后的端口进行连接。',
+ sshHelpIpv6: '使用 IPv6 连接时,可以直接使用公网 IPv6 地址和 22 端口进行连接,无需配置端口映射。',
+ rootPassword: 'Root 密码',
+ createdAt: '创建时间',
+ expiresAt: '到期时间',
+ suspended: '实例已封停',
+ suspendedAt: '封停时间',
+ suspendReasonLabel: '封停原因',
+ suspendReasonExpired: '实例已到期,请续费后解封',
+ suspendReasonDefault: '未填写封停原因',
+ suspendTip: '封停期间,实例无法启动、重启、重装等操作。如需解封或有疑问,请提交工单。',
+ copy: '复制',
+ show: '显示',
+ hide: '隐藏',
+ resourceUsage: '资源占用',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '硬盘',
+ inbound: '入站',
+ outbound: '出站',
+ ingressLimit: '入栈',
+ egressLimit: '出栈',
+ hostUid: '托管UID',
+ hostOwnerTitle: '托管者信息',
+ hostOwnerEmail: '邮箱',
+ hostOwnerHostCount: '托管节点数',
+ hostOwnerInstanceCount: '节点实例数',
+ hostOwnerRegisteredDays: '注册天数',
+ includesCache: '含页面缓存',
+ cannotEditConfig: '实例状态不允许修改配置',
+ redeem: '兑换',
+ redeemTitle: '兑换资源',
+ },
+ // Cloud-init 初始化状态
+ cloudInit: {
+ initializing: '初始化中',
+ retry: '重新检查',
+ retryUnknown: '重试检测',
+ retryStalled: '继续检测',
+ short: '初始化',
+ shortUnknown: '待确认',
+ shortStalled: '较慢',
+ clickToRetry: '系统正在初始化,点击重新检测',
+ clickToRetryUnknown: '当前无法确认 Cloud-init 状态,点击重新检测',
+ clickToRetryStalled: '初始化耗时较长,点击继续检测或手动标记完成',
+ statusUnknown: '状态待确认',
+ stalled: '初始化较慢',
+ manualComplete: '手动标记完成',
+ manualShort: '完成',
+ manualCompleteSuccess: '已手动标记实例初始化为完成',
+ },
+ // 网络标签页
+ network: {
+ title: '网络地址',
+ privateIpv4: '内网 IPv4',
+ publicIpv4: '公网 IPv4',
+ publicIpv6: '公网 IPv6',
+ portMappings: '端口映射',
+ publicIp: '公网 IP',
+ add: '添加',
+ noQuota: '请先分配端口配额',
+ quotaFull: '端口配额已满',
+ addPortMapping: '添加端口映射',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ perPage: '每页',
+ filterBoth: '全部',
+ selectAll: '全选当前页',
+ selectedCount: '已选择 {count} 项',
+ batchDelete: '批量删除',
+ noFilterResults: '当前筛选条件无匹配结果',
+ noPortQuota: '未分配端口配额',
+ allocateQuotaHint: '请先在「配额」标签页中分配端口配额',
+ noPortMappings: '暂无端口映射,点击「添加」创建',
+ ipv6OnlyPortMappingHint: 'IPv6 Only类型的实例无法进行端口映射,请直接使用实例的IPv6',
+ additionalIpv6: '额外 IPv6 地址',
+ addIpv6: '添加 IPv6',
+ noAdditionalIpv6: '暂无额外 IPv6 地址',
+ ipAdded: 'IPv6 地址添加成功',
+ ipAddFailed: 'IPv6 地址添加失败',
+ ipDeleted: 'IPv6 地址已删除',
+ ipDeleteFailed: 'IPv6 地址删除失败',
+ confirmDeleteIp: '确定要删除这个 IPv6 地址吗?',
+ // IPv6 管理新增
+ primaryIpv6: '主 IPv6',
+ extraIpv6: '额外 IPv6',
+ ipv6Subnets: 'IPv6 网段',
+ addSubnet: '分配网段',
+ customIpv6: '自定义 IPv6',
+ randomIpv6: '随机分配',
+ setCustom: '设置自定义',
+ custom: '自定义',
+ primary: '主',
+ addIpv6Modal: {
+ title: '添加 IPv6 地址',
+ randomHint: '系统将从节点 IPv6 子网中随机分配一个地址',
+ customHint: '输入您自己的 IPv6 地址(必须在节点子网范围内)',
+ addressLabel: 'IPv6 地址',
+ addressPlaceholder: '如 2001:db8::1',
+ invalidAddress: '无效的 IPv6 地址格式',
+ adding: '添加中...',
+ },
+ subnetModal: {
+ title: '分配 IPv6 网段',
+ hint: '选择要分配的网段大小,节点将自动从可用池中分配',
+ prefix112: '/112 (65,536 个 IP)',
+ prefix120: '/120 (256 个 IP)',
+ prefix124: '/124 (16 个 IP)',
+ allocating: '分配中...',
+ allocate: '分配',
+ },
+ noSubnets: '暂无分配的 IPv6 网段',
+ subnetAllocated: 'IPv6 网段分配成功',
+ subnetAllocateFailed: 'IPv6 网段分配失败',
+ subnetDeleted: 'IPv6 网段已删除',
+ subnetDeleteFailed: 'IPv6 网段删除失败',
+ confirmDeleteSubnet: '确定要删除这个 IPv6 网段吗?',
+ customIpv6Set: '自定义 IPv6 地址设置成功',
+ customIpv6Failed: '自定义 IPv6 地址设置失败',
+ ipv6NotInSubnet: 'IPv6 地址必须在节点子网范围内',
+ ipv6AlreadyExists: 'IPv6 地址已被占用',
+ instanceMustRunning: '实例必须处于运行状态才能管理 IPv6',
+ loading: '加载中...',
+ // 重新分配 IPv6
+ reassignIpv6: '重新获取',
+ reassignIpv6Confirm: '确定要重新分配 IPv6 地址吗?',
+ reassignIpv6ConfirmHint: '重新分配后需要重装系统才能生效',
+ reassignIpv6Success: 'IPv6 已重新分配,请重装系统使其生效',
+ reassignIpv6Failed: 'IPv6 重新分配失败',
+ reassignIpv6Loading: '重新分配中...',
+ reassignIpv6StopRequired: '实例必须先关机才能重新分配 IPv6',
+ reassignIpv6Cooldown: '每天只能重新获取一次,请等待 {hours} 小时后重试',
+ reassignIpv6CooldownShort: '{hours}小时后',
+ reassignIpv6NotSupported: '此实例不支持重新分配 IPv6',
+ },
+ // 配额标签页
+ quotaTab: {
+ title: '实例配额设置',
+ portLimit: 'NAT 端口数上限',
+ snapshotLimit: '快照数量上限',
+ backupLimit: '备份数量上限',
+ placeholder: '留空或输入0自动填入剩余额度',
+ currentUsage: '当前使用',
+ quotaLimit: '配额限制',
+ full: '已满',
+ remaining: '剩余',
+ defaultRemaining: '默认账户剩余额度',
+ unit: '个',
+ portMappings: '端口映射',
+ snapshots: '快照',
+ backups: '备份',
+ save: '保存配额设置',
+ saving: '保存中...',
+ },
+ },
+ // 修改配置弹窗
+ configEdit: {
+ title: '修改配置',
+ hint: '修改 CPU、内存配置将实时生效,无需重启实例。磁盘只能增大,不能缩小。',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '硬盘',
+ traffic: '流量',
+ trafficHint: '留空表示无限制,单位为GB',
+ cores: '核',
+ resourceType: '资源类型',
+ current: '当前',
+ modifyTo: '修改为',
+ diskOnlyIncrease: '只能增大',
+ quotaInsufficient: '配额不足',
+ quotaError: {
+ cpu: 'CPU 配额不足:可用 {available}%,需要 {need}%',
+ memory: '内存配额不足:可用 {available},需要 {need}',
+ disk: '磁盘配额不足:可用 {available},需要 {need}',
+ },
+ success: '配置修改成功',
+ failed: '配置修改失败',
+ packageRequired: '实例必须绑定套餐才能修改配置',
+ loadPackageFailed: '加载套餐信息失败,请稍后重试',
+ },
+ // 站点管理
+ sites: {
+ title: '建站',
+ addSite: '添加站点',
+ editSite: '编辑站点',
+ addFirstSite: '添加第一个站点',
+ empty: '暂无反代站点,点击上方按钮添加。',
+ caddyNotEnabled: '宿主机未启用 Caddy',
+ caddyNotEnabledHint: '当前实例所在宿主机未启用 Caddy 反代服务,无法使用建站功能。',
+ domain: '域名',
+ domainRequired: '请输入域名',
+ domainHint: '输入要绑定的域名,不支持泛域名',
+ wildcardNotAllowed: '不支持泛域名(如 *.example.com)',
+ targetPort: '内部端口',
+ portHint: '实例内部运行的 Web 服务端口(如 80, 3000, 8080)',
+ statusActive: '已生效',
+ statusPending: '等待解析',
+ statusError: '配置失败',
+ refresh: '刷新配置',
+ loadFailed: '加载站点列表失败',
+ addSuccess: '站点添加成功',
+ addFailed: '添加站点失败',
+ deleteConfirm: '确定要删除站点 {domain} 吗?',
+ deleteSuccess: '站点已删除',
+ deleteFailed: '删除站点失败',
+ updateSuccess: '站点配置已更新',
+ updateFailed: '更新站点失败',
+ refreshSuccess: '配置已刷新',
+ refreshFailed: '刷新配置失败',
+ addedSuccess: '域名添加成功',
+ dnsHintDesc: '请前往您的 DNS 服务商添加以下解析记录:',
+ dnsType: '类型',
+ dnsHost: '主机',
+ dnsValue: '值',
+ sslAutoHint: 'DNS 解析生效后,Caddy 将自动申请 SSL 证书。',
+ quotaInfo: '已使用 {used} / {limit} 个站点',
+ quotaFull: '已达上限',
+ disabled: '已禁用',
+ enableSite: '启用站点',
+ disableSite: '禁用站点',
+ toggleFailed: '切换状态失败',
+ enableHttps: '启用 HTTPS',
+ httpsHint: '自动申请 Let\'s Encrypt 证书,访问 HTTP 会跳转到 HTTPS',
+ httpsEnabled: '已启用 HTTPS',
+ httpOnly: '仅 HTTP',
+ checkCert: '检查证书状态',
+ certCheckFailed: '检查证书状态失败',
+ checkDns: '检测 DNS',
+ dnsActivated: 'DNS 验证通过,站点已激活',
+ dnsResolved: 'DNS 已正确解析',
+ dnsCheckFailed: 'DNS 检测失败',
+ dnsHintWithCheck: 'DNS 配置完成后,点击"检测 DNS"激活站点',
+ remark: '备注',
+ remarkPlaceholder: '可选,如:博客网站、API 服务等',
+ cert: {
+ title: '证书状态',
+ valid: '证书有效',
+ disabled: 'HTTPS 未启用',
+ pending: '待激活',
+ certPending: '证书申请中',
+ dnsError: 'DNS 未解析',
+ connectionRefused: '连接被拒绝',
+ timeout: '连接超时',
+ error: '检查失败',
+ issuer: '颁发者',
+ validFrom: '生效日期',
+ validTo: '过期日期',
+ daysRemaining: '剩余天数',
+ days: '天',
+ },
+ },
+ },
+
+ // 端口映射弹窗
+ portModal: {
+ title: '添加端口映射',
+ protocol: '协议',
+ bothHint: '将同时创建 TCP 和 UDP 映射,占用 2 个配额',
+ privatePort: '内部端口',
+ privatePortRequired: '*',
+ privatePortPlaceholder: '容器内部服务端口,如 80、22、3306',
+ privatePortPlaceholderRange: '如 80 或 80-85',
+ publicPort: '公网端口',
+ publicPortOptional: '(可选)',
+ publicPortPlaceholder: '留空自动分配',
+ publicPortPlaceholderRange: '如 20000 或 20000-20005',
+ publicPortHint: '留空将自动从端口池分配可用端口',
+ publicPortHintWithRange: '可选范围: {start}-{end},留空将自动分配',
+ remark: '备注',
+ remarkOptional: '(可选)',
+ remarkPlaceholder: '如:Web服务、数据库、SSH等',
+ cancel: '取消',
+ adding: '添加中...',
+ add: '添加',
+ // 新增:范围输入支持
+ rangeHint: '支持范围输入,如 80-85',
+ invalidPortFormat: '端口格式无效,请输入单个端口或端口范围(如 80-85)',
+ ipv6OnlySshPortHint: '22 端口不用映射,公网 IPv6 直连 SSH 就行,正门口已经亮灯了',
+ rangeMismatch: '内网端口数 ({private} 个) 与公网端口数 ({public} 个) 不匹配',
+ publicPortOutOfRange: '公网端口超出允许范围 ({start}-{end})',
+ quotaPreview: '将创建 {count} 个映射,占用 {quota} 个配额',
+ quotaRemaining: '剩余 {remain} 个',
+ quotaInsufficient: '配额不足,需要 {need} 个,剩余 {remain} 个',
+ },
+
+ // 端口冲突解决弹窗
+ portConflict: {
+ title: '部分端口已被占用',
+ subtitle: '共 {count} 个端口冲突',
+ description: '以下端口已被其他实例占用,您可以修改为新的端口或使用系统建议。',
+ originalPort: '原端口',
+ newPort: '新端口',
+ occupied: '已占用',
+ suggested: '建议',
+ rangeHint: '可用范围: {start}-{end}',
+ useSuggested: '使用全部建议',
+ cancel: '取消',
+ confirm: '确认修改',
+ },
+
+ // 重装系统弹窗
+ rebuildModal: {
+ title: '重装系统',
+ dangerWarning: '危险操作',
+ warningList: {
+ dataLoss: '重装系统将清除实例内的所有数据',
+ snapshotLoss: '所有快照将被永久删除',
+ irreversible: '此操作不可恢复!',
+ },
+ preserveInfo: '重装系统会保留端口映射。',
+ manualStartHint: '重装完成后需手动启动实例。',
+ selectImage: '选择新镜像',
+ imageHint: '只能选择该节点当前允许的镜像',
+ selectSshKey: '选择 SSH 密钥',
+ noSshKey: '暂无可用密钥',
+ addSshKeyHint: '请先在设置中添加 SSH 密钥',
+ passwordHint: '重装后将自动生成新的 root 密码,可在实例详情页查看。',
+ cancel: '取消',
+ rebuilding: '重装中...',
+ confirmRebuild: '确认重装',
+ },
+
+ // 重建实例弹窗
+ recreateModal: {
+ title: '重建实例',
+ dangerWarning: '危险操作',
+ warningList: {
+ dataLoss: '重建将清除实例内的所有数据',
+ snapshotLoss: '所有快照将被永久删除',
+ portMappingLoss: '所有端口映射将被删除',
+ backupLoss: '所有备份记录和备份策略将被删除',
+ proxySiteLoss: '所有反代站点和快照策略将被删除',
+ irreversible: '此操作不可恢复!',
+ },
+ differenceHint: '重建与重装不同:不需要先关机,会创建全新的实例替换旧实例。',
+ preserveInfo: '重建只保留计费状态和配额。',
+ selectImage: '选择新镜像',
+ selectSshKey: '选择 SSH 密钥',
+ noSshKey: '暂无可用密钥',
+ addSshKeyHint: '请先在设置中添加 SSH 密钥',
+ cancel: '取消',
+ recreating: '重建中...',
+ confirmRecreate: '确认重建',
+ },
+
+ // 快照管理
+ snapshot: {
+ title: '快照',
+ autoPolicy: '自动快照已启用',
+ autoPolicyEnabled: '已启用自动快照',
+ currentPolicy: '当前策略',
+ disableAutoPolicy: '取消自动',
+ minutes: '分钟',
+ manual: '手动管理',
+ autoSettings: '自动快照设置',
+ create: '创建',
+ noQuota: '请先分配快照配额',
+ quotaFull: '快照配额已满',
+ createSnapshot: '创建快照',
+ noSnapshots: '暂无快照',
+ noQuotaAllocated: '未分配快照配额',
+ allocateQuotaHint: '请先在「配额」标签页中分配快照配额',
+ statefulSnapshot: '状态快照',
+ restore: '恢复',
+ stopInstanceFirst: '请先停止实例',
+ delete: '删除',
+ createModal: {
+ title: '创建快照',
+ name: '名称',
+ nameRequired: '*',
+ namePlaceholder: 'snapshot-01',
+ description: '描述',
+ descriptionPlaceholder: '可选描述',
+ stateful: '保存内存状态(状态快照)',
+ cancel: '取消',
+ creating: '创建中...',
+ create: '创建',
+ },
+ policyModal: {
+ title: '自动快照设置',
+ enable: '启用自动快照',
+ interval: '快照间隔',
+ intervalOptions: {
+ min10: '每 10 分钟',
+ hour1: '每 1 小时',
+ hour6: '每 6 小时',
+ hour24: '每 24 小时',
+ day3: '每 3 天',
+ },
+ quotaFromPackage: '配额继承自套餐,当前限制为 {limit} 个。满额后将自动删除最早的自动快照。',
+ cancel: '取消',
+ saving: '保存中...',
+ save: '保存',
+ },
+ messages: {
+ createSuccess: '快照创建成功',
+ createFailed: '创建失败',
+ deleteConfirm: '确定删除快照 "{name}"?此操作不可恢复。',
+ deleteSuccess: '快照已删除',
+ deleteFailed: '删除失败',
+ restoreConfirm: '确定将实例恢复到快照 "{name}"?当前数据将被覆盖。',
+ restoreSuccess: '快照恢复成功',
+ restoreFailed: '恢复失败',
+ stopInstanceFirst: '请先停止实例再恢复快照',
+ policySaved: '自动快照策略已更新',
+ policyDisabled: '自动快照已关闭',
+ policySaveFailed: '保存失败',
+ },
+ },
+
+ // 备份管理
+ backup: {
+ title: '备份',
+ autoPolicy: '自动备份已启用',
+ autoPolicyEnabled: '已启用自动备份',
+ currentPolicy: '当前策略',
+ disableAutoPolicy: '取消自动',
+ minutes: '分钟',
+ manual: '手动管理',
+ autoSettings: '自动备份设置',
+ create: '创建',
+ noQuota: '请先分配备份配额',
+ quotaFull: '备份配额已满',
+ createBackup: '创建备份',
+ noBackups: '暂无备份',
+ noQuotaAllocated: '未分配备份配额',
+ allocateQuotaHint: '请先在「配额」标签页中分配备份配额',
+ status: {
+ creating: '创建中',
+ ready: '就绪',
+ error: '失败',
+ },
+ export: '导出',
+ preparing: '准备中...',
+ clickToDownload: '点击下载',
+ downloading: '下载中...',
+ retry: '重试',
+ delete: '删除',
+ createModal: {
+ title: '创建备份',
+ name: '名称',
+ nameRequired: '*',
+ namePlaceholder: 'backup-01',
+ description: '描述',
+ descriptionPlaceholder: '可选描述',
+ expiresIn: '过期天数(可选)',
+ neverExpire: '永不过期',
+ days7: '7 天',
+ days14: '14 天',
+ days30: '30 天',
+ days90: '90 天',
+ year1: '1 年',
+ createHint: '备份创建可能需要几分钟,请耐心等待。',
+ cancel: '取消',
+ creating: '创建中...',
+ create: '创建',
+ },
+ policyModal: {
+ title: '自动备份设置',
+ enable: '启用自动备份',
+ interval: '备份间隔',
+ intervalOptions: {
+ hour1: '每 1 小时',
+ hour6: '每 6 小时',
+ hour24: '每 24 小时',
+ day3: '每 3 天',
+ },
+ quotaFromPackage: '配额继承自套餐,当前限制为 {limit} 个。满额后将自动删除最早的自动备份。',
+ cancel: '取消',
+ saving: '保存中...',
+ save: '保存',
+ },
+ restore: '恢复',
+ restoring: '恢复中...',
+ rollback: '回滚',
+ restoreModal: {
+ title: '⚠️ 危险操作 - 恢复备份',
+ warning: '此操作将覆盖当前实例!',
+ warningDetail: '恢复操作会停止当前实例,并用备份内容替换。如果恢复失败,您可以选择回滚到原实例。',
+ dataLossWarning: '以下数据将被永久删除:',
+ dataLossItems: {
+ backups: '该实例的所有其他备份',
+ snapshots: '该实例的所有快照',
+ },
+ nameChangeNotice: '恢复成功后,实例名称将变更为:{name} | restored:{backup}',
+ backupName: '备份名称',
+ instanceName: '目标实例',
+ cancel: '取消',
+ confirm: '确认恢复',
+ },
+ messages: {
+ createSuccess: '备份创建中...',
+ createFailed: '创建失败',
+ deleteConfirm: '确定删除备份 "{name}"?此操作不可恢复。',
+ deleteSuccess: '备份已删除',
+ deleteFailed: '删除失败',
+ exportFailed: '准备导出失败',
+ downloadStarted: '下载已开始',
+ downloadFailed: '下载失败',
+ policySaved: '自动备份策略已更新',
+ policyDisabled: '自动备份已关闭',
+ policySaveFailed: '保存失败',
+ restoreStarted: '正在恢复备份 "{name}",请稍候...',
+ restoreInProgress: '已有恢复任务正在进行中',
+ restoreCompleted: '备份恢复成功!',
+ restoreFailed: '恢复失败',
+ rollbackCompleted: '回滚成功,原实例已恢复',
+ rollbackFailed: '回滚失败',
+ uploadStarted: '上传任务已创建',
+ uploadInProgress: '已有上传任务正在进行中',
+ uploadCompleted: '备份上传成功!',
+ uploadFailed: '上传失败',
+ uploadCancelled: '上传任务已取消',
+ },
+ // 上传到远程存储
+ upload: '上传',
+ uploadRemote: '上传到云端',
+ uploadModal: {
+ title: '上传备份到远程存储',
+ selectStorage: '选择存储',
+ useDefault: '使用默认存储',
+ noStorage: '暂无存储配置',
+ noStorageHint: '请先在「设置」中配置远程存储',
+ goToSettings: '前往设置',
+ cancel: '取消',
+ upload: '开始上传',
+ uploading: '上传中...',
+ },
+ uploadStatus: {
+ pending: '排队中',
+ processing: '上传中',
+ completed: '已完成',
+ failed: '失败',
+ queuePosition: '队列位置:第 {position} 位',
+ },
+ },
+
+ // 仪表盘
+ dashboard: {
+ title: '概览',
+ welcome: '欢迎回来',
+ totalInstances: '总实例',
+ runningInstances: '运行中',
+ stoppedInstances: '已停止',
+ creatingInstances: '创建中',
+ userBalance: '用户余额',
+ balance: '余额',
+ memberLevel: '会员等级',
+ memberLevelBasic: '普通会员',
+ uptimeProbe: '自营节点监控',
+ statusPage: '状态页',
+ rechargeNow: '立即充值',
+ walletDetails: '查看钱包',
+ walletHint: '前往钱包页面完成充值',
+ instanceStatusOverview: '实例状态概览',
+ accountOverview: '账户概览',
+ runningHealth: '运行健康度',
+ instanceOverviewSummary: '{running}/{total} 台实例运行中,在线率 {percent}%',
+ containerInstances: '容器实例',
+ vmInstances: '虚拟机实例',
+ totalRecharge: '累计充值',
+ totalConsume: '累计消费',
+ userPoints: '用户积分',
+ frozenBalance: '冻结余额',
+ frozenBalanceHint: '账户中有冻结余额,请在钱包查看明细',
+ accountReadyHint: '账户状态正常,可直接创建或续费实例',
+ instanceListSummary: '最近 {count} / 共 {total} 个实例',
+ vipProgressTitle: '会员成长进度',
+ vipProgressToNext: '{current} → {next}',
+ vipProgressMaxed: '已达到当前最高会员等级',
+ vipProgressNoRule: '暂无下一等级规则',
+ vipProgressUnavailable: '会员进度暂不可用',
+ vipProgressAllHint: '升级到 {level} 需要同时满足以下条件',
+ vipProgressAnyHint: '升级到 {level} 满足任一条件即可',
+ vipProgressSingleMetricHint: '升级到 {level} 需要达到{metric}门槛',
+ vipProgressStableHint: '保持当前权益,后续福利大厅开放后可领取对应福利',
+ vipMetricTotalRecharge: '累计充值',
+ vipMetricTotalConsume: '累计消费',
+ vipMetricTotalHostingIncome: '累计托管收入',
+ vipMetricInstanceCount: '托管实例数',
+ vipProgressConditionMet: '已满足',
+ vipProgressCurrent: '当前',
+ vipProgressTarget: '目标',
+ vipProgressRemaining: '还差',
+ vipProgressRemainingMoney: '还差 {amount}',
+ vipProgressRemainingCount: '还差 {count} 个',
+ resourceUsage: '资源使用',
+ resourceOverview: '这是您的资源使用概览',
+ newInstance: '新建实例',
+ quotaUsage: '配额使用',
+ increaseQuota: '增加配额',
+ pinnedArticles: '置顶帮助文章',
+ viewAllHelp: '查看全部帮助',
+ myInstances: '我的实例',
+ viewAll: '查看全部',
+ viewAllInstances: '查看全部 {count} 个实例',
+ viewAllInstancesWithCount: '查看全部 {count} 个实例',
+ memoryMetric: '内存',
+ diskMetric: '磁盘',
+ noPublicIp: '无公网 IP',
+ unknownHost: '未分配节点',
+ noInstances: '还没有创建任何实例',
+ createFirst: '创建第一个实例',
+ quickActions: '快速操作',
+ createInstance: '创建实例',
+ newContainer: '新建容器',
+ instanceList: '实例列表',
+ manageInstances: '管理实例',
+ profileSettings: '个人设置',
+ accountSecurity: '账号安全',
+ helpDocs: '帮助文档',
+ userGuide: '使用指南',
+ greeting: {
+ morning: '早上好',
+ afternoon: '下午好',
+ evening: '晚上好',
+ basicUser: '{username}',
+ memberUser: '尊贵的 {level} {username}',
+ full: '{greeting},{member}',
+ },
+ },
+
+ // 设置页面
+ profile: {
+ title: '个人设置',
+ basicInfo: '基本信息',
+ security: '安全设置',
+ changePassword: '修改密码',
+ currentPassword: '当前密码',
+ newPassword: '新密码',
+ confirmNewPassword: '确认新密码',
+ twoFactor: '两步验证',
+ enableTwoFactor: '启用两步验证',
+ disableTwoFactor: '禁用两步验证',
+ // 账户部分
+ account: {
+ title: '账户',
+ username: '用户名',
+ uid: 'UID',
+ role: '角色',
+ email: '邮箱',
+ notSet: '未设置',
+ admin: '管理员',
+ user: '用户',
+ changeEmail: '修改',
+ bindEmail: '绑定',
+ emailDialog: {
+ titleChange: '修改邮箱',
+ titleBind: '绑定邮箱',
+ stepCurrent: '验证当前邮箱',
+ stepCurrentSkipped: '当前邮箱未绑定',
+ stepCurrentHint: '先验证当前邮箱,确认这次操作由您本人发起。',
+ stepNew: '验证新邮箱',
+ stepNewHint: '输入新的邮箱地址,并完成新邮箱验证码验证。',
+ noCurrentEmailHint: '当前账户尚未绑定邮箱,可直接进入新邮箱验证并完成绑定。',
+ verifyCurrentTitle: '当前邮箱验证',
+ verifyCurrentDesc: '验证码将发送到当前邮箱 {email},验证通过后才能继续修改邮箱。',
+ currentCode: '当前邮箱验证码',
+ currentCodePlaceholder: '输入收到的 6 位验证码',
+ sendCurrentCode: '发送验证码',
+ resendCurrentCode: '重新发送',
+ currentCodeSent: '当前邮箱验证码已发送',
+ currentCodeRequired: '请输入当前邮箱验证码',
+ verifyCurrentAction: '验证当前邮箱',
+ currentVerifiedSuccess: '当前邮箱验证成功',
+ currentVerificationExpired: '当前邮箱验证已失效,请重新验证当前邮箱。',
+ verifyNewTitle: '新邮箱验证',
+ verifyNewDesc: '请验证新的邮箱地址,完成后将立即更新为新的登录邮箱。',
+ bindEmailDesc: '请验证新的邮箱地址,完成后将绑定到当前账户。',
+ newEmail: '新邮箱',
+ newEmailPlaceholder: '输入新的邮箱地址',
+ newEmailRequired: '请输入新邮箱地址',
+ newEmailInvalid: '请输入有效的邮箱地址',
+ newEmailSame: '新邮箱不能与当前邮箱相同',
+ newCode: '新邮箱验证码',
+ newCodePlaceholder: '输入新邮箱收到的 6 位验证码',
+ newCodeRequired: '请输入新邮箱验证码',
+ sendNewCode: '发送验证码',
+ resendNewCode: '重新发送',
+ newCodeSent: '新邮箱验证码已发送',
+ resendIn: '{seconds}s 后可重发',
+ confirmAction: '确认提交',
+ updateSuccess: '邮箱已更新',
+ verifying: '验证中...',
+ submitting: '提交中...'
+ }
+ },
+ // 头像
+ avatar: {
+ title: '头像风格',
+ saveSuccess: '头像风格已更新',
+ saveFailed: '更新头像风格失败',
+ styles: {
+ adventurer: '冒险者',
+ adventurerNeutral: '冒险者素雅',
+ avataaars: '扁平插画',
+ avataaarsNeutral: '扁平插画纯净',
+ bigEars: '大耳萌',
+ bigEarsNeutral: '大耳萌纯净',
+ bigSmile: '开怀大笑',
+ bottts: '机器人',
+ botttsNeutral: '机器人纯净',
+ croodles: '抽象涂鸦',
+ croodlesNeutral: '抽象涂鸦纯净',
+ dylan: '迪伦风',
+ funEmoji: '趣味表情',
+ glass: '毛玻璃',
+ icons: '常用图标',
+ identicon: '几何哈希',
+ initials: '首字母',
+ lorelei: '洛蕾莱',
+ loreleiNeutral: '洛蕾莱纯净',
+ micah: '迈卡极简',
+ miniavs: '迷你小人',
+ notionists: 'Notion风格',
+ notionistsNeutral: 'Notion风格纯净',
+ openPeeps: '手绘众生相',
+ personas: '人物志',
+ pixelArt: '像素艺术',
+ pixelArtNeutral: '像素艺术纯净',
+ rings: '同心圆',
+ shapes: '几何图形',
+ thumbs: '拇指人',
+ },
+ },
+ // 资源配额
+ resourceQuota: {
+ title: '资源配额',
+ hosts: '宿主机',
+ instances: '实例',
+ friends: '好友',
+ unit: '个',
+ },
+ // 账单(BillingSection组件使用)
+ billing: {
+ title: '账户余额',
+ currentBalance: '当前余额',
+ recharge: '充值',
+ totalRecharge: '累计充值',
+ totalConsume: '累计消费',
+ balanceLogs: '余额明细',
+ rechargeRecords: '充值记录',
+ noLogs: '暂无余额记录',
+ noRecords: '暂无充值记录',
+ loadLogsFailed: '加载余额明细失败',
+ loadProvidersFailed: '加载支付渠道失败',
+ loadRecordsFailed: '加载充值记录失败',
+ selectProvider: '请选择支付方式',
+ invalidAmount: '金额无效',
+ orderCreated: '订单已创建',
+ redirecting: '正在跳转支付页面...',
+ orderNo: '订单号',
+ createOrderFailed: '创建订单失败',
+ paymentMethod: '支付方式',
+ noProviders: '暂无可用支付渠道',
+ amount: '充值金额',
+ amountRange: '金额范围',
+ feeNote: '手续费',
+ pay: '立即支付',
+ logTypes: {
+ recharge: '充值',
+ consume: '消费',
+ refund: '退款',
+ adminAdjust: '管理员调整',
+ gift: '赠送',
+ },
+ status: {
+ pending: '待支付',
+ paid: '已支付',
+ completed: '已完成',
+ failed: '失败',
+ cancelled: '已取消',
+ refunded: '已退款',
+ },
+ },
+ // 余额充值(旧版兼容)
+ userBilling: {
+ title: '余额充值',
+ balance: '当前余额',
+ frozen: '冻结金额',
+ recharge: '充值',
+ rechargeTitle: '账户充值',
+ selectAmount: '选择金额',
+ customAmount: '自定义金额',
+ selectProvider: '选择支付方式',
+ noProviders: '暂无可用支付渠道',
+ confirmRecharge: '确认充值',
+ fee: '手续费',
+ actual: '实际到账',
+ balanceLogs: '余额明细',
+ viewLogs: '查看明细',
+ rechargeRecords: '充值记录',
+ viewRecords: '查看记录',
+ noLogs: '暂无余额记录',
+ noRecords: '暂无充值记录',
+ amountRequired: '请输入充值金额',
+ providerRequired: '请选择支付方式',
+ recharging: '充值中...',
+ rechargeSuccess: '充值成功',
+ rechargeFailed: '充值失败',
+ loadFailed: '加载失败',
+ },
+ // 增加配额
+ increaseQuota: {
+ title: '增加配额',
+ description: '当配额使用率达到50%时,您可以自行增加配额',
+ type: '配额类型',
+ selectType: '选择要增加的配额类型',
+ hosts: '宿主机',
+ instances: '实例',
+ friends: '好友',
+ amount: '增加数量',
+ hostsAmount: '每次可增加 5 个',
+ instancesAmount: '每次可增加 50 个',
+ friendsAmount: '每次可增加 10 名',
+ submit: '提交',
+ submitting: '提交中...',
+ success: '配额增加成功',
+ failed: '配额增加失败',
+ notEligible: '当前使用率未达到50%,无法增加配额',
+ usageTooLow: '当前使用率:{percent}%,需要达到50%才能增加配额',
+ selectTypeFirst: '请先选择配额类型',
+ invalidAmount: '增加数量不正确',
+ hostsInvalid: '宿主机每次只能增加 5 个',
+ instancesInvalid: '实例每次只能增加 50 个',
+ friendsInvalid: '好友每次只能增加 10 名',
+ },
+ // 密码部分
+ password: {
+ title: '修改密码',
+ current: '当前密码',
+ currentPlaceholder: '输入当前密码',
+ new: '新密码',
+ newPlaceholder: '至少6位',
+ confirm: '确认新密码',
+ confirmPlaceholder: '再次输入新密码',
+ mismatch: '两次输入的密码不一致',
+ tooShort: '密码长度至少6位',
+ updated: '密码已更新',
+ updateFailed: '更新失败',
+ updating: '更新中...',
+ update: '更新密码',
+ },
+ // 双因素认证
+ twoFactorAuth: {
+ title: '双因素认证 (2FA)',
+ status: '状态',
+ enabled: '已启用',
+ notEnabled: '未启用',
+ enable: '启用 2FA',
+ disable: '禁用 2FA',
+ loading: '加载中...',
+ description: '启用双因素认证后,登录时需要输入手机验证器应用生成的动态验证码,提高账户安全性。',
+ setup: '设置双因素认证',
+ scanQrCode: '使用 Google Authenticator、Microsoft Authenticator 或其他 TOTP 应用扫描二维码',
+ manualEntry: '或手动输入密钥',
+ saveRecoveryCodes: '请保存以下恢复码,用于在无法使用验证器时恢复账户',
+ enterCode: '输入验证器显示的6位验证码',
+ codePlaceholder: '000000',
+ verifying: '验证中...',
+ confirmEnable: '确认启用',
+ cancel: '取消',
+ disableTitle: '禁用双因素认证',
+ disableDesc: '禁用后登录将不再需要验证码,请确认操作。',
+ password: '当前密码',
+ passwordPlaceholder: '输入密码',
+ verificationCode: '验证码',
+ processing: '处理中...',
+ confirmDisable: '确认禁用',
+ recoveryCodesStatus: '恢复码状态',
+ regenerate: '重新生成',
+ remaining: '剩余',
+ used: '已使用',
+ lowCodesWarning: '恢复码即将用完,建议重新生成',
+ regenerateTitle: '重新生成恢复码',
+ regenerateDesc: '重新生成后,旧的恢复码将全部失效。',
+ newCodesGenerated: '新恢复码已生成,请妥善保存',
+ generating: '生成中...',
+ confirmGenerate: '确认生成',
+ done: '完成',
+ enabledSuccess: '双因素认证已启用',
+ disabledSuccess: '双因素认证已禁用',
+ codesRegenerated: '恢复码已重新生成,请妥善保存',
+ getStatusFailed: '获取状态失败',
+ initFailed: '初始化失败',
+ verifyFailed: '验证失败',
+ disableFailed: '禁用失败',
+ regenerateFailed: '重新生成失败',
+ enterCodeError: '请输入6位验证码',
+ fillPasswordAndCode: '请填写密码和验证码',
+ },
+ // 会话管理
+ sessions: {
+ title: '登录会话',
+ logoutAll: '登出所有设备',
+ processing: '处理中...',
+ loading: '加载中...',
+ noSessions: '暂无活跃会话',
+ current: '当前',
+ ip: 'IP',
+ lastActive: '最后活跃',
+ revoke: '撤销',
+ revoked: '会话已撤销',
+ confirmLogout: '确定要退出当前登录吗?',
+ confirmLogoutAll: '确定要登出所有设备吗?你需要重新登录。',
+ loadFailed: '加载会话失败',
+ revokeFailed: '撤销会话失败',
+ revokeAllFailed: '撤销所有会话失败',
+ unknownDevice: '未知设备',
+ unknownBrowser: '未知浏览器',
+ justNow: '刚刚',
+ minutesAgo: '{n} 分钟前',
+ hoursAgo: '{n} 小时前',
+ daysAgo: '{n} 天前',
+ },
+ // OAuth 关联账号
+ oauth: {
+ title: '关联账号',
+ description: '绑定后可使用快捷登录',
+ bound: '已绑定',
+ notBound: '未绑定',
+ bind: '绑定',
+ unbind: '解绑',
+ noProviders: '暂无可用的第三方登录方式',
+ bindSuccess: '{provider} 账号绑定成功',
+ unbindSuccess: '{provider} 账号已解绑',
+ unbindFailed: '解绑失败',
+ confirmUnbind: '确定解除 {provider} 账号绑定?解绑后将无法使用该方式登录。',
+ errors: {
+ notLoggedIn: '请先登录后再绑定',
+ alreadyBoundOther: '该账号已被其他用户绑定',
+ invalidSession: '会话已过期,请重新登录',
+ bindFailed: '绑定失败',
+ tokenError: '授权失败,请重试',
+ providerDisabled: '该登录方式已被禁用',
+ oauthError: '认证失败,请重试',
+ missingCode: '授权信息缺失,请重试',
+ },
+ },
+ // SSH 密钥
+ sshKeys: {
+ title: 'SSH 公钥',
+ description: '用于 SSH 连接到实例',
+ add: '添加',
+ generate: '生成',
+ name: '名称',
+ namePlaceholder: '我的笔记本',
+ publicKey: '公钥内容',
+ publicKeyPlaceholder: 'ssh-ed25519 AAAA... 或 ssh-rsa AAAA...',
+ save: '保存',
+ cancel: '取消',
+ noKeys: '暂无公钥',
+ addSuccess: '公钥添加成功',
+ addFailed: '添加失败',
+ deleteSuccess: '公钥已删除',
+ deleteFailed: '删除失败',
+ confirmDelete: '确定删除此公钥?',
+ invalidName: '密钥名称格式不正确',
+ generateSuccess: '密钥生成成功',
+ generateFailed: '生成失败',
+ privateKeyTitle: '请保存私钥',
+ privateKeyWarning: '请立即保存私钥',
+ privateKeyWarningDesc: '系统不会保存您的私钥,关闭此弹窗后将无法再次查看。请将私钥保存到安全的地方。',
+ privateKeyContent: '私钥内容',
+ download: '下载私钥',
+ noPrivateKey: '没有可下载的私钥',
+ copyFailed: '复制失败',
+ downloadFailed: '下载失败,请手动复制私钥',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ pageInfo: '第 {current}/{total} 页,共 {count} 条',
+ },
+ // 通知渠道
+ notifications: {
+ title: '通知渠道',
+ description: '接收实例、快照等事件通知',
+ add: '添加',
+ type: '类型',
+ name: '名称',
+ namePlaceholder: '我的通知',
+ save: '添加',
+ saving: '添加中...',
+ cancel: '取消',
+ noChannels: '暂无通知渠道',
+ addSuccess: '通知渠道添加成功',
+ addFailed: '添加失败',
+ deleteSuccess: '通知渠道已删除',
+ deleteFailed: '删除失败',
+ confirmDelete: '确定删除此通知渠道?',
+ enabled: '已启用',
+ disabled: '已禁用',
+ enable: '启用',
+ disable: '禁用',
+ toggleFailed: '操作失败',
+ test: '测试',
+ testSuccess: '测试通知已发送',
+ testFailed: '测试失败',
+ history: '历史',
+ historyTitle: '通知历史',
+ disabledSuffix: '(已禁用)',
+ statsTotal: '总计: {count}',
+ statsSent: '成功: {count}',
+ statsFailed: '失败: {count}',
+ filterAll: '全部',
+ filterSent: '成功',
+ filterFailed: '失败',
+ loadingLogs: '加载中...',
+ noLogs: '暂无通知记录',
+ statusSent: '成功',
+ statusFailed: '失败',
+ statusPending: '等待',
+ errorPrefix: '错误: {error}',
+ eventTypes: {
+ snapshot_created: '快照创建',
+ snapshot_restored: '快照恢复',
+ snapshot_deleted: '快照删除',
+ backup_created: '备份创建',
+ backup_failed: '备份失败',
+ backup_deleted: '备份删除',
+ backup_restored: '备份恢复',
+ backup_uploaded: '备份上传',
+ instance_created: '实例创建',
+ instance_started: '实例启动',
+ instance_stopped: '实例停止',
+ instance_deleted: '实例删除',
+ auto_snapshot: '自动快照',
+ auto_backup: '自动备份',
+ traffic_warning: '流量预警',
+ traffic_throttled: '流量限速',
+ test: '测试通知',
+ },
+ telegram: {
+ botToken: 'Bot Token',
+ botTokenPlaceholder: '123456:ABC-...',
+ chatId: 'Chat ID',
+ chatIdPlaceholder: '-100123456789',
+ },
+ discord: {
+ webhookUrl: 'Webhook URL',
+ webhookUrlPlaceholder: 'https://discord.com/api/webhooks/...',
+ },
+ webhook: {
+ url: 'URL',
+ urlPlaceholder: 'https://example.com/webhook',
+ secret: 'Secret(可选)',
+ secretPlaceholder: '用于验证签名',
+ },
+ },
+ telegramBinding: {
+ title: 'Telegram 绑定',
+ description: '绑定后可用于私有用户群准入等功能。',
+ refresh: '刷新',
+ refreshing: '刷新中',
+ unavailableTitle: '暂未启用',
+ unavailableDescription: '管理员尚未启用或尚未完整配置 Telegram 绑定。',
+ boundTitle: '已绑定 {name}',
+ telegramId: 'Telegram ID: {id}',
+ boundAt: '绑定时间: {date}',
+ joinHint: '如需申请私有群,私聊 {bot} 发送',
+ unlink: '解除绑定',
+ unlinking: '解除中',
+ unboundTitle: '未绑定 Telegram',
+ unboundDescription: '点击生成链接后,会跳转到 {bot}。在 Telegram 内点击开始即可完成绑定。',
+ generate: '生成绑定链接',
+ generating: '生成中',
+ openTelegram: '打开 Telegram',
+ copyLink: '复制链接',
+ linkHint: '链接 10 分钟内有效,完成绑定后回到本页刷新状态。',
+ expiresAt: '过期时间: {date}',
+ generated: 'Telegram 绑定链接已生成',
+ generateFailed: '生成绑定链接失败: {error}',
+ copied: '绑定链接已复制',
+ copyFailed: '复制失败,请手动复制链接',
+ confirmUnlink: '确定解除 Telegram 绑定?',
+ unlinked: 'Telegram 绑定已解除',
+ unlinkFailed: '解除绑定失败: {error}',
+ },
+ // 远程存储
+ storage: {
+ title: '远程存储',
+ description: '配置 WebDAV/FTP/SFTP 存储,用于备份上传',
+ add: '添加',
+ name: '名称',
+ namePlaceholder: '我的 NAS',
+ type: '类型',
+ host: '主机地址',
+ port: '端口',
+ username: '用户名',
+ password: '密码',
+ passwordUnchanged: '留空则不修改',
+ basePath: '基础路径',
+ setAsDefault: '设为默认',
+ default: '默认',
+ setDefault: '设为默认',
+ test: '测试',
+ noConfigs: '暂无存储配置',
+ nameHostRequired: '请填写名称和主机地址',
+ createSuccess: '存储配置创建成功',
+ updateSuccess: '存储配置更新成功',
+ deleteSuccess: '存储配置已删除',
+ saveFailed: '保存失败',
+ deleteFailed: '删除失败',
+ hasActiveTasks: '无法删除:该存储配置有上传任务正在进行中',
+ confirmDelete: '确定删除此存储配置?',
+ testSuccess: '连接测试成功',
+ testFailed: '连接测试失败',
+ setDefaultSuccess: '已设为默认',
+ setDefaultFailed: '设置失败',
+ },
+ // 登录历史
+ loginHistory: {
+ title: '登录历史',
+ description: '查看您的账户登录记录',
+ empty: '暂无登录记录',
+ },
+ },
+
+ // 管理后台
+ admin: {
+ statistics: {
+ title: '统计',
+ description: '用户、实例和计费数据',
+ timezone: '{timezone}',
+ refresh: '刷新',
+ reload: '重新加载',
+ noData: '暂无统计数据',
+ loadFailed: '统计数据加载失败:{message}',
+ unknownError: '未知错误',
+ tooltip: '{label} · {value}',
+ tabs: {
+ users: '用户',
+ instances: '实例',
+ billing: '计费',
+ },
+ periods: {
+ daily: '每日',
+ monthly: '每月',
+ },
+ billingMetrics: {
+ recharge: '充值',
+ consume: '消费',
+ aff: '返利',
+ destroyFee: '销毁手续费',
+ },
+ ranges: {
+ last30Days: '近 30 天',
+ last12Months: '近 12 个月',
+ },
+ cards: {
+ totalUsers: '总用户数',
+ recentDailyNewUsers: '近 30 天新增',
+ recentMonthlyNewUsers: '近 12 月新增',
+ averageNewUsers: '平均新增',
+ totalInstances: '系统总实例数',
+ availableInstances: '可用实例',
+ recentDailyCreatedInstances: '近 30 天创建',
+ recentMonthlyCreatedInstances: '近 12 月创建',
+ totalRecharge: '累计充值',
+ totalConsume: '累计消费',
+ totalAff: '累计返利',
+ totalDestroyFee: '累计销毁手续费',
+ },
+ captions: {
+ currentTotal: '当前累计',
+ dailyAggregate: '按日汇总',
+ monthlyAggregate: '按月汇总',
+ dailyAverage: '日均',
+ monthlyAverage: '月均',
+ nonDeletedInstances: '未删除实例',
+ notDeletedOrSuspended: '非删除 / 非封停',
+ completedOrders: '已完成订单',
+ totalScope: '托管和自营合计',
+ affCommission: 'AFF 新购 / 续费',
+ userDestroyFee: '用户销毁实例手续费',
+ },
+ sections: {
+ newUsers: '新增用户',
+ createdInstances: '创建实例',
+ paidFreeInstances: '付费 / 免费实例',
+ paidFreeDescription: '当前未删除实例占比',
+ metricTrend: '{metric}趋势',
+ billingScope: '{range},托管和自营合计',
+ },
+ labels: {
+ paidInstances: '付费实例',
+ freeInstances: '免费实例',
+ },
+ },
+ hosting: {
+ title: '托管',
+ description: '查看符合条件的托管机主和托管经营数据',
+ loadFailed: '加载托管数据失败:{message}',
+ unknownError: '未知错误',
+ tabs: {
+ owners: '托管机主',
+ zones: '专区机主',
+ hostingVipLevels: '托管 VIP 等级',
+ },
+ cards: {
+ owners: '托管机主',
+ ownersCaption: '满足托管条件的用户',
+ hosts: '节点数',
+ hostsCaption: '机主已添加的节点',
+ instances: '实例数',
+ instancesCaption: '机主节点上的未删除实例',
+ totalIncome: '历史总托管收入',
+ totalIncomeCaption: '收入日志累计',
+ },
+ owners: {
+ title: '托管机主用户列表',
+ description: '已添加节点、已上架公开套餐且历史总托管余额不为 0 的用户。',
+ searchPlaceholder: '搜索用户名、ID、邮箱...',
+ empty: '暂无托管机主',
+ emptyHint: '用户需要同时满足节点、上架套餐和历史托管余额条件。',
+ user: '用户',
+ vipLevel: 'VIP等级',
+ hostingBalance: '托管余额',
+ frozenBalance: '冻结余额',
+ totalIncome: '历史总收入',
+ hostCount: '节点数',
+ packageCount: '上架套餐数',
+ instanceCount: '实例数',
+ createdAt: '注册时间',
+ noEmail: '未设置邮箱',
+ },
+ zones: {
+ title: '专区机主列表',
+ description: '专区机主会在开通实例页作为独立选项卡展示,其套餐不再出现在托管选项卡中。',
+ createTitle: '添加专区机主',
+ createDescription: '输入专区名,选择托管机主用户,并填写图床 LOGO 链接。',
+ name: '专区名',
+ namePlaceholder: '例如:Tokyoo 专区',
+ owner: '托管机主用户',
+ ownerSearchPlaceholder: '搜索可选托管机主...',
+ selectOwner: '请选择托管机主',
+ ownerHint: '已设置专区的机主不会出现在可选列表中。',
+ logo: 'LOGO 链接',
+ logoPlaceholder: 'https://example.com/logo.png',
+ logoHint: '仅支持 http 或 https 图片链接,系统不会保存图片文件。',
+ logoPreview: 'LOGO 预览',
+ noLogo: 'LOGO',
+ previewName: '专区预览',
+ previewHint: '开通实例页会以圆形 LOGO 展示。',
+ create: '添加专区',
+ formRequired: '请填写专区名、选择托管机主并填写 LOGO 链接',
+ logoInvalid: '请填写有效的 http 或 https LOGO 图片链接',
+ createSuccess: '专区机主已添加',
+ createFailed: '添加专区机主失败',
+ loadFailed: '加载专区机主失败:{message}',
+ deleteConfirm: '确定删除专区「{name}」吗?删除后该机主套餐会重新显示在托管选项卡中。',
+ deleteSuccess: '专区机主已删除',
+ deleteFailed: '删除专区机主失败',
+ empty: '暂无专区机主',
+ emptyHint: '添加后会显示在开通实例页的直营与托管之间。',
+ zone: '专区',
+ },
+ },
+ vipRules: {
+ userTitle: '用户 VIP 等级',
+ userDescription: '全局选择累计充值或累计消费作为用户会员口径,并按该口径动态计算会员等级,最高 VIP10。',
+ hostingTitle: '托管 VIP 等级',
+ hostingDescription: '按累计托管收入、当前托管实例数动态计算托管等级,最高 VIP10。',
+ level: '等级',
+ badgeBgColor: '标签背景色',
+ badgeTextColor: '标签字体色',
+ enabled: '启用',
+ mode: '条件关系',
+ modeAny: '满足任一',
+ modeAll: '同时满足',
+ userMetricTitle: '用户 VIP 统计口径',
+ userMetricHint: '全站用户 VIP 只能使用一种升级口径。切换后,每个等级只保存并计算当前口径对应的阈值。',
+ metricRecharge: '按累计充值',
+ metricConsume: '按累计消费',
+ minRecharge: '累计充值满(元)',
+ minConsume: '累计消费满(元)',
+ minHostingIncome: '累计托管收入满(元)',
+ minHostingInstances: '当前托管实例数满',
+ noLimit: '不限制',
+ save: '保存等级规则',
+ saveSuccess: 'VIP 等级规则已保存',
+ saveFailed: '保存 VIP 等级规则失败',
+ loadFailed: '加载 VIP 等级规则失败:{message}',
+ unknownError: '未知错误',
+ conditionRequired: 'VIP{level} 至少需要配置一个条件',
+ moneyThresholdInvalid: 'VIP{level} 的金额条件必须是大于 0 的数字',
+ instanceThresholdInvalid: 'VIP{level} 的实例数条件必须是正整数',
+ colorInvalid: 'VIP{level} 的标签颜色必须是 #RRGGBB 格式',
+ },
+ vipBenefits: {
+ title: '会员福利大厅设置',
+ description: '为已启用的用户 VIP 等级配置可领取福利。余额和积分会自动发放,实例奖品会生成待发放记录。',
+ save: '保存福利设置',
+ saveSuccess: '会员福利设置已保存',
+ saveFailed: '保存会员福利设置失败',
+ loadFailed: '加载会员福利设置失败',
+ loadPlansFailed: '加载套餐方案失败',
+ noEnabledLevels: '暂无已启用的用户 VIP 等级,请先在用户 VIP 等级中启用等级。',
+ levelTitle: 'VIP{level} 福利',
+ levelHint: '用户达到该等级后,可在福利大厅领取对应奖励。',
+ addReward: '添加奖品',
+ noRewardsForLevel: '该等级暂无奖品。',
+ rewardDefaultTitle: '福利',
+ rewardTitle: '奖品标题',
+ rewardType: '奖品类型',
+ rewardDescription: '奖品说明',
+ claimLimit: '领取次数',
+ sortOrder: '排序',
+ enabled: '启用',
+ disabled: '禁用',
+ types: {
+ balance: '余额',
+ points: '积分',
+ instance: '实例',
+ },
+ balanceTitle: '领取赠金',
+ balanceDesc: '发放到用户账户余额,可用于实例续费或新购。',
+ balanceAmount: '赠金金额(元)',
+ pointsTitle: '领取积分',
+ pointsDesc: '发放到用户积分账户,可用于福利和积分消耗。',
+ pointsAmount: '积分数量',
+ instanceTitle: '领取套餐实例',
+ instanceDesc: '指定一个套餐方案,用户领取后进入待发放状态,后续可扩展自动创建。',
+ package: '套餐',
+ plan: '方案',
+ selectPackage: '选择套餐',
+ selectPlan: '选择方案',
+ instanceDays: '赠送天数',
+ instanceQuantity: '数量',
+ balancePreview: '赠金 {amount}',
+ pointsPreview: '积分 {amount}',
+ instancePreview: '套餐实例',
+ instancePreviewWithPlan: '{plan} · {quantity} 台 · {days} 天',
+ titleRequired: 'VIP{level} 的奖品标题不能为空',
+ claimLimitInvalid: 'VIP{level} 的领取次数必须是正整数',
+ amountInvalid: 'VIP{level} 的余额或积分奖品数量必须大于 0',
+ balanceInvalid: 'VIP{level} 的赠金金额必须大于 0',
+ pointsInvalid: 'VIP{level} 的积分数量必须是正整数',
+ instancePlanRequired: 'VIP{level} 的套餐实例福利需要选择套餐和方案',
+ instanceDaysInvalid: 'VIP{level} 的赠送天数必须是正整数',
+ instanceQuantityInvalid: 'VIP{level} 的实例数量必须是正整数',
+ },
+ // 域名邮箱管理
+ mail: {
+ title: '邮箱管理',
+ description: '管理邮箱源、方案和用户订阅',
+ tabs: {
+ sources: '邮箱源',
+ plans: '方案',
+ subscriptions: '订阅',
+ domains: '域名',
+ },
+ createSource: '添加邮箱源',
+ editSource: '编辑邮箱源',
+ sourceCreated: '邮箱源创建成功',
+ sourceUpdated: '邮箱源更新成功',
+ sourceDeleted: '邮箱源已删除',
+ confirmDeleteSource: '确定要删除邮箱源 {name} 吗?请确保已无关联方案。',
+ noSources: '暂无邮箱源,请先添加',
+ createPlan: '添加方案',
+ editPlan: '编辑方案',
+ planCreated: '方案创建成功',
+ planUpdated: '方案更新成功',
+ planDeleted: '方案已删除',
+ confirmDeletePlan: '确定要删除方案 {name} 吗?',
+ noPlans: '暂无方案,请先添加',
+ noSubscriptions: '暂无订阅记录',
+ noDomains: '暂无域名记录',
+ searchSubscriptions: '搜索用户名、邮箱或ID...',
+ searchDomains: '搜索域名、用户名、邮箱或ID...',
+ fillRequired: '请填写必填字段',
+ createSourceFirst: '请先创建邮箱源',
+ region: '地区',
+ apiEndpoint: 'API 端点',
+ apiKey: 'API 密钥',
+ smtpHost: 'SMTP 服务器',
+ smtpPort: 'SMTP 端口',
+ webmailUrl: 'Webmail 地址',
+ sourcePlaceholder: '例如:美国数据中心',
+ source: '邮箱源',
+ domainLimit: '域名限制',
+ diskLimit: '磁盘限制',
+ diskLimitGb: '磁盘限制(GB)',
+ price: '价格',
+ billingCycle: '计费周期',
+ planPlaceholder: '例如:基础版',
+ user: '用户',
+ plan: '方案',
+ expiresAt: '到期时间',
+ domain: '域名',
+ accounts: '账户数',
+ plans: '方案数',
+ unsub: {
+ button: '退订',
+ title: '退订邮箱',
+ refundType: '退款方式',
+ refundNone: '不退款',
+ refundNoneDesc: '直接取消订阅,不退还任何费用',
+ refundFull: '全额退款',
+ refundFullDesc: '退还方案全价 {amount}',
+ refundRemaining: '剩余价值退款',
+ refundRemainingDesc: '按剩余有效期比例退款',
+ reason: '退款原因',
+ reasonPlaceholder: '请输入退款原因,将记录到余额日志...',
+ reasonRequired: '退款时必须填写原因',
+ confirm: '确认退订',
+ success: '订阅已退订',
+ successWithRefund: '订阅已退订,已退款 ¥{amount}',
+ },
+ },
+ // 管理员创建实例
+ instanceCreate: {
+ title: '创建实例',
+ description: '为用户赠送实例,无需支付任何费用',
+ targetUser: '目标用户',
+ usernamePlaceholder: '输入用户名',
+ usernameHint: '实例将创建到该用户账户下',
+ userHint: '实例将创建到该用户账户下',
+ checkUser: '查询',
+ checking: '查询中...',
+ userFound: '用户找到 (ID: {id})',
+ userNotFound: '用户不存在',
+ userNotFoundHint: '请检查用户名是否正确',
+ noSshKey: '该用户未设置 SSH 密钥',
+ noSshKeyHint: '请让用户先在个人设置中添加 SSH 密钥',
+ orderSummary: '赠送摘要',
+ freeGift: '免费赠送',
+ freeGiftHint: '此实例由管理员免费创建,不计入收费统计',
+ createFor: '创建给',
+ create: '创建实例',
+ creating: '创建中...',
+ submit: '创建实例',
+ success: '实例创建成功',
+ selectUser: '请先选择目标用户',
+ createSuccess: '实例已创建,将显示在用户 {username} 的实例列表中',
+ createFailed: '创建实例失败',
+ selectUserFirst: '请先查询并确认目标用户',
+ packageScope: {
+ official: '自营套餐',
+ hosted: '托管套餐',
+ },
+ // 付费实例相关
+ instanceType: '实例类型',
+ freeInstance: '免费实例',
+ paidInstance: '付费实例',
+ selectPlan: '选择方案',
+ noPlanHint: '该套餐暂无可用方案',
+ chargeFirstMonth: '扣除首月费用',
+ chargeFirstMonthHint: '从用户余额扣除首月费用',
+ noChargeFirstMonthHint: '首月免费赠送,次月起正常计费',
+ planPrice: '方案月费',
+ setupFee: '开通费',
+ totalCharge: '本次扣费',
+ freeFirstMonth: '首月免费',
+ userBalance: '用户余额',
+ insufficientBalance: '用户余额不足',
+ paidSummary: '付费摘要',
+ paidInstanceHint: '创建付费实例,按方案计费',
+ },
+ // 全站公告
+ broadcast: {
+ title: '发送全站公告',
+ description: '发送站内信给所有活跃用户',
+ messageTitle: '公告标题',
+ titlePlaceholder: '输入公告标题',
+ titleRequired: '请输入公告标题',
+ titleTooLong: '标题超过 200 字符',
+ messageContent: '公告内容',
+ contentPlaceholder: '输入公告内容',
+ contentRequired: '请输入公告内容',
+ contentTooLong: '内容超过 5000 字符',
+ send: '发送公告',
+ sendSuccess: '已成功发送给 {count} 个用户',
+ sendFailed: '发送失败',
+ hint: '公告将发送给所有状态为「正常」的用户。',
+ // 历史记录
+ history: '历史记录',
+ noHistory: '暂无发送记录',
+ recipients: '发送给 {count} 人',
+ sender: '发送者',
+ types: {
+ system_broadcast: '全站公告',
+ host_broadcast: '节点通知',
+ admin_message: '管理员私信',
+ host_message: '宿主机私信',
+ },
+ },
+ // 系统设置
+ system: {
+ title: '系统设置',
+ description: '配置系统初始参数和默认值',
+ tabs: {
+ system: '系统设置',
+ popupAnnouncement: '弹窗公告',
+ telegram: 'Telegram 设置',
+ },
+ sections: {
+ access: {
+ title: '访问与注册',
+ description: '管理注册入口、邀请码生成、默认配额和实例转移规则',
+ },
+ hosting: {
+ title: '托管与站点',
+ description: '管理托管入口、托管公告和白嫖站赠送策略',
+ },
+ brand: {
+ title: '品牌与外观',
+ description: '管理系统名称、Logo、头像服务和底部联系方式',
+ },
+ security: {
+ title: '安全验证',
+ description: '管理 Turnstile 人机验证和注册邮箱域名白名单',
+ },
+ mail: {
+ title: '邮件服务',
+ description: '管理 SMTP 邮件发送配置和测试邮件',
+ },
+ tickets: {
+ title: '工单与附件',
+ description: '管理工单入口和工单图片存储配置',
+ },
+ },
+ popupAnnouncement: {
+ title: '弹窗公告',
+ description: '配置用户访问网站时弹出的全站公告。',
+ content: '公告内容',
+ placeholder: '在此输入弹窗公告内容,留空并保存表示删除公告,不再广播。',
+ hint: '用户看到后可以选择今日不见或再也不见;公告内容更新后会按新公告重新弹出。',
+ promoTitle: '图片推广弹窗',
+ promoDescription: '配置新机器推广弹窗,前台会展示图片并引导用户购买指定套餐。',
+ promoImageUrl: '图片 URL',
+ promoImagePlaceholder: 'https://example.com/promo.jpg',
+ promoImageHint: '建议使用清晰横幅图片,前台会按原比例完整显示。',
+ promoPackage: '目标套餐',
+ promoPackagePlaceholder: '选择要推广的套餐',
+ promoPackageLoading: '正在加载套餐...',
+ promoPackageEmpty: '暂无可推广套餐',
+ promoPackageHint: '图片 URL 和目标套餐都填写后才会显示推广弹窗。',
+ promoPreview: '前台预览',
+ promoPreviewEmpty: '填写图片 URL 后显示预览',
+ promoNoPackage: '尚未选择套餐',
+ promoPackageFallback: '目标套餐',
+ },
+ defaultQuota: '用户默认配额',
+ defaultQuotaDesc: '新注册用户将自动获得以下配额限制',
+ quotaHost: '默认宿主机配额',
+ quotaHostDesc: '新用户默认可创建宿主机数量',
+ quotaFriend: '默认好友配额',
+ quotaFriendDesc: '新用户默认可添加好友数量(0 = 未授权)',
+ quotaPackage: '默认套餐配额',
+ quotaPackageDesc: '新用户默认可创建套餐数量(0 = 未授权)',
+ registration: '注册设置',
+ registrationDesc: '配置用户注册相关选项',
+ registrationEnabled: '开放注册',
+ registrationEnabledDesc: '关闭后,新用户将无法注册,现有用户仍可正常登录',
+ registrationOpen: '开放注册',
+ registrationClosed: '关闭注册',
+ requireInviteCode: '邀请码注册',
+ requireInviteCodeDesc: '开启后,用户注册时需要填写邀请码',
+ openRegistration: '开放注册',
+ inviteOnly: '仅邀请',
+ hostingFeature: {
+ title: '托管功能',
+ description: '控制托管节点与收益入口是否向新用户显示,已创建过节点的用户始终保留入口。',
+ enable: '显示托管节点入口',
+ enableDesc: '关闭后,从未创建过节点的用户将看不到托管节点和收益入口;已有节点用户仍可继续管理。',
+ marketEntry: '显示托管套餐购买入口',
+ marketEntryDesc: '控制开通实例页面是否显示托管专区和托管套餐购买入口;关闭后用户只能从该页面选择官方套餐。',
+ marketEntryVisible: '显示入口',
+ marketEntryHidden: '隐藏入口',
+ notice: '托管公告',
+ noticePlaceholder: '在此输入托管公告内容,留空则前台不显示',
+ noticeHint: '显示在托管收益页面,支持换行,留空则隐藏。',
+ visibleToAll: '所有用户可见',
+ hiddenForNewUsers: '新用户隐藏',
+ },
+ brand: {
+ title: '品牌设置',
+ description: '配置系统名称、副标题与 Logo,留空则使用默认品牌。',
+ name: '系统名称',
+ nameDesc: '显示在侧边栏、顶部栏、登录注册页和 SEO 信息中。',
+ subtitle: '网站副标题',
+ subtitlePlaceholder: '基于 Incus 的低价 NAT VPS',
+ subtitleDesc: '显示在公开站头部/底部、浏览器默认标题和 SEO 默认描述中。',
+ logo: 'Logo 地址',
+ logoDesc: '支持 http(s) 图片地址或站内绝对路径,留空则使用默认 Logo。',
+ },
+ ticket: {
+ title: '工单设置',
+ description: '控制普通用户是否可以发起工单。',
+ enable: '开放工单',
+ enableDesc: '关闭后,用户端隐藏工单入口,普通用户也无法通过接口创建工单。',
+ enabled: '已开放',
+ disabled: '已关闭',
+ },
+ freeSite: {
+ title: '白嫖站',
+ description: '控制用户端余额页是否展示充值和推荐计划相关功能。',
+ enable: '启用白嫖站',
+ enableDesc: '开启后,用户端余额页隐藏充值按钮、充值记录和推荐计划,接口也会拒绝创建或重新支付充值订单。',
+ enabled: '已启用',
+ disabled: '未启用',
+ registerGift: '注册自动赠送',
+ registerGiftDesc: '开启后,新用户注册成功会自动收到余额和积分到账通知。',
+ giftEnabled: '赠送中',
+ giftDisabled: '不赠送',
+ giftBalance: '赠送余额',
+ giftBalanceDesc: '注册成功后自动进入账户余额,单位为元。',
+ giftPoints: '赠送积分',
+ giftPointsDesc: '注册成功后自动进入娱乐中心积分账户。',
+ giftRequiresFreeSite: '需要先启用白嫖站,才能设置注册自动赠送。'
+ },
+ unitCount: '个',
+ reset: '重置',
+ save: '保存配置',
+ saving: '保存中...',
+ loadFailed: '加载配置失败',
+ saveSuccess: '配置已保存',
+ saveFailed: '保存失败',
+ notes: '说明',
+ note1: '修改默认配额只会影响新注册的用户,不会影响已有用户的配额。',
+ note2: '如需修改已有用户的配额,请前往「用户管理」页面单独调整。',
+ note3: '宿主机配额限制用户可以创建的宿主机数量(0 = 功能未授权)。',
+ note4: '好友配额限制用户可以添加的好友数量(0 = 功能未授权)。',
+ note5: '套餐配额限制用户可以创建的套餐数量(0 = 功能未授权)。',
+ // Turnstile 配置
+ turnstile: {
+ title: 'Cloudflare Turnstile',
+ description: '配置人机验证,保护登录、注册和敏感操作',
+ enable: '启用 Turnstile',
+ enableDesc: '启用后,用户在登录、注册和敏感操作时需要完成人机验证',
+ enabled: '已启用',
+ disabled: '已禁用',
+ siteKey: 'Site Key',
+ siteKeyPlaceholder: '输入 Cloudflare Turnstile Site Key',
+ siteKeyDesc: '前端使用的站点密钥',
+ secretKey: 'Secret Key',
+ secretKeyPlaceholder: '输入 Cloudflare Turnstile Secret Key',
+ secretKeyDesc: '后端验证使用的密钥(请妥善保管)',
+ helpText: '前往 Cloudflare 控制台获取密钥:',
+ },
+ avatar: {
+ title: '头像服务',
+ description: '配置用户头像生成服务,默认使用 DiceBear 官方 API',
+ apiBase: 'API 地址',
+ apiBaseDesc: 'DiceBear 头像 API 基础地址,可自建服务以提高访问速度',
+ helpText: '了解如何自建 DiceBear 服务:',
+ },
+ // SMTP Email configuration
+ smtp: {
+ title: 'SMTP 邮件服务',
+ description: '配置 SMTP 服务器以启用邮箱验证码功能,用户注册时需要验证邮箱',
+ enable: '启用邮箱验证',
+ enableDesc: '启用后,用户注册时需要通过邮箱验证码验证',
+ enabled: '已启用',
+ disabled: '已禁用',
+ host: 'SMTP 服务器',
+ hostPlaceholder: 'smtp.example.com',
+ port: 'SMTP 端口',
+ secure: '使用 SSL/TLS',
+ secureHint: '端口 465 通常需要启用,端口 587 通常不需要',
+ username: '用户名',
+ usernamePlaceholder: '邮箱账号或用户名',
+ password: '密码',
+ passwordPlaceholder: '授权码或密码',
+ fromEmail: '发件人邮箱',
+ fromEmailPlaceholder: "noreply{'@'}example.com",
+ fromName: '发件人名称',
+ testConnection: '测试连接',
+ testing: '测试中...',
+ testSuccess: 'SMTP 连接测试成功',
+ testFailed: 'SMTP 连接测试失败',
+ sendTestEmail: '发送测试邮件',
+ sendTestEmailDesc: '发送一封测试邮件到指定邮箱,验证邮件发送功能是否正常',
+ testEmailPlaceholder: '输入收件人邮箱地址',
+ sending: '发送中...',
+ send: '发送',
+ invalidEmail: '请输入有效的邮箱地址',
+ testEmailSent: '测试邮件已发送至 {email}',
+ testEmailFailed: '测试邮件发送失败',
+ helpText: '请确保 SMTP 服务器地址、端口、用户名和密码正确。常见邮箱服务商需要使用授权码而非登录密码。',
+ },
+ // 邮箱域名白名单配置
+ emailDomain: {
+ title: '邮箱域名白名单',
+ description: '限制只允许特定邮箱域名进行注册,提高用户质量',
+ enable: '启用邮箱域名白名单',
+ enableDesc: '启用后,只有白名单中的邮箱域名才能注册',
+ enabled: '已启用',
+ disabled: '已禁用',
+ allowedDomains: '允许的邮箱域名',
+ allowedDomainsPlaceholder: 'gmail.com,outlook.com,icloud.com\n每行一个域名或用逗号分隔\n留空则使用默认白名单',
+ allowedDomainsDesc: '输入允许注册的邮箱域名,用逗号分隔或每行一个。留空则使用默认白名单(包含 Gmail、Outlook、iCloud、Yahoo、Proton 等主流邮箱服务)。',
+ helpText: '默认白名单包含:Gmail、Outlook/Hotmail、iCloud、Yahoo、Zoho、Proton、Fastmail、Tuta、Posteo、Disroot、Riseup 等主流邮箱服务。',
+ },
+ // 转移设置
+ transfer: {
+ title: '转移设置',
+ description: '配置实例转移功能的相关参数',
+ feeLabel: '转移手续费',
+ feeUnit: '元/次',
+ feeDesc: '用户发起转移时需要支付的手续费(0 表示免费),对方拒绝接收时将自动退还',
+ feeRangeError: '转移手续费必须在 0-{max} 元之间,最多支持两位小数',
+ },
+ footerLinks: {
+ title: '底部联系方式',
+ description: '配置侧边栏底部的邮箱按钮',
+ email: '联系邮箱',
+ emailPlaceholder: "support{'@'}example.com 或 mailto:support{'@'}example.com",
+ emailDesc: '留空则隐藏邮箱按钮;支持填写邮箱地址或完整 mailto: 链接。',
+ telegram: 'Telegram 群链接',
+ telegramPlaceholder: 'https://t.me/your_group',
+ telegramDesc: '留空则隐藏 Telegram 按钮。',
+ },
+ ticketImages: {
+ title: '工单图片存储',
+ description: '配置工单图片上传到兰空图床。面板只转发上传并记录元数据,不在本地落盘。',
+ baseUrl: 'Lsky 地址',
+ baseUrlPlaceholder: 'https://img.example.com',
+ baseUrlDesc: '兰空图床站点根地址,不要追加 /api/v1/upload',
+ token: 'Lsky Token',
+ tokenPlaceholder: '输入兰空图床 API Token',
+ tokenDesc: '仅后端使用,不会下发到前端',
+ apiVersion: 'API 版本',
+ apiVersionDesc: '根据你的兰空版本选择上传接口版本',
+ targetId: '策略/存储 ID',
+ targetIdPlaceholder: 'v1 填 strategy_id,v2 填 storage_id',
+ targetIdDesc: '可留空,留空时使用兰空默认策略或默认存储',
+ },
+ },
+ // 用户管理
+ users: {
+ title: '用户管理',
+ description: '管理平台用户和邀请码',
+ create: '创建用户',
+ generateInvite: '生成邀请码',
+ userInfo: '用户',
+ role: '角色',
+ status: '状态',
+ quotaUsage: '配额使用',
+ allInstances: '所有实例',
+ instances: '个实例',
+ registeredAt: '注册时间',
+ admin: '管理员',
+ user: '用户',
+ active: '正常',
+ banned: '已封禁',
+ searchPlaceholder: '搜索用户名、ID、邮箱...',
+ searchRange: '搜索范围',
+ searchFieldUsername: '用户名',
+ searchFieldId: 'ID',
+ searchFieldEmail: '邮箱',
+ exactMatch: '绝对匹配',
+ noUsers: '暂无用户',
+ noMatchingUsers: '未找到匹配的用户',
+ noEmail: '未设置邮箱',
+ resourceQuota: '资源配额',
+ viewInstances: '查看实例',
+ quota: '配额',
+ ban: '封禁',
+ unban: '解封',
+ promoteAdmin: '设为管理员',
+ demoteAdmin: '取消管理员',
+ confirmBan: '确定封禁用户 "{name}"?',
+ confirmUnban: '确定解封用户 "{name}"?',
+ confirmPromoteAdmin: '确定将用户 "{name}" 设为管理员?该用户需要重新登录后生效。',
+ confirmDemoteAdmin: '确定取消用户 "{name}" 的管理员权限?该用户当前会话会被撤销。',
+ userBanned: '用户已封禁',
+ userUnbanned: '用户已解封',
+ userPromotedAdmin: '已设为管理员',
+ userDemotedAdmin: '已取消管理员权限',
+ onlyActiveCanBeAdmin: '只有正常状态的用户可以设为管理员',
+ loadFailed: '加载用户失败',
+ userVipLevels: '用户 VIP 等级',
+ vipBenefits: '会员福利大厅设置',
+ // 邀请码
+ invites: '邀请码',
+ inviteCode: '邀请码',
+ inviteStatus: '状态',
+ createdBy: '创建者',
+ usedBy: '使用者',
+ createdAt: '创建时间',
+ usedExpireAt: '使用/过期时间',
+ noInvites: '暂无邀请码',
+ noMatchingInvites: '暂无符合条件的邀请码',
+ inviteFilterAll: '全部',
+ inviteFilterUsed: '已用',
+ inviteFilterUnused: '未用',
+ inviteUsed: '已使用',
+ inviteExpired: '已过期',
+ inviteUnused: '未使用',
+ permanent: '永久',
+ deleteInvite: '删除',
+ confirmDeleteInvite: '确定删除邀请码 {code}?',
+ inviteDeleted: '邀请码已删除',
+ deleteFailed: '删除失败',
+ // 生成邀请码
+ generateInviteTitle: '生成邀请码',
+ inviteCount: '生成数量',
+ countUnit: '个',
+ expireDays: '有效期',
+ day1: '1 天',
+ day3: '3 天',
+ day7: '7 天',
+ day14: '14 天',
+ day30: '30 天',
+ permanentValid: '永久有效',
+ expireHint: '设置邀请码的过期时间',
+ generate: '生成',
+ generating: '生成中...',
+ generateFailed: '生成邀请码失败',
+ // 邀请码结果
+ inviteGenerated: '邀请码已生成',
+ validUntil: '有效期至',
+ copyCode: '复制邀请码',
+ copyAllCodes: '一键复制全部',
+ copyLink: '复制注册链接',
+ copied: '已复制!',
+ // 配额编辑
+ editQuota: '编辑配额',
+ instanceLimit: '实例上限',
+ instanceLimitHint: '用户可创建的实例数量上限',
+ hostLimit: '宿主机上限',
+ hostLimitHint: '用户可拥有的宿主机数量上限',
+ friendLimit: '好友上限',
+ friendLimitHint: '用户可添加的好友数量上限',
+ packageLimitHint: '用户可创建的套餐数量上限',
+ packageLimit: '套餐限制',
+ notAuthorized: '未授权',
+ cpuAllowance: 'CPU 额配',
+ cpuAllowanceHint: '额配 ÷ 100 ≈ 可用核心数,最小 10,步进 5',
+ cpuCores: '~{n} 核',
+ memoryLimit: '内存 (MB)',
+ diskLimit: '磁盘 (MB)',
+ portLimit: 'NAT 端口上限',
+ portLimitHint: '所有实例的端口配额总上限',
+ snapshotLimit: '快照上限',
+ snapshotLimitHint: '所有实例的快照配额总上限',
+ backupLimit: '备份上限',
+ backupLimitHint: '所有实例的备份配额总上限',
+ trafficLimit: '月流量上限',
+ trafficLimitPlaceholder: '留空表示不限制',
+ trafficLimitHint: '单位 GB,留空表示不限制',
+ quotaUpdated: '配额已更新',
+ totalRecords: '共 {count} 条记录',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ // 重置密码
+ resetPassword: '重置密码',
+ resetPasswordConfirm: '确定要重置用户 "{name}" 的密码吗?',
+ resetPasswordHint: '系统将自动生成新密码,用户的所有会话将被撤销,需要使用新密码重新登录。',
+ confirmResetPassword: '确认重置',
+ resetting: '重置中...',
+ passwordResetSuccess: '密码重置成功',
+ newPasswordFor: '用户 "{name}" 的新密码:',
+ copyPassword: '复制密码',
+ copyPasswordHint: '请妥善保管此密码,关闭弹窗后将无法再次查看',
+ // 取消2FA
+ disable2FA: '取消双因素认证',
+ disable2FAConfirm: '确定要取消用户 "{name}" 的双因素认证吗?',
+ disable2FAWarning: '取消后,该用户登录时将不再需要验证码,可能降低账户安全性。',
+ twoFADisabled: '双因素认证已取消',
+ // 解绑GitHub
+ unbindGitHub: '解绑 GitHub',
+ unbindGitHubConfirm: '确定要解除用户 "{name}" 的 GitHub 绑定吗?',
+ unbindGitHubWarning: '解除后,该用户将无法使用 GitHub 快捷登录。',
+ githubUnbound: 'GitHub 绑定已解除',
+ // 用户活动和登录记录
+ userActivity: '用户活动',
+ registeredNew: '新',
+ registeredDays: '注册 {days} 天',
+ viewLoginRecords: '查看登录记录',
+ noLoginRecord: '无登录记录',
+ loginRecords: '登录记录',
+ noLoginRecords: '暂无登录记录',
+ loadLoginRecordsFailed: '加载登录记录失败',
+ // 发送站内信
+ sendMessage: '发送站内信',
+ sendMessageTo: '发送站内信给 {username}',
+ messageTitle: '消息标题',
+ messageTitlePlaceholder: '输入消息标题',
+ messageTitleRequired: '请输入消息标题',
+ messageContent: '消息内容',
+ messageContentPlaceholder: '输入消息内容',
+ messageContentRequired: '请输入消息内容',
+ messageSent: '消息已发送',
+ messageSendFailed: '发送失败',
+ // 用户余额
+ balance: '余额',
+ viewBalance: '查看余额详情',
+ consumed: '已消费',
+ totalConsumed: '已消费总金额',
+ balanceDetails: '余额详情',
+ balanceOverview: '账户概览',
+ balanceLogs: '余额明细',
+ rechargeRecords: '充值记录',
+ currentBalance: '当前余额',
+ totalRecharge: '累计充值',
+ totalConsume: '累计消费',
+ noBalanceLogs: '暂无余额变动',
+ noRechargeRecords: '暂无充值记录',
+ loadBalanceFailed: '加载余额信息失败',
+ loadBalanceLogsFailed: '加载余额明细失败',
+ loadRechargeRecordsFailed: '加载充值记录失败',
+ balanceType: {
+ recharge: '充值',
+ consume: '消费',
+ refund: '退款',
+ admin_adjust: '管理员调整',
+ gift: '赠送',
+ transfer_fee: '转移手续费',
+ transfer_refund: '手续费退还',
+ },
+ // 调整余额
+ adjustBalance: '调整余额',
+ adjustBalanceFor: '调整 {username} 的余额',
+ adjustType: '调整类型',
+ addBalance: '增加余额',
+ deductBalance: '扣除余额',
+ amount: '金额',
+ amountPlaceholder: '请输入金额',
+ adjustReason: '调整理由',
+ adjustReasonPlaceholder: '请输入调整理由(必填)',
+ invalidAmount: '请输入有效的金额',
+ reasonRequired: '请输入调整理由',
+ balanceAdjusted: '余额调整成功',
+ balanceAdjustFailed: '余额调整失败',
+ // 积分相关
+ points: '积分',
+ earned: '累计',
+ totalEarnedPoints: '累计获得积分',
+ spent: '已用',
+ spentPoints: '已用积分',
+ adjustPoints: '调整积分',
+ currentPoints: '当前积分',
+ pointsAmount: '调整数量',
+ pointsAmountHint: '正数增加,负数扣除',
+ pointsAmountPlaceholder: '如:100 或 -50',
+ pointsReasonPlaceholder: '请输入调整理由(必填)',
+ invalidPointsAmount: '请输入有效的积分数量',
+ pointsAdjusted: '积分调整成功',
+ pointsAdjustFailed: '积分调整失败',
+ // 托管余额相关
+ hostingBalance: '托管余额',
+ hostingBalanceDetails: '托管余额详情',
+ hostingBalanceOverview: '概览',
+ hostingBalanceLogs: '明细',
+ adjustHostingBalance: '调整托管余额',
+ viewHostingBalance: '查看托管余额',
+ frozenHostingBalance: '冻结托管余额',
+ availableBalance: '可用余额',
+ frozenBalance: '冻结余额',
+ frozen: '冻结中',
+ available: '可用',
+ operation: '操作类型',
+ operationAdd: '增加',
+ operationDeduct: '扣减',
+ hostingReasonPlaceholder: '请输入调整理由(必填)',
+ hostingBalanceAdjusted: '托管余额调整成功',
+ hostingBalanceAdjustFailed: '托管余额调整失败',
+ loadHostingLogsFailed: '加载托管余额明细失败',
+ logTime: '时间',
+ logType: '类型',
+ logAmount: '金额',
+ logStatus: '状态',
+ logDescription: '描述',
+ hostingLogType: {
+ income: '收入',
+ deduction: '扣减',
+ unfreeze: '解冻',
+ withdraw: '提现',
+ admin_adjust: '管理员调整',
+ },
+ // 关联账号检测
+ linkedAccounts: '关联账号检测',
+ detectDays: '检测范围',
+ daysUnit: '天',
+ startDetect: '开始检测',
+ detecting: '检测中...',
+ detectingHint: '正在分析用户数据,请稍候...',
+ clickToDetect: '点击上方按钮开始检测关联账号',
+ loadLinkedAccountsFailed: '加载关联账号检测失败',
+ detectTime: '检测时间',
+ detectDuration: '耗时',
+ detectRange: '检测范围',
+ ipGroupCount: '个IP关联组',
+ emailGroupCount: '个邮箱相似组',
+ usernameGroupCount: '个用户名相似组',
+ ipLinkedGroups: 'IP关联组',
+ emailSimilarGroups: '邮箱相似组',
+ usernameSimilarGroups: '用户名相似组',
+ usersCount: '个用户',
+ loginsCount: '次登录',
+ lastLoginAt: '最后登录',
+ noLinkedAccounts: '未检测到关联账号,您的用户非常贞洁!',
+ },
+ // 节点管理
+ hosts: {
+ title: '节点管理',
+ description: '管理宿主机和节点组',
+ create: '添加宿主机',
+ address: '地址',
+ status: '状态',
+ online: '在线',
+ offline: '离线',
+ maintenance: '维护中',
+ hostsTab: '宿主机',
+ searchPlaceholder: '搜索宿主机...',
+ noHosts: '暂无宿主机',
+ name: '名称',
+ resources: '资源',
+ instances: '实例',
+ actions: '操作',
+ cpu: 'CPU',
+ cpuQuota: 'CPU 配额',
+ memory: '内存',
+ memoryQuota: '内存配额',
+ disk: '硬盘',
+ diskUsage: '硬盘使用情况',
+ cores: '核',
+ allowanceLimit: '额配上限',
+ memoryLimit: '内存上限',
+ instanceType: '类型',
+ typeContainer: '容器',
+ typeVm: '虚拟机',
+ typeBoth: '两者',
+ edit: '编辑',
+ test: '测试',
+ delete: '删除',
+ testSuccess: '连接成功',
+ testFailed: '连接失败',
+ confirmDelete: '确定删除宿主机 "{name}"?',
+ hostDeleted: '宿主机已删除',
+ deleteFailed: '删除失败',
+ // 添加/编辑宿主机
+ addHost: '添加宿主机',
+ editHost: '编辑宿主机',
+ hostName: '名称',
+ hostNameHint: '只能包含英文字母、数字、- 和 _',
+ hostNameRequired: '请输入节点名称',
+ hostDesc: '描述',
+ apiUrl: 'API URL',
+ ipAddress: '连接地址',
+ ipAddressHint: '支持 IPv4、IPv6 裸地址或域名',
+ ipAddressRequired: '请输入服务器地址',
+ apiPort: 'API 端口',
+ apiPortHint: '默认 8443',
+ tokenPrompt: '若安装时提示输入安全通信 Token,请复制下方内容并粘贴:',
+ copyToken: '复制 Token',
+ country: '国家或地区',
+ certPath: '证书路径',
+ keyPath: '密钥路径',
+ natPublicIp: '网卡IP',
+ natPublicIpPlaceholder: '输入服务器的网卡IP',
+ natConfig: 'NAT 配置',
+ natPublicIpv4: '公网 IPv4',
+ natPublicIpv4Placeholder: '请输入用户访问时显示的公网 IPv4',
+ natPublicIpv4Desc: '用户访问 IPv4 端口映射时看到的公网 IPv4 地址。',
+ natPublicIpv6: '公网 IPv6',
+ natPublicIpv6Placeholder: '例如 2600:1900:41a0:5bb::',
+ natPublicIpv6Desc: '用于向用户展示的公网 IPv6 地址,不一定是实际监听端口时使用的地址。',
+ natBindIpv4: '监听 IPv4',
+ natBindIpv4Placeholder: '留空则自动识别,例如 0.0.0.0 或 10.170.0.3',
+ natBindIpv4Desc: '实际绑定 IPv4 端口时使用的地址。留空后由系统自动选择。',
+ natBindIpv6: '监听 IPv6',
+ natBindIpv6Placeholder: '留空则自动识别,例如 2600:1900:41a0:5bb::',
+ natBindIpv6Desc: '实际绑定 IPv6 端口时使用的地址。留空后由系统自动选择。',
+ natPublicIpv6Invalid: '公网 IPv6 地址格式无效',
+ natBindIpv6Invalid: '监听 IPv6 地址格式无效',
+ portRangeStart: '端口范围起始',
+ portRangeEnd: '端口范围结束',
+ portRangeEndMustBeGreater: '端口范围结束不能小于起始',
+ cpuAllowanceMax: '总 CPU 时间片配额',
+ memoryMax: '内存最大值',
+ instanceTypeLabel: '实例类型',
+ networkModeLabel: '网络模式',
+ networkModeNat: 'IPv4 NAT',
+ networkModeNatIpv6: 'IPv4 NAT & IPv6',
+ networkModeNatIpv6Nat: 'IPv4 NAT & IPv6 NAT',
+ networkModeIpv6Only: 'IPv6 Only',
+ networkModeIpv6Nat: 'IPv6 NAT',
+ autoAssign: '自动分配',
+ hostAdded: '宿主机添加成功',
+ hostUpdated: '宿主机更新成功',
+ typeChangeWarning: '节点类型变更警告',
+ addFailed: '添加失败',
+ updateFailed: '更新失败',
+ // 初始化配置
+ initConfig: '初始化配置',
+ // 存储配置
+ storageConfig: '存储配置',
+ storageDriver: '存储驱动',
+ storageDriverZfs: 'ZFS (推荐)',
+ storageDriverLvm: 'LVM',
+ storageType: '存储类型',
+ storageTypeLoop: 'Loop 文件',
+ storageTypeDisk: '物理磁盘',
+ storagePath: '设备路径',
+ storagePathHint: '如 /dev/sdb',
+ storageSize: '存储大小',
+ // 网络配置
+ networkConfig: '网络配置',
+ networkOption: '网络选项',
+ networkOptionHint: '选择容器实例的网络出口方式',
+ independentIpv6: '独立 IPv6',
+ ipv6Mode: 'IPv6 模式',
+ ipv6Routed: 'Routed',
+ ipv6Nat: 'NAT',
+ ipv6Disabled: '禁用',
+ ipv6Subnet: 'IPv6 子网',
+ ipv6SubnetHint: '分配给容器的 IPv6 子网段',
+ ipv6SubnetRequired: '请输入 IPv6 网段',
+ ipv6SubnetInvalid: 'IPv6 网段格式无效,需要包含 CIDR 前缀(如 /48)',
+ ipv6Gateway: 'IPv6 网关',
+ ipv6ParentInterface: 'IPv6 父接口',
+ ipv6ParentInterfaceHint: 'IPv6 routed 模式使用的宿主机物理网卡名(如 eth0)',
+ ipv6ParentInterfaceRequired: '请输入 IPv6 父接口',
+ enableApi: '启用 API',
+ // 内核参数
+ sysctlConfig: '内核参数',
+ sysctlConfigHint: '自定义 sysctl 配置,留空使用默认值',
+ resetSysctl: '恢复默认',
+ enableBBR: '启用 BBR',
+ bbrEnabled: 'BBR 已启用',
+ // 安装脚本
+ installScript: '安装脚本',
+ runOnHost: '请在宿主机上以 root 权限运行以下命令:',
+ copyCommand: '复制命令',
+ step1RunScript: '执行安装脚本',
+ step2Verify: '验证并连接',
+ verifyHint: '脚本执行完成后,点击下方按钮验证连接',
+ verifyAndConnect: '验证并连接',
+ verifying: '验证中...',
+ verifySuccess: '纳管成功!',
+ verifyFailed: '验证失败',
+ reinstall: '重新安装',
+ reinstallScript: '重新安装脚本',
+ reinstallFailed: '生成安装命令失败',
+ waitingInstall: '等待安装完成...',
+ installSuccess: '安装成功!',
+ tokenExpired: 'Token 已过期,请重新创建宿主机',
+ // 详情页
+ tabInfo: '信息',
+ tabConfig: '配置',
+ tabInstances: '实例',
+ tabStorage: '存储',
+ tabImages: '镜像',
+ tabOps: '运维',
+ tabCreate: '创建',
+ basicInfo: '基本信息',
+ config: '配置',
+ resourceLimits: '资源限制',
+ transferControl: '转移控制',
+ transferEnabled: '允许转移',
+ transferEnabledHint: '关闭后,该节点上的实例将无法发起转移请求',
+ notificationSettings: '通知设置',
+ notificationSettingsHint: '仅通过你已启用的 Telegram、Discord、Webhook 渠道发送,这里不会发送 Email。',
+ notifyPurchase: '购买通知',
+ notifyPurchaseHint: '当有用户在该节点购买付费实例时通知你。',
+ notifyRenew: '续费通知',
+ notifyRenewHint: '当有用户在该节点续费付费实例时通知你。',
+ notifyDestroy: '销毁通知',
+ notifyDestroyHint: '当有用户销毁该节点实例时通知你,并包含实际退款金额和手续费金额。',
+ extraConfig: '额外配置',
+ trafficConfig: '流量配置',
+ trafficResetDay: '流量重置日',
+ trafficResetDayHint: '每月何日重置实例流量(可设置 1-28)',
+ enableResourcePool: '参与资源池玩法',
+ enableResourcePoolHint: '开启后,该节点的实例可参与签到/抽奖资源应用',
+ announcement: '节点公告',
+ announcementPlaceholder: '在此输入公告内容,将显示在该节点下所有实例的详情页',
+ announcementHint: '留空则不显示公告,支持换行',
+ probeUrl: '探针地址',
+ probeUrlPlaceholder: '输入节点探针监控页面地址',
+ probeUrlHint: '可以输入此节点的探针、脚本测试结果等链接,配置后,用户在选择节点时可点击图标跳转查看',
+ portRange: '端口范围',
+ portsUsed: '已用端口',
+ recalculateResources: '对齐已用',
+ recalculateResourcesTip: '重新计算资源使用量,并将配额对齐到已用配额',
+ recalculateSuccess: '资源校对完成,配额已对齐',
+ recalculateNoChanges: '资源数据正确,无需修正',
+ recalculateFailed: '资源校对失败',
+ ops: {
+ title: '宿主机运维中心',
+ description: '优先执行非破坏性盘点、基线同步和网络修复,用于旧节点纳管补齐与状态校准。',
+ discover: '实例盘点',
+ baselineSync: '基线同步',
+ networkRepair: '网络修复',
+ refresh: '刷新结果',
+ managed: '已纳管实例',
+ orphaned: '未纳管遗留实例',
+ missing: '数据库异常实例',
+ summary: '摘要',
+ totalIncus: '宿主机实例',
+ totalDb: '数据库实例',
+ managedCount: '已纳管',
+ orphanedCount: '未纳管',
+ missingCount: '异常缺失',
+ runSuccess: '操作执行成功',
+ runFailed: '操作执行失败',
+ lastRunAt: '最近执行时间',
+ empty: '暂无结果,请先执行上方运维操作。',
+ sectionInventory: '宿主机实例盘点',
+ sectionRepair: '安全修复动作',
+ sectionReport: '执行结果',
+ resultInventory: '盘点结果',
+ resultBaseline: '基线同步结果',
+ resultNetwork: '网络修复结果',
+ resultPreview: '实例预检结果',
+ resultInstanceSync: '单实例同步结果',
+ resultInstanceRestart: '单实例重启结果',
+ resultDanger: '高风险动作结果',
+ instanceName: '实例名',
+ instanceType: '类型',
+ incusStatus: '宿主机状态',
+ dbStatus: '数据库状态',
+ dbInstance: '数据库实例',
+ changes: '变更数',
+ synced: '已同步',
+ failed: '失败',
+ total: '总数',
+ ipv4: 'IPv4',
+ ipv6: 'IPv6',
+ details: '详情',
+ noManaged: '未发现已纳管实例',
+ noOrphaned: '未发现未纳管遗留实例',
+ noMissing: '未发现数据库异常实例',
+ baselineHint: '同步宿主机资源占用,并批量回填运行中实例的状态与 IP。',
+ networkHint: '批量校准非删除实例的状态、IPv4 与 IPv6 记录。',
+ discoverHint: '读取宿主机当前所有 Incus 容器 / KVM,与数据库记录对账。',
+ selectInstanceHint: '从已纳管实例中选择一个对象,执行单实例同步、重启或高风险动作。',
+ instancePanel: '单实例操作面板',
+ loadPreview: '加载预检',
+ syncInstance: '同步实例',
+ safeRestart: '安全重启',
+ forceRestart: '强制重启',
+ dangerZone: '高风险操作区',
+ dangerHint: '这里的动作会清空或替换实例系统数据,仅建议在盘点、同步和重启均无法解决问题时使用。',
+ rebuild: '重装当前实例',
+ recreate: '重建替换实例',
+ imageAlias: '镜像别名',
+ imageAliasPlaceholder: '例如 ubuntu/22.04 或 debian/12',
+ selectImage: '镜像选择',
+ imagePlaceholder: '请选择目标镜像',
+ loadingImages: '正在加载可用镜像...',
+ noImagesAvailable: '当前节点暂无可用镜像',
+ sshKeyId: 'SSH 密钥 ID',
+ selectSshKey: 'SSH 密钥',
+ sshKeyPlaceholder: '不指定 SSH 密钥',
+ loadingSshKeys: '正在加载 SSH 密钥...',
+ noSshKeysAvailable: '该实例用户暂无 SSH 密钥,不指定时将沿用后端默认处理',
+ sshKeyOptionalHint: '可选,不指定时使用实例用户的默认密钥策略',
+ customInitCommandIds: '自定义初始化命令 ID',
+ selectInitCommands: '初始化命令',
+ loadingInitCommands: '正在加载初始化命令...',
+ noInitCommandsAvailable: '当前镜像没有可选初始化命令',
+ optionalField: '可选项,可留空',
+ confirmDangerTitle: '执行前确认',
+ riskCheckbox: '我已知晓该操作可能导致实例系统或数据不可恢复,并确认这是预期行为。',
+ confirmTextHint: '请输入实例真实名称进行确认,例如 u2-g65uoeo1',
+ ownerUserId: '用户 ID',
+ fullInstanceName: '完整实例名称',
+ selectedOnlyHint: '单实例操作仅作用于当前选中的实例,不会对未选中的实例产生干扰。',
+ localizedNone: '无需操作',
+ dangerActionType: '动作类型',
+ dangerConfirm1Title: '第一次确认高风险操作',
+ dangerConfirm1Hint: '请再次确认你即将执行不可逆的高风险动作。',
+ dangerConfirm1Btn: '确认继续',
+ dangerConfirm2Title: '第二次确认高风险操作',
+ dangerConfirm2Hint: '这是最后一次确认,执行后将立即创建任务。',
+ dangerConfirm2Btn: '确认执行',
+ dangerConfirmStep: '确认步骤 {step} / {total}',
+ executeDangerAction: '执行高风险动作',
+ suggestedAction: '建议动作',
+ activeTask: '活跃任务',
+ latestInstanceAction: '最近单实例动作',
+ },
+ statusOnline: '在线',
+ statusOffline: '离线',
+ statusMaintenance: '维护中',
+ invalidId: '无效的节点 ID',
+ loadFailed: '加载失败',
+ noInstances: '该节点上暂无实例',
+ search: '搜索',
+ instanceSearchPlaceholder: '搜索实例ID、名称、用户名、邮箱、IP地址...',
+ imagesOnHost: '节点 {name} 上的镜像',
+ noImagesOnHost: '该节点上暂无镜像',
+ selectImagesToSync: '选择要同步到节点 {name} 的镜像',
+ allImagesSynced: '所有镜像都已同步到该节点',
+ imagePolicy: {
+ title: '镜像策略',
+ description: '配置节点「{name}」在开通和重装时可选的镜像范围',
+ defaultMode: '使用面板默认镜像',
+ defaultDesc: '不单独限制该节点,创建和重装时按节点架构与实例类型加载全部可用镜像。',
+ restrictedMode: '限制为以下镜像',
+ restrictedDesc: '只允许勾选的镜像出现在该节点的开通和重装列表中。',
+ selectableImages: '可选镜像',
+ defaultHint: '当前未对该节点单独限制镜像。',
+ selectedCount: '当前已选择 {count} 个镜像',
+ searchPlaceholder: '搜索镜像名称、别名或发行版...',
+ emptySelection: '限制模式下至少需要选择一个镜像,否则请改为默认模式。',
+ noImages: '当前没有适用于该节点架构和实例类型的可见镜像。',
+ loadFailed: '加载镜像策略失败',
+ saveSuccess: '镜像策略已保存',
+ saveFailed: '保存镜像策略失败',
+ },
+ addHostDesc: '添加新的宿主机节点',
+ allocated: '已分配',
+ includesPageCache: '包含页面缓存',
+ syncTime: '同步时间',
+ deleteHost: '删除宿主机',
+ deleteWarning: '此操作不可撤销!删除宿主机前,请确保该节点上没有任何实例。',
+ deleteConfirmHint: '请输入宿主机名称 "{name}" 以确认删除:',
+ enterHostName: '宿主机名称',
+ confirmDeleteBtn: '确认删除',
+ deleteNameMismatch: '输入的名称不匹配',
+ hasInstances: '该节点上还有 {count} 个实例,请先删除或迁移实例',
+ checkFailed: '检查失败',
+ // 批量延期
+ batchExtend: '赠送时长',
+ extendHint: '该节点下共有 {count} 个付费实例将被延期',
+ extendDaysLabel: '延期天数',
+ extendDaysPlaceholder: '请输入 1-365 的整数',
+ extendDaysInvalid: '请输入有效的延期天数(1-365)',
+ confirmExtendBtn: '确认赠送',
+ extendSuccess: '已成功为 {count} 个付费实例延期 {days} 天',
+ extendFailed: '批量延期失败',
+ // 批量删除实例
+ selectedCount: '已选择 {count} 个实例',
+ noInstanceSelected: '请勾选实例以使用批量操作',
+ batchDelete: '批量删除',
+ batchDeleteTitle: '批量删除实例',
+ batchDeleteWarning: '此操作不可撤销!删除后实例数据将无法恢复。',
+ batchDeleteConfirm: '确定要删除这 {count} 个实例吗?',
+ confirmBatchDelete: '确认删除',
+ databaseOnlyDelete: '仅数据库删除',
+ batchDeleteSuccess: '成功删除 {count} 个实例',
+ batchDeletePartial: '成功删除 {success} 个实例,{failed} 个失败',
+ batchDeleteFailed: '批量删除失败',
+ batchDeleteRefundWarning: '删除付费实例将自动退还剩余价值给用户,并从您的托管余额扣除相应金额。',
+ batchDeleteRefundTotal: '总退款金额',
+ instanceName: '实例名称',
+ instanceUser: '所属用户',
+ refundAmount: '退款金额',
+ // 批量同步实例状态
+ batchSyncStatus: '同步状态',
+ batchSyncSuccess: '成功同步 {synced} 个实例,{changed} 个状态已更新',
+ batchSyncWithIpv4: '成功同步 {synced} 个实例,{changed} 个状态更新,{ipv4Changed} 个内网 IP 更新',
+ batchSyncPartial: '同步 {synced} 个,更新 {changed} 个,失败 {failed} 个',
+ batchSyncNoChange: '成功同步 {synced} 个实例,状态无变化',
+ batchSyncFailed: '同步状态失败',
+ // 批量封停实例
+ batchSuspend: '批量封停',
+ batchSuspendTitle: '批量封停实例',
+ batchSuspendWarning: '封停后,实例所有者将无法对实例进行任何操作,直到解除封停。',
+ batchSuspendConfirm: '确定要封停这 {count} 个实例吗?',
+ confirmBatchSuspend: '确认封停',
+ batchSuspendSuccess: '成功封停 {count} 个实例',
+ batchSuspendPartial: '成功封停 {success} 个实例,{failed} 个失败',
+ batchSuspendFailed: '批量封停失败',
+ batchUnsuspend: '批量解封',
+ batchUnsuspendSuccess: '成功解封 {count} 个实例',
+ batchUnsuspendPartial: '成功解封 {success} 个实例,{failed} 个失败',
+ batchUnsuspendNone: '选中的实例中没有已封停的实例',
+ batchUnsuspendFailed: '批量解封失败',
+ suspendReason: '封停原因(可选)',
+ suspendReasonPlaceholder: '填写封停原因后,系统将通过站内信通知实例所有者...',
+ deleteReason: '删除原因(可选)',
+ deleteReasonPlaceholder: '填写删除原因后,系统将通过站内信和通知渠道告知用户...',
+ deleteReasonHint: '如果填写了删除原因,被删除实例的所有者将收到通知',
+ // 实例流量
+ trafficUsage: '流量使用',
+ trafficUnlimited: '不限制',
+ resetTraffic: '重置流量',
+ trafficResetSuccess: '流量已重置',
+ trafficResetFailed: '重置流量失败',
+ resetTrafficTitle: '重置实例流量',
+ resetTrafficWarning: '如果是已超额的实例,重置流量不会自动恢复实例的带宽限速(1Mbps),请手动为该实例恢复带宽速率。',
+ resetTrafficDesc: '确定要重置实例 "{instance}" 的流量吗?',
+ // 节点流量统计
+ trafficStats: '流量统计',
+ monthlyUsed: '本月已用',
+ hostTotalLimit: '节点总分配',
+ // 宿主机状态
+ agentStatusTitle: '宿主机状态',
+ agentStatusDesc: 'Agent 按 {seconds} 秒间隔上报宿主机实时资源与运行状态',
+ agentStatusRefreshSuccess: '状态接口已刷新,请以最后心跳时间为准',
+ agentStatusLoadFailed: '加载宿主机状态失败',
+ agentNotInstalled: '未安装',
+ agentDisabled: '已停用',
+ agentOnline: '在线',
+ agentOffline: '离线',
+ agentUnknown: '未知',
+ agentVersionLatest: '最新',
+ agentVersionOutdated: '可升级',
+ agentVersionUnknown: '版本未知',
+ agentLatestVersion: '最新版本:{version}',
+ agentUpgradeClickHint: '点击请求升级到 {version},Agent 会在下次心跳执行',
+ agentUpgradeRequestSuccess: '已请求升级,Agent 会在约 {seconds} 秒内的下一次心跳执行',
+ agentUpgradeRequestFailed: '请求 Agent 升级失败',
+ agentAlreadyLatest: 'Agent 已是最新版本',
+ agentInstallCommand: '安装/重装 Agent',
+ agentInstallCommandTitle: 'Agent 安装命令',
+ agentInstallCommandHint: '可在宿主机执行完整命令,也可在 Agent 菜单中粘贴该命令或其中的 ait_ token。',
+ agentInstallCommandConfirm: '生成新的 Agent 安装命令会轮换该宿主机的 Agent 凭据。旧 Agent 会在重新安装前无法继续上报,确定继续吗?',
+ agentInstallTokenExpiresAt: '有效期至 {time}',
+ agentInstallCommandSuccess: 'Agent 安装命令已生成',
+ agentInstallCommandFailed: '生成 Agent 安装命令失败',
+ agentInstallCommandCopied: 'Agent 安装命令已复制',
+ agentNoRecordHint: '该宿主机还没有 Agent 心跳记录。重新安装或安装 Agent 后会自动上报。',
+ agentId: 'Agent ID',
+ agentLastSeen: '最后心跳',
+ agentHeartbeatIp: '心跳 IP',
+ agentReportedAt: '上报时间',
+ agentIncus: 'Incus 检测',
+ agentIncusAvailable: '可用',
+ agentIncusUnavailable: '不可用',
+ agentCpuTotal: 'CPU 核心',
+ agentMemoryTotal: '内存总量',
+ agentUptime: '宿主机运行时间',
+ agentSocket: 'Incus Socket',
+ agentCpuUsage: 'CPU 使用率',
+ agentCpuCores: '核心',
+ agentMemoryUsage: '内存使用',
+ agentSwapUsage: 'SWAP 使用',
+ agentDiskUsage: '磁盘使用',
+ agentLoadAverage: '负载',
+ agentLoadAverageHint: '1 / 5 / 15 分钟',
+ agentProcessCount: '进程数',
+ // 存储池管理
+ storage: {
+ title: '存储池',
+ subtitle: '管理此宿主机上的 Incus 存储池',
+ create: '添加存储池',
+ createTitle: '创建存储池',
+ empty: '暂无存储池',
+ loadFailed: '加载存储池失败',
+ createSuccess: '存储池创建成功',
+ createFailed: '创建存储池失败',
+ deleteSuccess: '存储池已删除',
+ deleteFailed: '删除存储池失败',
+ deleteConfirm: '确定删除存储池 "{name}"?此操作不可撤销。',
+ updateSuccess: '存储池已更新',
+ updateFailed: '更新存储池失败',
+ editTitle: '编辑存储池',
+ currentSize: '当前大小',
+ newSize: '新大小',
+ newSizeHint: '仅支持扩容,不支持缩容',
+ nameRequired: '请输入存储池名称',
+ sourceRequired: '请输入存储源(设备路径)',
+ sizeRequired: '请输入存储大小',
+ pathRequired: '请输入目录路径',
+ poolName: '存储池名称',
+ driver: '驱动类型',
+ description: '描述',
+ source: '存储源',
+ size: '存储大小',
+ usedBy: '使用者',
+ volumes: '个卷',
+ // 驱动描述
+ zfsDesc: '推荐:功能最全(快照、克隆、压缩、配额),性能极佳',
+ lvmDesc: 'Linux 标配,极其稳定,建议开启 Thin Provisioning',
+ btrfsDesc: '类似 ZFS 特性,单盘或 RAID1/10 可用',
+ dirDesc: '目录存储,性能最差,仅用于测试',
+ // 通用
+ useLoop: '使用 Loop 文件',
+ loopSizeHint: '将在 /var/lib/incus/ 下创建镜像文件',
+ // ZFS
+ zfsSourceHint: '物理盘或分区路径,如 /dev/disk/by-id/nvme-xxx',
+ zfsPoolName: 'ZFS 池名称',
+ zfsPoolNameHint: '可选,不填则使用存储池名称',
+ // LVM
+ lvmSourceHint: '物理盘路径,如 /dev/sdb',
+ lvmVgName: '卷组名称',
+ lvmUseThinpool: '启用精简卷(Thin Provisioning)',
+ lvmThinpoolHint: '强烈建议开启,否则快照性能极差',
+ // Btrfs
+ btrfsSourceHint: '物理盘路径,如 /dev/sdb',
+ // DIR
+ dirPath: '目录路径',
+ dirPathHint: '指定一个存在的目录,如 /mnt/data/incus-storage',
+ // 存储用途
+ purpose: '存储用途',
+ forInstances: '用于实例系统盘',
+ forInstancesHint: '创建实例时作为默认存储',
+ forVolumes: '用于实例存储盘',
+ forVolumesHint: '可手动挂载到实例',
+ purposeSystemDisk: '系统盘',
+ purposeStorageDisk: '存储盘',
+ // 模式切换
+ modeCreate: '创建新存储池',
+ modeExisting: '关联已有存储池',
+ modeImport: '导入已有存储',
+ modeCreateHint: '在宿主机上创建新的存储池,需要配置驱动类型、存储源等参数',
+ modeExistingHint: '关联已在宿主机上创建的存储池,只需输入存储池名称即可',
+ modeImportHint: '导入底层已存在但 Incus 还未知晓的存储池(如手动创建的 ZFS 池、LVM VG)',
+ linkSuccess: '存储池关联成功',
+ importSuccess: '存储池导入成功',
+ // 导入存储池
+ importZfsSource: 'ZFS 池名称',
+ importLvmSource: 'LVM 卷组名称',
+ importBtrfsSource: 'Btrfs 设备或子卷路径',
+ importDirSource: '目录路径',
+ importZfsHint: '底层已存在的 ZFS 池名称(通过 zpool list 查看)',
+ importLvmHint: '底层已存在的 LVM 卷组名称(通过 vgs 查看)',
+ importBtrfsHint: '已格式化为 Btrfs 的设备或子卷路径',
+ importDirHint: '已存在的目录路径,如 /mnt/storage',
+ importSourceRequired: 'Btrfs 和 DIR 类型必须提供底层存储源路径',
+ },
+ },
+ // 镜像管理
+ images: {
+ title: '镜像管理',
+ description: '管理系统镜像库,用户可在创建实例时选择这些镜像',
+ create: '添加镜像',
+ edit: '编辑镜像',
+ imagesTab: '镜像库',
+ syncTab: '同步状态',
+ noImages: '还没有添加任何镜像',
+ noImagesForArchitecture: '当前架构下没有镜像',
+ addImage: '添加镜像',
+ syncToHost: '同步镜像到节点',
+ delete: '删除',
+ confirmDelete: '确定删除镜像 "{name}"?',
+ imageDeleted: '镜像已删除',
+ deleteFailed: '删除失败',
+ deleteSuccess: '镜像已删除',
+ enabled: '启用',
+ disabled: '禁用',
+ active: '已启用',
+ show: '显示',
+ hide: '隐藏',
+ shown: '镜像已显示',
+ hidden: '镜像已隐藏',
+ statusVisible: '可见',
+ statusHidden: '已隐藏',
+ createSuccess: '镜像创建成功',
+ updateSuccess: '镜像更新成功',
+ saveFailed: '保存失败',
+ loadFailed: '加载镜像列表失败',
+ validation: {
+ requiredFields: '请填写名称、远程别名和图标',
+ },
+ fields: {
+ icon: '图标',
+ name: '名称',
+ remoteAlias: '远程别名',
+ osType: '系统类型',
+ architecture: '架构',
+ instanceType: '实例类型',
+ sortOrder: '排序',
+ status: '状态',
+ hidden: '对用户隐藏此镜像',
+ },
+ placeholder: {
+ name: '例如: Ubuntu 24.04 LTS',
+ remoteAlias: '例如: ubuntu/noble/cloud',
+ icon: '请选择图标',
+ },
+ hint: {
+ remoteAlias: 'Incus 远程镜像别名,如 ubuntu/noble/cloud',
+ sortOrder: '越小排序越靠前',
+ instanceType: '指定该镜像适用于容器、虚拟机或两者皆可',
+ },
+ // 实例类型
+ typeContainer: '容器',
+ typeVm: '虚拟机',
+ typeBoth: '通用',
+ // 同步状态
+ syncStatusDesc: '查看各镜像在节点上的同步状态,点击单元格可进行同步或删除操作',
+ refresh: '刷新',
+ addToLibraryFirst: '请先添加镜像到镜像库',
+ noSystemImages: '暂无系统镜像',
+ image: '镜像',
+ statusReady: '已就绪',
+ statusSyncing: '同步中',
+ statusPending: '等待中',
+ statusError: '错误',
+ statusNotSynced: '未同步',
+ retry: '重试',
+ sync: '同步',
+ confirmDeleteFromHost: '确定从该节点删除此镜像?',
+ deletedFromHost: '镜像已从节点删除',
+ imageInUse: '该镜像正在被 {count} 个实例使用,无法删除',
+ syncStarted: '同步任务已启动',
+ syncFailed: '同步失败',
+ loadStatusFailed: '加载镜像状态失败',
+ loadHostsFailed: '加载节点列表失败',
+ // 表单
+ imageName: '名称',
+ osType: '系统类型',
+ remoteAlias: '远程别名',
+ imageDesc: '描述',
+ sortOrder: '排序',
+ activeStatus: '启用状态',
+ selectHosts: '选择同步节点',
+ imageAdded: '镜像已添加',
+ imageCreated: '镜像已创建',
+ imageUpdated: '镜像已更新',
+ // 镜像表单弹窗
+ selectDistro: '选择发行版',
+ selectVersion: '选择版本',
+ configureInfo: '配置信息',
+ selectDistroHint: '选择要添加的 Linux 发行版',
+ versionsCount: '{count} 个版本',
+ imageVariant: '镜像变体',
+ cloudVariantDesc: '云优化版,支持 cloud-init',
+ defaultVariantDesc: '标准版本',
+ displayName: '显示名称',
+ descPlaceholder: '可选的备注信息...',
+ enableImage: '启用此镜像',
+ syncHosts: '同步节点',
+ selectAll: '全选',
+ deselectAll: '取消全选',
+ editSyncHint: '取消选择的节点将在保存时从该节点删除镜像,新选择的节点将开始同步',
+ createSyncHint: '选中的节点将在保存后开始同步镜像',
+ // 同步弹窗
+ selectTargetHosts: '选择目标节点',
+ syncWarning: '镜像同步可能需要几分钟时间,具体取决于镜像大小和网络速度',
+ startSync: '开始同步',
+ },
+ // 套餐管理
+ packages: {
+ title: '套餐管理',
+ description: '配置实例规格模板',
+ create: '创建套餐',
+ noPackages: '暂无套餐',
+ createFirst: '创建第一个套餐',
+ noDesc: '无描述',
+ enabled: '启用',
+ disabled: '禁用',
+ status: '状态',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '硬盘',
+ cores: '约 {n} 核',
+ networkNat: 'NAT',
+ networkIpv6: 'IPv6',
+ networkDual: '双栈',
+ nested: '嵌套虚拟化',
+ privileged: '特权容器',
+ allNodes: '全部节点',
+ enable: '启用',
+ disable: '禁用',
+ edit: '编辑',
+ delete: '删除',
+ confirmDelete: '确定删除套餐 "{name}"?',
+ packageDeleted: '套餐已删除',
+ packageEnabled: '套餐已上架',
+ packageDisabled: '套餐已下架',
+ deleteFailed: '删除失败',
+ operationFailed: '操作失败',
+ // 表单
+ createPackage: '创建套餐',
+ editPackage: '编辑套餐',
+ basicInfo: '基本信息',
+ name: '名称',
+ namePlaceholder: '入门版',
+ descLabel: '描述',
+ descPlaceholder: '适合轻量级应用',
+ resourceLimits: '资源上限',
+ maxCpuAllowance: '最大 CPU 额配',
+ cpuAllowanceHint: '额配值 ÷ 100 ≈ 可用核心数,最小10,步长5',
+ maxMemory: '最大内存 (MB)',
+ maxDisk: '最大硬盘 (MB)',
+ networkAndScheduling: '网络与调度',
+ networkMode: '网络模式',
+ allNodesNoLimit: '全部节点(不限制)',
+ boundHosts: '绑定的宿主机',
+ mustBindHost: '必须至少绑定一个宿主机',
+ noHostsBound: '未绑定宿主机',
+ noHostsAvailable: '暂无可用宿主机',
+ advancedOptions: '高级选项',
+ nestedLabel: '嵌套虚拟化',
+ nestedHint: '允许在容器内运行 Docker/VM',
+ privilegedLabel: '特权容器',
+ privilegedHint: '授予更高系统权限(有安全风险)',
+ enablePackage: '启用套餐',
+ enablePackageHint: '禁用后用户无法选择此套餐',
+ enterName: '请输入套餐名称',
+ packageCreated: '套餐创建成功',
+ packageUpdated: '套餐更新成功',
+ trafficLimit: '月流量限额',
+ trafficLimitPlaceholder: '留空表示无限制',
+ trafficLimitHint: '单位 GB,留空表示无限制',
+ syncTraffic: '同步流量限额',
+ confirmSyncTraffic: '确定将所有套餐的流量限额同步到对应的实例吗?',
+ syncTrafficSuccess: '已同步 {count} 个实例的流量限额',
+ syncTrafficFailed: '同步失败',
+ // 套餐列表
+ traffic: '月流量限额',
+ unlimited: '无限制',
+ unlimitedPlaceholder: '留空表示无限制',
+ active: '已启用',
+ inactive: '已归档',
+ archivedHint: '归档套餐不会显示在创建实例的套餐选择中',
+ activeLabel: '启用',
+ saveFailed: '保存失败',
+ loadFailed: '加载套餐失败',
+ instanceMaxLimit: '实例最大上限',
+ },
+ // 帮助管理
+ helpManage: {
+ title: '帮助文档管理',
+ description: '创建和管理帮助文档',
+ create: '新建文档',
+ noArticles: '暂无文档',
+ createFirst: '创建第一篇文档',
+ edit: '编辑',
+ delete: '删除',
+ confirmDelete: '确定删除文档 "{title}"?',
+ articleDeleted: '文档已删除',
+ deleteFailed: '删除失败',
+ // 标签页
+ articlesTab: '文档',
+ categoriesTab: '分类管理',
+ // 分类
+ addCategory: '添加分类',
+ noCategories: '暂无分类,点击"添加分类"创建',
+ categoryId: '分类 ID',
+ categoryIdHint: '只能包含小写字母、数字和连字符',
+ categoryName: '分类名称',
+ categoryColor: '标识颜色',
+ categoryAdded: '分类已添加',
+ categoryUpdated: '分类已更新',
+ categoryDeleted: '分类已删除',
+ categoryInUse: '该分类下有文档,无法删除',
+ confirmDeleteCategory: '确定删除分类 "{name}"?',
+ duplicateCategoryId: '该分类ID已存在',
+ invalidCategoryId: '分类ID只能包含小写字母、数字和连字符',
+ fillCategoryIdAndName: '请填写分类ID和名称',
+ // 文档表格
+ articleTitleCol: '标题',
+ categoryCol: '分类',
+ statusCol: '状态',
+ updatedAtCol: '更新时间',
+ actionsCol: '操作',
+ totalArticles: '共 {count} 篇文档',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ hide: '隐藏',
+ publish: '发布',
+ articleHidden: '文档已隐藏',
+ articlePublished: '文档已发布',
+ // 表单
+ createArticle: '创建文档',
+ editArticle: '编辑文档',
+ articleTitle: '标题',
+ titlePlaceholder: '文档标题',
+ urlSlug: 'URL 标识符',
+ urlSlugPlaceholder: 'getting-started',
+ urlSlugHint: '只能包含小写字母、数字和连字符',
+ category: '分类',
+ sortOrder: '排序',
+ content: '内容',
+ contentMarkdown: '内容 (Markdown)',
+ preview: '预览',
+ activeStatus: '发布状态',
+ published: '已发布',
+ draft: '草稿',
+ pinned: '已置顶',
+ notPinned: '未置顶',
+ articleCreated: '文档已创建',
+ articleUpdated: '文档已更新',
+ saveFailed: '保存失败',
+ loadFailed: '加载失败',
+ operationFailed: '操作失败',
+ fillRequired: '请填写标题、URL 标识符和内容',
+ invalidSlug: 'URL 标识符只能包含小写字母、数字和连字符',
+ noTitle: '无标题',
+ // Markdown 帮助
+ showMarkdownHelp: '查看语法帮助',
+ hideMarkdownHelp: '隐藏语法帮助',
+ markdownHeading1: '一级标题',
+ markdownHeading2: '二级标题',
+ markdownBold: '粗体文本',
+ markdownItalic: '斜体文本',
+ markdownLink: '超链接',
+ markdownImage: '图片',
+ markdownUnorderedList: '无序列表',
+ markdownOrderedList: '有序列表',
+ markdownInlineCode: '行内代码',
+ markdownCodeBlock: '代码块',
+ markdownQuote: '引用块',
+ markdownHr: '分隔线',
+ customAlerts: '自定义提示框:',
+ alertInfo: '信息提示',
+ alertSuccess: '成功提示',
+ alertWarning: '警告提示',
+ alertDanger: '危险提示',
+ alertNote: '备注提示',
+ },
+ // OAuth 配置
+ oauth: {
+ title: 'OAuth 配置',
+ description: '配置第三方登录方式',
+ noProviders: '暂无 OAuth 提供商',
+ provider: '提供商',
+ clientId: 'Client ID',
+ clientSecret: 'Client Secret',
+ enabled: '已启用',
+ disabled: '已禁用',
+ enable: '启用',
+ disable: '禁用',
+ edit: '编辑',
+ save: '保存',
+ saving: '保存中...',
+ saveSuccess: '配置已保存',
+ saveFailed: '保存失败',
+ notConfigured: '尚未配置。请前往',
+ developerConsole: '开发者控制台',
+ createOAuthApp: '创建 OAuth 应用。',
+ callbackUrl: '回调 URL (Callback URL)',
+ usageGuide: '使用说明',
+ step1: '1. 在对应平台的开发者控制台创建 OAuth 应用,获取 Client ID 和 Client Secret。',
+ step2: '2. 将上方显示的回调 URL 填入 OAuth 应用配置中。',
+ step3: '3. 在此页面填入 Client ID 和 Client Secret,并启用。',
+ step4: '4. 用户可在个人设置中绑定 OAuth 账号,绑定后即可使用快捷登录。',
+ warning: '注意:用户必须先注册账号并绑定,无法直接使用 OAuth 注册新账号。',
+ configure: '配置',
+ enterClientId: '输入 Client ID',
+ enterClientSecret: '输入 Client Secret',
+ leaveEmptyUnchanged: '(留空表示不更改)',
+ enableLogin: '启用此登录方式',
+ enableLoginHint: '启用后用户可使用此方式快捷登录',
+ },
+ // 支付渠道管理
+ paymentProviders: {
+ title: '支付渠道管理',
+ description: '配置和管理支付渠道',
+ add: '添加渠道',
+ create: '添加渠道',
+ noProviders: '暂无支付渠道',
+ empty: '暂无配置支付渠道',
+ createFirst: '添加第一个支付渠道',
+ providerName: '渠道名称',
+ providerType: '渠道类型',
+ displayName: '显示名称',
+ description_label: '描述',
+ status: '状态',
+ statusActive: '启用',
+ statusDisabled: '禁用',
+ statusTesting: '测试中',
+ feeRate: '费率',
+ feeFixed: '固定费用',
+ minAmount: '最小金额',
+ maxAmount: '最大金额',
+ sortOrder: '排序',
+ methods: '支付方式',
+ paymentMethods: '支持的支付方式',
+ configLabel: '配置',
+ configPlaceholder: 'JSON 格式的渠道配置',
+ edit: '编辑',
+ delete: '删除',
+ name: '名称',
+ namePlaceholder: '输入渠道名称',
+ nameRequired: '请输入渠道名称',
+ type: '类型',
+ notImplemented: '待实现',
+ providerTypes: {
+ yipay: '易支付',
+ heleket: 'Heleket',
+ stripe: 'Stripe',
+ alipayDirect: '支付宝直连',
+ wechatDirect: '微信直连',
+ manual: '人工充值',
+ },
+ deleteConfirm: '确认删除',
+ confirmDelete: '确定删除支付渠道 "{name}"?',
+ deleteWarning: '确定删除支付渠道 "{name}"?此操作不可恢复。',
+ createSuccess: '支付渠道创建成功',
+ updateSuccess: '支付渠道更新成功',
+ deleteSuccess: '支付渠道已删除',
+ statusUpdated: '状态已更新',
+ statusUpdateSuccess: '状态更新成功',
+ statusUpdateFailed: '状态更新失败',
+ saveFailed: '保存失败',
+ createFailed: '创建支付渠道失败',
+ updateFailed: '更新支付渠道失败',
+ deleteFailed: '删除失败',
+ loadFailed: '加载失败',
+ createProvider: '添加支付渠道',
+ editProvider: '编辑支付渠道',
+ displayNamePlaceholder: '用户看到的名称',
+ descPlaceholder: '可选的渠道描述',
+ feeRateHint: '如 0.02 表示 2% 手续费',
+ types: {
+ alipay: '支付宝',
+ wechat: '微信支付',
+ stripe: 'Stripe',
+ paypal: 'PayPal',
+ usdt: 'USDT',
+ manual: '人工充值',
+ },
+ config: {
+ sdkVersion: 'SDK版本',
+ apiurl: '支付接口地址',
+ pid: '商户ID',
+ key: '商户密钥',
+ platformPublicKey: '平台公钥',
+ merchantPrivateKey: '商户私钥',
+ heleketMerchantUuid: 'Merchant UUID',
+ heleketApiKey: 'API Key',
+ heleketInvoiceCurrency: '发票法币币种',
+ heleketLifetime: '发票有效期(秒)',
+ heleketApiUrlHint: '默认使用 Heleket 官方接口地址,可按需切换到代理或私有网关。',
+ heleketCurrencyHint: '创建 Heleket 发票时使用的法币币种,常用为 CNY。',
+ heleketLifetimeHint: 'Heleket 发票有效期。默认 3600 秒,并会同步到本地订单过期时间。',
+ yipayVersionV1: 'V1 (MD5签名) - 传统版本',
+ yipayVersionV2: 'V2 (RSA签名) - 新版本',
+ yipayVersionV1Hint: 'V1 版本使用 MD5 签名,适用于传统易支付平台。',
+ yipayVersionV2Hint: 'V2 版本使用 RSA 签名,适用于新版彩虹易支付。',
+ yipayApiUrlPlaceholder: '如: https://pay.example.com/',
+ yipayApiUrlHint: '支付接口地址,以 / 结尾。',
+ yipayPidPlaceholder: '商户ID',
+ yipayKeyPlaceholder: '商户密钥',
+ yipayKeyHint: '易支付平台提供的商户密钥(一串字符)。',
+ platformPublicKeyPlaceholder: '平台公钥(RSA)',
+ platformPublicKeyHint: '易支付平台提供的公钥,用于验证平台返回的签名。',
+ merchantPrivateKeyPlaceholder: '商户私钥(RSA)',
+ merchantPrivateKeyHint: '您生成的 RSA 私钥,用于对请求进行签名。',
+ yipayMethodsHint: '选择该渠道支持的支付方式,至少选择一种。',
+ yipayMethodFeeHint: '手续费按用户选择的支付方式加到应付金额中,充值本金仍按原金额入账。',
+ yipayFeeFieldHint: '易支付手续费请在上方每个支付方式中设置。',
+ heleketMethods: '常见币种展示',
+ heleketMethodsPlaceholder: 'USDT@TRON\nUSDT@BSC\nBTC\nETH',
+ heleketMethodsHint: '仅作为后台展示与记录参考,不会限制用户在 Heleket 支付页最终选择的币种和网络。',
+ instructions: '充值说明',
+ instructionsPlaceholder: '请输入用户充值时看到的说明信息...',
+ },
+ },
+ // 计费管理(兼容旧键名 billingManage)
+ billingManage: {
+ title: '计费管理',
+ description: '管理用户付费实例和计费记录',
+ tabOverview: '概览',
+ tabInstances: '付费实例',
+ tabRecords: '扣费记录',
+ // 概览
+ totalIncome: '总收入',
+ monthIncome: '本月收入',
+ todayIncome: '今日收入',
+ totalRefund: '总退款',
+ paidInstances: '付费实例数',
+ activeInstances: '活跃实例',
+ // 实例列表
+ searchPlaceholder: '搜索实例...',
+ instanceId: '实例ID',
+ instanceName: '名称',
+ user: '用户',
+ plan: '方案',
+ expiresAt: '到期时间',
+ status: '状态',
+ actions: '操作',
+ noInstances: '暂无付费实例',
+ suspend: '封停',
+ unsuspend: '解封',
+ extend: '延期',
+ refund: '退款',
+ confirmSuspend: '确定封停实例 "{name}"?',
+ confirmUnsuspend: '确定解封实例 "{name}"?',
+ suspendSuccess: '实例已封停',
+ unsuspendSuccess: '实例已解封',
+ // 延期
+ extendTitle: '延期实例',
+ extendDays: '延期天数',
+ extendReason: '延期原因',
+ freeExtend: '免费延期',
+ freeExtendHint: '勾选后不扣除用户余额',
+ extendSuccess: '实例已延期',
+ extendFailed: '延期失败',
+ // 退款
+ refundTitle: '退款实例',
+ refundAmount: '退款金额',
+ refundReason: '退款原因',
+ refundSuccess: '退款成功',
+ refundFailed: '退款失败',
+ // 扣费记录
+ recordId: '记录ID',
+ recordType: '类型',
+ amount: '金额',
+ period: '账期',
+ remark: '备注',
+ createdAt: '时间',
+ noRecords: '暂无扣费记录',
+ // 用户余额
+ userBalance: '用户余额',
+ adjustBalance: '调整余额',
+ giftBalance: '赠送余额',
+ adjustTitle: '调整用户余额',
+ giftTitle: '赠送余额',
+ adjustAmount: '调整金额',
+ giftAmount: '赠送金额',
+ adjustRemark: '调整原因',
+ giftRemark: '赠送备注',
+ adjustHint: '正数表示增加,负数表示扣除',
+ adjustSuccess: '余额调整成功',
+ giftSuccess: '余额赠送成功',
+ operationFailed: '操作失败',
+ },
+ // 计费管理(新键名 billing,与组件对应)
+ billing: {
+ title: '计费管理',
+ description: '管理用户付费实例和计费记录',
+ loadFailed: '加载计费数据失败',
+ loadInstancesFailed: '加载实例列表失败',
+ loadRecordsFailed: '加载扣费记录失败',
+ // Tab 页签
+ tabs: {
+ overview: '概览',
+ instances: '付费实例',
+ records: '扣费记录',
+ rechargeRecords: '充值记录',
+ affConversions: 'AFF 转化',
+ paymentProviders: '支付渠道',
+ },
+ // 概览统计
+ totalRevenue: '总收入',
+ thisMonthRevenue: '本月收入',
+ todayRevenue: '今日收入',
+ totalRefunds: '总退款',
+ paidInstances: '付费实例数',
+ activeInstances: '活跃实例',
+ suspendedInstances: '已封停实例',
+ expiringInstances: '即将到期',
+ netRevenueLabel: '净收入',
+ thisMonthVsLastMonth: '本月 / 上月',
+ hostedRevenueShare: '托管收入占比',
+ revenueBreakdownTitle: '收入结构',
+ directRevenue: '自营收入',
+ hostedRevenue: '托管收入',
+ instanceHealthTitle: '实例状态',
+ affOverviewTitle: 'AFF返利',
+ rechargeLabel: '充值',
+ revenueLabel: '收入',
+ overviewPeriods: {
+ total: '总览',
+ thisMonth: '本月',
+ today: '今日',
+ },
+ // 实例列表
+ allStatus: '全部状态',
+ allHosts: '全部节点',
+ showExpiring: '仅显示即将到期',
+ showDateColumns: '显示日期列',
+ searchPlaceholder: '搜索用户名/节点/实例名/方案/套餐...',
+ user: '用户',
+ host: '节点',
+ plan: '方案',
+ package: '套餐',
+ price: '价格',
+ expiresAt: '到期时间',
+ purchaseDate: '购买日期',
+ remainingDays: '剩余天数',
+ expired: '已过期',
+ days: '天',
+ instanceType: '实例类型',
+ instanceName: '实例名',
+ instanceStatus: '状态',
+ noInstances: '暂无付费实例',
+ viewInstance: '查看',
+ autoRenew: '自动续费',
+ // 托管类型
+ hostingType: '托管类型',
+ direct: '直营',
+ hosted: '托管',
+ cycle: '周期',
+ cycleMonths: '{months}个月',
+ perPage: '每页',
+ totalCount: '共 {count} 条',
+ // 操作
+ suspend: '封停',
+ unsuspend: '解封',
+ extend: '延期',
+ refund: '退款',
+ deleteRefund: '删除并退款',
+ // 操作弹窗
+ suspendTitle: '封停实例',
+ unsuspendTitle: '解封实例',
+ extendTitle: '延期实例',
+ refundTitle: '退款',
+ deleteRefundTitle: '删除并退款',
+ targetInstance: '目标实例',
+ reason: '原因',
+ reasonPlaceholder: '请输入原因...',
+ extendDays: '延期天数',
+ freeExtend: '免费延期(不扣余额)',
+ refundAmount: '退款金额',
+ refundReasonPlaceholder: '请输入退款原因...',
+ refundReasonRequired: '请填写退款原因',
+ // 删除并退款相关
+ deleteRefundWarning: '警告:此操作将永久删除实例及其所有数据(快照、备份、端口映射等),此操作不可撤销!',
+ refundTypeLabel: '退款方式',
+ refundTypeRemaining: '按剩余价值退款(根据剩余天数计算)',
+ refundTypeFull: '全额退款(退还所有已消费金额)',
+ deleteRefundReasonPlaceholder: '请输入删除原因...',
+ deleteRefundReasonRequired: '请填写删除原因',
+ deleteRefundSuccess: '实例已删除并退款',
+ databaseOnlyDelete: '仅数据库删除',
+ deleteRefundDatabaseOnlySuccess: '实例已从数据库删除',
+ // 应用折扣相关
+ applyDiscount: '应用折扣',
+ applyDiscountTitle: '应用续费折扣',
+ applyDiscountHint: '输入AFF优惠码后,该实例后续续费时将自动享受5%折扣,同时优惠码创建者也将获得续费返利。',
+ affCodeLabel: 'AFF优惠码',
+ affCodePlaceholder: '请输入AFF优惠码...',
+ affCodeRequired: '请输入AFF优惠码',
+ applyDiscountSuccess: '已成功应用续费折扣',
+ // 修改价格相关
+ updatePrice: '修改价格',
+ owner: '所有者',
+ currentPrice: '当前价格',
+ newPrice: '新价格',
+ enterNewPrice: '请输入新价格',
+ priceHint: '此价格为单周期价格,修改后将影响续费费用',
+ settleBalance: '结算差价(根据剩余天数补交/退还)',
+ needPay: '需补交',
+ willRefund: '将退还',
+ priceDiffHint: '根据剩余 {days} 天计算,将自动处理用户余额',
+ noSettleHint: '不结算差价,仅修改后续续费价格,不影响当前周期余额',
+ affDiscount: 'AFF折扣',
+ actualRenewPrice: '实际续费价',
+ affAppliedHint: '该实例已应用优惠码,续费时享受 {discount}% 折扣。差价计算已基于折扣后价格。',
+ newActualPrice: '新实际续费价',
+ // 切换方案相关
+ upgradePlan: '切换',
+ upgradePlanTitle: '切换方案',
+ currentPlan: '当前方案',
+ planName: '方案名称',
+ monthlyPrice: '月均价',
+ memoryLabel: '内存',
+ diskLabel: '磁盘',
+ selectNewPlan: '选择新方案',
+ noAvailablePlans: '没有可升级的方案(新方案月均价需高于当前方案)',
+ priceDifference: '补差价',
+ priceDifferenceHint: '根据剩余天数计算,将从用户余额中扣除',
+ userBalance: '用户余额',
+ insufficientBalance: '用户余额不足,无法完成升级',
+ confirmUpgrade: '确认升级',
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ yearly: '年付',
+ month: '月',
+ months: '个月',
+ // 操作结果
+ suspendSuccess: '实例已封停',
+ unsuspendSuccess: '实例已解封',
+ extendSuccess: '实例已延期',
+ refundSuccess: '退款成功',
+ batchUpdatePrice: '批量修改价格',
+ batchSelectedCount: '已选择 {count} 个实例',
+ selectAllCurrentPage: '选择当前页实例',
+ selectInstance: '选择实例',
+ batchPriceNoSelection: '请先选择实例',
+ batchSelected: '已选实例',
+ batchPreviewChanged: '将更新',
+ batchPreviewFailed: '失败项',
+ batchPriceHint: '批量使用同一个单周期价格;预览会按每个实例的周期、剩余天数和AFF折扣分别计算。',
+ batchTotalCharge: '合计补交',
+ batchTotalRefund: '合计退还',
+ batchNetAmount: '净影响',
+ batchPreviewBlocked: '当前预览包含失败项或用户余额不足,不能提交。',
+ batchUserImpact: '用户余额影响',
+ batchPreviewDetails: '预览明细',
+ batchPreviewWaiting: '输入价格后将自动生成预览。',
+ batchPriceStatusReady: '可更新',
+ batchPriceStatusUnchanged: '无变化',
+ batchPriceStatusFailed: '失败',
+ result: '结果',
+ // 扣费记录
+ recordType: '类型',
+ recordTypes: {
+ newPurchase: '新购',
+ renew: '续费',
+ upgrade: '升级',
+ downgrade: '降级',
+ refund: '退款',
+ transfer_fee: '转移手续费',
+ },
+ amount: '金额',
+ instance: '实例',
+ remark: '备注',
+ time: '时间',
+ noRecords: '暂无扣费记录',
+ // 充值统计
+ totalRecharge: '总充值',
+ thisMonthRecharge: '本月充值',
+ todayRecharge: '今日充值',
+ orders: '笔',
+ // AFF 返利统计
+ totalAffCommission: 'AFF返利总额',
+ thisMonthAff: '本月AFF返利',
+ affConverted: '已转化金额',
+ affPendingConvert: '待审批转化',
+ // 充值记录
+ loadRechargeRecordsFailed: '加载充值记录失败',
+ noRechargeRecords: '暂无充值记录',
+ rechargeOrderNo: '订单号',
+ creditAmount: '到账金额',
+ actualAmount: '实际到账',
+ estimatedAmount: '预计到账',
+ payChannel: '支付渠道',
+ paymentDetails: '支付详情',
+ paymentUuid: 'UUID:',
+ paymentTxid: 'TxID:',
+ rechargeStatusLabel: '状态',
+ tradeNo: '第三方单号',
+ sync: '同步',
+ syncSuccess: '同步成功,充值已到账',
+ rechargeStatus: {
+ pending: '待支付',
+ completed: '已完成',
+ cancelled: '已取消',
+ failed: '失败',
+ },
+ },
+ },
+
+ // 错误页面
+ error: {
+ notFound: '页面不存在',
+ notFoundDesc: '抱歉,您访问的页面不存在',
+ backHome: '返回首页',
+ serverError: '服务器错误',
+ networkError: '网络错误',
+ },
+
+ // 日志页面
+ logs: {
+ title: '系统日志',
+ module: '模块',
+ allModules: '全部模块',
+ search: '搜索',
+ searchPlaceholder: '搜索用户名、操作或内容...',
+ reset: '重置',
+ time: '时间',
+ user: '用户',
+ action: '操作',
+ content: '内容',
+ result: '结果',
+ system: '系统',
+ loading: '加载中...',
+ noLogs: '暂无日志记录',
+ loadFailed: '加载日志失败',
+ loadModulesFailed: '加载模块列表失败',
+ totalRecords: '共 {total} 条记录,第 {page} / {totalPages} 页',
+ success: '成功',
+ failed: '失败',
+ expand: '展开',
+ collapse: '收起',
+ },
+
+ // 帮助页面
+ help: {
+ title: '帮助中心',
+ search: '搜索帮助文档...',
+ description: '查看使用指南和常见问题',
+ backToHelp: '返回帮助中心',
+ updatedAt: '更新于 {date}',
+ all: '全部',
+ noArticles: '暂无帮助文档',
+ articleNotFound: '文档不存在或已被删除',
+ totalArticles: '共 {count} 篇文档',
+ categories: {
+ general: '常规',
+ gettingStarted: '快速开始',
+ instances: '实例管理',
+ networking: '网络配置',
+ billing: '计费相关',
+ faq: '常见问题',
+ },
+ },
+
+ // 日志模块翻译
+ logModules: {
+ security: '安全事件',
+ instance: '实例操作',
+ snapshot: '快照操作',
+ backup: '备份操作',
+ image: '镜像操作',
+ host: '节点操作',
+ package: '套餐操作',
+ user: '用户管理',
+ personal: '个人设置',
+ ssh_key: 'SSH 密钥',
+ notification: '通知设置',
+ system: '系统配置',
+ auth: '认证操作',
+ storage: '远程存储',
+ // 兼容旧数据中的中文模块名
+ '登录操作': '登录操作',
+ '安全事件': '安全事件',
+ '实例操作': '实例操作',
+ '快照操作': '快照操作',
+ '备份操作': '备份操作',
+ '镜像操作': '镜像操作',
+ '节点操作': '节点操作',
+ '节点组操作': '节点组操作',
+ '套餐操作': '套餐操作',
+ '用户管理': '用户管理',
+ '个人设置': '个人设置',
+ '通知设置': '通知设置',
+ '系统配置': '系统配置',
+ '认证操作': '认证操作',
+ '远程存储': '远程存储',
+ },
+
+ // 日志操作翻译
+ logActions: {
+ // 安全事件
+ 'login_success': '登录成功',
+ 'login_failed': '登录失败',
+ 'logout': '登出',
+ 'register_success': '注册成功',
+ 'rate_limit_exceeded': '登录尝试过多',
+ 'invalid_invite_code': '无效邀请码',
+ 'suspicious_activity': '可疑活动',
+ 'permission_denied': '权限不足',
+ 'unauthorized_access': '未授权访问',
+ 'admin_action': '管理员操作',
+ // 实例操作
+ 'instance.create': '创建实例',
+ 'instance.delete': '删除实例',
+ 'instance.start': '启动实例',
+ 'instance.stop': '停止实例',
+ 'instance.restart': '重启实例',
+ 'instance.rebuild': '重装系统',
+ 'instance.recreate': '重建实例',
+ 'instance.change_host': '改节点',
+ 'instance.cloud_init_manual_complete': '手动完成初始化检测',
+ 'instance.update_quota': '更新实例配额',
+ 'instance.rename': '重命名实例',
+ // 端口映射
+ 'port.add': '添加端口映射',
+ 'port.delete': '删除端口映射',
+ // 快照操作
+ 'snapshot.create': '创建快照',
+ 'snapshot.delete': '删除快照',
+ 'snapshot.restore': '恢复快照',
+ // 备份操作
+ 'backup.create': '创建备份',
+ 'backup.delete': '删除备份',
+ 'backup.export': '导出备份',
+ // 镜像操作
+ 'image.sync': '同步镜像',
+ 'image.delete': '删除镜像',
+ 'image.create': '创建镜像',
+ 'image.update': '更新镜像',
+ // 节点操作
+ 'host.create': '添加节点',
+ 'host.update': '更新节点',
+ 'host.delete': '删除节点',
+ 'host.test': '测试节点',
+ // 节点组操作
+ // 套餐操作
+ 'package.create': '创建套餐',
+ 'package.update': '更新套餐',
+ 'package.delete': '删除套餐',
+ // 用户管理
+ 'user.ban': '封禁用户',
+ 'user.unban': '解封用户',
+ 'user.delete': '删除用户',
+ 'user.update_quota': '更新用户配额',
+ 'user.revoke_sessions': '撤销用户会话',
+ // 个人设置
+ 'profile.update': '更新个人信息',
+ 'password.change': '修改密码',
+ // SSH 密钥
+ 'ssh_key.add': '添加 SSH 密钥',
+ 'ssh_key.delete': '删除 SSH 密钥',
+ // 通知设置
+ 'notification.add': '添加通知渠道',
+ 'notification.delete': '删除通知渠道',
+ // 安全设置
+ '2fa.setup': '设置双因素认证',
+ '2fa.enable': '启用双因素认证',
+ '2fa.disable': '禁用双因素认证',
+ '2fa.recovery_reset': '重置恢复码',
+ // 认证操作
+ 'session.revoke': '撤销会话',
+ 'session.revoke_all': '撤销所有会话',
+ // 系统配置
+ 'system.config_update': '更新系统配置',
+ // 帮助文档
+ 'help.create': '创建帮助文档',
+ 'help.update': '更新帮助文档',
+ 'help.delete': '删除帮助文档',
+ // 备份上传操作
+ 'backup.upload': '上传备份',
+ 'backup.upload.queue': '备份上传排队',
+ 'backup.upload.cancel': '取消备份上传',
+ // 备份恢复操作
+ 'backup.restore': '恢复备份',
+ 'backup.restore.rollback': '回滚恢复',
+ 'backup.restore.cancel': '取消恢复',
+ // 存储配置操作
+ 'storage.create': '创建存储配置',
+ 'storage.update': '更新存储配置',
+ 'storage.delete': '删除存储配置',
+ // AFF 推荐计划
+ 'aff.create_code': '创建优惠码',
+ 'aff.convert_request': '申请 AFF 转化',
+ 'aff.approve_convert': '审核通过 AFF 转化',
+ 'aff.reject_convert': '审核拒绝 AFF 转化',
+ },
+
+ // 日志结果翻译
+ logResults: {
+ success: '成功',
+ failed: '失败',
+ warning: '警告',
+ },
+
+ // API 错误码翻译
+ errors: {
+ // 通用错误
+ INVALID_ID: '无效的 ID',
+ NOT_FOUND: '资源不存在',
+ UNAUTHORIZED: '未授权',
+ FORBIDDEN: '无权访问',
+ ADMIN_REQUIRED: '需要管理员权限',
+ // 用户错误
+ USER_NOT_FOUND: '用户不存在',
+ USER_EXISTS: '用户名已存在',
+ CANNOT_MODIFY_SELF: '不能修改自己的状态',
+ CANNOT_DELETE_SELF: '不能删除自己',
+ CANNOT_BAN_ADMIN: '不能封禁管理员账户',
+ CANNOT_DELETE_ADMIN: '不能删除管理员账户',
+ USER_HAS_INSTANCES: '该用户还有实例,请先删除实例',
+ // 认证错误
+ INVALID_CREDENTIALS: '用户名或密码错误',
+ ACCOUNT_BANNED: '账户已被禁用',
+ TOO_MANY_ATTEMPTS: '登录尝试次数过多,请稍后再试',
+ REGISTRATION_DISABLED: '当前已关闭注册',
+ INVALID_INVITE_CODE: '邀请码无效或已使用',
+ INVITE_CODE_EXPIRED: '邀请码已过期',
+ INVALID_2FA_CODE: '验证码或恢复码错误',
+ TWO_FA_REQUIRED: '请输入双因素认证码',
+ TWO_FA_ALREADY_ENABLED: '2FA 已启用,请先禁用后再重新设置',
+ TWO_FA_NOT_ENABLED: '2FA 未启用',
+ REFRESH_TOKEN_MISSING: '缺少刷新令牌',
+ REFRESH_TOKEN_INVALID: '刷新令牌无效或已过期',
+ SESSION_NOT_FOUND: '会话不存在',
+ // 验证错误
+ INVALID_EMAIL: '请输入有效的邮箱地址',
+ EMAIL_CONTAINS_ILLEGAL: '邮箱包含非法字符',
+ USERNAME_CONTAINS_ILLEGAL: '用户名包含非法字符',
+ PASSWORD_TOO_WEAK: '密码强度不足',
+ INVALID_SSH_KEY: '无效的 SSH 公钥格式',
+ SSH_KEY_EXISTS: '该公钥已添加',
+ INVALID_NAME: '名称格式不正确',
+ // 实例错误
+ INSTANCE_NOT_FOUND: '实例不存在',
+ INSTANCE_ALREADY_RUNNING: '实例已在运行',
+ INSTANCE_ALREADY_STOPPED: '实例已停止',
+ INSTANCE_STATUS_INVALID: '实例状态不允许此操作',
+ INSTANCE_SUSPENDED: '实例已被封停,无法执行此操作',
+ INSTANCE_NOT_SUSPENDED: '实例未处于封停状态',
+ INSTANCE_SUSPENDED_EXPIRED: '实例因到期被封停,请续费后解封',
+ INSTANCE_DESTROY_TRAFFIC_LIMIT_EXCEEDED: '当前月流量周期无法销毁,已用流量达到或超过 5G',
+ // 节点错误
+ HOST_NOT_FOUND: '宿主机不存在',
+ HOST_OFFLINE: '宿主机离线',
+ HOST_HAS_INSTANCES: '宿主机上有实例,请先删除',
+ HOST_ALREADY_OFFICIAL: '该节点已经是自营节点',
+ HOST_TAKEOVER_PACKAGE_BINDING_CONFLICT: '接管失败,部分套餐会失去全部绑定节点',
+ NO_AVAILABLE_HOSTS: '没有可用的节点',
+ // 镜像错误
+ IMAGE_NOT_FOUND: '镜像不存在',
+ IMAGE_SYNCED_ON_HOSTS: '镜像已在节点上同步,请先从节点删除',
+ IMAGE_TYPE_MISMATCH: '所选镜像与套餐实例类型不兼容',
+ // 套餐错误
+ PACKAGE_NOT_FOUND: '套餐不存在',
+ PACKAGE_IN_USE: '套餐正在被实例使用',
+ // 配额错误
+ QUOTA_EXCEEDED: '配额不足',
+ QUOTA_CPU_EXCEEDED: 'CPU 配额不足',
+ QUOTA_MEMORY_EXCEEDED: '内存配额不足',
+ QUOTA_DISK_EXCEEDED: '磁盘配额不足',
+ QUOTA_INSTANCE_EXCEEDED: '实例数量已达上限',
+ QUOTA_PORT_EXCEEDED: '端口配额不足',
+ QUOTA_SNAPSHOT_EXCEEDED: '快照配额不足',
+ QUOTA_BACKUP_EXCEEDED: '备份配额不足',
+ QUOTA_NOT_ALLOCATED: '请先分配配额',
+ // 快照错误
+ SNAPSHOT_NOT_FOUND: '快照不存在',
+ SNAPSHOT_RESTORE_REQUIRES_STOP: '请先停止实例再恢复快照',
+ // 备份错误
+ BACKUP_NOT_FOUND: '备份不存在',
+ BACKUP_NOT_READY: '备份尚未就绪,无法导出',
+ EXPORT_TASK_NOT_FOUND: '导出任务不存在或已过期',
+ EXPORT_TASK_EXPIRED: '导出任务已过期',
+ // 端口映射错误
+ PORT_IN_USE: '该端口已被使用',
+ PORT_OUT_OF_RANGE: '端口超出允许范围',
+ PORT_MAPPING_NOT_FOUND: '端口映射不存在',
+ // 节点组错误
+ NODE_GROUP_NOT_FOUND: '节点组不存在',
+ NODE_GROUP_HAS_HOSTS: '节点组下有宿主机,请先移除',
+ NODE_GROUP_HAS_PACKAGES: '节点组被套餐使用中',
+ // 通知错误
+ NOTIFICATION_CHANNEL_NOT_FOUND: '通知渠道不存在',
+ // 帮助错误
+ ARTICLE_NOT_FOUND: '文档不存在',
+ SLUG_EXISTS: 'Slug 已存在',
+ // 邀请码错误
+ INVITE_CODE_USED: '邀请码已被使用',
+ INVITE_CODE_NOT_FOUND: '邀请码不存在',
+ // OAuth 错误
+ OAUTH_PROVIDER_DISABLED: '该登录方式已被禁用',
+ OAUTH_ALREADY_BOUND: '该账号已被其他用户绑定',
+ OAUTH_NOT_BOUND: '账号未绑定',
+ OAUTH_TOKEN_ERROR: '获取授权失败',
+ // SSH 密钥错误
+ SSH_KEY_NOT_FOUND: 'SSH 密钥不存在',
+ SSH_KEY_REQUIRED: '必须选择一个 SSH 密钥,请先在个人设置中添加',
+ SSH_KEY_NOT_OWNED: 'SSH 密钥不存在或不属于该用户',
+ // 套餐错误(补充)
+ PACKAGE_UNAVAILABLE: '套餐不可用或已下架',
+ CANNOT_CREATE_OWN_PAID_PACKAGE: '不能使用自己的付费套餐创建实例',
+ // 节点资源错误
+ HOST_UNAVAILABLE: '选择的宿主机不可用或资源不足',
+ HOST_NO_ONLINE: '没有在线的宿主机,请先在节点管理中测试连接',
+ HOST_RESOURCES_NOT_SYNCED: '宿主机资源信息未同步,请在节点管理中点击"测试连接"',
+ HOST_NODE_GROUP_NO_HOSTS: '套餐要求的节点组中没有可用节点',
+ HOST_RESOURCES_INSUFFICIENT: '所有宿主机资源不足,请稍后再试或选择其他套餐',
+ HOST_NAME_EXISTS: '宿主机名称已存在',
+ HOST_ADDRESS_EXISTS: '宿主机连接地址已存在',
+ HOST_ADDRESS_UNRESOLVABLE: '面板当前无法解析该宿主机连接地址',
+ HOST_CERT_NOT_CONFIGURED: '请先配置证书路径',
+ HOST_INVALID_CPU_MAX: 'CPU 最大额配不能为负数',
+ HOST_INVALID_MEMORY_MAX: '内存最大值不能为负数或低于 256MB',
+ HOST_INVALID_IPV6_MODE: 'IPv6 模式必须是 1(路由)、2(NAT) 或 3(禁用)',
+ HOST_IPV6_ROUTE_REQUIRES_CONFIG: 'IPv6 路由模式需要提供子网和父接口',
+ HOST_CPU_BELOW_USED: 'CPU 最大额配不能低于实例已使用的资源',
+ HOST_MEMORY_BELOW_USED: '内存最大值不能低于实例已使用的资源',
+ // 实例操作错误
+ INSTANCE_STOP_REQUIRED: '请先停止实例',
+ INSTANCE_IMAGE_REQUIRED: '请指定镜像',
+ INSTANCE_IMAGE_UNAVAILABLE: '选择的镜像在当前节点不可用',
+ INSTANCE_REBUILD_FAILED: '重装系统失败',
+ INSTANCE_IPV6_NOT_SUPPORTED: '只有 NAT + IPv6 模式的实例才能重新分配 IPv6',
+ INSTANCE_IPV6_REASSIGN_FAILED: '重新分配 IPv6 地址失败',
+ INSTANCE_NO_IPV4: '虚拟机实例需要分配 IPv4 地址才能添加端口映射',
+ HOST_NO_IPV6_SUBNET: '宿主机未配置 IPv6 子网',
+ IPV6_POOL_EXHAUSTED: 'IPv6 地址池已耗尽,请稍后重试',
+ IPV6_REASSIGN_COOLDOWN: '每实例每天只能重新分配一次 IPv6,请稍后再试',
+ // 端口映射错误(补充)
+ PORT_MAPPING_NAT_ONLY: '仅 NAT 和双栈模式支持端口映射',
+ PORT_NO_AVAILABLE: '没有可用端口,请联系管理员',
+ PORT_CONFLICT: '端口已被占用,请使用其他端口',
+ PORT_MAPPING_INVALID_ID: '无效的实例或端口映射 ID',
+ // 镜像错误(补充)
+ IMAGE_CREATE_FAILED: '镜像创建失败',
+ IMAGE_UPDATE_FAILED: '镜像更新失败',
+ IMAGE_INVALID_HOST_ID: '无效的镜像或节点 ID',
+ // 备份错误(补充)
+ BACKUP_CREATE_FAILED: '创建备份失败',
+ BACKUP_DELETE_FAILED: '删除备份失败',
+ BACKUP_EXPORT_FAILED: '导出备份失败',
+ BACKUP_QUOTA_NOT_SET: '请先为该实例设置备份配额',
+ BACKUP_EXPORT_STATUS_INVALID: '导出任务状态异常',
+ // 配置错误
+ CONFIG_INVALID_KEY: '无效的配置键',
+ CONFIG_INVALID_VALUE: '配置值必须是非负整数',
+ // 快照错误(补充)
+ SNAPSHOT_QUOTA_NOT_SET: '请先为该实例设置快照配额',
+ // 套餐错误(补充)
+ PACKAGE_HAS_INSTANCES: '该套餐正在被实例使用,无法删除',
+ // 资源限制错误
+ RESOURCE_CPU_EXCEEDS_PACKAGE: 'CPU 配置超出套餐限制',
+ RESOURCE_MEMORY_EXCEEDS_PACKAGE: '内存配置超出套餐限制',
+ RESOURCE_DISK_EXCEEDS_PACKAGE: '磁盘配置超出套餐限制',
+ // 用户配额错误(详细)
+ QUOTA_CPU_INSUFFICIENT: 'CPU 配额不足',
+ QUOTA_MEMORY_INSUFFICIENT: '内存配额不足',
+ QUOTA_DISK_INSUFFICIENT: '磁盘配额不足',
+ QUOTA_INSTANCE_LIMIT_REACHED: '实例数量已达上限',
+ QUOTA_HOST_LIMIT_REACHED: '宿主机数量已达上限',
+ QUOTA_FRIEND_LIMIT_REACHED: '您的好友数量已达上限',
+ QUOTA_PORT_BELOW_USED: '端口配额不能小于当前已使用量',
+ QUOTA_SNAPSHOT_BELOW_USED: '快照配额不能小于当前已使用量',
+ QUOTA_BACKUP_BELOW_USED: '备份配额不能小于当前已使用量',
+ QUOTA_PORT_TOTAL_EXCEEDED: '端口配额超出用户总配额',
+ QUOTA_SNAPSHOT_TOTAL_EXCEEDED: '快照配额超出用户总配额',
+ QUOTA_BACKUP_TOTAL_EXCEEDED: '备份配额超出用户总配额',
+ // 镜像错误(补充)
+ IMAGE_NOT_SYNCED: '镜像尚未同步到所选宿主机',
+ IMAGE_SYNCING_CANNOT_DELETE: '镜像正在同步中,无法删除',
+ IMAGE_NO_HOSTS: '没有可用的节点,请先添加节点',
+ // 端口错误(补充)
+ PORT_RANGE_INVALID: '端口必须在允许范围内',
+ // 快照策略错误
+ SNAPSHOT_MANUAL_FULL: '手动快照已达实例配额,无法启用自动快照',
+ SNAPSHOT_RETENTION_EXCEEDS: '保留数量超出可用配额',
+ // 备份策略错误
+ BACKUP_MANUAL_FULL: '手动备份已达实例配额,无法启用自动备份',
+ BACKUP_RETENTION_EXCEEDS: '保留数量超出可用配额',
+ // OAuth 错误(补充)
+ OAUTH_NOT_ENABLED: '该登录方式未启用',
+ // 存储池错误
+ STORAGE_POOL_NOT_CONFIGURED: '宿主机未配置系统盘存储池,无法创建实例',
+ // 好友系统错误
+ CANNOT_ADD_SELF: '不能添加自己为好友',
+ ALREADY_FRIENDS: '你们已经是好友了',
+ FRIEND_REQUEST_PENDING: '好友请求已在待处理中',
+ FRIEND_REQUEST_NOT_FOUND: '好友请求不存在',
+ FRIEND_REQUEST_NOT_PENDING: '该请求已被处理',
+ FRIENDSHIP_NOT_FOUND: '好友关系不存在',
+ TARGET_FRIEND_QUOTA_FULL: '对方好友数量已达上限,无法添加',
+ // 套餐共享错误
+ CANNOT_SHARE_TO_SELF: '不能共享给自己',
+ PACKAGE_ALREADY_SHARED: '套餐已共享给该用户',
+ SHARE_NOT_FOUND: '共享记录不存在',
+ NOT_FRIENDS: '对方不是您的好友',
+ SHARE_QUOTA_CPU_EXCEEDED: '共享套餐 CPU 配额已用完',
+ SHARE_QUOTA_MEMORY_EXCEEDED: '共享套餐内存配额已用完',
+ SHARE_QUOTA_INSTANCES_EXCEEDED: '共享套餐实例数量已达上限',
+ // Email verification errors
+ EMAIL_VERIFICATION_DISABLED: '邮箱验证功能未启用',
+ EMAIL_CODE_REQUIRED: '请输入邮箱验证码',
+ INVALID_EMAIL_CODE: '验证码无效或已过期',
+ TOO_MANY_VERIFICATION_REQUESTS: '验证码请求过于频繁,请稍后再试',
+ EMAIL_SEND_FAILED: '验证邮件发送失败,请稍后再试',
+ EMAIL_ALREADY_REGISTERED: '该邮箱地址已被使用',
+ EMAIL_DOMAIN_NOT_ALLOWED: '该邮箱域名不允许注册,请使用其他邮箱',
+ // 转移错误
+ TRANSFER_NOT_FOUND: '转移请求不存在',
+ TRANSFER_TO_SELF: '不能转移给自己',
+ TRANSFER_TO_BANNED: '不能转移给被封禁的用户',
+ TRANSFER_ALREADY_PENDING: '该实例已有待处理的转移请求',
+ TRANSFER_NOT_PENDING: '转移请求不在等待状态',
+ TRANSFER_INVALID_STATUS: '实例状态不允许转移',
+ TRANSFER_QUOTA_NOT_FOUND: '接收方配额信息不存在',
+ TRANSFER_QUOTA_INSUFFICIENT: '接收方配额不足',
+ TRANSFER_INSTANCE_LOCKED: '实例正在转移中,暂时无法操作',
+ TRANSFER_HOST_DISABLED: '该节点已禁止转移操作',
+ TRANSFER_INSUFFICIENT_BALANCE: '余额不足,无法支付转移手续费',
+ // 转移过程错误
+ INSTANCE_MUST_BE_STOPPED: '实例必须在转移前停止',
+ INCUS_RENAME_FAILED: '在宿主机上重命名实例失败',
+ DATABASE_ERROR: '数据库操作失败,请重试',
+ // 敏感操作二次验证错误
+ VERIFICATION_REQUIRED: '此操作需要二次验证',
+ INVALID_CODE: '验证码无效或已过期',
+ NO_NOTIFICATION_CHANNEL: '未绑定通知渠道,无需二次验证',
+ EMAIL_NOT_CONFIGURED: '未配置邮箱,无法发送验证码',
+ SEND_FAILED: '验证码发送失败',
+ // 签到错误
+ CHECKIN_NO_INSTANCE: '您需要至少拥有一个实例才能签到',
+ CHECKIN_ALREADY_TODAY: '今日已签到',
+ REDEEM_ALREADY_TODAY: '今日已兑换过兑换码',
+ REDEEM_CODE_NOT_FOUND: '兑换码不存在',
+ REDEEM_CODE_USED: '兑换码已被使用',
+ REDEEM_CODE_EXPIRED: '兑换码已过期',
+ REDEEM_CODE_SELF_ONLY: '此兑换码只能由所有者自己使用',
+ REDEEM_CODE_DISABLED: '此兑换码已被禁用',
+ REDEEM_CODE_INVALID_FORMAT: '兑换码格式错误,仅支持 h- 开头的系统兑换码',
+ REDEEM_CODE_EXHAUSTED: '此兑换码已达到最大使用次数',
+ REDEEM_CODE_ALREADY_USED_BY_USER: '您已使用过此兑换码',
+ REDEEM_CODE_HOST_MISMATCH: '此兑换码只能用于同一节点的实例',
+ REDEEM_CODE_BATCH_LIMIT: '您已使用过该批次的其他兑换码',
+ REDEEM_EXCEEDS_PACKAGE_QUOTA: '兑换后将超出实例套餐配额限制',
+ REDEEM_ALREADY_AT_LIMIT: '实例资源已达到套餐上限',
+ CHECKIN_CODE_PAID_INSTANCE: '签到兑换码只能用于免费实例',
+ PAID_INSTANCE_DELETION_NOT_ALLOWED: '付费实例不允许删除',
+ // 余额错误
+ INSUFFICIENT_BALANCE: '余额不足,请先充值',
+ INTERNAL_ERROR: '服务器内部错误',
+ },
+
+ // 流量统计
+ traffic: {
+ title: '流量统计',
+ monthlyUsage: '本月流量',
+ used: '已使用',
+ unlimited: '无限制',
+ total: '总计',
+ history30Days: '近 30 天流量',
+ historyPeriod: '周期流量',
+ noData: '暂无流量数据',
+ noHistoryData: '暂无历史数据',
+ throttledHint: '已限速至 1Mbps',
+ resetHint: '每月 1 号重置流量',
+ periodResetHint: '每月 {date} 号重置流量',
+ status: {
+ normal: '正常',
+ warning: '预警',
+ limited: '已限速',
+ },
+ download: '下载',
+ upload: '上传',
+ limit: '限额',
+ extraQuota: '额外配额',
+ resetDate: '重置日期',
+ nextReset: '下次重置',
+ },
+
+ // 实例转移
+ transfer: {
+ title: '实例转移',
+ sentTab: '转移记录',
+ receivedTab: '接收记录',
+ pendingCount: '待处理',
+ noTransfers: '暂无转移记录',
+ noPendingTransfers: '暂无待接收的转移',
+ searchPlaceholder: '搜索实例名称、接收方用户名或备注...',
+ // 状态
+ status: {
+ pending: '等待接收',
+ processing: '处理中',
+ accepted: '已接收',
+ rejected: '已拒绝',
+ cancelled: '已取消',
+ },
+ // 操作
+ actions: {
+ transfer: '转移',
+ accept: '接受',
+ reject: '拒绝',
+ cancel: '取消',
+ push: '直接推送',
+ },
+ // 完成时间
+ completedAt: '完成时间',
+ rejectedAt: '拒绝时间',
+ cancelledAt: '取消时间',
+ // 转移弹窗
+ modal: {
+ title: '转移实例',
+ targetUser: '接收方用户名',
+ targetUserPlaceholder: '输入接收方用户名',
+ searchUser: '搜索用户',
+ userNotFound: '用户不存在',
+ userBanned: '该用户已被封禁',
+ cannotTransferToSelf: '不能转移给自己',
+ remark: '备注(可选)',
+ remarkPlaceholder: '输入转移备注',
+ quotaCheck: '配额检查',
+ instance: '实例',
+ quotaSufficient: '配额充足',
+ quotaInsufficient: '配额不足',
+ canTransfer: '用户状态正常,可以进行转移操作',
+ confirmTransfer: '确认转移',
+ transferring: '转移中...',
+ deleteWarning: '注意:如果对方接收转移,系统将自动删除该实例的端口映射、建站反代、快照、备份等关联资源。',
+ feeLabel: '转移手续费',
+ balanceLabel: '当前余额',
+ insufficientBalance: '余额不足,请先充值',
+ feeRefundHint: '确认转移时将扣除手续费,对方拒绝接收将自动退还',
+ },
+ // 拒绝弹窗
+ rejectModal: {
+ title: '拒绝转移',
+ reason: '拒绝原因(可选)',
+ reasonPlaceholder: '输入拒绝原因',
+ },
+ // 配置详情弹窗
+ configModal: {
+ title: '转移时配置详情',
+ instanceName: '实例名称',
+ hostInfo: '宿主机信息',
+ networkMode: '网络模式',
+ portMappings: '端口映射',
+ snapshots: '快照',
+ backups: '备份',
+ package: '套餐',
+ },
+ hasRemark: '有备注',
+ // 详情
+ detail: {
+ fromUser: '发起方',
+ toUser: '接收方',
+ instance: '实例',
+ snapshot: '转移时配置',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '磁盘',
+ ports: '端口映射',
+ snapshots: '快照',
+ backups: '备份',
+ host: '节点',
+ package: '套餐',
+ remark: '备注',
+ rejectReason: '拒绝原因',
+ createdAt: '发起时间',
+ acceptedAt: '接收时间',
+ rejectedAt: '拒绝时间',
+ cancelledAt: '取消时间',
+ },
+ // 提示消息
+ messages: {
+ transferSuccess: '转移请求已发送',
+ transferComplete: '转移完成',
+ acceptSuccess: '已接受转移',
+ rejectSuccess: '已拒绝转移',
+ cancelSuccess: '已取消转移',
+ pushSuccess: '已成功推送实例',
+ instanceLocked: '该实例正在转移中,暂时无法操作',
+ },
+ // 错误
+ errors: {
+ TRANSFER_NOT_FOUND: '转移请求不存在',
+ TRANSFER_TO_SELF: '不能转移给自己',
+ TRANSFER_TO_BANNED: '不能转移给被封禁的用户',
+ TRANSFER_ALREADY_PENDING: '该实例已有待处理的转移请求',
+ TRANSFER_NOT_PENDING: '转移请求不在等待状态',
+ TRANSFER_INVALID_STATUS: '实例状态不允许转移',
+ TRANSFER_QUOTA_NOT_FOUND: '接收方配额信息不存在',
+ TRANSFER_QUOTA_INSUFFICIENT: '接收方配额不足',
+ TRANSFER_INSTANCE_LOCKED: '实例正在转移中,暂时无法操作',
+ TRANSFER_HOST_DISABLED: '该节点已禁止转移操作',
+ TRANSFER_INSUFFICIENT_BALANCE: '余额不足,无法支付转移手续费',
+ PUSH_NOT_HOST_OWNER: '只有宿主机所有者才能直接推送',
+ },
+ },
+
+ // 好友系统
+ friends: {
+ title: '好友',
+ description: '管理您的好友列表,好友之间可以共享资源',
+ friendsList: '好友列表',
+ pendingRequests: '待处理请求',
+ historyRequests: '历史记录',
+ addFriend: '添加好友',
+ username: '用户名',
+ usernamePlaceholder: '输入对方用户名',
+ usernameHint: '输入您想添加的好友用户名',
+ remark: '备注',
+ remarkPlaceholder: '请输入备注信息(可选)',
+ remarkHint: '例如:认识原因、用途说明等',
+ sendRequest: '发送请求',
+ accept: '接受',
+ reject: '拒绝',
+ noFriends: '暂无好友',
+ noFriendsHint: '添加好友后可以共享节点、套餐和镜像资源',
+ noSearchResult: '未找到匹配的好友',
+ noHistorySearchResult: '未找到匹配的历史记录',
+ noPendingRequests: '暂无待处理的好友请求',
+ noHistoryRecords: '暂无历史记录',
+ noHistoryRecordsHint: '已处理的好友请求将显示在这里',
+ addedOn: '添加于',
+ requestedOn: '请求于',
+ sentOn: '发送于',
+ sentTo: '发送给 {username}',
+ processedOn: '处理于',
+ confirmRemove: '确定要删除好友 {name} 吗?',
+ requestSent: '好友请求已发送',
+ requestAccepted: '已接受好友请求',
+ requestRejected: '已拒绝好友请求',
+ friendRemoved: '已删除好友',
+ statusAccepted: '已接受',
+ statusRejected: '已拒绝',
+ filterAll: '全部',
+ filterAccepted: '已接受',
+ filterRejected: '已拒绝',
+ hosts: '节点',
+ instances: '实例',
+ // 邀请码
+ invites: '邀请码',
+ generateInvite: '生成邀请码',
+ generateInviteTitle: '生成邀请码',
+ inviteCode: '邀请码',
+ inviteStatus: '状态',
+ inviteUsed: '已使用',
+ inviteExpired: '已过期',
+ inviteUnused: '未使用',
+ usedBy: '使用者',
+ createdAt: '创建时间',
+ expiresAt: '过期时间',
+ permanent: '永久有效',
+ noInvites: '暂无邀请码',
+ noInvitesHint: '生成邀请码后,其他用户可以使用它们注册账号',
+ inviteCount: '生成数量',
+ inviteCountHint: '可以批量生成 1-10 个邀请码',
+ expireDays: '过期天数',
+ expireDaysPlaceholder: '0 表示永不过期',
+ expireDaysHint: '设置邀请码的过期时间,0 表示永不过期',
+ generate: '生成',
+ confirmDeleteInvite: '确定删除邀请码 {code}?',
+ inviteDeleted: '邀请码已删除',
+ inviteGenerated: '邀请码已生成',
+ copyCode: '复制邀请码',
+ copyLink: '复制邀请链接',
+ inviteCodeCopied: '邀请码已复制',
+ inviteLinkCopied: '邀请链接已复制',
+ close: '关闭',
+ deleteInvite: '删除',
+ userNotFound: '未找到该用户',
+ cannotAddSelf: '不能添加自己为好友',
+ alreadyFriend: '你们已经是好友了',
+ requestAlreadyPending: '好友请求已在待处理中',
+ requestNotFound: '请求不存在或已处理',
+ friendshipNotFound: '好友关系不存在',
+ // 套餐共享
+ selectFriendHint: '请选择一个好友',
+ selectFriendDesc: '点击左侧好友卡片来管理套餐共享',
+ sharedPackages: '已共享的套餐',
+ availablePackages: '可共享的套餐',
+ noSharedPackages: '尚未共享任何套餐',
+ addShare: '添加共享',
+ addFirstShare: '共享第一个套餐',
+ removeShare: '取消共享',
+ editQuota: '编辑配额',
+ quotaMultiplier: '配额倍数',
+ quotaMultiplierHint: '例如 1、1.5、2 倍,设置好友可使用的资源比例',
+ maxInstances: '最大实例数',
+ maxInstancesHint: '限制好友可创建的实例数量',
+ noLimit: '无限制',
+ currentUsage: '当前使用',
+ shareAdded: '套餐已共享',
+ shareRemoved: '已取消共享',
+ quotaUpdated: '配额已更新',
+ confirmRemoveShare: '确定要取消共享套餐 {package} 吗?',
+ addShareTitle: '共享套餐',
+ shareToFriend: '共享给该好友',
+ selectPackage: '选择套餐',
+ selectPackagePlaceholder: '请选择要共享的套餐',
+ confirmShare: '确认共享',
+ editQuotaTitle: '编辑配额限制',
+ sharedTo: '共享给',
+ removeFriend: '删除好友',
+ noPackagesToShare: '您还没有可共享的套餐',
+ createPackageFirst: '请先在“我的套餐”中创建套餐',
+ noPackageSearchResult: '未找到匹配的套餐',
+ },
+
+ // 套餐标签
+ package: {
+ shared: '好友',
+ globalShared: '可用',
+ friendPrefix: '好友:',
+ myPackage: '我的套餐',
+ soldOut: '售罄',
+ },
+
+ // 用户资源管理
+ resources: {
+ hosts: {
+ title: '我的节点',
+ description: '管理您的节点,这些节点可以供您和您的好友使用',
+ create: '添加节点',
+ createDesc: '添加一个新的 Incus 节点',
+ ubuntuOnlyHint: '目前仅支持 Ubuntu 22.04+ 和 Debian 11+ 系统。',
+ installHintTitle: '填写节点信息并提交后,系统会生成包含面板地址和 Token 的安装命令,复制到节点宿主机执行即可完成安装。',
+ installHintIpv6: '提示:如需 IPv6 网络模式(NAT+IPv6、IPv6 Only),请先在宿主机执行安装脚本,脚本会自动生成 IPv6 子网信息供您填入下方表单。',
+ ipv6OptionalHint: '如不清楚以上信息,可先留空。在节点宿主机执行安装脚本后,脚本会自动检测并输出可用的 IPv6 子网,届时回到面板编辑节点补充即可。',
+ storagePoolAfterConnectHint: '节点连接成功后,请记得前往节点详细页的“存储”标签页创建存储池。',
+ noHosts: '暂无节点',
+ noHostsHint: '添加节点后可以在其上创建实例',
+ calibrateAll: '对齐全部',
+ noOnlineHosts: '没有在线的节点可对齐',
+ calibrateAllDone: '已对齐 {total} 个节点,其中 {changed} 个有差异已修正',
+ calibrateAllNoChange: '已对齐 {total} 个节点,无差异',
+ nameHint: '节点名称以 PEER + 您的用户ID 作为前缀,后面可自定义',
+ nameSuffixRequired: '请输入节点名称后缀',
+ // 管理员专用:节点切换器
+ mine: '我的节点',
+ hosted: '托管节点',
+ owner: '所有者',
+ filterByUserId: '用户ID',
+ takeoverOfficial: '接管为自营',
+ takeoverOfficialLoading: '接管中...',
+ takeoverOfficialConfirm: '确定将节点“{name}”接管为自营吗?该操作会转移当前节点,并自动接管可安全转移的套餐;现有实例归属用户不变。',
+ takeoverOfficialSuccess: '已接管节点 {name},转移 {packages} 个套餐,保留 {instances} 个实例',
+ takeoverOfficialDetached: '其中 {count} 个套餐仍绑定其他托管节点,已移除当前节点绑定:{names}',
+ takeoverOfficialBlocked: '无法接管:有 {count} 个套餐在移除当前节点后将失去全部绑定节点,请先手动处理这些套餐:{names}',
+ },
+ packages: {
+ title: '我的套餐',
+ description: '管理您的套餐配置,这些套餐可以供您和您的好友使用',
+ create: '创建套餐',
+ noPackages: '暂无套餐',
+ noPackagesHint: '创建套餐后可以在创建实例时使用',
+ share: '共享套餐',
+ viewShares: '查看共享列表',
+ selectFriend: '选择好友',
+ selectFriendPlaceholder: '请选择要共享的好友',
+ noFriends: '暂无好友,请先添加好友',
+ shareSuccess: '套餐共享成功',
+ shareFailed: '套餐共享失败',
+ confirmUnshare: '确定要取消共享吗?',
+ unshareSuccess: '已取消共享',
+ unshareFailed: '取消共享失败',
+ sharesList: '共享列表',
+ sharesCount: '{count} 人',
+ noShares: '暂无共享记录',
+ noSharesHint: '共享后好友可使用此套餐创建实例',
+ sharedAt: '共享时间',
+ unshare: '取消共享',
+ searchFriend: '搜索好友...',
+ noAvailableFriends: '所有好友都已共享此套餐',
+ selectToShare: '选择要共享的好友',
+ // 配额限制
+ quotaSettings: '配额限制',
+ quotaMultiplier: '资源配额倍数',
+ quotaMultiplierHint: '限制好友可使用的 CPU/内存占套餐比例',
+ maxInstances: '最大实例数',
+ maxInstancesHint: '限制好友最多可开通的实例数量',
+ noLimit: '无限制',
+ instanceUnit: '{n} 台',
+ quotaDisplay: '配额: {multiplier} · 实例: {instances}',
+ usageDisplay: '已用: {cpu}% CPU / {memory} MB 内存 / {instances} 台',
+ updateQuota: '修改配额',
+ updateQuotaSuccess: '配额已更新',
+ updateQuotaFailed: '更新配额失败',
+ // 美化弹窗新增
+ shareToFriend: '分享套餐给好友',
+ noFriendsHint: '先添加好友才能共享套餐',
+ allFriendsShared: '已全部共享',
+ confirmShare: '确认共享',
+ editQuota: '编辑配额',
+ editQuotaFor: '正在编辑 {username} 的配额',
+ quotaUpdated: '配额已更新',
+ quotaUpdateFailed: '更新配额失败',
+ usageStatus: '使用情况',
+ instanceCount: '实例数量',
+ packageInstanceCount: '套餐下实例数',
+ networkModeColumn: '网络模式',
+ instanceTypeColumn: '实例类型',
+ trafficMultiplierColumn: '流量倍率',
+ hostColumn: '宿主机',
+ instanceColumn: '实例数',
+ publicBadge: '已公开',
+ currentUsage: '当前已用: {cpu}% CPU / {memory} MB 内存 / {instances} 台实例',
+ currentUsageInfo: '当前已使用 {cpu}% CPU、{memory} MB 内存、{instances} 台实例',
+ addShare: '添加共享',
+ searchPlaceholder: '搜索套餐名、节点名、描述...',
+ noSearchResults: '未找到匹配的套餐',
+ clearSearch: '清除搜索',
+ // 通知渠道设置
+ notifyChannel: '通知渠道',
+ notifyChannelTitle: '资源释放通知渠道',
+ notifyChannelDesc: '当用户删除实例或宿主机所有者释放配额时,系统会通过此渠道发送通知',
+ // 分享链接
+ copyShareLink: '复制分享链接',
+ shareLinkCopied: '分享链接已复制,用户访问该链接可直达开通实例页面',
+ // 管理员专用:套餐切换器
+ mine: '我的套餐',
+ hosted: '托管套餐',
+ owner: '所有者',
+ filterByUserId: '用户ID',
+ },
+ // 套餐方案管理
+ plans: {
+ title: '方案管理',
+ manage: '管理方案',
+ noPlans: '暂无方案',
+ noPlansHint: '创建方案后,用户可以购买此套餐的付费实例',
+ add: '添加方案',
+ create: '创建方案',
+ edit: '编辑方案',
+ name: '方案名称',
+ namePlaceholder: '如:基础版、专业版、企业版',
+ description: '方案描述',
+ descriptionPlaceholder: '可选,用于向用户展示方案特点',
+ resourceConfig: '资源配置',
+ portLimit: '端口数',
+ snapshotLimit: '快照数',
+ backupLimit: '备份数',
+ siteLimit: '站点数',
+ swapSize: 'SWAP 大小',
+ trafficLimit: '流量限额',
+ trafficSpeed: '带宽限制',
+ unlimitedHint: '留空表示无限制',
+ billingConfig: '计费配置',
+ price: '价格',
+ billingCycle: '计费周期',
+ setupFee: '开通费',
+ slaGuarantee: 'SLA保证',
+ stock: '库存',
+ isActive: '启用方案',
+ status: '方案状态',
+ statusActive: '可售',
+ statusActiveHint: '正常展示并允许用户选择和开通',
+ statusSoldOut: '售罄',
+ statusSoldOutHint: '继续展示,但禁止新开通和改方案',
+ statusInactive: '下架',
+ statusInactiveHint: '不在开通入口展示,也不能被选择',
+ sortOrder: '排序',
+ daily: '按天',
+ weekly: '按周',
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ yearly: '年付',
+ days: '天',
+ months: '个月',
+ createSuccess: '方案创建成功',
+ updateSuccess: '方案更新成功',
+ saveFailed: '保存方案失败',
+ priceRangeHint: '最高 ¥{max}',
+ priceRangeError: '方案价格必须在 0-{max} 元之间,最多支持两位小数',
+ confirmDelete: '确定要删除方案「{name}」吗?',
+ deleteSuccess: '方案已删除',
+ deleteFailed: '删除方案失败',
+ },
+ images: {
+ title: '我的镜像',
+ description: '管理您的镜像配置,这些镜像可以供您和您的好友使用',
+ create: '添加镜像',
+ noImages: '暂无镜像',
+ noImagesHint: '添加镜像后可以在创建实例时使用',
+ },
+ },
+
+ // 实例配置标签页
+ instanceConfig: {
+ title: '高级配置',
+ sections: {
+ swap: 'SWAP',
+ storageIO: '存储 I/O 限制',
+ networkLimits: '网络限制',
+ processScheduling: '进程与调度',
+ bootSettings: '启动设置',
+ },
+ swap: {
+ size: '大小',
+ enabled: '已启用',
+ disabled: '已关闭',
+ enableButton: '启用 SWAP',
+ disableButton: '关闭 SWAP',
+ toggleHint: 'SWAP 可按需启用或关闭,重装/重建后会保持当前状态。',
+ runningRequired: '虚拟机需要先启动后才能启用 SWAP。',
+ vmHint: '虚拟机将通过实例内 swapfile 持久启用。',
+ containerHint: '容器将通过 Incus 内存交换限制启用。',
+ enableConfirmTitle: '启用 SWAP',
+ enableConfirmText: '确定要为该实例启用 {size} 的 SWAP 吗?',
+ disableConfirmTitle: '关闭 SWAP',
+ disableConfirmText: '确定要为该实例关闭 SWAP 吗?',
+ enableSuccess: 'SWAP 已启用',
+ enableFailed: '启用 SWAP 失败',
+ disableSuccess: 'SWAP 已关闭',
+ disableFailed: '关闭 SWAP 失败',
+ },
+ changeHost: {
+ title: '改节点',
+ description: '将实例重新创建到同套餐的其他节点,实例 ID 与计费信息会保留。',
+ currentHost: '当前节点',
+ availableCount: '{count} 个可选节点',
+ button: '改节点',
+ loadFailed: '加载可用节点失败',
+ submitFailed: '提交改节点失败',
+ taskQueued: '改节点任务已提交,请稍候...',
+ modalTitle: '选择目标节点',
+ modalSubtitle: '只能选择同套餐下 CPU 和内存容量充足的节点。',
+ warning: '此操作会重新创建实例系统盘,旧实例数据、快照、备份、端口映射和反代站点会被清空。',
+ selectSshKey: 'SSH 密钥',
+ noSshKey: '没有可用 SSH 密钥',
+ confirm: '确认改节点',
+ current: '当前',
+ available: '可用',
+ memory: '内存',
+ reasons: {
+ current_host: '当前节点',
+ host_offline: '离线',
+ host_type_mismatch: '类型不匹配',
+ cpu_full: '已满',
+ memory_full: '已满',
+ resource_unconfigured: '未配置配额',
+ image_unavailable: '镜像不可用',
+ },
+ },
+ overridden: '已覆盖',
+ resetToDefault: '重置为套餐默认值',
+ saveSuccess: '配置已保存',
+ saveFailed: '保存配置失败',
+ boostProcesses: {
+ button: '提高上限',
+ title: '提高进程数上限',
+ confirm: '确定要为该 {type} 实例提高进程数限制吗?操作后进程数上限将提升至 {limit}。',
+ hint: '此操作仅提高进程数上限,不会影响实例的其他配置',
+ success: '进程数上限已提升至 {limit}',
+ failed: '提升进程数上限失败',
+ },
+ },
+
+ // 套餐表单页面
+ packageForm: {
+ createTitle: '创建套餐',
+ editTitle: '编辑套餐',
+ description: '配置套餐的资源限制和高级选项',
+ sections: {
+ basicInfo: '基本信息',
+ resourceLimits: '资源限制',
+ storageIO: '存储 I/O 限制',
+ networkLimits: '网络限制',
+ processScheduling: '进程与调度',
+ bootSettings: '启动设置',
+ prerequisite: '前置套餐',
+ visibility: '可见性',
+ instancePermissions: '实例操作权限',
+ advancedOptions: '高级选项',
+ instanceQuota: '实例配额',
+ },
+ fields: {
+ networkMode: '网络模式',
+ instanceType: '实例类型',
+ packageCreationMode: '套餐用途',
+ ioLimitMode: 'IO 限制模式',
+ limitsRead: '读取速率限制',
+ limitsWrite: '写入速率限制',
+ limitsReadIops: '读取 IOPS 限制',
+ limitsWriteIops: '写入 IOPS 限制',
+ limitsIngress: '入站带宽限制',
+ limitsEgress: '出站带宽限制',
+ limitsProcesses: '最大进程数',
+ limitsCpuPriority: 'CPU 优先级',
+ bootAutostart: '随宿主机自动启动',
+ bootAutostartPriority: '启动优先级',
+ bootAutostartDelay: '启动延迟',
+ bootHostShutdownTimeout: '关机超时时间',
+ portLimit: '端口映射数量限制',
+ snapshotLimit: '快照数量限制',
+ backupLimit: '备份数量限制',
+ siteLimit: '站点数量限制',
+ hostStoragePools: '节点系统盘存储池',
+ hostTrafficMultiplier: '节点流量倍率',
+ requiredPackage: '前置套餐',
+ publicAccess: '公开套餐',
+ globalMaxInstances: '最大实例数',
+ allowInstanceDeletion: '允许用户删除实例',
+ },
+ hostSelector: {
+ official: '自营节点',
+ searchPlaceholder: '搜索节点名、地区、地址、所有者...',
+ selectedCount: '已选择 {count} 台宿主机',
+ noSearchResults: '未找到匹配的宿主机',
+ detailUnavailable: '当前视图未加载此宿主机详情,保存时仍会保留绑定',
+ },
+ creationModes: {
+ free: {
+ title: '免费实例套餐',
+ description: '实例直接使用套餐里的资源、配额和带宽限制',
+ },
+ paid: {
+ title: '付费实例套餐',
+ description: '创建后通过方案设置资源、配额、流量和价格',
+ },
+ },
+ ioMode: {
+ throughput: '读写速率限制',
+ iops: 'IOPS 限制',
+ },
+ hints: {
+ ioLimitMode: 'Incus 仅支持其中一种 IO 限制模式,请选择一种',
+ cpuPriority: '0 为最低优先级,10 为最高优先级',
+ bootPriority: '数值越小越先启动',
+ startupDelay: '实例启动前等待的秒数 (5-600)',
+ shutdownTimeout: '宿主机关机时等待实例关闭的秒数 (30-600)',
+ instanceQuota: '限制用户在此套餐实例上可创建的资源数量',
+ portLimit: '每个实例可创建的端口映射数量上限',
+ snapshotLimit: '每个实例可创建的快照数量上限(0表示无配额)',
+ backupLimit: '每个实例可创建的备份数量上限(0表示无配额)',
+ siteLimit: '每个实例可创建的反代站点数量上限(0表示无配额)',
+ hostStoragePools: '为每个已绑定节点指定默认系统盘存储池;留空时按节点默认规则自动选择',
+ hostTrafficMultiplier: '实例月流量 = 套餐或方案流量 × 此倍率,默认 1',
+ requiredPackage: '选择后,用户需要先拥有该套餐的实例,才能创建此套餐实例',
+ noSystemStoragePools: '此节点当前没有可用于实例系统盘的存储池',
+ instanceType: '容器轻量快速,虚拟机提供完整隔离',
+ publicAccess: '开启后,套餐将对所有用户可见,他们可以使用此套餐创建实例;关闭后套餐将被隐藏/归档',
+ globalMaxInstances: '限制用户最多可开通的实例数量,必须填写 1-5 之间的整数',
+ allowInstanceDeletion: '关闭后,使用此套餐创建的实例将不允许用户删除',
+ freePackageCreationMode: '免费实例无需创建方案,实例会直接继承本页配置。若需要门槛或想按付费实例流程开通,请选择付费实例套餐,创建后添加方案并将价格设置为 0 元。',
+ paidPackageCreationMode: '付费实例会使用方案里的资源、实例配额、流量和价格。本页会隐藏这些会被方案覆盖的项目,并使用默认值保存套餐。',
+ },
+ units: {
+ seconds: '秒',
+ },
+ placeholders: {
+ unlimited: '留空表示无限制',
+ autoStoragePool: '自动选择(未指定)',
+ noPrerequisite: '无前置套餐',
+ },
+ validation: {
+ cpuPriorityRange: 'CPU 优先级必须在 0-10 之间',
+ bootPriorityRange: '启动优先级必须在 0-100 之间',
+ startupDelayRange: '启动延迟必须在 5-600 秒之间',
+ shutdownTimeoutRange: '关机超时时间必须在 30-600 秒之间',
+ portLimitMin: '端口映射数量至少为 1',
+ globalMaxInstancesRange: '公开套餐最大实例数必须是 1-5 之间的整数',
+ },
+ typeHelp: {
+ containerFast: '启动快速,秒级响应',
+ containerLight: '资源占用小,适合大多数 Linux 应用',
+ containerDocker: '支持 Docker(需启用嵌套虚拟化)',
+ vmIsolation: '完整隔离,独立内核',
+ vmKernel: '支持自定义内核和内核模块',
+ vmWindows: '可运行 Windows 等非 Linux 系统',
+ },
+ },
+
+ // 敏感操作二次验证
+ sensitiveVerification: {
+ title: '敏感操作验证',
+ description: '此操作需要二次验证以确保账户安全',
+ operationLabel: '操作类型',
+ requestHint: '点击下方按钮,验证码将发送到您绑定的通知渠道',
+ sendCode: '发送验证码',
+ sendingCode: '发送中...',
+ resendCode: '重新发送',
+ resendIn: '{seconds} 秒后可重新发送',
+ codeSent: '验证码已发送',
+ codeSentTo: '验证码已发送至 {channel}',
+ enterCode: '请输入验证码',
+ codePlaceholder: '000000',
+ verify: '验证',
+ verifying: '验证中...',
+ verifySuccess: '验证成功,请重新执行操作',
+ verifyFailed: '验证失败',
+ codeExpired: '验证码已过期,请重新发送',
+ invalidCode: '验证码无效,请输入6位数字',
+ operationTypes: {
+ change_password: '修改密码',
+ disable_2fa: '禁用双因素认证',
+ change_email: '修改邮箱地址',
+ delete_account: '删除账户',
+ delete_instance: '删除实例',
+ reinstall_instance: '重装实例',
+ recreate_instance: '重建实例',
+ transfer_instance: '转移实例',
+ delete_snapshot: '删除快照',
+ delete_backup: '删除备份',
+ },
+ channels: {
+ email: '邮件',
+ telegram: 'Telegram',
+ discord: 'Discord',
+ webhook: 'Webhook',
+ },
+ },
+
+ // 宿主机 Caddy 管理
+ host: {
+ caddy: {
+ title: 'Caddy 反代',
+ description: '通过 Caddy 为您的实例提供域名反向代理服务,自动申请 SSL 证书。',
+ enabled: '已启用',
+ disabled: '未启用',
+ notInstalled: 'Caddy 尚未安装,请点击下方按钮生成安装命令。',
+ generateCommand: '生成安装命令',
+ installCommand: '安装命令',
+ commandLabel: '在宿主机上执行以下命令',
+ confirmInstalled: '确认已安装',
+ viewCommand: '查看安装命令',
+ resetCredentials: '重置凭据',
+ resetConfirm: '重置凭据后,您需要在宿主机上重新执行安装命令。确定要继续吗?',
+ resetSuccess: '凭据已重置,请在宿主机上重新执行安装命令',
+ resetFailed: '重置凭据失败',
+ testConnection: '测试连接',
+ apiPort: 'API 端口',
+ username: '用户名',
+ password: '密码',
+ publicIp: '公网 IP',
+ sitesCount: '站点数量',
+ loadFailed: '加载 Caddy 状态失败',
+ generateFailed: '生成安装命令失败',
+ confirmSuccess: 'Caddy 已确认安装',
+ confirmFailed: '确认失败',
+ testSuccess: '连接成功',
+ testFailed: '连接失败',
+ installHint: '安装步骤',
+ step1: '复制上方命令,在宿主机上以 root 权限执行',
+ step2: '等待安装完成,应看到 "Caddy Reverse Proxy Ready" 提示',
+ step3: '返回此页面,点击“确认已安装”按钮',
+ // 站点列表
+ sitesList: '反代站点列表',
+ sitesTotalCount: '共 {count} 个站点',
+ loadSitesFailed: '加载站点列表失败',
+ noSites: '暂无反代站点',
+ instance: '实例',
+ targetPort: '目标端口',
+ siteActive: '已激活',
+ sitePending: '待激活',
+ siteError: '错误',
+ siteDisabled: '已禁用',
+ prevPage: '上一页',
+ nextPage: '下一页',
+ pageInfo: '第 {current}/{total} 页,共 {count} 条',
+ },
+ // 节点创建实例
+ createInstance: {
+ title: '创建实例',
+ selfMode: '给自己创建',
+ giftMode: '赠送给用户',
+ selfModeHint: '使用自己的 SSH 密钥和初始化命令,在当前节点上创建免费实例。',
+ giftModeHint: '在当前节点上为其他用户创建实例。付费实例仅支持赠送免费时长,不会扣费。',
+ adminModeHint: '管理员可在当前节点上为指定用户创建免费实例,或赠送付费实例首期时长。',
+ selectPackage: '选择套餐',
+ noPackages: '该节点暂无可用套餐',
+ noPackagesHint: '请先在套餐管理中创建并绑定套餐到此节点',
+ instanceName: '实例名称',
+ giftDaysLabel: '免费赠送天数',
+ giftDaysHint: '仅赠送免费时长,不会从被赠送用户余额扣费。',
+ giftDaysRange: '范围:1-365 天',
+ giftDuration: '赠送时长',
+ giftDurationValue: '免费 {days} 天',
+ giftOnlyFreeHint: '免费期结束后,将按所选方案价格正常续费。',
+ creating: '正在创建...',
+ success: '实例创建成功',
+ giftSuccess: '实例已为用户 {username} 创建',
+ userInactive: '该用户未处于可用状态',
+ cannotGiftToSelf: '如果要给自己创建实例,请切换到“给自己创建”模式',
+ },
+ // 节点所有者通知实例用户
+ notify: {
+ title: '通知用户',
+ sendToUsers: '通知用户',
+ hint: '站内信会立即发送给本节点上所有实例对应的用户,系统会自动去重',
+ hintSelected: '站内信会立即发送给选中的 {count} 个实例对应的用户,系统会自动去重',
+ deliveryHint: '勾选邮件通知后,单个收件人会立即发送邮件;多个收件人会进入队列,并按每分钟 1 封的节奏发送。',
+ messageTitle: '消息标题',
+ titlePlaceholder: '输入消息标题',
+ titleRequired: '请输入消息标题',
+ messageContent: '消息内容',
+ contentPlaceholder: '输入消息内容',
+ contentRequired: '请输入消息内容',
+ sendEmail: '同时发送邮件通知',
+ sendEmailHint: '仅对已设置邮箱的用户发送邮件。批量邮件会自动排队,避免短时间内集中发信。',
+ send: '发送通知',
+ sendSuccess: '已成功发送给 {count} 个用户',
+ sendSuccessBase: '站内信已发送给 {count} 个用户',
+ emailDirectSuccess: '邮件已立即发送 {count} 封',
+ emailQueuedSuccess: '邮件队列已新增 {count} 封',
+ emailSkipped: '{count} 位用户未设置邮箱,已跳过邮件发送',
+ emailFailed: '{count} 封邮件发送或入队失败',
+ sendFailed: '发送失败',
+ // 发送给单个实例用户
+ sendToUser: '发送站内信',
+ sendToUserTitle: '发送站内信给 {username}',
+ sendToUserHint: '消息将发送给实例「{instance}」的所有者',
+ sendToUserSuccess: '消息已发送',
+ },
+ // 修改续费价格
+ price: {
+ editPrice: '修改续费价格',
+ modalTitle: '修改续费价格',
+ hint: '修改实例「{instance}」的续费价格,新价格将于下次续费时生效,本月不受影响。',
+ currentPrice: '当前价格',
+ newPrice: '新价格',
+ placeholder: '输入新的续费价格',
+ effectHint: '新价格将于下次续费时生效,不影响本月剩余时间',
+ minPriceError: '价格不能为负数',
+ samePriceError: '新价格与原价格相同',
+ updateSuccess: '续费价格已更新,已通知用户',
+ updateFailed: '更新失败',
+ },
+ // 批量修改配置
+ batchConfig: {
+ title: '批量修改配置',
+ button: '批量配置',
+ targetAll: '应用范围:宿主机全部 {count} 个实例',
+ targetSelected: '应用范围:已选中的 {count} 个实例',
+ enableFieldHint: '勾选复选框以启用对应字段的修改',
+ // 分类
+ section: {
+ resources: '资源配置',
+ quota: '配额限制',
+ permissions: '容器权限',
+ advanced: '高级配置',
+ io: '存储 I/O 限制',
+ network: '网络限制',
+ process: '进程与调度',
+ boot: '启动设置',
+ },
+ // 字段
+ cpu: 'CPU 核心数',
+ memory: '内存',
+ disk: '硬盘',
+ traffic: '流量',
+ swapEnabled: 'SWAP 开关',
+ swapSize: 'SWAP 大小',
+ portLimit: '端口数量',
+ snapshotLimit: '快照数量',
+ backupLimit: '备份数量',
+ siteLimit: '站点数量',
+ nested: '嵌套虚拟化',
+ privileged: '特权容器',
+ limitsRead: '读取限制',
+ limitsWrite: '写入限制',
+ limitsIngress: '入站限制',
+ limitsEgress: '出站限制',
+ limitsProcesses: '最大进程数',
+ limitsCpuPriority: 'CPU 优先级',
+ bootPriority: '启动优先级',
+ bootAutostart: '开机自启',
+ bootDelay: '启动延迟',
+ shutdownTimeout: '关机超时',
+ // 占位符
+ placeholder: {
+ cpu: '如 1, 2, 4',
+ memory: '如 512, 1024, 2048',
+ disk: '如 10, 20, 50',
+ traffic: '如 100, 500, 1000',
+ swapSize: '如 512, 1024, 2048',
+ limit: '如 5, 10, 20',
+ ioLimit: '如 100MB',
+ priority: '如 5',
+ processLimit: '如 500, 1000',
+ bootPriority: '如 0, 1, 2',
+ },
+ // 状态
+ processing: '正在批量修改配置...',
+ processed: '已处理 {current} / {total}',
+ // 结果
+ success: '成功',
+ failed: '失败',
+ successAll: '批量修改成功,共 {count} 个实例',
+ partial: '部分成功:{success} 成功,{failed} 失败',
+ allFailed: '全部失败',
+ submitFailed: '提交失败',
+ retrySuccess: '重试成功,共 {count} 个实例',
+ retryPartial: '重试部分成功:{success} 成功,{failed} 失败',
+ retryFailed: '重试失败项',
+ failedDetails: '失败详情',
+ instanceName: '实例名称',
+ incusId: 'Incus ID',
+ errorReason: '错误原因',
+ copyIncusIds: '复制失败的 Incus ID',
+ copiedIncusIds: '已复制 {count} 个 Incus ID',
+ // 操作
+ submit: '应用配置 ({count})',
+ close: '关闭',
+ noFieldsEnabled: '请至少启用一个配置字段',
+ noChanges: '没有需要修改的配置',
+ },
+ // 批量迁移实例
+ migrate: {
+ title: '迁移实例到其他节点',
+ button: '改节点',
+ selectedCount: '已选择 {count} 个实例',
+ targetNode: '目标节点',
+ selectTarget: '请选择目标节点',
+ targetImage: '目标系统',
+ selectImage: '请选择目标系统',
+ selectImageRequired: '请选择目标系统',
+ loadImagesFailed: '加载系统列表失败',
+ noImageAvailable: '目标节点无可用系统',
+ imageHint: '迁移会使用该系统重建实例,不再沿用实例旧镜像',
+ instances: '个实例',
+ warning: '迁移注意事项',
+ warningCloudInit: '将重新执行 cloud-init 初始化',
+ warningImage: '实例将使用所选系统重建',
+ warningIp: '实例将获得新的 IP 地址',
+ warningNotify: '迁移完成后将通知用户',
+ confirm: '确认迁移',
+ migrating: '正在迁移...',
+ resultSummary: '成功 {success} 个,失败 {failed} 个',
+ failedInstances: '失败的实例',
+ loadHostsFailed: '加载节点列表失败',
+ selectTargetRequired: '请选择目标节点',
+ noInstancesSelected: '请先选择要迁移的实例',
+ failed: '迁移失败',
+ // 付费实例方案选择
+ targetPlan: '目标方案',
+ selectPlan: '请选择目标方案',
+ selectPlanRequired: '请选择目标方案',
+ loadPlansFailed: '加载方案列表失败',
+ noPlanAvailable: '目标节点无可用方案',
+ planHint: '付费实例将使用新方案的续费价格,保留原到期时间和优惠码',
+ },
+ // 批量赠送时长
+ giftDays: {
+ title: '赠送时长',
+ button: '赠送时长',
+ hint: '为选中的付费实例免费延长到期时间,不扣除任何费用。',
+ confirm: '将为 {count} 个付费实例赠送时长',
+ daysLabel: '赠送天数',
+ daysRange: '范围:1-365 天',
+ confirmButton: '确认赠送',
+ success: '成功为 {count} 个实例赠送 {days} 天',
+ partial: '成功 {success} 个,失败 {failed} 个',
+ failed: '赠送失败',
+ skipped: '跳过了 {count} 个免费实例',
+ noPaidInstances: '请选择付费实例',
+ },
+ },
+
+ // 站内信
+ inbox: {
+ title: '通知中心',
+ description: '查看您的所有系统通知',
+ notifications: '通知',
+ unread: '未读',
+ all: '全部',
+ allCategories: '全部类型',
+ markAllRead: '全部已读',
+ markRead: '标记已读',
+ clearRead: '清空已读',
+ noMessages: '暂无通知',
+ noUnread: '没有未读通知',
+ noCategoryMessages: '该类型暂无消息',
+ viewAll: '查看全部',
+ justNow: '刚刚',
+ minutesAgo: '{n} 分钟前',
+ hoursAgo: '{n} 小时前',
+ daysAgo: '{n} 天前',
+ deleteConfirm: '确定删除这条通知吗?',
+ clearConfirm: '确定清空所有已读通知吗?',
+ deleted: '已删除',
+ cleared: '已清空',
+ markedRead: '已标记为已读',
+ markedAllRead: '已全部标记为已读',
+ currentPageFiltered: '当前页筛选结果:{count} 条',
+ // 消息类别
+ categories: {
+ instance: '实例',
+ snapshot: '快照',
+ backup: '备份',
+ social: '好友',
+ transfer: '转移',
+ package: '套餐',
+ security: '安全',
+ quota: '配额',
+ ticket: '工单',
+ system: '系统',
+ },
+ },
+
+ // 配额释放
+ quotaRelease: {
+ title: '释放配额',
+ packageQuota: '套餐配额',
+ noHosts: '无可用宿主机',
+ noHostsHint: '此套餐未绑定任何宿主机',
+ selectHosts: '选择宿主机',
+ selectAll: '全选',
+ deselectAll: '取消全选',
+ available: '可用',
+ quotaToAdd: '增加配额',
+ preview: '将为 {count} 台宿主机各增加 {cpu}% CPU 和 {memory} 内存',
+ notificationChannel: '通知渠道',
+ noNotification: '不发送通知',
+ notificationHint: '释放配额后,系统会通过此渠道发送通知',
+ selectChannel: '选择通知渠道',
+ bindChannelHint: '仅显示禁用状态的通知渠道,用于资源释放通知',
+ unbindChannel: '解绑',
+ sendNotification: '发送通知',
+ noDisabledChannel: '暂无可用的通知渠道',
+ noDisabledChannelHint: '请先在「个人设置 - 通知」中添加一个渠道并禁用它',
+ channelEnabledWarning: '此渠道已启用,将同时接收系统事件通知',
+ noGlobalChannel: '暂无全局通知渠道',
+ noGlobalChannelHint: '请联系管理员在系统设置中创建全局通知渠道',
+ confirm: '释放配额',
+ success: '成功为 {count} 台宿主机释放配额',
+ failed: '释放配额失败',
+ loadFailed: '加载宿主机信息失败',
+ selectHost: '请至少选择一台宿主机',
+ enterQuota: '请输入要增加的配额',
+ channelUpdated: '通知渠道已更新',
+ channelUpdateFailed: '更新通知渠道失败',
+ },
+
+ // Web 终端
+ terminal: {
+ title: '终端',
+ connecting: '正在连接...',
+ connected: '已连接',
+ disconnected: '已断开连接',
+ failed: '连接失败',
+ connectionFailed: '连接失败',
+ reconnect: '重新连接',
+ requiresRunning: '需要实例处于运行状态',
+ clear: '清屏',
+ fullscreen: '全屏',
+ exitFullscreen: '退出全屏',
+ fontSize: '字体大小',
+ fontSizeIncrease: '增大字体',
+ fontSizeDecrease: '缩小字体',
+ connectionError: '连接错误',
+ reconnecting: '正在重新连接...',
+ close: '关闭终端',
+ disconnect: '断开连接',
+ modeExec: 'Shell',
+ modeConsole: '控制台',
+ modeBootConsole: '启动控制台',
+ modeSwitching: '切换到 Shell',
+ modeUnknown: '未知模式',
+ consoleFallback: '当前已回退到控制台模式',
+ consoleFallbackHint: '该虚拟机未能进入 Shell 模式,当前使用的是串口控制台。若体验异常,请检查 qemu-guest-agent、cloud-init 和串口登录配置。',
+ switchingToShell: '正在切换到 Shell...',
+ switchingToShellHint: 'Shell 已就绪,终端正在从启动控制台切换到交互式 Shell。',
+ shellReadyNotice: '已进入 Shell',
+ shellReadyHint: '启动控制台已完成,当前终端已切换到交互式 Shell。',
+ consoleOnlyHint: '当前仍处于控制台模式,Shell 尚未就绪或正在重连。',
+ instanceInfo: '实例: {name}',
+ statusConnecting: '连接中',
+ statusConnected: '已连接',
+ statusDisconnected: '未连接',
+ statusError: '错误',
+ escToClose: '按 Esc 关闭',
+ pressCtrlShiftFToSearch: '按 Ctrl+Shift+F 搜索',
+ searchPlaceholder: '搜索...',
+ searchNext: '下一个',
+ searchPrevious: '上一个',
+ restore: '恢复终端',
+ exportLog: '导出日志',
+ // 多标签
+ tab: '标签',
+ newTab: '新建标签',
+ closeTab: '关闭标签',
+ maxTabsReached: '已达到最大标签数',
+ // 右键菜单
+ contextMenu: {
+ copy: '复制',
+ paste: '粘贴',
+ selectAll: '全选',
+ },
+ // Cloud-init 状态
+ cloudInitChecking: '正在检查实例初始化状态...',
+ cloudInitInProgress: '实例正在初始化中',
+ cloudInitInProgressHint: '实例正在运行 Cloud-init 初始化,可能需要等待 10-60 秒。请稍后点击“重新检查”尝试连接。',
+ cloudInitUnknown: 'Cloud-init 状态待确认',
+ cloudInitUnknownHint: '当前无法可靠检测 KVM 实例内的 Cloud-init 状态。你可以继续连接,或手动标记为已完成。',
+ cloudInitRetry: '重新检查',
+ cloudInitSkip: '忽略并连接',
+ cloudInitManualComplete: '手动标记完成',
+ cloudInitManualCompleteSuccess: '已手动标记该实例初始化为完成',
+ // 移动端提示
+ mobileKeyboardHint: '请切换至英文键盘以获得最佳体验',
+ // 帮助
+ help: '帮助',
+ helpTitle: '终端使用帮助',
+ helpShortcuts: '快捷键',
+ helpShortcutSearch: '搜索内容',
+ helpShortcutCopy: '复制选中',
+ helpShortcutPaste: '粘贴',
+ helpShortcutFontIncrease: '增大字体',
+ helpShortcutFontDecrease: '缩小字体',
+ helpShortcutFontReset: '重置字体',
+ helpShortcutExport: '导出日志',
+ helpShortcutNewTab: '新建标签',
+ helpShortcutCloseTab: '关闭标签',
+ helpMouseOps: '鼠标操作',
+ helpMouseSelect: '拖动选中文本',
+ helpMouseCopy: '右键菜单复制/粘贴',
+ helpMouseScroll: '滚轮滚动历史',
+ helpTouchOps: '触控操作',
+ helpTouchPinchZoom: '双指捨合调整字体大小',
+ helpTouchSwipeScroll: '单指滑动滚动历史',
+ // 设置面板
+ settings: '设置',
+ settingBell: '终端提示音',
+ settingBellDesc: '程序发出提醒时播放声音(如命令完成、错误警告等)',
+ settingAutoCopy: '选中自动复制',
+ settingAutoCopyDesc: '选中文本后自动复制到剪贴板',
+ settingLinkPreview: '链接预览',
+ settingLinkPreviewDesc: '鼠标悬停在链接上时显示 URL',
+ settingTouch: '触控优化',
+ settingTouchDesc: '启用移动端手势支持(双指缩放等)',
+ settingTheme: '终端主题',
+ settingThemeDesc: '选择终端的配色方案',
+ themeDark: '深色',
+ themeLight: '浅色',
+ themeHighContrast: '高对比度',
+ currentStatus: '当前状态',
+ latency: '延迟',
+ savedCommands: {
+ cloud: '云端同步',
+ title: '快捷命令',
+ subtitle: '保存常用命令,随时发送到当前终端',
+ add: '新建',
+ synced: '云端保存',
+ encrypted: '加密存储',
+ count: '共 {count} 条',
+ collapse: '收起快捷命令',
+ expand: '展开快捷命令',
+ open: '打开快捷命令',
+ short: '命令',
+ new: '新建命令',
+ edit: '编辑命令',
+ name: '名称',
+ namePlaceholder: '例如:更新软件源',
+ command: '命令内容',
+ commandPlaceholder: '输入要保存到云端的终端命令',
+ description: '备注',
+ descriptionPlaceholder: '可选备注,说明这条命令做什么',
+ loadFailed: '加载快捷命令失败',
+ createSuccess: '快捷命令已保存',
+ updateSuccess: '快捷命令已更新',
+ saveFailed: '保存快捷命令失败',
+ deleteSuccess: '快捷命令已删除',
+ deleteFailed: '删除快捷命令失败',
+ deleteConfirm: '确定删除“{name}”吗?',
+ emptyTitle: '还没有快捷命令',
+ emptyDescription: '点击右上角“新建”保存常用命令。',
+ selected: '已选中:{name}',
+ notSelected: '选择一条快捷命令后可执行或删除',
+ execute: '执行',
+ runHint: '会直接发送到当前激活终端并立即执行。',
+ disconnectedHint: '当前终端未连接,连接成功后才可执行命令',
+ shellRequiredHint: '当前还未进入 Shell,Shell 就绪后才可执行快捷命令',
+ },
+ // 移动端快捷工具栏
+ paste: '粘贴',
+ hideKeyboard: '收起键盘',
+ },
+
+ // 终端管理页面
+ terminalPage: {
+ newConnection: '新建连接',
+ selectInstance: '选择实例',
+ runningCount: '运行中实例 {count} 台',
+ selectedInstance: '当前选择',
+ selectionHint: '请选择一个运行中的实例,终端会直接连入该实例。',
+ directShell: '控制台优先连接',
+ directShellHint: '终端会先附着启动控制台,并在交互式 Shell 就绪后自动切换。',
+ host: '节点',
+ package: '套餐',
+ instanceId: '实例 ID',
+ statusRunning: '运行中',
+ noConnections: '暂无终端连接',
+ noRunningInstances: '没有运行中的实例',
+ noRunningInstancesHint: '请先启动一个实例,或回到实例详情页确认运行状态。',
+ connect: '连接',
+ description: '管理所有实例的终端连接',
+ searchInstances: '搜索实例名称或镜像...',
+ noMatchingInstances: '没有匹配的实例',
+ noMatchingInstancesHint: '换个关键词试试,或清空搜索条件查看全部运行中实例。',
+ },
+
+ // 工单系统
+ tickets: {
+ title: '工单中心',
+ myTickets: '我的工单',
+ hostTickets: '收到的工单',
+ createTicket: '创建工单',
+ newTicket: '新建工单',
+ ticketDetails: '工单详情',
+ noTickets: '暂无工单',
+ noTicketsHint: '您还没有创建任何工单',
+ noHostTickets: '暂无收到的工单',
+ noHostTicketsHint: '您的宿主机没有收到任何工单',
+ noUserTickets: '暂无用户工单',
+ noUserTicketsHint: '当前没有未绑定实例、直接发给管理员的用户工单',
+ noOfficialTickets: '暂无自营工单',
+ noOfficialTicketsHint: '当前没有来自自营节点的工单',
+ noHostedTickets: '暂无托管工单',
+ noHostedTicketsHint: '当前没有来自托管节点的工单',
+ // 工单状态
+ status: {
+ open: '待处理',
+ in_progress: '处理中',
+ resolved: '已解决',
+ closed: '已关闭',
+ },
+ // 工单优先级
+ priority: {
+ low: '低',
+ normal: '普通',
+ high: '高',
+ urgent: '紧急',
+ },
+ // 工单分类
+ category: {
+ general: '常规咨询',
+ billing: '计费问题',
+ technical: '技术支持',
+ abuse: '滥用举报',
+ },
+ // 表单
+ subject: '主题',
+ subjectPlaceholder: '简要描述问题',
+ content: '内容',
+ contentPlaceholder: '请至少写 10 个字,详细描述您的问题,或上传图片附件',
+ selectInstance: '选择实例',
+ selectInstanceHint: '选择实例(可选)',
+ noInstancesHint: '您还没有实例,可直接提交工单',
+ hostedInstanceHint: '如需针对托管实例提交工单,请务必选择对应实例,工单将发送至该节点负责人处理。',
+ hostedInstanceHintTitle: '托管实例提示',
+ selectCategory: '选择分类',
+ selectPriority: '选择优先级',
+ // 操作
+ reply: '回复',
+ replyPlaceholder: '输入回复内容...',
+ close: '关闭工单',
+ reopen: '重新开启',
+ updateStatus: '更新状态',
+ markResolved: '标记为已解决',
+ markInProgress: '标记为处理中',
+ // 确认对话框
+ confirmClose: '确认关闭工单?',
+ confirmCloseHint: '关闭后将无法继续回复',
+ // 消息
+ createSuccess: '工单创建成功',
+ replySuccess: '回复成功',
+ closeSuccess: '工单已关闭',
+ deleteMessage: '删除消息',
+ confirmDeleteMessage: '确定要删除这条消息吗?删除后所有人都将无法查看。',
+ deleteMessageSuccess: '消息已删除',
+ statusUpdated: '状态已更新',
+ // 其他
+ host: '宿主机',
+ instance: '实例',
+ from: '来自',
+ assignedTo: '分配给',
+ createdAt: '创建时间',
+ lastReply: '最后回复',
+ messages: '消息',
+ viewDetails: '查看详情',
+ pendingCount: '待处理',
+ allHosts: '所有宿主机',
+ filterByHost: '按宿主机筛选',
+ filterByStatus: '按状态筛选',
+ sourceFilter: {
+ all: '全部工单',
+ user: '用户工单',
+ official: '自营工单',
+ hosted: '托管工单',
+ },
+ activeStatus: '活跃',
+ allStatus: '全部',
+ ownerReply: '客服回复',
+ userReply: '用户回复',
+ ticketClosed: '工单已关闭,无法回复',
+ mustSelectInstance: '请先选择实例',
+ noInstancesAvailable: '没有可用的实例',
+ instanceDetails: '实例详情',
+ instanceStatus: '状态',
+ instanceId: '实例ID',
+ incusId: 'Incus ID',
+ packageName: '套餐',
+ cores: '核',
+ memory: '内存',
+ disk: '硬盘',
+ image: '镜像',
+ loadMoreMessages: '加载更多消息',
+ remaining: '条剩余',
+ needsReply: '需要回复',
+ searchPlaceholder: '搜索工单ID、主题、用户名...',
+ perPage: '每页',
+ totalCount: '共 {count} 条',
+ images: {
+ label: '图片附件',
+ hint: '支持 JPG、PNG、WebP、GIF、AVIF,最多 {count} 张,每张不超过 {size}MB',
+ add: '添加图片',
+ remove: '移除图片',
+ selected: '已选择 {count}/{max} 张图片',
+ maxReached: '最多只能上传 {count} 张图片',
+ invalidType: '仅支持 JPG、PNG、WebP、GIF、AVIF 图片',
+ fileTooLarge: '单张图片不能超过 {size}MB',
+ loadFailed: '图片加载失败',
+ zoomIn: '放大',
+ zoomOut: '缩小',
+ resetZoom: '重置缩放',
+ },
+ },
+
+ // 签到系统
+ checkin: {
+ title: '每日签到',
+ checkinTab: '签到',
+ redeemTab: '兑换',
+ recordsTab: '记录',
+ // 签到状态
+ notCheckedIn: '今日尚未签到',
+ alreadyCheckedIn: '今日已签到',
+ checkinButton: '签到',
+ checkinSuccess: '签到成功',
+ noInstance: '您需要至少拥有一个实例才能签到',
+ clickToOpen: '点击礼盒抽取今日奖励',
+ opening: '开启中',
+ revealing: '揭晓奖励',
+ congratulations: '恭喜您获得以下奖励',
+ // 兑换码
+ redeemCode: '兑换码',
+ codeExpired: '已过期',
+ codeUsed: '已使用',
+ expiresIn: '剩余时间',
+ copyCode: '复制兑换码',
+ codeCopied: '兑换码已复制',
+ // 资源类型
+ resourceType: '资源类型',
+ resourceValue: '资源数值',
+ cpu: 'CPU',
+ memory: '内存',
+ disk: '硬盘',
+ traffic: '流量',
+ points: '积分',
+ // 兑换
+ redeemTitle: '兑换兑换码',
+ inputCode: '请输入兑换码',
+ selectInstance: '选择实例',
+ selectInstanceHint: '选择要兑换资源的免费实例',
+ redeemButton: '兑换',
+ redeemSuccess: '兑换成功',
+ cappedFromPackageLimit: '已达套餐上限',
+ redeemHint: '签到兑换码仅可用于免费实例',
+ alreadyRedeemed: '今日签到码已兑换过',
+ noInstancesForRedeem: '没有可用的免费实例',
+ // 记录
+ checkinRecords: '签到记录',
+ redeemRecords: '兑换记录',
+ noRecords: '暂无记录',
+ showingRecent: '显示最近 {count} 条,共 {total} 条',
+ usedBy: '使用者',
+ usedFor: '兑换实例',
+ self: '自己',
+ others: '他人',
+ unused: '未使用',
+ // 分享规则
+ selfOnlyMode: '仅限自用',
+ selfOnlyHint: '您的兑换码已连续两天被他人使用,从今天起只能自己使用,直到您自己使用一次',
+ // 规则说明
+ rulesTitle: '签到说明',
+ rulesCheckin: '签到规则',
+ rulesCheckin1: '每日 1 次,0 点重置',
+ rulesCheckin2: '需拥有至少 1 个实例',
+ rulesCheckin3: '随机获得 CPU / 内存 / 硬盘 / 流量 奖励',
+ rulesRedeem: '兑换规则',
+ rulesRedeem1: '兑换码有效期 3 小时',
+ rulesRedeem2: '每人每日限兑换 1 次',
+ rulesRedeem3: '资源不能超过套餐上限',
+ rulesShare: '分享',
+ rulesShare1: '兑换码可分享给好友使用',
+ rulesShare2: '连续 2 天被他人使用将限制为仅自己可用,直到再次自己使用后解除',
+ // 单位
+ percent: '%',
+ mb: 'MB',
+ gb: 'GB',
+ // 资源池系统新增
+ tabCheckin: '签到',
+ tabRedeem: '兑换',
+ tabPool: '资源池',
+ tabLogs: '记录',
+ clickToCheckin: '点击签到领取奖励',
+ noInstances: '您需要至少拥有一个实例才能签到',
+ bonusPoints: '额外获得 {points} 积分',
+ savedToPool: '已存入资源池',
+ redeemSystemCode: '系统兑换码',
+ enterCode: '请输入兑换码',
+ systemCodePlaceholder: '输入 h- 开头的系统兑换码',
+ systemCodeHint: '系统兑换码(h-前缀)需选择目标实例,资源将直接应用到该实例',
+ redeem: '兑换',
+ redeemToInstanceSuccess: '{type} +{value}{unit} 已应用到 {instance}',
+ applyToInstance: '应用资源到实例',
+ amount: '数量',
+ targetInstance: '目标实例',
+ kvmCpuHint: 'CPU参数兑换到KVM实例时,必须是100的整数倍',
+ kvmHint: 'KVM实例限制:CPU必须是100的倍数,内存必须是128MB的倍数,硬盘必须是1GB的倍数,且调整内存/硬盘需先停止实例。LXC实例无此限制。',
+ apply: '应用',
+ applySuccess: '{type} +{value}{unit} 已应用到 {instance}',
+ insufficientPool: '资源池余额不足',
+ enterAmount: '请输入数量',
+ allActions: '全部操作',
+ allResources: '全部资源',
+ noLogs: '暂无记录',
+ action: '操作',
+ instance: '实例',
+ time: '时间',
+ actionCheckin: '签到',
+ actionRedeem: '兑换',
+ actionAdminGrant: '管理员赠送',
+ actionSystemGrant: '系统奖励',
+ actionLottery: '抽奖',
+ actionApply: '应用',
+ actionSystemRedeem: '系统兑换码',
+ },
+
+ // 兑换码管理
+ redeemCodes: {
+ title: '兑换码管理',
+ create: '创建兑换码',
+ createTitle: '创建兑换码',
+ createBatch: '批量创建',
+ codeType: '资源类型',
+ codeValue: '资源数值',
+ resourceType: '资源类型',
+ resourceValue: '资源数值',
+ maxUses: '最大使用次数',
+ usedCount: '已使用',
+ expiresAt: '过期时间',
+ expiresAtHint: '留空表示永不过期',
+ neverExpires: '永不过期',
+ enabled: '已启用',
+ disabled: '已禁用',
+ enable: '启用',
+ disable: '禁用',
+ remark: '备注',
+ remarkPlaceholder: '可选备注信息',
+ batchCount: '生成数量',
+ batchCountHint: '数量大于1时将批量生成一次性兑换码',
+ createSuccess: '兑换码创建成功',
+ batchCreateSuccess: '成功创建 {count} 个兑换码',
+ createFailed: '创建失败',
+ deleteConfirm: '确认删除',
+ deleteSuccess: '成功删除 {count} 个兑换码',
+ deleteFailed: '删除失败',
+ confirmDelete: '确认删除',
+ confirmDeleteMessage: '确定要删除选中的 {count} 个兑换码吗?此操作不可撤销。',
+ deleteSelected: '删除选中 ({count})',
+ updateSuccess: '更新成功',
+ copyCode: '复制兑换码',
+ copyAll: '复制全部',
+ copyCodes: '复制所有兑换码',
+ codesCopied: '兑换码已复制',
+ exhausted: '已用完',
+ expired: '已过期',
+ active: '有效',
+ paused: '已暂停',
+ usages: '使用记录',
+ usageRecords: '使用记录',
+ noUsages: '暂无使用记录',
+ user: '用户',
+ instance: '实例',
+ usedAt: '使用时间',
+ selectType: '选择资源类型',
+ selectValue: '请选择资源数值',
+ empty: '暂无兑换码',
+ emptyList: '暂无兑换码,点击上方按钮创建',
+ filterAll: '全部',
+ filterEnabled: '已启用',
+ filterDisabled: '已禁用',
+ batchDelete: '批量删除',
+ batchDeleteConfirm: '确定要删除选中的 {count} 个兑换码吗?',
+ usesHint: '设为 1 表示一次性兑换码',
+ batchHint: '批量创建只能生成一次性兑换码',
+ batchResult: '批量创建结果',
+ code: '兑换码',
+ type: '类型',
+ usage: '使用情况',
+ status: '状态',
+ actions: '操作',
+ loadFailed: '加载失败',
+ batchId: '批次 ID',
+ batchLimitHint: '同一批次的兑换码,每个用户只能使用一张',
+ batch: '批次',
+ valueRange: '范围:{min} - {max}',
+ valueOutOfRange: '数值必须在 {min} 到 {max} 之间',
+ valueMustBeInteger: '数值必须为整数',
+ },
+
+ // 扩展
+ extensions: {
+ title: '脚本',
+ description: '管理扩展功能',
+ initCommands: {
+ title: '自定义初始化命令',
+ description: '创建命令模板,在实例创建/重装时执行',
+ add: '添加命令',
+ addFirst: '添加第一个命令',
+ edit: '编辑命令',
+ view: '查看详情',
+ viewDetail: '命令详情',
+ empty: '暂无自定义命令',
+ emptyHint: '点击上方按钮创建第一个初始化命令模板',
+ name: '名称',
+ namePlaceholder: '输入命令名称',
+ command: '命令内容',
+ commandPlaceholder: '输入 Shell 命令,每行一条\n示例:\napt update\napt install -y nginx',
+ commandHint: '每行一条命令,实例初始化时按顺序执行',
+ distros: '适配发行版',
+ distrosHint: '选择此命令兼容的 Linux 发行版',
+ remark: '备注',
+ remarkPlaceholder: '可选描述',
+ createdAt: '创建时间',
+ actions: '操作',
+ status: '状态',
+ statusEnabled: '已启用',
+ statusDisabled: '已禁用',
+ enabled: '命令已启用',
+ disabled: '命令已禁用',
+ clickToEnable: '点击启用',
+ clickToDisable: '点击禁用',
+ toggleFailed: '切换状态失败',
+ confirmDelete: '确定要删除命令「{name}」吗?此操作不可撤销。',
+ createSuccess: '命令创建成功',
+ updateSuccess: '命令更新成功',
+ deleteSuccess: '命令已删除',
+ createFailed: '创建失败',
+ updateFailed: '更新失败',
+ deleteFailed: '删除失败',
+ loadFailed: '加载命令失败',
+ loadDetailFailed: '加载命令详情失败',
+ noContent: '无内容',
+ lineCount: '{count} 行',
+ // Modal
+ addTitle: '添加初始化命令',
+ editTitle: '编辑初始化命令',
+ modalDesc: '命令将在实例初始化时执行',
+ // Selector
+ selectTitle: '初始化命令',
+ optional: '(可选)',
+ noAvailable: '暂无可用的初始化命令',
+ goToManage: '前往扩展页面创建',
+ selectHint: '已选命令将在实例初始化完成后执行',
+ selectedCount: '已选择 {count} 个',
+ showAll: '展开全部 {count} 条',
+ collapse: '收起',
+ // Distro names
+ distroAll: '所有发行版',
+ distroNames: {
+ all: '所有发行版',
+ ubuntu: 'Ubuntu',
+ debian: 'Debian',
+ rhel: 'RHEL/CentOS/Fedora',
+ alpine: 'Alpine',
+ arch: 'Arch Linux',
+ suse: 'openSUSE/SLES',
+ },
+ },
+ },
+
+ // 计费管理
+ billing: {
+ // 通用
+ balance: '余额',
+ frozen: '冻结',
+ totalRecharge: '累计充值',
+ totalConsume: '累计消费',
+ yuan: '元',
+ months: '个月',
+ month: '月',
+ save: '节省',
+ days: '天',
+ freeInstance: '免费实例',
+ paidInstance: '付费实例',
+ traffic: '月流量',
+ trafficBidirectional: '双向',
+ soldOut: '售罄',
+ setupFee: '开通费',
+ cycle: {
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ annual: '年付',
+ months: '个月',
+ },
+
+ // 实例计费信息
+ billingInfo: '计费信息',
+ currentPlan: '当前方案',
+ expiresAt: '到期时间',
+ neverExpires: '永不过期',
+ autoRenew: '自动续费',
+ autoRenewOn: '自动开',
+ autoRenewOff: '自动关',
+ autoRenewEnabled: '已开启自动续费',
+ autoRenewDisabled: '已关闭自动续费',
+ autoRenewing: '自动续费',
+ enableAutoRenew: '开启自动续费',
+ disableAutoRenew: '关闭自动续费',
+ autoRenewHint: '到期前 24 小时将自动从余额扣款续费',
+ autoRenewDesc: '开启自动续费后,实例将在到期前自动按 {cycle} 周期续费,每次续费 ¥{price}',
+ currentStatus: '当前状态',
+
+ // 续费
+ renew: '续费',
+ renewInstance: '实例续费',
+ renewTitle: '续费实例',
+ selectRenewPeriod: '选择续费时长',
+ renewMonths: '{months} 个月',
+ renewPrice: '续费金额',
+ originalPrice: '原价',
+ affDiscount: '优惠码折扣',
+ actualPrice: '实付金额',
+ newExpiresAt: '续费后到期',
+ currentBalance: '当前余额',
+ balanceAfterRenew: '续费后余额',
+ insufficientBalance: '余额不足',
+ goRecharge: '前往充值',
+ renewing: '续费中...',
+ renewSuccess: '续费成功',
+ renewFailed: '续费失败',
+ freeInstanceNoRenew: '免费实例无需续费',
+ hostingRenewTooEarly: '托管实例仅可在到期前 7 天内续费(当前剩余 {days} 天)',
+
+ // 升降级
+ changePlan: '升级',
+ changePlanTitle: '变更套餐方案',
+ upgrade: '升级',
+ selectNewPlan: '选择新方案',
+ currentPlanLabel: '当前方案',
+ newPlanLabel: '新方案',
+ remainingDays: '剩余天数',
+ remainingValue: '剩余价值',
+ newPlanCost: '新方案费用',
+ priceDiff: '差价',
+ needPay: '需要支付',
+ noPriceChange: '无需补差价',
+ isUpgrade: '升级',
+ planNotActive: '方案已下架',
+ planNoStock: '方案已售罄',
+ changePlanInProgress: '升级方案中...',
+ changePlanSuccess: '方案升级成功',
+ changePlanFailed: '方案升级失败',
+ needRestart: '请重启实例以应用新配置',
+ newConfig: '新配置',
+ freeInstanceNoChange: '免费实例不支持升级',
+ samePlan: '不能切换到相同方案',
+ // 变更规则
+ viewRules: '变更规则',
+ hideRules: '收起规则',
+ changePlanRulesTitle: '方案升级规则',
+ changePlanRule1: '按日价计算差价,升级补差价',
+ changePlanRule2: '剩余天数 ≥ 15 天才可升级方案',
+ changePlanRule3: '到期时间保持不变',
+ changePlanRule4: '优惠码折扣继续享受',
+ // 已是最高方案
+ alreadyHighestPlan: '当前已是最高方案',
+ contactForCustomPlan: '更高配置可提交工单定制',
+ // KVM/LXC 重启提示
+ kvmRestartHint: 'KVM 实例升级后需重启以应用新配置;若使用过重装脚本,可能需自行扩容分区',
+ lxcInstantHint: 'LXC 实例升级后配置即时生效',
+ kvmRestartRequired: 'KVM 实例方案已变更,请重启实例以应用新配置',
+ // 不能变更原因
+ cannotChange: '暂时无法升级',
+ cannotChangeRemainingDays: '剩余天数不足 {days} 天,无法升级方案',
+ cannotChangeInstanceStatus: '当前实例状态不允许升级方案,仅运行中或已停止的实例可以操作',
+ cannotChangeUnknown: '当前无法升级方案',
+ // 计算详情
+ oldDailyPrice: '原方案日价',
+ newDailyPrice: '新方案日价',
+ day: '天',
+ newPlanCostOriginal: '新方案剩余费用',
+ newPlanCostFinal: '新方案剩余费用(折后)',
+ discountAmount: '优惠码折扣',
+
+ // 计费记录
+ records: '计费记录',
+ billingRecords: '计费记录',
+ recordType: '类型',
+ recordAmount: '金额',
+ recordPeriod: '账期',
+ recordRemark: '备注',
+ recordTypes: {
+ purchase: '购买',
+ renewal: '续费',
+ upgrade: '升级',
+ downgrade: '降级',
+ admin_extension: '管理员延期',
+ },
+
+ // 充值
+ recharge: '充值',
+ rechargeTitle: '账户充值',
+ rechargeAmount: '充值金额',
+ actualAmount: '实际到账',
+ fee: '手续费',
+ minAmount: '最低充值',
+ maxAmount: '最高充值',
+ selectPaymentMethod: '选择支付方式',
+ noPaymentProviders: '暂无可用支付渠道',
+ createOrder: '创建订单',
+ creatingOrder: '创建订单中...',
+ orderCreated: '订单已创建',
+ orderNo: '订单号',
+ orderStatus: '订单状态',
+ orderExpiredAt: '支付截止时间',
+ cancelOrder: '取消订单',
+ orderCancelled: '订单已取消',
+ orderStatus_pending: '待支付',
+ orderStatus_paid: '已支付',
+ orderStatus_completed: '已完成',
+ orderStatus_failed: '已失败',
+ orderStatus_cancelled: '已取消',
+ orderStatus_expired: '已过期',
+ orderStatus_refunded: '已退款',
+
+ // 充值记录
+ rechargeRecords: '充值记录',
+ noRecords: '暂无记录',
+ viewDetails: '查看详情',
+
+ // 余额记录
+ balanceLogs: '余额明细',
+ balanceLogTypes: {
+ recharge: '充值',
+ purchase: '购买',
+ renewal: '续费',
+ upgrade: '升级',
+ downgrade: '降级',
+ refund: '退款',
+ admin_adjust: '管理员调整',
+ },
+
+ // 套餐方案
+ plan: '方案',
+ planName: '方案名称',
+ planPrice: '价格',
+ planBillingCycle: '账期',
+ planConfig: '配置',
+ planStock: '库存',
+ planSoldOut: '已售罄',
+ planInactive: '已下架',
+ billingCycleMonthly: '月付',
+ billingCycleQuarterly: '季付',
+ billingCycleYearly: '年付',
+ perMonth: '/月',
+ perQuarter: '/季',
+ perYear: '/年',
+ },
+
+ // 托管准入
+ hosting: {
+ accessDenied: {
+ title: '暂不满足托管条件',
+ description: '您需要满足以下条件才能使用托管功能:',
+ condition: '至少拥有过 1 台实例',
+ currentInstances: '当前拥有 {count} 台实例',
+ hint: '实例包括免费和付费实例,官方直营和托管节点均可。',
+ featureHiddenCondition: '托管功能暂未向新用户开放',
+ featureHiddenCurrent: '您还没有创建过节点,当前入口已被系统隐藏。',
+ featureHiddenHint: '如需使用托管功能,请联系管理员开放,或在功能重新开放后再试。',
+ },
+ },
+
+ // 托管收益
+ hostingWallet: {
+ title: '托管收益',
+ description: '查看您的节点托管收益和提现记录',
+ hostingMember: '托管者会员',
+ notice: {
+ title: '托管公告',
+ },
+ // 余额概览
+ balance: {
+ available: '可用余额',
+ frozen: '冻结中',
+ frozenNote: '收入冻结30天后解冻',
+ totalIncome: '累计收入',
+ },
+ // 统计信息
+ stats: {
+ myHostsCount: '托管节点',
+ instancesOnMyHosts: '节点实例',
+ uniqueCustomersCount: '客户数',
+ monthIncome: '本月收入',
+ },
+ // 提现相关
+ withdraw: {
+ button: '申请提现',
+ minAmountNote: '可用余额达到 {amount} 后可提现',
+ },
+ // 选项卡
+ tabs: {
+ overview: '概览',
+ logs: '收支明细',
+ withdrawals: '提现记录',
+ blocks: '黑名单',
+ },
+ // 概览内容
+ overview: {
+ title: '提现说明',
+ howItWorks: '收益流程',
+ step1Title: '用户购买实例',
+ step1Desc: '用户在您的节点上购买/续费实例',
+ step2Title: '收入冻结',
+ step2Desc: '收益计入托管余额,冻结30天',
+ step3Title: '解冻提现',
+ step3Desc: '冻结期满后可申请提现',
+ minAmountTitle: '最低提现',
+ minAmount: '最低提现金额 {amount}',
+ feeTitle: '提现手续费',
+ feeDesc: '提现到面板余额手续费 {rate}%',
+ feeDescNew: '提现到面板余额手续费 5%,提现到指定方式手续费 10%',
+ manualTitle: '手动提现',
+ manualDesc: '如需提现到其他方式(手续费 10%),请提交工单申请',
+ withdrawMethodTitle: '提现方式',
+ withdrawMethodDesc: '自助提现至余额或发送工单申请其他提现方式',
+ },
+ // 收支明细
+ logs: {
+ noRecords: '暂无收支记录',
+ emptyHint: '当用户在您的节点上购买实例后,收益将显示在这里',
+ searchPlaceholder: '搜索用户名/邮箱/实例名...',
+ filterAll: '全部类型',
+ freePlan: '免费',
+ columns: {
+ type: '类型',
+ amount: '金额',
+ status: '状态',
+ buyer: '购买者',
+ instance: '实例',
+ host: '节点',
+ package: '套餐',
+ plan: '方案',
+ remark: '备注',
+ time: '时间',
+ },
+ types: {
+ income: '收入',
+ unfreeze: '解冻',
+ withdraw: '提现',
+ deduction: '扣除',
+ },
+ actionTypes: {
+ purchase: '开通',
+ renew: '续费',
+ upgrade: '升级',
+ destroy: '销毁',
+ unfreeze: '解冻',
+ withdraw: '提现',
+ admin_adjust: '管理员调整',
+ },
+ status: {
+ frozen: '冻结中',
+ unfrozen: '已解冻',
+ },
+ unknownInstance: '未知实例',
+ unknownUser: '未知用户',
+ unknownHost: '未知节点',
+ },
+ // 每页条数
+ perPage: '条/页',
+ // 分页按钮
+ prevPage: '上一页',
+ nextPage: '下一页',
+ // 提现记录
+ withdrawals: {
+ noRecords: '暂无提现记录',
+ emptyTitle: '还没有提现记录',
+ emptyHint: '当您的可用余额达到最低提现金额后,可以申请提现',
+ startEarning: '立即提现',
+ columns: {
+ amount: '金额',
+ actualAmount: '实际到账',
+ target: '提现方式',
+ status: '状态',
+ time: '申请时间',
+ },
+ target: {
+ balance: '面板余额',
+ },
+ status: {
+ pending: '待审核',
+ approved: '已通过',
+ rejected: '已拒绝',
+ completed: '已完成',
+ },
+ },
+ blocks: {
+ title: '用户黑名单',
+ description: '被拉黑用户无法在开通实例处看到您的托管套餐和方案,也无法新开通您的实例。',
+ total: '共 {count} 位用户',
+ searchLabel: '搜索站内用户',
+ searchPlaceholder: '输入 UID、用户名或邮箱,至少 2 个字符',
+ noSearchResults: '没有找到匹配的用户',
+ blockedUsers: '已拉黑用户',
+ effectHint: '这些用户无法新开通您的托管套餐,已有实例和续费不受影响。',
+ emptyTitle: '暂无拉黑用户',
+ emptyHint: '可以通过上方搜索添加。',
+ block: '拉黑',
+ unblock: '解除拉黑',
+ blockSuccess: '已加入黑名单',
+ unblockSuccess: '已移出黑名单',
+ },
+ // 提现弹窗
+ modal: {
+ title: '申请提现',
+ amount: '提现金额',
+ availableNote: '可用余额:{amount}',
+ targetBalance: '提现到面板余额(手续费 {rate}%)',
+ manualWithdrawNote: '如需手动提现到指定方式(手续费 10%),请提交工单申请提现托管余额',
+ summary: {
+ amount: '提现金额',
+ fee: '手续费',
+ actual: '实际到账',
+ },
+ cancel: '取消',
+ confirm: '确认提现',
+ submitting: '提交中...',
+ },
+ // 错误信息
+ errors: {
+ minAmount: '最低提现金额为 {amount}',
+ exceedBalance: '提现金额超过可用余额',
+ },
+ },
+
+ vipBenefits: {
+ commonMember: '普通会员',
+ overviewLabel: 'VIP 福利大厅',
+ overviewTitle: '当前等级 {level}',
+ overviewDesc: '达到最高 {level},即可领取下方已解锁等级的全部奖励。请先领完低等级福利,再领取更高等级福利。',
+ noVipDesc: '升级 VIP 后可领取等级福利,所有奖品会按等级从低到高展示。',
+ availableSummary: '已解锁奖励汇总',
+ availableSummaryDesc: '这里汇总当前等级已解锁的全部奖励额度。',
+ remainingSummary: '剩余可领取',
+ noAvailableReward: '暂无已解锁奖励',
+ empty: '暂无 VIP 福利,请稍后再来。',
+ levelRewards: 'VIP{level} 奖励',
+ levelUnlocked: '已达到该等级',
+ levelLocked: '升级后可领取',
+ rewardCount: '{count} 个奖品',
+ rewardValue: '奖励内容',
+ claimProgress: '领取进度',
+ claim: '领取',
+ claimAll: '一键领取剩余奖励',
+ claimingAll: '正在领取...',
+ claimed: '已领取',
+ pendingDelivery: '待发放',
+ upgradeRequired: '待升级',
+ claimLowerFirst: '先领 VIP{level}',
+ claimSuccess: '领取成功',
+ noClaimableReward: '暂无可领取奖励',
+ rewardReceived: 'VIP 福利奖励',
+ pointsUnit: '积分',
+ instanceReward: '套餐实例',
+ instanceValue: '{plan} · {quantity} 台 · {days} 天',
+ feedback: {
+ delivered: '已到账',
+ pending: '待发放',
+ },
+ types: {
+ balance: '余额',
+ points: '积分',
+ instance: '实例',
+ },
+ summary: {
+ unlocked: '已解锁',
+ claimable: '可领取',
+ claimed: '已领取',
+ pending: '待发放',
+ },
+ status: {
+ claimable: '可领取',
+ claimed: '已领取',
+ locked: '待升级',
+ blocked: '先领取 VIP{level}',
+ pending: '待发放',
+ },
+ },
+
+ // 福利系统
+ entertainment: {
+ title: '福利',
+ description: '领取会员福利,管理积分和徽章奖励',
+ currentPoints: '当前积分',
+ convertPoints: '兑换积分',
+ noPointsToConvert: '暂无可兑换积分',
+ convertSuccess: '成功兑换 {points} 积分',
+ convertFailed: '兑换失败',
+ pointsUnit: '分',
+ tabs: {
+ lottery: '抽奖',
+ records: '抽奖记录',
+ points: '积分明细',
+ },
+ mainTabs: {
+ vipBenefits: 'VIP 福利',
+ lottery: '抽奖',
+ badge: '徽章',
+ blindbox: '盲盒',
+ checkin: '签到',
+ },
+ comingSoon: '敬请期待',
+ blindbox: {
+ title: '盲盒',
+ },
+ checkinSection: {
+ title: '每日签到',
+ },
+ // 抽奖
+ spin: '抽奖',
+ spinCost: '消耗 {points} 积分',
+ spinFailed: '抽奖失败',
+ selectLottery: '请选择抽奖活动',
+ notEnoughPoints: '积分不足',
+ noActiveLotteries: '暂无可用的抽奖活动',
+ prizeList: '奖品列表',
+ probability: '概率',
+ remaining: '剩余',
+ // 十连抽
+ multiDraw: '十连抽',
+ multiDrawAgain: '再次十连',
+ multiDrawFailed: '十连抽失败',
+ multiDrawResults: '十连抽结果',
+ multiDrawStopped: '抽奖提前结束',
+ notEnoughPointsForMulti: '积分不足,需要 {required} 积分,当前仅有 {current} 积分',
+ totalDraws: '抽奖次数',
+ totalPointsSpent: '消耗积分',
+ badgeUnit: '枚',
+ instanceUnit: '台',
+ multiDrawBadgesTitle: '本次十连抽中了徽章',
+ multiDrawBadgesSubtitle: '本轮十连共获得 {count} 枚徽章,先看看这次的新收获。',
+ continueToMultiResults: '继续查看十连结果',
+ // 奖品类型
+ prizeTypes: {
+ nothing: '再接再励',
+ points: '积分',
+ balance: '余额',
+ badge: '随机徽章',
+ instance: '实例',
+ cpu: 'CPU资源',
+ memory: '内存资源',
+ disk: '硬盘资源',
+ traffic: '流量资源',
+ },
+ // 中奖结果
+ congratulations: '恭喜中奖!',
+ betterLuckNextTime: '再接再励',
+ wonPoints: '获得 {points} 积分',
+ wonBalance: '获得 ¥{amount} 余额',
+ wonBadge: '获得徽章:{badge}',
+ wonInstance: '请提交工单领取实例奖励',
+ wonCpu: '获得 {value}% CPU,已存入资源池',
+ wonMemory: '获得 {value}MB 内存,已存入资源池',
+ wonDisk: '获得 {value}MB 硬盘,已存入资源池',
+ wonTraffic: '获得 {value}GB 流量,已存入资源池',
+ // 抽奖记录
+ lotteryName: '活动名称',
+ prize: '奖品',
+ prizeType: '奖品类型',
+ value: '奖励值',
+ time: '时间',
+ noRecords: '暂无抽奖记录',
+ loadRecordsFailed: '加载抽奖记录失败',
+ loadLotteriesFailed: '加载抽奖活动失败',
+ // 积分明细
+ pointsLogType: '类型',
+ pointsChange: '变动',
+ pointsAfter: '变动后',
+ remark: '备注',
+ noPointsLogs: '暂无积分记录',
+ loadPointsLogsFailed: '加载积分记录失败',
+ pointsLogTypes: {
+ convert: '消费兑换',
+ lotteryWin: '抽奖获得',
+ lotterySpend: '抽奖消耗',
+ badgeDrawSpend: '徽章随机抽取消耗',
+ badgeSelectSpend: '徽章自选消耗',
+ adminAdjust: '管理员调整',
+ checkin: '签到奖励',
+ },
+ badges: {
+ drawTab: '抽卡',
+ myTab: '我的徽章',
+ randomTitle: '随机抽取',
+ randomHint: '等概率,必中一个徽章',
+ randomDescription: '消耗 {points} 积分,所有徽章等概率,必定获得一个徽章副本。',
+ randomButton: '随机抽一次({points} 积分)',
+ randomMultiButton: '随机十连({points} 积分)',
+ selectTitle: '自选领取',
+ selectHint: '直接选择一个指定徽章',
+ selectDescription: '消耗 {points} 积分,从下方直接选择一个想要的徽章。',
+ selectButton: '领取当前选中徽章({points} 积分)',
+ multiDrawTitle: '十连抽获得的徽章',
+ multiDrawSubtitle: '本次十连共获得 {count} 枚徽章。',
+ myTitle: '我的徽章',
+ summary: '可用 {available},已应用 {applied}',
+ filterAll: '全部类型',
+ ownedCount: '已拥有 {count}',
+ empty: '还没有获得任何徽章。',
+ statusAvatar: '已应用到头像',
+ statusInstance: '已应用到实例',
+ statusUnused: '未应用',
+ sourceLabel: '来源',
+ sourceDraw: '随机抽取',
+ sourceLottery: '抽奖获得',
+ sourceSelect: '自选领取',
+ sourceAdminGrant: '管理员发放',
+ obtainedAt: '获得时间',
+ currentInstance: '当前实例',
+ applyAvatar: '应用到头像',
+ applyInstance: '应用到实例图标',
+ applyInstanceButton: '应用到实例',
+ selectInstance: '选择一个实例',
+ unapply: '取消应用',
+ selectRequired: '请先选择一个徽章',
+ instanceRequired: '请先选择实例',
+ drawSuccess: '获得徽章:{badge}',
+ selectSuccess: '领取成功:{badge}',
+ rewardTitleDraw: '抽卡成功',
+ rewardTitleSelect: '领取成功',
+ rewardSubtitle: '新徽章已经加入你的收藏,现在就可以前往“我的徽章”应用到头像或实例。',
+ rewardSeriesLabel: '所属系列',
+ rewardRemainingPoints: '剩余积分',
+ rewardDrawAgain: '再抽一次',
+ rewardViewMine: '查看我的徽章',
+ applyAvatarSuccess: '已应用到头像',
+ applyInstanceSuccess: '已应用到实例图标',
+ unapplySuccess: '已取消应用',
+ },
+ // 管理端
+ admin: {
+ title: '娱乐管理',
+ description: '管理抽奖活动、奖品和用户积分',
+ tabs: {
+ lotteries: '抽奖活动',
+ records: '中奖记录',
+ users: '用户积分',
+ badges: '徽章',
+ },
+ createLottery: '创建抽奖',
+ editLottery: '编辑抽奖',
+ lotteryName: '活动名称',
+ enterLotteryName: '请输入活动名称',
+ lotteryDesc: '描述',
+ enterDescription: '请输入描述(可选)',
+ costPoints: '消耗积分',
+ startAt: '开始时间',
+ endAt: '结束时间',
+ isActive: '启用',
+ enterName: '请输入名称',
+ invalidCostPoints: '消耗积分必须大于0',
+ createSuccess: '创建成功',
+ updateSuccess: '更新成功',
+ saveFailed: '保存失败',
+ deleteSuccess: '删除成功',
+ deleteFailed: '删除失败',
+ noLotteries: '暂无抽奖活动',
+ loadLotteriesFailed: '加载抽奖活动失败',
+ prizes: '奖品',
+ totalDraws: '抽奖次数',
+ status: '状态',
+ active: '已启用',
+ inactive: '已停用',
+ // 奖品管理
+ managePrizes: '管理奖品',
+ addPrize: '添加奖品',
+ prizeName: '奖品名称',
+ prizeType: '奖品类型',
+ prizeValue: '奖励值',
+ balanceValue: '余额(分)',
+ balanceCents: '输入分,如100=1元',
+ cpuPercent: '输入CPU百分比',
+ memoryMB: '输入内存(MB)',
+ diskMB: '输入硬盘(MB)',
+ trafficGB: '输入流量(GB)',
+ probability: '权重',
+ quantity: '数量',
+ unlimited: '无限',
+ noQuantityForType: '此类型奖品不能设置数量限制',
+ replenish: '补充库存',
+ remaining: '剩余',
+ replenishPlaceholder: '输入补充数量',
+ instanceDesc: '实例描述',
+ instanceDescPlaceholder: '如:1核/1G/10G SSD',
+ noPrizes: '暂无奖品,点击上方按钮添加',
+ enterPrizeName: '请输入奖品名称',
+ invalidProbability: '权重必须大于0',
+ savePrizesSuccess: '奖品保存成功',
+ savePrizesFailed: '奖品保存失败',
+ // 中奖记录
+ user: '用户',
+ searchUser: '搜索用户名',
+ noRecords: '暂无中奖记录',
+ loadRecordsFailed: '加载中奖记录失败',
+ prize: '奖品',
+ value: '奖励值',
+ // 用户积分
+ currentPoints: '当前积分',
+ totalEarned: '累计获得',
+ totalSpent: '累计消耗',
+ lastConvertedAt: '最后兑换',
+ noUsers: '暂无用户积分数据',
+ loadUsersFailed: '加载用户积分失败',
+ // 徽章目录
+ badgeCatalog: {
+ loadFailed: '加载徽章目录失败',
+ fillSeriesRequired: '请填写系列 ID、标题、名称和说明',
+ seriesUpdated: '系列已更新',
+ seriesCreated: '系列已创建',
+ saveSeriesFailed: '保存系列失败',
+ seriesDeleted: '系列已删除',
+ deleteSeriesFailed: '删除系列失败',
+ fillBadgeRequired: '请填写徽章 ID、名称、标签、系列和默认图片地址',
+ badgeUpdated: '徽章已更新',
+ badgeCreated: '徽章已创建',
+ saveBadgeFailed: '保存徽章失败',
+ badgeDeleted: '徽章已删除',
+ deleteBadgeFailed: '删除徽章失败',
+ addSeries: '新增系列',
+ addBadge: '新增徽章',
+ series: {
+ title: '系列',
+ description: '控制前台筛选分组和整组启用状态',
+ add: '新增',
+ all: '全部系列',
+ enabledCount: '{active} / {total} 个启用',
+ empty: '暂无系列',
+ editTitle: '编辑系列',
+ createTitle: '新增系列',
+ id: '系列 ID',
+ sort: '排序',
+ nameZh: '中文名',
+ nameEn: '英文名',
+ titleLabel: '标题',
+ titlePlaceholder: 'SUPREME 尊贵系列',
+ descriptionLabel: '说明',
+ sourceId: '来源 ID',
+ sourceLabel: '来源名称',
+ enable: '启用该系列',
+ },
+ badges: {
+ title: '徽章',
+ currentFilter: '当前筛选:{name}',
+ empty: '暂无徽章',
+ tableBadge: '徽章',
+ tableSeries: '系列',
+ tableAssetUrl: '图片地址',
+ tableStatus: '状态',
+ tableUsage: '使用',
+ drawable: '可抽取',
+ notDrawable: '不可抽取',
+ usage: '拥有 {ownership} / 头像 {avatar} / 实例 {instance}',
+ editTitle: '编辑徽章',
+ createTitle: '新增徽章',
+ id: '徽章 ID',
+ series: '系列',
+ name: '名称',
+ nameEn: '英文名',
+ fullLabel: '完整标签',
+ fullLabelPlaceholder: '精英 (Elite)',
+ sourceId: '来源 ID',
+ sourceLabel: '来源名称',
+ assetUrl: '默认图片地址',
+ assetUrlPlaceholder: '/badges/dark/elite.svg 或 https://example.com/badge.svg',
+ assetUrlDark: '深色图片地址',
+ assetUrlLight: '浅色图片地址',
+ sort: '排序',
+ enable: '启用该徽章',
+ preview: '预览',
+ previewName: '徽章名称',
+ previewLabel: '完整标签',
+ },
+ },
+ // 通知配置
+ notification: {
+ title: '中奖通知',
+ enabled: '启用通知',
+ type: '通知方式',
+ conditions: '通知条件',
+ notifyBalance: '中余额时通知',
+ notifyInstance: '中实例时通知',
+ secret: '签名密钥',
+ secretPlaceholder: '用于验证 Webhook 请求(可选)',
+ fillTelegram: '请填写 Telegram Bot Token 和 Chat ID',
+ fillDiscord: '请填写 Discord Webhook URL',
+ fillWebhook: '请填写 Webhook URL',
+ saveSuccess: '通知配置保存成功',
+ saveFailed: '通知配置保存失败',
+ },
+ },
+ },
+
+ // 邮箱模块
+ mail: {
+ title: '邮箱',
+ description: '管理您的专业邮箱服务',
+ tabs: {
+ my: '我的邮箱',
+ buy: '购买邮箱',
+ accounts: '邮箱账户',
+ dns: 'DNS 配置',
+ settings: '设置',
+ },
+ noSubscription: '您还没有邮箱服务',
+ buyNowHint: '立刻购买专业的邮箱服务',
+ buyNow: '立即购买',
+ subscriptionOverview: '订阅概览',
+ expiresAt: '到期时间',
+ domainsUsed: '已用域名',
+ diskUsed: '已用空间',
+ totalSpace: '总空间',
+ accounts: '账户',
+ plan: '方案',
+ domains: '个域名',
+ month: '月',
+ year: '年',
+ renew: '续费',
+ myDomains: '我的域名',
+ addDomain: '添加域名',
+ noDomains: '暂无域名,点击上方按钮添加',
+ used: '已用',
+ status: {
+ active: '活跃',
+ expired: '已过期',
+ suspended: '已暂停',
+ },
+ domainStatus: {
+ pending: '待验证',
+ verified: '已验证',
+ suspended: '已暂停',
+ },
+ selectRegion: '选择服务地区',
+ nodeStatus: {
+ available: '节点状态: 充足',
+ limited: '库存紧张',
+ },
+ plans: '个方案',
+ planDetails: '方案配置详情',
+ planTag: '正式版方案',
+ allFeaturesIncluded: '包含所有基础及进阶功能',
+ pureStorage: '纯净存储空间',
+ feature: {
+ domains: '支持 {count} 个自定义域名绑定',
+ domainStorage: '{count}主域名 {storage}G存储空间',
+ unlimitedAliases: '每个域无限个别名',
+ unlimitedMailboxes: '每个域无限个邮箱',
+ emailLimit: '每个域 600 封电子邮件/小时',
+ emClientPro: '免费的 eM Client Pro 许可证',
+ catchAll: '支持 Catch All',
+ antispam: '自研反垃圾邮件网关',
+ protocols: '支持 SMTP/IMAP/POP3 全协议',
+ aliases: '无限邮箱别名设置',
+ },
+ otherOptions: '其他选项',
+ verify: '验证',
+ checkout: {
+ title: '结算汇总',
+ region: '选定地区',
+ serviceStatus: '服务状态',
+ instant: '即时开通',
+ amount: '应付金额',
+ confirm: '立即开通',
+ securePayment: '加密支付保障,支持随时申请退款',
+ balanceRequired: '需要先充值余额,才能购买',
+ },
+ help: {
+ title: '遇到问题?',
+ desc: '如果您在购买过程中遇到任何技术问题,请提交工单联系支持人员。',
+ },
+ selectPlan: '选择方案',
+ storage: '存储空间',
+ unlimitedAccounts: '无限邮箱账户',
+ orderConfirm: '订单确认',
+ billingCycle: '计费周期',
+ monthly: '月付',
+ yearly: '年付',
+ totalPrice: '总价',
+ confirmRenew: '确认续费',
+ confirmPurchase: '确认购买',
+ renewSubscription: '续费订阅',
+ renewMonths: '续费时长',
+ monthlyPrice: '月单价',
+ yearlyPrice: '年单价',
+ alreadyPurchased: '您已购买该地区的邮箱服务',
+ alreadyPurchasedDesc: '每个用户仅可购买一个地区的邮箱服务。如需更换方案,请先到「我的邮箱」进行管理。',
+ viewMySubscription: '查看我的订阅',
+ renewDuration: '续费时长',
+ months: '个月',
+ renewSuccess: '续费成功',
+ selectPlanFirst: '请先选择一个方案',
+ purchaseSuccess: '购买成功',
+ domainName: '域名',
+ domainPlaceholder: '例如:example.com',
+ domainHint: '请输入您拥有的域名,添加后需要配置 DNS 记录验证',
+ domainRequired: '请输入域名',
+ domainAdded: '域名添加成功,请配置 DNS 记录',
+ accountsDescription: '管理此域名下的邮箱账户',
+ createAccount: '创建账户',
+ verifyFirst: '请先验证域名 DNS 配置后再创建邮箱账户',
+ completeDnsFirst: '请先完成 DNS 配置验证,邮箱服务才能正常使用',
+ goDnsConfig: '前往配置',
+ adminAccount: '管理员账号',
+ adminAccountDesc: '此账号在添加域名时自动创建,可用于登录 Webmail 管理邮箱',
+ webmailUrl: '登录地址',
+ helpDoc: '查看帮助文档',
+ noAdminAccount: '管理员账号信息不可用',
+ refreshStatus: '刷新状态',
+ noAccounts: '暂无邮箱账户',
+ admin: '管理员',
+ resetPassword: '重置密码',
+ deleteAccountConfirm: '确定要删除邮箱账户 {email} 吗?此操作不可恢复。',
+ accountDeleted: '账户已删除',
+ dnsDescription: '请在您的域名 DNS 管理面板中添加以下记录',
+ recordType: '记录类型',
+ hostRecord: '主机记录',
+ recordValue: '记录值',
+ emailAddress: '邮箱地址',
+ txtVerification: 'TXT 验证记录',
+ pending: '待验证',
+ verified: '已验证',
+ mxRecords: 'MX 记录',
+ cnameRecords: 'CNAME 记录',
+ spfRecord: 'SPF 记录',
+ dkimRecord: 'DKIM 记录',
+ optional: '可选',
+ required: '必需',
+ recommended: '推荐',
+ dnsHint: {
+ txt: '域名验证记录,添加后点击“刷新状态”验证',
+ mx: '邮件交换记录,数字表示优先级(数字越小优先级越高)',
+ cname: '别名记录,用于 Webmail 和自动发现功能',
+ spf: '发件人策略框架,防止邮件伪造,提高送达率',
+ dkim: '域名密钥认证,提高邮件可信度',
+ dmarc: '域名邮件认证策略,防止铳鱼和欺诈邮件',
+ },
+ domainInfo: '域名信息',
+ createdAt: '创建时间',
+ verifiedAt: '验证时间',
+ dangerZone: '危险区域',
+ deleteDomainWarning: '删除域名将同时删除该域名下所有邮箱账户和数据,此操作不可恢复。',
+ deleteDomain: '删除域名',
+ deleteDomainConfirm: '确定要删除域名 {domain} 吗?此操作将删除所有相关数据且不可恢复。',
+ domainDeleted: '域名已删除',
+ domainVerified: '域名验证成功',
+ domainNotVerified: 'DNS 记录尚未生效,请稍后重试',
+ username: '用户名',
+ password: '密码',
+ displayName: '显示名称',
+ diskLimit: '空间配额',
+ setAsAdmin: '设为域名管理员',
+ usernamePlaceholder: '例如:admin',
+ passwordPlaceholder: '至少8位字符',
+ passwordHint: '密码至少8位,建议包含大小写字母和数字',
+ displayNamePlaceholder: '例如:张三',
+ accountFieldsRequired: '请填写用户名和密码',
+ accountCreated: '账户创建成功',
+ accountUpdated: '账户更新成功',
+ passwordMinLength: '密码至少8位字符',
+ passwordReset: '密码重置成功',
+ editAccount: '编辑账户',
+ newPassword: '新密码',
+ newPasswordPlaceholder: '输入新密码',
+ },
+}
diff --git a/client/src/locales/zh-TW.ts b/client/src/locales/zh-TW.ts
new file mode 100644
index 0000000..4d332b4
--- /dev/null
+++ b/client/src/locales/zh-TW.ts
@@ -0,0 +1,7777 @@
+// 繁體中文(台灣)語言檔案
+// Traditional Chinese (Taiwan) - 使用台灣用語習慣
+export default {
+ // 應用程式名稱
+ app: {
+ name: 'Incus NAT VPS',
+ tagline: '簡潔而強大的雲端實例託管平台',
+ description: '一個現代化的雲端實例託管平台',
+ },
+
+ // 通用
+ common: {
+ confirm: '確認',
+ cancel: '取消',
+ save: '儲存',
+ saving: '儲存中...',
+ send: '發送',
+ sending: '發送中...',
+ syncing: '同步中...',
+ processing: '處理中...',
+ submitting: '提交中...',
+ delete: '刪除',
+ noIncudalHint: '請勿在名稱或描述中使用字樣',
+ edit: '編輯',
+ create: '建立',
+ creating: '建立中...',
+ deleting: '刪除中...',
+ deleteSuccess: '刪除成功',
+ search: '搜尋',
+ loading: '載入中...',
+ noData: '暫無資料',
+ noSearchResults: '搜尋無結果',
+ success: '操作成功',
+ error: '操作失敗',
+ warning: '警告',
+ info: '提示',
+ yes: '是',
+ no: '否',
+ back: '返回',
+ next: '下一步',
+ previous: '上一步',
+ close: '關閉',
+ reset: '重設',
+ refresh: '重新整理',
+ copy: '複製',
+ copied: '已複製',
+ copyFailed: '複製失敗',
+ show: '顯示',
+ hide: '隱藏',
+ done: '完成',
+ actions: '操作',
+ details: '詳情',
+ status: '狀態',
+ name: '名稱',
+ description: '描述',
+ createdAt: '建立時間',
+ updatedAt: '更新時間',
+ none: '無',
+ notSet: '未設定',
+ turnstileFailed: '人機驗證失敗,請重試',
+ filter: '篩選',
+ total: '共',
+ items: '條',
+ searchPlaceholder: '搜尋...',
+ developing: '開發中',
+ developingHint: '此功能正在開發中,敬請期待...',
+ gotIt: '我知道了',
+ expand: '展開',
+ collapse: '收合',
+ page: '頁',
+ pageInfo: '第 {current}/{total} 頁,共 {count} 條',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ loadFailed: '載入失敗',
+ all: '全部',
+ perPage: '每頁',
+ totalCount: '共 {count} 條',
+ day: '天',
+ days: '天',
+ seconds: '秒',
+ month: '月',
+ totalRecords: '共 {count} 筆記錄',
+ deleted: '已刪除',
+ // 國家名稱
+ countries: {
+ // 亞洲
+ cn: '中國',
+ hk: '中國香港',
+ mo: '中國澳門',
+ tw: '中國台灣',
+ jp: '日本',
+ kr: '韓國',
+ sg: '新加坡',
+ my: '馬來西亞',
+ th: '泰國',
+ vn: '越南',
+ ph: '菲律賓',
+ id: '印尼',
+ in: '印度',
+ pk: '巴基斯坦',
+ bd: '孟加拉',
+ kz: '哈薩克',
+ uz: '烏茲別克',
+ ae: '阿聯酋',
+ sa: '沙烏地阿拉伯',
+ il: '以色列',
+ tr: '土耳其',
+ // 歐洲
+ gb: '英國',
+ de: '德國',
+ fr: '法國',
+ nl: '荷蘭',
+ be: '比利時',
+ lu: '盧森堡',
+ ch: '瑞士',
+ at: '奧地利',
+ it: '義大利',
+ es: '西班牙',
+ pt: '葡萄牙',
+ ie: '愛爾蘭',
+ se: '瑞典',
+ no: '挪威',
+ dk: '丹麥',
+ fi: '芬蘭',
+ pl: '波蘭',
+ cz: '捷克',
+ hu: '匈牙利',
+ ro: '羅馬尼亞',
+ bg: '保加利亞',
+ gr: '希臘',
+ ua: '烏克蘭',
+ ru: '俄羅斯',
+ // 北美洲
+ us: '美國',
+ ca: '加拿大',
+ mx: '墨西哥',
+ // 南美洲
+ br: '巴西',
+ ar: '阿根廷',
+ cl: '智利',
+ co: '哥倫比亞',
+ pe: '秘魯',
+ // 大洋洲
+ au: '澳洲',
+ nz: '紐西蘭',
+ // 非洲
+ za: '南非',
+ eg: '埃及',
+ ng: '奈及利亞',
+ ke: '肯亞',
+ },
+ // 網路模式(統一定義)
+ networkMode: {
+ nat: 'IPv4 NAT',
+ nat_ipv6: 'IPv4 NAT & IPv6',
+ nat_ipv6_nat: 'IPv4 NAT & IPv6 NAT',
+ ipv6_only: 'IPv6 Only',
+ ipv6_nat: 'IPv6 NAT',
+ },
+ // 實例類型
+ instanceType: {
+ container: 'LXC',
+ vm: 'KVM',
+ },
+ // 啟用/停用狀態
+ enabled: '啟用',
+ disabled: '停用',
+ active: '已啟用',
+ inactive: '已停用',
+ },
+
+ validation: {
+ fields: {
+ name: '名稱',
+ identifier: '識別符',
+ content: '內容',
+ serverAddress: '伺服器地址',
+ ipAddress: 'IP地址',
+ ipOrDomain: 'IP地址或網域',
+ },
+ required: '{field}不能為空',
+ minLength: '{field}長度至少 {min} 個字元',
+ maxLength: '{field}長度不能超過 {max} 個字元',
+ illegalChars: '{field}包含非法字元',
+ safeNameChars: '{field}只能包含中文、字母、數字、連字符、底線、空格、逗號和圓括號',
+ identifierChars: '{field}只能包含字母、數字、連字符和底線,且必須以字母開頭',
+ invalidFormat: '{field}格式不正確',
+ urlProtocol: '{field}必須以 http:// 或 https:// 開頭',
+ hostAddressInvalid: '{field}格式不正確,請輸入有效的 IPv4、IPv6 地址或網域',
+ ipAddressInvalid: '{field}格式不正確,請輸入有效的 IPv4 或 IPv6 地址',
+ ipv4Invalid: '{field}格式不正確,請輸入有效的 IPv4 地址',
+ },
+
+ // 導航
+ nav: {
+ main: '常用',
+ dashboard: '總覽',
+ instances: '實例',
+ transfers: '移轉',
+ friends: '好友',
+ tickets: '工單',
+ resources: '資源',
+ myHosts: '我的節點',
+ myPackages: '我的方案',
+ myImages: '我的映像檔',
+ logs: '日誌',
+ inbox: '通知',
+ settings: '設定',
+ wallet: '錢包',
+ invites: '邀請',
+ help: '說明',
+ admin: '管理',
+ expand: '擴展',
+ system: '系統',
+ users: '使用者',
+ statistics: '統計',
+ hosts: '節點',
+ images: '映像檔',
+ packages: '方案',
+ helpManage: '說明',
+ oauth: 'OAuth',
+ broadcast: '公告',
+ paymentProviders: '支付渠道',
+ billing: '計費管理',
+ withdrawals: '提現',
+ aff: '推薦',
+ openMenu: '開啟選單',
+ collapseSidebar: '收合側邊欄',
+ toggleTheme: '切換佈景主題',
+ toggleLanguage: '切換語言',
+ terminal: '終端機',
+ extensions: '擴充',
+ scripts: '腳本',
+ operations: '運維',
+ createInstance: '建立實例',
+ create: '建立',
+ entertainment: '福利',
+ hosting: '託管',
+ hostingWallet: '託管收益',
+ earnings: '收益',
+ mail: '郵箱',
+ instanceDetail: '實例詳情',
+ mailDomain: '郵箱網域',
+ myHostCreate: '建立節點',
+ myHostDetail: '節點詳情',
+ myPackageCreate: '建立方案',
+ myPackageEdit: '編輯方案',
+ telegramSettings: 'Telegram 設定',
+ adminCreateInstance: '管理員建立實例',
+ },
+
+ // 佈景主題
+ theme: {
+ dark: '深色模式',
+ light: '淺色模式',
+ system: '跟隨系統',
+ },
+
+ freeSite: {
+ billingCycleLabel: {
+ monthly: '月付?月亮替你付了',
+ quarterly: '季付?四季都免單',
+ semiAnnual: '半年快樂通行證',
+ annual: '年付?錢包裝睡中',
+ custom: '{months} 個月,快樂代扣空氣',
+ free: '免費亂逛許可證',
+ },
+ billingCycleShort: {
+ monthly: '/月,免驚',
+ quarterly: '/季,免單',
+ semiAnnual: '/半年快樂',
+ annual: '/年也白送',
+ custom: '/{months} 個月快樂',
+ },
+ copy: {
+ finalPrice: '裝模作樣價',
+ renewPrice: '續費?儀式感拉滿',
+ billingCycle: '快樂檔位',
+ needPay: '象徵性付款',
+ originalPrice: '原價標本',
+ oldDailyPrice: '舊日租,考古用',
+ newDailyPrice: '新日租,擺設用',
+ remainingValue: '剩餘價值:快樂無價',
+ newPlanCost: '新方案成本:空氣幣',
+ currentBalance: '餘額吉祥物',
+ balanceAfterRenew: '續後餘額:紋絲不動',
+ walletBalanceTab: '快樂餘額',
+ walletLogsTab: '免單流水',
+ walletCurrentBalance: '目前快樂刻度',
+ walletDescription: '白嫖站模式已開啟,儲值入口去喝茶了,餘額負責站崗。',
+ walletLogsDescription: '這裡記錄餘額的小動作。別緊張,主線任務依舊是免費玩。',
+ walletTotalRecharge: '累計免單',
+ walletTotalConsume: '快樂蒸發',
+ walletDestroyedValue: '銷毀紀念值',
+ dashboardNewInstance: '快樂實例',
+ dashboardCreateInstance: '召喚快樂機',
+ dashboardCreateFirst: '先召喚一台',
+ dashboardUserBalance: '快樂餘額',
+ dashboardBalanceValue: '免費無價',
+ dashboardNewContainer: '錢包坐好,我們直接開整',
+ instanceCreate: '快樂實例',
+ instanceCreateFirst: '先召喚一台',
+ instanceBatchRenewTitle: '批量續快樂',
+ instanceBatchRenewDescription: '白嫖站模式下,續費只是蓋個章,機器繼續開心營業。',
+ instanceBatchTotalAmount: '空氣合計',
+ instanceBatchBalanceAfter: '續後心情',
+ instanceBatchCurrentBalance: '目前吉祥物餘額',
+ instanceBatchRenewAction: '確認快樂續杯',
+ moneyJustForShow: '數字巡演',
+ marketPriceFree: '免單起飛',
+ marketPlanCount: '{count} 個快樂檔位',
+ marketCreateNow: '立即開薅',
+ marketLoginToOrder: '登入後開薅',
+ marketSelectedPlanTitle: '快樂檔位',
+ marketCycleMonthly: '月付?月亮替你付了',
+ marketMonthlyPrice: '月均?快樂不均攤',
+ createOrderSummary: '免單概覽',
+ createPromoCode: '神秘暗號',
+ createPromoPlaceholder: '可填可不填,免費列車已發車',
+ createPromoHostedDisabled: '託管節點不吃暗號,直接上車',
+ createPromoValid: '暗號有效,快樂加成 {rate}',
+ createPromoUsing: '暗號燈已亮',
+ createPromoBenefit: '折扣和返利在旁邊表演,情緒價值負責鼓掌。',
+ createCommissionEstimate: '預計給分享者投餵 ¥{amount} 的想像返利',
+ createPlanFee: '標價展覽品',
+ createMonthlyEquivalent: '折算?快樂拒絕被折算',
+ mailPrice: '郵箱也免單',
+ mailCheckoutTitle: '郵箱免單確認',
+ mailBillingCycle: '快樂週期',
+ mailCheckoutAmount: '象徵性結算',
+ mailCheckoutConfirm: '確認領取郵箱',
+ mailBalanceRequired: '餘額不餘額的,白嫖站講究先用再說。',
+ },
+ },
+
+ publicSite: {
+ brandTagline: 'Incus 驅動的 NAT VPS 平台',
+ nav: {
+ home: '首頁',
+ overview: '概覽',
+ products: '商品瀏覽',
+ help: '幫助中心',
+ },
+ actions: {
+ signIn: '登入',
+ console: '進入控制台',
+ consoleCompact: '控制台',
+ browseProducts: '瀏覽全部商品',
+ browseOfficial: '瀏覽直營商品',
+ browseMarket: '瀏覽託管商品',
+ viewCatalog: '查看全部',
+ },
+ footer: {
+ description: '精選全球多節點 LXC / KVM 商品,配置豐富、方案齊全,持續提供高性價比 NAT VPS 選擇。',
+ explore: '瀏覽',
+ account: '帳戶',
+ purchaseHint: '未登入時打開購買連結,會先進入公開商品瀏覽頁;登入後仍維持原本的建立流程。',
+ },
+ seo: {
+ keywords: 'Incus,NAT VPS,LXC,KVM,VPS 面板,雲伺服器',
+ homeTitle: '基於 Incus 的 NAT VPS 入口與控制台',
+ homeDescription: '精選全球多節點 LXC / KVM 商品,配置豐富、方案齊全,持續提供高性價比 NAT VPS 選擇。',
+ marketTitle: '商品瀏覽',
+ marketDescription: '瀏覽全部公開商品,並依來源、地區、資源配置與計費方案篩選 NAT VPS。',
+ marketPackageTitle: '{name} - 商品瀏覽',
+ marketPackageDescription: '查看 {name} 的 {type} 配置、月流量與商品資訊,並在商品瀏覽頁延續開通流程。',
+ },
+ portal: {
+ badge: 'Incus Driven NAT Platform',
+ title: '基於 Incus 的 NAT VPS 入口與控制台',
+ description: '精選全球多節點 LXC / KVM 商品,覆蓋直營與託管供給,配置檔位豐富,持續提供高性價比 NAT VPS 選擇。',
+ authPanelDescription: '把穩定供給、更多地區與更豐富價格帶放在同一入口,讓挑選套餐這一步更直接。',
+ authPanelFlowLabel: 'ORDER FLOW',
+ authPanelFlowTitle: '先看商品,登入後繼續下單',
+ authPanelFlowDescription: '未登入時可以先瀏覽公開商品;從分享連結進入也會保留商品上下文,登入後可直接續上原本的購買流程。',
+ authPanelTagValue: '高性價比',
+ previewLabel: 'CONTROL PLANE',
+ previewTitle: '控制台概覽',
+ previewDescription: '從公開入口到登入後控制台,整體體驗都圍繞實例選型、部署、維運與商品轉化。',
+ controlPoint1: '$ incus launch ubuntu:24.04 edge-vm',
+ controlPoint2: '# LXC / KVM NAT 實例統一入口',
+ controlPoint3: '# 官方直營與託管市場共用同一套瀏覽到開通鏈路',
+ packageFallback: '公開商品,支援從瀏覽頁直接跳轉購買。',
+ stats: {
+ packages: '商品數量',
+ regions: '地區數量',
+ official: '直營商品',
+ market: '託管商品',
+ },
+ officialTitle: '官方直營',
+ officialDescription: '標準化供給、較穩定的預期,適合重視節點品質、基礎體驗與持續可用性的業務。',
+ officialPoint1: '官方節點更適合需要穩定供給、清晰預期與一致維運體驗的場景。',
+ officialPoint2: '公開瀏覽與開通鏈路一致,適合站外推廣、落地頁轉化與持續上架。',
+ officialPoint3: '登入後仍沿用既有建立實例流程,不打斷後台使用習慣。',
+ marketTitle: '託管市場',
+ marketDescription: '價格帶與地區分佈更靈活,適合追求更多節點選擇與成本效率的使用者。',
+ marketPoint1: '託管市場適合尋找冷門地區、特殊價格帶與更靈活的資源來源。',
+ marketPoint2: '公開瀏覽、地區篩選與方案查看都對齊到同一套體驗。',
+ marketPoint3: '分享購買連結時,未登入訪客先看商品,已登入使用者仍直接進入開通流程。',
+ experienceNoLoginTitle: '全球多節點供給',
+ experienceNoLoginDescription: '直營與託管商品並行上架,熱門地區與更多節點選擇可在同一入口集中瀏覽。',
+ experienceRoutingTitle: 'LXC / KVM 商品更全',
+ experienceRoutingDescription: '從輕量容器到完整虛擬機,資源配置與方案檔位覆蓋更廣,方便依需求挑選。',
+ experienceThemeTitle: '多價位高性價比',
+ experienceThemeDescription: '持續補充不同預算與定位的 NAT VPS 商品,方便橫向比較後再決定。',
+ catalogLabel: 'ECOSYSTEM',
+ catalogTitle: '官方直營與託管生態並行',
+ catalogDescription: '直營與託管商品並行提供,無論你想優先看穩定供給,或是挑選更多地區與價格帶,都能在這裡找到。',
+ emptyPackages: '目前暫無可展示商品',
+ browseLabel: 'CATALOG',
+ browseTitle: '依配置與地區篩選合適商品',
+ browseDescription: '支援依地區、配置與價格篩選商品,方便新用戶選購,也方便老用戶補購。',
+ },
+ market: {
+ badge: '公開商品瀏覽',
+ title: '瀏覽全部公開商品',
+ description: '依來源、地區、配置與方案篩選的公開商品目錄。',
+ publicNotice: '這裡彙總全部公開商品,可直接依地區、來源與資源配置進行篩選。',
+ buyLinkNotice: '你目前打開的是一條購買連結。因為尚未登入,所以先顯示對應商品;登入後仍會進入原本的建立實例流程。',
+ searchPlaceholder: '搜尋商品名稱、描述或虛擬化類型',
+ allRegions: '全部地區',
+ official: '官方直營',
+ market: '託管市場',
+ noPackages: '目前暫無公開商品',
+ noResults: '沒有符合目前篩選條件的商品',
+ soldOut: '暫時售罄',
+ inStock: '可開通',
+ free: '免費',
+ fromMonthly: '¥{price}/月起',
+ planCount: '{count} 個方案',
+ featuresTitle: '套餐詳情',
+ plansTitle: '可選方案',
+ planCycle: '{months} 個月',
+ selectedPlanTitle: '目前方案',
+ customConfigTitle: '自訂配置',
+ customConfigDescription: '此商品目前沒有固定方案。登入後會進入建立頁,並依套餐允許範圍自訂資源配置。',
+ createNow: '立即開通',
+ loginToOrder: '登入後開通',
+ loginHint: '登入後會依目前商品直接延續至建立實例流程。',
+ choosePackage: '從左側選擇一個商品查看詳細資訊。',
+ summary: {
+ total: '公開商品',
+ available: '可開通',
+ regions: '覆蓋地區',
+ source: '目前來源',
+ },
+ labels: {
+ startingPrice: '起售價',
+ traffic: '月流量',
+ plans: '方案數',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '硬碟',
+ network: '網路模式',
+ hosts: '宿主機數',
+ nesting: '巢狀支援',
+ },
+ },
+ },
+
+ // 錢包
+ wallet: {
+ title: '錢包',
+ description: '管理您的帳戶餘額和充值',
+ tabs: {
+ balance: '帳戶餘額',
+ logs: '餘額明細',
+ records: '充值記錄',
+ },
+ currentBalance: '目前餘額',
+ recharge: '充值',
+ totalRecharge: '累計充值',
+ totalConsume: '累計消費',
+ totalDestroyedValue: '已銷毀總價值',
+ noLogs: '暫無餘額記錄',
+ noRecords: '暫無充值記錄',
+ type: '類型',
+ amount: '金額',
+ actualAmount: '實際到帳',
+ estimatedAmount: '預計到帳',
+ balanceAfter: '餘額',
+ instanceOrRemark: '實例/備註',
+ time: '時間',
+ orderNo: '訂單號',
+ completedAt: '完成時間',
+ statusLabel: '狀態',
+ paymentMethod: '支付方式',
+ paymentMethodType: '選擇支付方式',
+ heleketSelectionHint: '將在 Heleket 支付頁選擇具體的加密貨幣和網路,本頁不會限制最終付款幣種。',
+ paymentChannel: '支付渠道',
+ paymentUuid: 'UUID:',
+ paymentTxid: 'TxID:',
+ paymentMethods: {
+ alipay: '支付寶',
+ wxpay: '微信支付',
+ qqpay: 'QQ錢包',
+ bank: '網銀支付',
+ jdpay: '京東支付',
+ },
+ noProviders: '暫無可用支付渠道',
+ amountLabel: '充值金額',
+ amountRange: '金額範圍',
+ feeNote: '手續費',
+ payableAmount: '應付金額',
+ pay: '立即支付',
+ void: '作廢',
+ logTypes: {
+ recharge: '充值',
+ consume: '消費',
+ refund: '退款',
+ adminAdjust: '管理員調整',
+ gift: '贈送',
+ transferFee: '移轉手續費',
+ transferRefund: '手續費退還',
+ hostingWithdraw: '託管餘額提現',
+ hostingDeduction: '託管實例扣款',
+ },
+ status: {
+ pending: '待支付',
+ paid: '已支付',
+ completed: '已完成',
+ failed: '失敗',
+ cancelled: '已取消',
+ refunded: '已退款',
+ },
+ loadLogsFailed: '載入餘額明細失敗',
+ showLotteryGift: '抽獎贈送',
+ showingLotteryGift: '抽獎贈送',
+ loadProvidersFailed: '載入支付渠道失敗',
+ loadRecordsFailed: '載入充值記錄失敗',
+ selectProvider: '請選擇支付方式',
+ invalidAmount: '金額無效',
+ orderCreated: '訂單已建立',
+ redirecting: '正在跳轉支付頁面...',
+ createOrderFailed: '建立訂單失敗',
+ noPayUrl: '獲取支付連結失敗',
+ repayFailed: '重新支付失敗',
+ orderCancelled: '訂單已取消',
+ noRefundNotice: '所有充值都無法原路退款。',
+ rechargeNotice: '我知道不好用的實例可以銷毀退至面板餘額,可轉移實例 PUSH 是免費的,郵箱可自行修改。',
+ cancelFailed: '取消訂單失敗',
+ orderExpired: '訂單已過期',
+ rechargeSuccess: '充值成功!餘額已到帳',
+ verifyingPayment: '正在驗證支付狀態...',
+ paymentProcessing: '支付處理中,請稍後刷新',
+ verifyFailed: '驗證支付狀態失敗',
+ amountMismatch: '支付金額與訂單金額不匹配,請聯繫客服',
+ natDisclaimer: '我知道所售實例都是 NAT 屬性,不保證 IP 在大陸的連通性,所有充值都無法退款。',
+ },
+
+ // 推薦計劃
+ aff: {
+ title: '推薦計劃',
+ description: '邀請好友使用您的優惠碼,獲取返利收益',
+ notActivated: '推薦計劃已可用',
+ activateHint: '您可以建立優惠碼並分享給朋友,當他們使用您的優惠碼購買您購買過的方案時,您將獲得返利收益。',
+ goRecharge: '前往充值',
+ affBalance: 'AFF 餘額',
+ totalEarnings: '累計收益',
+ balanceHint: 'AFF 餘額僅可轉化為帳戶餘額用於面板消費,無法直接提現。',
+ convert: '申請轉化',
+ myCodes: '我的優惠碼',
+ createCode: '建立優惠碼',
+ noCodes: '暫無優惠碼,建立一個開始推廣吧',
+ code: '優惠碼',
+ plan: '方案',
+ discount: '折扣',
+ commission: '返利',
+ usedCount: '使用次數',
+ earnings: '收益',
+ status: '狀態',
+ enabled: '已啟用',
+ disabled: '已停用',
+ toggle: '切換',
+ selectPlan: '選擇方案',
+ selectPlanHint: '選擇全域優惠碼或方案專有碼',
+ alreadyCreated: '已建立',
+ globalCode: '全域優惠碼',
+ globalCodeBadge: '全站通用',
+ globalCodeHint: '可用於全站所有付費套餐和方案',
+ orSelectPlan: '或選擇方案專有碼',
+ discountCommission: '折扣/返利比例',
+ discountCommissionHint: '購買者享 {discount} 折扣,您獲 {commission} 返利',
+ fixedRate: '折扣與返利比例',
+ fixedRateHint: '系統固定折扣率和返利率均為 5%',
+ createSuccess: '優惠碼建立成功',
+ createFailed: '建立失敗',
+ deleteCodeConfirm: '確定刪除優惠碼 {code} 嗎?',
+ deleteCodeSuccess: '優惠碼已刪除',
+ deleteCodeFailed: '刪除失敗',
+ toggleSuccess: '狀態已切換',
+ toggleFailed: '切換失敗',
+ earningsLog: '收益明細',
+ noLogs: '暫無收益記錄',
+ logType: {
+ new_purchase: '新購返利',
+ renew: '續費返利',
+ convert: '餘額轉化',
+ },
+ convertModal: {
+ title: '申請轉化',
+ currentBalance: '目前 AFF 餘額',
+ amount: '轉化金額',
+ minAmount: '最低轉化金額:0.10 元',
+ hint: '提交後將自動轉入帳戶餘額。',
+ submit: '確認轉化',
+ success: '轉化成功,已轉入帳戶餘額',
+ failed: '提交失敗',
+ invalidAmount: '請輸入有效的轉化金額',
+ },
+ withdrawals: '轉化記錄',
+ noWithdrawals: '暫無轉化記錄',
+ withdrawalStatus: {
+ pending: '待審核',
+ approved: '已通過',
+ rejected: '已拒絕',
+ },
+ leaderboard: {
+ title: 'AFF 榜單',
+ loadFailed: '載入榜單失敗',
+ empty: '暫無榜單資料',
+ you: '就是你',
+ },
+ // 實例建立頁優惠碼輸入
+ promoCode: '優惠碼',
+ promoCodeOptional: '優惠碼(可選)',
+ promoCodePlaceholder: '輸入優惠碼(可選)',
+ promoCodeInputPlaceholder: '輸入折扣代碼',
+ promoCodeHostedDisabled: '託管節點不支援使用優惠碼',
+ promoCodeValid: '優惠碼有效,享受 {rate} 折扣',
+ promoCodeInvalid: '優惠碼無效',
+ verifying: '驗證中...',
+ originalPrice: '原價',
+ discountAmount: '折扣',
+ promoDiscount: '推廣折扣',
+ finalPrice: '實付',
+ usingPromoCode: '您正在使用優惠碼',
+ promoCodeBenefit: '您享受 {discount} 折扣,同時為分享者帶來約 {commission}% 的返利',
+ commissionEstimate: '預計為分享者帶來 ¥{amount} 返利',
+ // 管理員審核
+ adminTitle: 'AFF 轉化審核',
+ adminDescription: '審核使用者的 AFF 餘額轉化申請',
+ user: '使用者',
+ userBalance: '使用者 AFF 餘額',
+ requestAmount: '申請金額',
+ requestTime: '申請時間',
+ approve: '通過',
+ reject: '拒絕',
+ rejectReason: '拒絕原因',
+ rejectReasonPlaceholder: '請輸入拒絕原因',
+ approveSuccess: '已通過,已轉入使用者餘額',
+ approveFailed: '審核失敗',
+ rejectSuccess: '已拒絕',
+ rejectFailed: '拒絕失敗',
+ noRequests: '暫無待審核的轉化申請',
+ filterStatus: '狀態篩選',
+ all: '全部',
+ },
+
+ popupAnnouncement: {
+ title: '站點公告',
+ subtitle: '請留意這條最新通知',
+ promoLabel: '新機器推廣',
+ buyNow: '立即購買 {name}',
+ viewImage: '查看完整圖片',
+ promoPlans: '可選方案',
+ soldOut: '售罄',
+ dismissToday: '今日不見',
+ dismissForever: '再也不見',
+ },
+
+ // 語言
+ language: {
+ zh: '中文',
+ en: 'English',
+ },
+
+ // 認證
+ auth: {
+ login: '登入',
+ loginTo: '登入',
+ logout: '登出',
+ register: '註冊',
+ registerTo: '註冊',
+ username: '使用者名稱',
+ usernamePlaceholder: '輸入使用者名稱',
+ usernameOrEmail: '使用者名稱或電子郵件',
+ usernameOrEmailPlaceholder: '輸入使用者名稱或電子郵件',
+ password: '密碼',
+ passwordPlaceholder: '輸入密碼',
+ confirmPassword: '確認密碼',
+ confirmPasswordPlaceholder: '再次輸入密碼',
+ email: '電子郵件',
+ emailPlaceholder: '輸入電子郵件',
+ rememberMe: '記住我',
+ forgotPasswordLink: '忘記密碼',
+ contactEmail: '聯絡信箱',
+ noAccount: '還沒有帳號?',
+ hasAccount: '已有帳號?',
+ loginSuccess: '登入成功',
+ logoutSuccess: '已登出',
+ registerSuccess: '註冊成功',
+ invalidCredentials: '使用者名稱或密碼錯誤',
+ sessionExpired: '會話已過期,請重新登入',
+ continue: '繼續',
+ loggingIn: '登入中...',
+ registering: '註冊中...',
+ orUse: '或使用',
+ oauthBindHint: '需先在設定中綁定帳號才能使用快速登入',
+ enterUsernamePassword: '請輸入使用者名稱和密碼',
+ enterUsernameOrEmailPassword: '請輸入帳號和密碼',
+ twoFactorCode: '雙重驗證碼',
+ twoFactorCodePlaceholder: '輸入 6 位驗證碼',
+ twoFactorHint: '請輸入驗證器應用程式中顯示的驗證碼',
+ twoFactorOptional: '選填',
+ twoFactorOptionalHint: '如果您啟用了雙重驗證,請輸入驗證碼',
+ recoveryCode: '恢復碼',
+ recoveryCodePlaceholder: '輸入恢復碼',
+ recoveryCodeHint: '輸入設定 2FA 時儲存的恢復碼(一次性使用)',
+ useRecoveryCode: '無法存取驗證器?使用恢復碼',
+ useTotpCode: '使用驗證器驗證碼',
+ enterRecoveryCode: '請輸入恢復碼',
+ enterTotpCode: '請輸入驗證碼',
+ rememberPassword: '想起密碼了?',
+ verificationCode: '驗證碼',
+ verificationCodePlaceholder: '輸入 6 位驗證碼',
+ invalidCode: '請輸入 6 位驗證碼',
+ forgotPassword: {
+ title: '找回密碼',
+ subtitle: '透過電子郵件驗證碼重設您的密碼',
+ sendCode: '發送驗證碼',
+ codeSent: '驗證碼已發送,請查收郵件',
+ codeHint: '請輸入發送到您電子郵件的 6 位驗證碼',
+ resetPassword: '重設密碼',
+ resetSuccess: '密碼重設成功!新密碼已發送到您的電子郵件,請查收。',
+ twoFactorDisabled: '您的雙重驗證(2FA)已被自動停用,建議重新啟用以確保帳號安全。'
+ },
+ oauthNotBound: '請先在個人設定中綁定 {provider} 帳號後再使用快速登入',
+ oauthUserNotFound: '使用者不存在',
+ oauthAccountBanned: '帳號已被停用',
+ oauthProviderDisabled: '該登入方式已被停用',
+ oauthTokenError: '取得授權失敗,請重試',
+ oauthError: 'OAuth 登入失敗,請重試',
+ loginFailed: '登入失敗',
+ createAccount: '註冊',
+ creatingAccount: '建立中...',
+ backToLogin: '返回登入',
+ registerSuccessRedirect: '註冊成功,正在跳轉...',
+ inviteCode: '邀請碼',
+ inviteCodePlaceholder: '輸入邀請碼',
+ usernameHint: '字母開頭,3-32 個字元',
+ passwordHint: '至少 8 位,包含大小寫字母和數字',
+ fillAllRequired: '請填寫所有必填項',
+ invalidEmail: '請輸入有效的電子郵件地址',
+ emailContainsIllegal: '電子郵件包含非法字元',
+ passwordMismatch: '兩次密碼不一致',
+ passwordTooShort: '密碼至少 8 位',
+ passwordNeedsUppercase: '密碼需要包含至少一個大寫字母',
+ passwordNeedsLowercase: '密碼需要包含至少一個小寫字母',
+ passwordNeedsNumber: '密碼需要包含至少一個數字',
+ turnstileRequired: '請完成人機驗證',
+ turnstileFailed: '人機驗證失敗,請重試',
+ // 電子郵件驗證
+ emailCode: '電子郵件驗證碼',
+ emailCodePlaceholder: '請輸入 6 位驗證碼',
+ emailCodeRequired: '請輸入電子郵件驗證碼',
+ registrationClosedTitle: '目前已關閉註冊',
+ registrationClosedMessage: '抱歉,目前站點暫時關閉註冊,如需開通帳號請聯絡管理員。',
+ registrationClosedShort: '目前已關閉註冊',
+ sendCode: '發送驗證碼',
+ sendingCode: '發送中...',
+ codeSentHint: '驗證碼已發送至您的電子郵件,10 分鐘內有效',
+ invalidEmailCode: '驗證碼無效或已過期',
+ allowedEmailDomains: '僅支援以下電子郵件',
+ emailUsernamePlaceholder: '使用者名稱',
+ confirmEmail: '確認電子郵件地址',
+ confirmEmailMessage: '驗證碼將發送至以下電子郵件,請確認地址是否正確:',
+ confirmAndSend: '確認發送',
+ // 服務條款
+ tos: {
+ title: '服務條款',
+ agreePrefix: '我已閱讀並同意',
+ termsLink: '《服務條款》',
+ mustAgree: '請閱讀並同意服務條款',
+ understood: '我已瞭解',
+ loadFailed: '載入服務條款失敗',
+ },
+ },
+
+ // 使用者選單
+ userMenu: {
+ profile: '個人設定',
+ myInstances: '我的實例',
+ logout: '登出',
+ },
+
+ // 配額
+ quota: {
+ hosts: '主機',
+ instances: '實例',
+ friends: '好友',
+ packages: '方案',
+ },
+
+ // 儀表板
+ dashboard: {
+ title: '總覽',
+ welcome: '歡迎回來',
+ welcomeMessage: '歡迎回來,{name}!',
+ overview: '總覽',
+ recentActivity: '最近活動',
+ quickActions: '快速操作',
+ // 統計卡片(頂層定義)
+ totalInstances: '實例總數',
+ runningInstances: '運行中',
+ stoppedInstances: '已停止',
+ creatingInstances: '建立中',
+ userBalance: '使用者餘額',
+ balance: '餘額',
+ memberLevel: '會員等級',
+ memberLevelBasic: '普通會員',
+ uptimeProbe: '自營節點監控',
+ statusPage: '狀態頁',
+ rechargeNow: '立即充值',
+ walletDetails: '查看錢包',
+ walletHint: '前往錢包頁面完成充值',
+ instanceStatusOverview: '實例狀態概覽',
+ accountOverview: '帳戶概覽',
+ runningHealth: '運行健康度',
+ instanceOverviewSummary: '{running}/{total} 台實例運行中,在線率 {percent}%',
+ containerInstances: '容器實例',
+ vmInstances: '虛擬機實例',
+ totalRecharge: '累計充值',
+ totalConsume: '累計消費',
+ userPoints: '使用者積分',
+ frozenBalance: '凍結餘額',
+ frozenBalanceHint: '帳戶中有凍結餘額,請在錢包查看明細',
+ accountReadyHint: '帳戶狀態正常,可直接建立或續費實例',
+ instanceListSummary: '最近 {count} / 共 {total} 個實例',
+ vipProgressTitle: '會員成長進度',
+ vipProgressToNext: '{current} → {next}',
+ vipProgressMaxed: '已達到目前最高會員等級',
+ vipProgressNoRule: '暫無下一等級規則',
+ vipProgressUnavailable: '會員進度暫不可用',
+ vipProgressAllHint: '升級到 {level} 需要同時滿足以下條件',
+ vipProgressAnyHint: '升級到 {level} 滿足任一條件即可',
+ vipProgressSingleMetricHint: '升級到 {level} 需要達到{metric}門檻',
+ vipProgressStableHint: '保持目前權益,後續福利大廳開放後可領取對應福利',
+ vipMetricTotalRecharge: '累計充值',
+ vipMetricTotalConsume: '累計消費',
+ vipMetricTotalHostingIncome: '累計託管收入',
+ vipMetricInstanceCount: '託管實例數',
+ vipProgressConditionMet: '已滿足',
+ vipProgressCurrent: '目前',
+ vipProgressTarget: '目標',
+ vipProgressRemaining: '還差',
+ vipProgressRemainingMoney: '還差 {amount}',
+ vipProgressRemainingCount: '還差 {count} 個',
+ resourceUsage: '資源使用',
+ resourceOverview: '這是您的資源使用概覽',
+ newInstance: '新增實例',
+ quotaUsage: '配額使用',
+ increaseQuota: '增加配額',
+ pinnedArticles: '置頂說明文章',
+ viewAllHelp: '查看全部說明',
+ myInstances: '我的實例',
+ viewAll: '查看全部',
+ viewAllInstancesWithCount: '查看全部 {count} 個實例',
+ memoryMetric: '記憶體',
+ diskMetric: '磁碟',
+ noPublicIp: '無公網 IP',
+ unknownHost: '未分配節點',
+ createFirst: '建立第一個實例',
+ // 快速操作卡片
+ createInstance: '建立實例',
+ newContainer: '新增容器',
+ instanceList: '實例列表',
+ manageInstances: '管理實例',
+ profileSettings: '個人設定',
+ accountSecurity: '帳戶安全',
+ helpDocs: '說明文件',
+ userGuide: '使用指南',
+ // 問候語
+ greeting: {
+ morning: '早安',
+ afternoon: '午安',
+ evening: '晚安',
+ basicUser: '{username}',
+ memberUser: '尊貴的 {level} {username}',
+ full: '{greeting},{member}',
+ },
+ // 統計(嵌套定義)
+ stats: {
+ totalInstances: '實例總數',
+ runningInstances: '運行中的實例',
+ totalHosts: '主機總數',
+ totalUsers: '使用者總數',
+ activeUsers: '活躍使用者',
+ resourceUsage: '資源使用',
+ systemStatus: '系統狀態',
+ },
+ actions: {
+ createInstance: '建立實例',
+ viewInstances: '查看實例',
+ viewHosts: '查看主機',
+ viewUsers: '查看使用者',
+ },
+ allRunning: '全部運行中',
+ xRunning: '{count} 個運行中',
+ allStopped: '全部已停止',
+ instancePlural: '個實例',
+ noInstances: '暫無實例',
+ noInstancesHint: '您還沒有任何實例,快去建立一個吧!',
+ hostNotice: '您的實例運行在以下主機上',
+ instances: '我的實例',
+ hosts: '實例所在主機',
+ viewAllInstances: '查看全部實例',
+ viewAllHosts: '查看全部主機',
+ hostName: '主機名稱',
+ ownerName: '所有者',
+ instanceCount: '實例數',
+ publicIpDisplay: '公網 IP',
+ cards: {
+ runningInstances: '運行中實例',
+ totalStorage: '總儲存空間',
+ totalCpu: '總 CPU',
+ totalMemory: '總記憶體',
+ },
+ funnyQuote: '一句話',
+ hostStatus: '主機狀態',
+ noHostsFound: '找不到任何主機',
+ viewAllPackages: '查看全部方案',
+ selectedPackages: '已選方案',
+ noPackagesSelected: '未選擇方案',
+ selectPackages: '選擇方案',
+ },
+
+ // 服務條款模態框
+ tosModal: {
+ title: '服務條款',
+ content: '服務條款內容',
+ agree: '我同意服務條款',
+ agreeButton: '同意並繼續',
+ mustAgree: '您必須同意服務條款才能繼續使用',
+ lastUpdated: '最後更新:{date}',
+ scrollToRead: '請滾動閱讀完整服務條款',
+ contactUs: '如有疑問,請聯繫我們',
+ contactEmail: '聯繫電子郵件',
+ iAgree: '我已閱讀並同意',
+ readCarefully: '請仔細閱讀以下服務條款',
+ loading: '載入服務條款中...',
+ loadFailed: '載入服務條款失敗,請重新整理頁面重試',
+ },
+
+ // 實例相關
+ instance: {
+ title: '實例管理',
+ titlePlural: '實例',
+ name: '實例名稱',
+ image: '映像檔',
+ package: '方案',
+ host: '節點',
+ ip: 'IP 位址',
+ port: '連接埠',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '磁碟',
+ bandwidth: '頻寬',
+ expireAt: '到期時間',
+ expiredLabel: '已到期',
+ freeInstanceLabel: '免費實例',
+ config: '設定',
+ user: '使用者',
+ details: '詳情',
+ // 資源配額(訂單概覽用)
+ ports: '連接埠',
+ snapshots: '快照',
+ backups: '備份',
+ hostAnnouncement: '節點公告',
+ badgeModal: {
+ open: '查看或修改實例徽章',
+ kicker: '實例徽章',
+ title: '實例徽章',
+ subtitle: '查看目前實例徽章,並快速切換到其他已擁有徽章:{name}',
+ detailsTab: '徽章說明',
+ ownedTab: '我的徽章',
+ noBadgeSeries: '未設定徽章',
+ noBadgeTitle: '目前尚未為這個實例設定徽章',
+ noBadgeSummary: '這個實例目前仍在使用預設圖示。',
+ noBadgeDescription: '你可以切換到「我的徽章」,把已擁有的徽章快速套用到目前實例。',
+ statusLabel: '目前狀態',
+ statusApplied: '已套用到目前實例',
+ statusNotApplied: '未設定實例徽章',
+ ownedCountLabel: '已擁有副本',
+ openOwnedTab: '從我的徽章中選擇',
+ manageUnavailable: '目前實例不在你的可管理實例清單中,這裡只能查看徽章資訊,不能直接修改。',
+ emptyOwnedTitle: '你還沒有可用的實例徽章',
+ emptyOwnedHint: '先前往娛樂中心取得徽章,之後就可以在這裡快速切換目前實例的徽章。',
+ applyCurrent: '套用到目前實例',
+ replaceCurrent: '替換目前實例徽章',
+ moveCurrent: '轉移到目前實例',
+ moveFromAvatar: '從頭像改為目前實例',
+ appliedHere: '目前實例中',
+ currentHint: '這個徽章已經套用在目前實例上。',
+ replaceHint: '套用後會替換目前實例正在使用的徽章。',
+ moveFromAvatarHint: '套用後會先從目前頭像上移除。',
+ moveFromInstanceHint: '套用後會先從實例「{name}」上移除。',
+ removeCurrent: '移除目前實例徽章',
+ updateSuccess: '實例徽章已更新',
+ removeSuccess: '已移除目前實例徽章',
+ },
+ errorBanner: {
+ title: '實例異常',
+ description: '實例異常,您可直接銷毀(付費實例剩餘價值退款不扣除手續費)',
+ destroyNow: '立即銷毀',
+ confirmDestroy: '確定要銷毀此異常實例嗎?付費實例的剩餘價值將退款至您的錢包(不扣除手續費)。',
+ },
+ actions: {
+ start: '啟動',
+ stop: '停止',
+ restart: '重新啟動',
+ delete: '刪除',
+ console: '控制台',
+ snapshot: '快照',
+ backup: '備份',
+ rename: '重新命名',
+ clone: '複製',
+ suspend: '封停',
+ unsuspend: '解封',
+ },
+ renameModal: {
+ title: '重新命名實例',
+ name: '實例名稱',
+ namePlaceholder: '輸入新的實例名稱',
+ cancel: '取消',
+ confirm: '確認',
+ renaming: '重新命名中...',
+ success: '實例已重新命名',
+ failed: '重新命名失敗',
+ },
+ statusLabel: '狀態',
+ modeLabel: '模式',
+ quotaLabel: '配額',
+ trafficLabel: '流量',
+ manageDesc: '管理您的容器實例',
+ userInstances: '使用者「{name}」的實例',
+ clearFilter: '清除篩選',
+ searchPlaceholder: '搜尋實例名稱、IP...',
+ totalCount: '共 {count} 個實例',
+ noInstances: '暫無實例',
+ noMatchingInstances: '未找到符合的實例',
+ tryOtherKeywords: '嘗試使用其他關鍵字',
+ createFirstInstance: '建立第一個容器實例開始使用',
+ listLayout: '列表',
+ cardLayout: '卡片',
+ order: {
+ label: '調整排序',
+ top: '置頂',
+ up: '上移',
+ down: '下移',
+ bottom: '置底',
+ updateSuccess: '實例順序已更新',
+ updateFailed: '儲存實例順序失敗',
+ },
+ confirmDelete: '確定刪除實例「{name}」?此操作不可回復。',
+ createPage: {
+ title: '建立實例',
+ description: '選擇方案並設定您的容器實例',
+ instanceName: '實例名稱',
+ instanceNamePlaceholder: 'my-instance',
+ selectPackage: '請選擇方案',
+ selectSshKey: '請選擇 SSH 金鑰',
+ creating: '建立中...',
+ createSuccess: '實例建立中,請稍後在實例列表中查看狀態',
+ loadFailed: '載入資料失敗',
+ loadHostsFailed: '載入可用主機失敗',
+ loadImagesFailed: '載入可用映像檔失敗',
+ missingSshKey: '缺少 SSH 金鑰',
+ missingSshKeyDesc: '建立實例需要 SSH 金鑰。請先前往',
+ profileSettings: '個人設定',
+ addSshKey: '新增 SSH 公鑰。',
+ quotaInsufficient: '配額不足',
+ quotaCpu: 'CPU: 已用 {used}%/{limit}% 額配,本次需要 {need}%',
+ quotaMemory: '記憶體: 已用 {used}/{limit} MB,本次需要 {need} MB',
+ quotaDisk: '磁碟: 已用 {used}/{limit} MB,本次需要 {need} MB',
+ quotaInstance: '實例: 已達上限 {used}/{limit} 個',
+ packageNoHosts: '方案未綁定主機,請聯繫管理員',
+ sharedPackageNotFound: '分享的方案不存在或已失效,已為您選擇其他可用方案',
+ quotaInfo: {
+ prefix: '該方案',
+ you: '您',
+ maxInstances: '',
+ count: '數量',
+ instances: '個實例',
+ unlimited: '無限制',
+ cpu: 'CPU',
+ memory: '記憶體',
+ remaining: '該方案剩餘配額',
+ },
+ resourceLimit: {
+ title: '資源配額不足,無法建立實例',
+ noInstances: '剩餘實例數量為 0,無法建立新實例',
+ insufficientMemory: '剩餘記憶體不足(<128MB),無法建立實例',
+ insufficientCpu: '剩餘 CPU 不足(<15%),無法建立實例',
+ },
+ ownPaidPackageWarning: '這是您自己建立的付費方案,不能為自己開通實例',
+ destroyTrafficNotice: '銷毀限制:實例本月已用流量低於 5 GB 時才可銷毀。',
+ firstPaidInstanceNotice: '銷毀限制:實例本月已用流量低於 5 GB 時才可銷毀。',
+ // 訂單概覽
+ orderSummary: '訂單概覽',
+ packageName: '方案',
+ planName: '方案',
+ billingCycle: '計費週期',
+ months: '個月',
+ month: '月',
+ resourceConfig: '資源設定',
+ planFee: '方案費用',
+ // 套餐來源
+ source: {
+ official: '直營',
+ market: '託管',
+ friends: '好友共享',
+ },
+ fun: {
+ selectRegion: '選擇國家/地區',
+ packageCount: '{count} 個方案',
+ selectPackage: '選擇方案',
+ selectPlan: '選擇方案',
+ planDesc: '選擇適合您需求的付費方案',
+ customPlanHint: '找不到合適的方案?您可以提交工單申請定製配置',
+ noPlans: '該方案暫無可用方案',
+ planSoldOut: '已售罄',
+ noPackages: '暫無可用方案',
+ selectHost: '選擇主機',
+ hostAutoSelected: '預設選中第一台可用節點',
+ selectSystem: '選擇系統',
+ noImages: '暫無可用映像檔',
+ selectSshKey: '選擇 SSH 金鑰',
+ },
+ // 託管免責提示
+ hostedDisclaimer: {
+ title: '託管節點提示',
+ content: '此為用戶託管節點,售後由託管者(UID:{uid})負責。僅提供平台服務,不擔保節點品質。若託管者失聯,將處理其託管餘額退還至受影響用戶的面板餘額。',
+ },
+ zoneNotice: {
+ badge: '專區',
+ content: '此為 UID:{uid} 的專區,售後由 {username} 處理。',
+ },
+ },
+ startingInstance: '{name} 啟動中',
+ stoppedInstance: '{name} 已停止',
+ restartingInstance: '{name} 重新啟動中',
+ deletedInstance: '{name} 已刪除',
+ actionFailed: '操作失敗',
+ verificationRequiredHint: '此操作需要二次驗證,請前往實例詳情頁面執行',
+ totalRecords: '共 {count} 筆記錄',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ batch: {
+ selectedCount: '已選 {count} 個實例',
+ currentPageOnly: '批量操作僅作用於目前頁面選中的實例',
+ clear: '取消選擇',
+ start: '批量開機',
+ stop: '批量關機',
+ restart: '批量重啟',
+ sync: '批量同步',
+ renew: '批量續費',
+ autoRenewOn: '開啟自動續費',
+ autoRenewOff: '關閉自動續費',
+ destroy: '批量銷毀',
+ noEligibleAction: '所選實例中沒有可執行此操作的項目',
+ partialResult: '已完成 {success} 個,失敗 {failed} 個,跳過 {skipped} 個',
+ successResult: '已成功處理 {count} 個實例',
+ actionFailed: '批量操作失敗',
+ previewFailed: '載入批量預覽失敗',
+ renewTitle: '批量續費',
+ renewDescription: '為目前選中的付費實例統一續費,只有支援所選時長的實例會被執行。',
+ selectedMonths: '續費時長',
+ eligibleCount: '可執行數量',
+ totalAmount: '合計金額',
+ renewEmpty: '所選實例中沒有可續費項目',
+ eligibleList: '可執行實例({count})',
+ skippedList: '跳過實例({count})',
+ hosted: '託管實例',
+ unsupportedPeriod: '目前所選時長不可續費',
+ unknownReason: '目前無法處理',
+ destroyTitle: '批量銷毀',
+ destroyDescription: '銷毀目前選中的實例,只有符合條件的實例會被執行。',
+ destroyEmpty: '所選實例中沒有可銷毀項目',
+ refundTotal: '預估總退款',
+ feeTotal: '預估總手續費',
+ feeWaived: '免手續費',
+ confirmHint: '請輸入 DESTROY 以確認批量銷毀',
+ confirmPlaceholder: '輸入 DESTROY 確認',
+ },
+ batchReason: {
+ notFoundOrForbidden: '實例不存在或無權操作',
+ freeNoRenew: '免費實例無需續費',
+ billingUnavailable: '無法取得實例計費資訊',
+ noRenewOptions: '暫無可用續費選項',
+ renewWindow: '僅可在到期前 {days} 天內續費',
+ renewFailed: '續費失敗',
+ freeNoAutoRenew: '免費實例不支援自動續費',
+ autoRenewAlreadyOn: '已經開啟自動續費',
+ autoRenewAlreadyOff: '已經關閉自動續費',
+ autoRenewFailed: '自動續費設定失敗',
+ deleted: '實例已刪除',
+ creating: '實例正在建立中,無法銷毀',
+ suspended: '實例已被封停,無法銷毀,請先聯繫管理員解封',
+ destroyTrafficLimit: '目前月流量週期無法銷毀,已用流量達到或超過 5G',
+ destroyFailed: '銷毀實例失敗',
+ },
+ // 行動裝置卡片
+ mobileCard: {
+ ipAddress: 'IP 位址',
+ config: '設定',
+ disk: '硬碟',
+ traffic: '流量',
+ host: '節點',
+ user: '使用者',
+ unlimited: '無限制',
+ cpuCore: '%核',
+ quota: '配額',
+ ports: '連接埠',
+ snapshots: '快照',
+ backups: '備份',
+ sites: '反代',
+ },
+ // 實例建立組件
+ selector: {
+ // 地區選擇
+ selectRegion: '選擇國家/地區',
+ allRegions: '全部',
+ noRegions: '暫無可用地區',
+ packageCount: '{count} 個方案',
+ // 方案選擇
+ selectPackage: '選擇方案',
+ selectPlan: '選擇方案',
+ planDesc: '選擇適合您需求的付費方案',
+ customPlanHint: '找不到合適的方案?您可以提交工單申請定製配置',
+ noPlans: '該方案暫無可用方案',
+ planSoldOut: '已售罄',
+ noPackages: '暫無可用方案',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '硬碟',
+ cores: '核',
+ networkMode: {
+ nat: 'NAT',
+ natDesc: '透過連接埠映射存取',
+ nat_ipv6: 'NAT + IPv6',
+ nat_ipv6Desc: 'NAT + 公網 IPv6',
+ },
+ docker: '可嵌套',
+ privileged: '特權',
+ configureResources: '設定資源',
+ adjustBasedOnPackage: '基於方案上限自由調節',
+ cpuAllowance: '額配',
+ accountQuota: '帳戶配額',
+ packageLimit: '方案限制',
+ selectHost: '選擇主機',
+ hostOptional: '可選,不選則系統自動分配',
+ hostAutoSelected: '預設選中第一台可用節點',
+ hostTraffic: '該節點下實例月流量',
+ autoAssign: '自動分配',
+ autoAssignDesc: '系統根據負載自動選擇最佳節點',
+ available: '可用',
+ selectSystem: '選擇系統',
+ showSyncedImages: '顯示目前節點允許的映像檔',
+ noImages: '暫無可用映像檔',
+ noImagesOnHost: '所選節點暫無可用映像檔',
+ contactAdmin: '請聯繫節點所有者或管理員調整映像檔策略',
+ selectSshKey: '選擇 SSH 金鑰',
+ noSshKeys: '暫無 SSH 金鑰',
+ addSshKeyHint: '請先在設定頁面新增 SSH 公鑰',
+ hostInsufficient: '主機資源不足',
+ hostInsufficientDesc: '目前設定需要 {cpu}% CPU 和 {memory}GB 記憶體,但方案內所有主機均無法滿足。',
+ hostInsufficientSuggest: '建議降低實例設定或等待資源釋放。',
+ viewProbe: '查看探針監控',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ pageInfo: '第 {current}/{total} 頁,共 {count} 筆',
+ // 方案資源配額
+ ports: '連接埠',
+ snapshots: '快照',
+ backups: '備份',
+ sites: '站點',
+ bandwidth: '頻寬',
+ },
+ // 付費訂閱卡片
+ subscription: {
+ premium: '付費訂閱',
+ expiresAt: '到期日期',
+ expiresIn: '剩餘時間',
+ expired: '已過期',
+ days: '天',
+ billingCycle: '計費週期',
+ renewPrice: '續費價格',
+ month: '月',
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ annual: '年付',
+ months: '個月',
+ perMonth: '/月',
+ perQuarter: '/季',
+ perHalfYear: '/半年',
+ perYear: '/年',
+ renew: '續費',
+ renewSuccess: '續費成功',
+ applyAffShort: '優惠碼',
+ applyAffTitle: '綁定 AFF 優惠碼',
+ applyAffInstance: '實例',
+ applyAffCurrentRenewPrice: '當前續費價格',
+ applyAffCodeLabel: 'AFF 優惠碼',
+ applyAffCodePlaceholder: '輸入 AFF 優惠碼',
+ applyAffEffectHint: '綁定成功後僅影響後續續費價格。',
+ applyAffNoRefundHint: '不會退還當前週期差價,也不會改變當前週期費用。',
+ applyAffOwnCodeHint: '只能綁定他人的優惠碼,不能綁定自己的優惠碼。',
+ applyAffSubmit: '確認綁定',
+ applyAffSubmitting: '綁定中...',
+ applyAffSuccess: '優惠碼綁定成功,後續續費將享受折扣',
+ // 自動續費
+ autoRenew: '自動續費',
+ autoRenewOn: '已開啟自動續費',
+ autoRenewOff: '未開啟自動續費',
+ autoRenewEnabled: '已開啟自動續費',
+ autoRenewDisabled: '已關閉自動續費',
+ enableAutoRenew: '開啟自動續費',
+ disableAutoRenew: '關閉自動續費',
+ autoRenewHint: '到期前 24 小時將自動從餘額扣款續費',
+ autoRenewDesc: '開啟自動續費後,實例將在到期前自動按 {cycle} 週期續費,每次續費 ¥{price}',
+ currentStatus: '當前狀態',
+ },
+ // 實例銷毀
+ destroy: {
+ button: '銷毀',
+ title: '銷毀實例',
+ warning: '此操作將永久刪除實例及其所有資料,包括快照、備份、端口映射等,且不可恢復。',
+ warningFree: '此操作將永久刪除實例及其所有資料,且不可恢復。',
+ rulesTitle: '銷毀規則',
+ rulesDesc: '了解銷毀功能的使用規則和限制',
+ ruleFirstFree: '首次銷毀免手續費',
+ ruleFirstFreeDesc: '您的第一次銷毀操作將免除手續費,全額退款',
+ ruleFeeRate: '後續銷毀收取 {rate}% 手續費',
+ ruleFeeRateDesc: '第二次及以後銷毀收取 {rate}% 手續費',
+ ruleTrafficThreshold: '付費實例當前月流量週期已用流量需低於 5G',
+ ruleTrafficThresholdDesc: '若當前月流量週期已用流量達到或超過 5G,則本次無法銷毀付費實例',
+ ruleFreeInstance: '免費實例可直接銷毀',
+ ruleFreeInstanceDesc: '免費實例銷毀無退款,不計入銷毀次數',
+ // 預覽資訊
+ instanceInfo: '實例資訊',
+ instanceName: '實例名稱',
+ hostName: '所在節點',
+ planName: '當前方案',
+ refundInfo: '退款資訊',
+ remainingDays: '剩餘天數',
+ remainingValue: '剩餘價值',
+ maxRefundable: '退款上限',
+ feeRate: '手續費率',
+ feeAmount: '手續費',
+ refundAmount: '實際退款',
+ firstTimeFree: '首次免手續費',
+ freeInstanceNoRefund: '免費實例無退款',
+ days: '天',
+ // 確認
+ confirmTitle: '確認銷毀',
+ confirmHint: '請輸入實例名稱 {name} 以確認銷毀',
+ confirmPlaceholder: '輸入實例名稱確認',
+ confirmButton: '確認銷毀',
+ destroying: '銷毀中...',
+ cancel: '取消',
+ // 狀態
+ success: '實例已銷毀',
+ successWithRefund: '實例已銷毀,已退款 ¥{amount}',
+ failed: '銷毀失敗',
+ loadFailed: '載入銷毀資訊失敗',
+ // 不可銷毀原因
+ cannotDestroy: '無法銷毀',
+ },
+ create: '建立實例',
+ createNew: '建立新實例',
+ edit: '編輯實例',
+ delete: '刪除實例',
+ start: '啟動',
+ stop: '停止',
+ restart: '重新啟動',
+ console: '控制台',
+ terminal: '終端機',
+ backup: '備份',
+ backupAction: '建立備份',
+ snapshot: '快照',
+ snapshotAction: '建立快照',
+ resize: '調整規格',
+ reinstall: '重裝系統',
+ migrate: '遷移',
+ transfer: '移轉',
+ rename: '重新命名',
+ forceStop: '強制停止',
+ status: {
+ running: '運行中',
+ stopped: '已停止',
+ starting: '啟動中',
+ stopping: '停止中',
+ restarting: '重新啟動中',
+ error: '錯誤',
+ unknown: '未知',
+ suspended: '已暫停',
+ creating: '建立中',
+ migrating: '遷移中',
+ restoring: '還原中',
+ deleting: '刪除中',
+ installing: '安裝中',
+ copying: '複製中',
+ backingUp: '備份中',
+ },
+ statusFilter: {
+ all: '全部狀態',
+ },
+ createdAt: '建立時間',
+ detail: {
+ invalidId: '無效的實例 ID',
+ notExist: '實例不存在',
+ loadFailed: '載入實例失敗',
+ title: '實例詳情',
+ overview: '總覽',
+ specs: '規格',
+ storage: '儲存',
+ activity: '活動',
+ tabs: {
+ info: '資訊',
+ overview: '總覽',
+ network: '網路',
+ siteProxy: '建站',
+ traffic: '流量',
+ quota: '配額',
+ storage: '儲存',
+ snapshots: '快照',
+ backups: '備份',
+ logs: '日誌',
+ settings: '設定',
+ config: '設定',
+ },
+ task: {
+ start: '正在啟動...',
+ stop: '正在停止...',
+ restart: '正在重新啟動...',
+ rebuild: '正在重裝...',
+ recreate: '正在重建...',
+ clone: '正在複製...',
+ change_host: '正在改節點...',
+ },
+ actions: {
+ starting: '實例啟動中',
+ stopped: '實例已停止',
+ restarting: '實例重新啟動中',
+ deleted: '實例已刪除',
+ confirmDelete: '確定刪除實例「{name}」?\n\n此操作將永久刪除實例和所有資料,不可回復。',
+ actionFailed: '操作失敗',
+ taskQueued: '操作已提交,請稍候...',
+ taskInProgress: '實例正在執行其他操作,請稍候',
+ rebuildSuccess: '系統重裝成功,新密碼已產生',
+ recreateSuccess: '實例重建成功,新密碼已產生',
+ selectImageAndKey: '請選擇映像檔和 SSH 金鑰',
+ clone: '複製實例',
+ cloning: '複製中...',
+ cloneSuccess: '實例複製成功',
+ cloneFailed: '實例複製失敗',
+ confirmClone: '確定要複製實例「{name}」嗎?',
+ cloneNotice: '複製操作將建立一個新的實例副本,新實例將繼承源實例的所有設定,但會分配新的連接埠映射。複製可能需要幾分鐘時間,請耐心等待。',
+ stopRequired: '請先停止實例',
+ stopRequiredHint: '此操作需要實例處於停止狀態',
+ suspend: '封停實例',
+ unsuspend: '解除封停',
+ suspending: '封停中...',
+ unsuspending: '解封中...',
+ suspendSuccess: '實例已封停',
+ unsuspendSuccess: '實例已解封',
+ syncStatus: '同步',
+ help: '幫助',
+ syncStatusChanged: '狀態已同步:{from} → {to}',
+ syncStatusNoChange: '狀態已更新,已同步網路位址',
+ syncIpv4Changed: '內網 IP 已更新: {from} → {to}',
+ syncProxySitesUpdated: '已同步更新 {count} 個反代站點配置',
+ confirmSuspend: '確定要封停實例「{name}」嗎?',
+ confirmSuspendNotice: '封停後,實例所有者將無法對該實例進行任何操作,直到所有者手動解除封禁。',
+ suspendReason: '封停原因',
+ suspendReasonPlaceholder: '請輸入封停原因,將透過站內信通知實例所有者...',
+ confirmUnsuspend: '確定要解除實例「{name}」的封停狀態嗎?',
+ },
+ rebuild: {
+ title: '重裝',
+ noHostInfo: '無法取得節點資訊',
+ noImages: '該節點目前沒有可用映像檔,請聯繫節點所有者或管理員調整映像檔策略。',
+ loadImagesFailed: '載入映像檔列表失敗',
+ loadKeysFailed: '載入 SSH 金鑰失敗',
+ },
+ recreate: {
+ title: '重建實例',
+ },
+ port: {
+ fillPrivatePort: '請填寫內部連接埠',
+ added: '連接埠映射已新增',
+ addedBoth: 'TCP 和 UDP 連接埠映射已新增',
+ batchAdded: '已新增 {count} 個連接埠映射',
+ stillConflict: '部分連接埠仍有衝突,請重新選擇',
+ deleted: '連接埠映射已刪除',
+ deleteFailed: '刪除失敗',
+ confirmDelete: '確定刪除此連接埠映射?',
+ confirmBatchDelete: '確定刪除選中的 {count} 個連接埠映射?',
+ batchDeleted: '已刪除 {count} 個連接埠映射',
+ batchDeletePartial: '成功刪除 {success} 個,{fail} 個刪除失敗',
+ },
+ password: {
+ loadFailed: '載入密碼失敗',
+ },
+ quota: {
+ saved: '配額已更新',
+ saveFailed: '儲存失敗',
+ portExceedUsed: '連接埠配額不能小於目前已使用量:目前已使用 {used} 個連接埠,輸入 {input}',
+ snapshotExceedUsed: '快照配額不能小於目前已使用量:目前已使用 {used} 個快照,輸入 {input}',
+ backupExceedUsed: '備份配額不能小於目前已使用量:目前已使用 {used} 個備份,輸入 {input}',
+ portOutOfRange: '連接埠配額必須在 1-1000 範圍內',
+ snapshotOutOfRange: '快照配額必須在 1-1000 範圍內',
+ backupOutOfRange: '備份配額必須在 1-1000 範圍內',
+ },
+ copy: {
+ success: '已複製到剪貼簿',
+ failed: '複製失敗',
+ },
+ // 資訊標籤頁
+ info: {
+ title: '基本資訊',
+ instanceId: '實例 ID',
+ image: '映像檔',
+ host: '節點',
+ networkMode: '網路模式',
+ instanceMode: '實例模式',
+ nat: 'NAT',
+ ipv6: 'IPv6',
+ sshPort: 'SSH 連接埠',
+ sshHelpTitle: 'SSH 連線說明',
+ sshHelpIpv4: '使用 IPv4 連線時,需要先前往「網路」標籤頁新增 22 連接埠的映射,然後使用公網 IP 和映射後的連接埠進行連線。',
+ sshHelpIpv6: '使用 IPv6 連線時,可以直接使用公網 IPv6 位址和 22 連接埠進行連線,無需設定連接埠映射。',
+ rootPassword: 'Root 密碼',
+ createdAt: '建立時間',
+ expiresAt: '到期時間',
+ suspended: '實例已封停',
+ suspendedAt: '封停時間',
+ suspendReasonLabel: '封停原因',
+ suspendReasonExpired: '實例已到期,請續費後解封',
+ suspendReasonDefault: '未填寫封停原因',
+ suspendTip: '封停期間,實例無法啟動、重新啟動、重裝等操作。如需解封或有疑問,請提交工單。',
+ copy: '複製',
+ show: '顯示',
+ hide: '隱藏',
+ resourceUsage: '資源佔用',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '硬碟',
+ inbound: '入站',
+ outbound: '出站',
+ ingressLimit: '入棧',
+ egressLimit: '出棧',
+ hostUid: '託管UID',
+ hostOwnerTitle: '託管者資訊',
+ hostOwnerEmail: '郵箱',
+ hostOwnerHostCount: '託管節點數',
+ hostOwnerInstanceCount: '節點實例數',
+ hostOwnerRegisteredDays: '註冊天數',
+ includesCache: '含頁面快取',
+ cannotEditConfig: '實例狀態不允許修改設定',
+ redeem: '兑換',
+ redeemTitle: '兑換資源',
+ },
+ // Cloud-init 初始化狀態
+ cloudInit: {
+ initializing: '初始化中',
+ retry: '重新檢查',
+ retryUnknown: '重試檢測',
+ retryStalled: '繼續檢測',
+ short: '初始化',
+ shortUnknown: '待確認',
+ shortStalled: '較慢',
+ clickToRetry: '系統正在初始化,點擊重新檢測',
+ clickToRetryUnknown: '目前無法確認 Cloud-init 狀態,點擊重新檢測',
+ clickToRetryStalled: '初始化耗時較長,點擊繼續檢測或手動標記完成',
+ statusUnknown: '狀態待確認',
+ stalled: '初始化較慢',
+ manualComplete: '手動標記完成',
+ manualShort: '完成',
+ manualCompleteSuccess: '已手動標記實例初始化為完成',
+ },
+ // 網路標籤頁
+ network: {
+ title: '網路位址',
+ privateIpv4: '內網 IPv4',
+ publicIpv4: '公網 IPv4',
+ publicIpv6: '公網 IPv6',
+ portMappings: '連接埠映射',
+ publicIp: '公網 IP',
+ add: '新增',
+ noQuota: '請先分配連接埠配額',
+ quotaFull: '連接埠配額已滿',
+ addPortMapping: '新增連接埠映射',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ perPage: '每頁',
+ filterBoth: '全部',
+ selectAll: '全選當前頁',
+ selectedCount: '已選擇 {count} 項',
+ batchDelete: '批量刪除',
+ noFilterResults: '當前篩選條件無匹配結果',
+ noPortQuota: '未分配連接埠配額',
+ allocateQuotaHint: '請先在「配額」標籤頁中分配連接埠配額',
+ noPortMappings: '暫無連接埠映射,點擊「新增」建立',
+ ipv6OnlyPortMappingHint: 'IPv6 Only類型的實例無法進行連接埠映射,請直接使用實例的IPv6',
+ additionalIpv6: '額外 IPv6 位址',
+ addIpv6: '新增 IPv6',
+ noAdditionalIpv6: '暫無額外 IPv6 位址',
+ ipAdded: 'IPv6 位址新增成功',
+ ipAddFailed: 'IPv6 位址新增失敗',
+ ipDeleted: 'IPv6 位址已刪除',
+ ipDeleteFailed: 'IPv6 位址刪除失敗',
+ confirmDeleteIp: '確定要刪除這個 IPv6 位址嗎?',
+ // IPv6 管理新增
+ primaryIpv6: '主 IPv6',
+ extraIpv6: '額外 IPv6',
+ ipv6Subnets: 'IPv6 網段',
+ addSubnet: '分配網段',
+ customIpv6: '自訂 IPv6',
+ randomIpv6: '隨機分配',
+ setCustom: '設定自訂',
+ custom: '自訂',
+ primary: '主',
+ addIpv6Modal: {
+ title: '新增 IPv6 位址',
+ randomHint: '系統將從節點 IPv6 子網中隨機分配一個位址',
+ customHint: '輸入您自己的 IPv6 位址(必須在節點子網範圍內)',
+ addressLabel: 'IPv6 位址',
+ addressPlaceholder: '如 2001:db8::1',
+ invalidAddress: '無效的 IPv6 位址格式',
+ adding: '新增中...',
+ },
+ subnetModal: {
+ title: '分配 IPv6 網段',
+ hint: '選擇要分配的網段大小,節點將自動從可用池中分配',
+ prefix112: '/112 (65,536 個 IP)',
+ prefix120: '/120 (256 個 IP)',
+ prefix124: '/124 (16 個 IP)',
+ allocating: '分配中...',
+ allocate: '分配',
+ },
+ noSubnets: '暫無分配的 IPv6 網段',
+ subnetAllocated: 'IPv6 網段分配成功',
+ subnetAllocateFailed: 'IPv6 網段分配失敗',
+ subnetDeleted: 'IPv6 網段已刪除',
+ subnetDeleteFailed: 'IPv6 網段刪除失敗',
+ confirmDeleteSubnet: '確定要刪除這個 IPv6 網段嗎?',
+ customIpv6Set: '自訂 IPv6 位址設定成功',
+ customIpv6Failed: '自訂 IPv6 位址設定失敗',
+ ipv6NotInSubnet: 'IPv6 位址必須在節點子網範圍內',
+ ipv6AlreadyExists: 'IPv6 位址已被佔用',
+ instanceMustRunning: '實例必須處於運行狀態才能管理 IPv6',
+ loading: '載入中...',
+ // 重新分配 IPv6
+ reassignIpv6: '重新取得',
+ reassignIpv6Confirm: '確定要重新分配 IPv6 位址嗎?',
+ reassignIpv6ConfirmHint: '重新分配後需要重裝系統才能生效',
+ reassignIpv6Success: 'IPv6 已重新分配,請重裝系統使其生效',
+ reassignIpv6Failed: 'IPv6 重新分配失敗',
+ reassignIpv6Loading: '重新分配中...',
+ reassignIpv6StopRequired: '實例必須先關機才能重新分配 IPv6',
+ reassignIpv6Cooldown: '每天只能重新取得一次,請等待 {hours} 小時後重試',
+ reassignIpv6CooldownShort: '{hours}小時後',
+ reassignIpv6NotSupported: '此實例不支援重新分配 IPv6',
+ },
+ // 配額標籤頁
+ quotaTab: {
+ title: '實例配額設定',
+ portLimit: 'NAT 連接埠數上限',
+ snapshotLimit: '快照數量上限',
+ backupLimit: '備份數量上限',
+ placeholder: '留空或輸入0自動填入剩餘額度',
+ currentUsage: '目前使用',
+ quotaLimit: '配額限制',
+ full: '已滿',
+ remaining: '剩餘',
+ defaultRemaining: '預設帳戶剩餘額度',
+ unit: '個',
+ portMappings: '連接埠映射',
+ snapshots: '快照',
+ backups: '備份',
+ save: '儲存配額設定',
+ saving: '儲存中...',
+ },
+
+ storageTitle: '儲存',
+ rootDisk: '根磁碟',
+ totalDisk: '總磁碟',
+ diskUsage: '磁碟使用量',
+ usedDisk: '已使用',
+ snapshotsTitle: '快照',
+ noSnapshots: '無快照',
+ createSnapshot: '建立快照',
+ restoreSnapshot: '還原快照',
+ deleteSnapshot: '刪除快照',
+ snapshotName: '快照名稱',
+ snapshotNamePlaceholder: '輸入快照名稱',
+ snapshotCreated: '快照建立成功',
+ snapshotRestored: '快照還原成功',
+ snapshotDeleted: '快照刪除成功',
+ snapshotFailed: '快照操作失敗',
+ backupsTitle: '備份',
+ noBackups: '無備份',
+ createBackup: '建立備份',
+ restoreBackup: '還原備份',
+ deleteBackup: '刪除備份',
+ backupName: '備份名稱',
+ backupCreated: '備份建立成功',
+ backupRestored: '備份還原成功',
+ backupDeleted: '備份刪除成功',
+ backupFailed: '備份操作失敗',
+ logsTitle: '日誌',
+ noLogs: '無日誌',
+ settingsTitle: '設定',
+ confirmDelete: '確認刪除',
+ confirmDeleteText: '您確定要刪除此實例嗎?此操作無法復原。',
+ confirmStop: '確認停止',
+ confirmStopText: '您確定要停止此實例嗎?',
+ confirmRestart: '確認重新啟動',
+ confirmRestartText: '您確定要重新啟動此實例嗎?',
+ confirmForceStop: '確認強制停止',
+ confirmForceStopText: '強制停止可能會導致資料遺失。您確定要繼續嗎?',
+ confirmReinstall: '確認重裝系統',
+ confirmReinstallText: '重裝系統將清除所有資料。您確定要繼續嗎?',
+ typeInstanceNameToConfirm: '輸入實例名稱「{name}」以確認刪除',
+ deleteWarning: '此操作不可復原!所有資料將被永久刪除。',
+ startSuccess: '實例啟動成功',
+ stopSuccess: '實例停止成功',
+ restartSuccess: '實例重新啟動成功',
+ deleteSuccess: '實例刪除成功',
+ startFailed: '實例啟動失敗',
+ stopFailed: '實例停止失敗',
+ restartFailed: '實例重新啟動失敗',
+ deleteFailed: '實例刪除失敗',
+ renameSuccess: '實例重新命名成功',
+ renameFailed: '實例重新命名失敗',
+ reinstallSuccess: '實例重裝成功',
+ reinstallFailed: '實例重裝失敗',
+ instanceNamePlaceholder: '輸入實例名稱',
+ cpuCores: '{cores} 核心',
+ memorySize: '{size} 記憶體',
+ diskSize: '{size} 磁碟',
+ viewConsole: '開啟控制台',
+ viewTerminal: '開啟終端機',
+ uptime: '運行時間',
+ uptimeValue: '{days} 天 {hours} 小時 {minutes} 分鐘',
+ uptimeDays: '{days} 天',
+ uptimeHours: '{hours} 小時',
+ uptimeMinutes: '{minutes} 分鐘',
+ uptimeSeconds: '{seconds} 秒',
+ uptimeShort: '{value}{unit}',
+ host: '主機',
+ hostInfo: '主機資訊',
+ noHostInfo: '無主機資訊',
+ package: '方案',
+ packageInfo: '方案資訊',
+ noPackageInfo: '無方案資訊',
+ created: '建立於',
+ basicInfo: '基本資訊',
+ instanceName: '實例名稱',
+ instanceId: '實例 ID',
+ image: '映像檔',
+ os: '作業系統',
+ kernel: '核心',
+ instanceType: '實例類型',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '磁碟',
+ traffic: '流量',
+ trafficUsed: '已使用流量',
+ trafficTotal: '總流量',
+ trafficReset: '流量重設時間',
+ bandwidth: '頻寬',
+ incusId: 'Incus ID',
+ incusIdHint: '實例在 Incus 中的唯一標識符',
+ currentUsage: '目前使用量',
+ noCurrentUsage: '無法取得目前使用量',
+ noTrafficInfo: '無流量資訊',
+ usageNote: '注意:記憶體和磁碟使用量需要安裝 guest agent',
+ specDefault: '預設規格',
+ specCustom: '自訂規格',
+ hostName: '主機名稱',
+ hostNameHint: '主機在系統中的名稱',
+ hostOwner: '主機擁有者',
+ publicIpHint: '連接到實例的公網位址',
+ noPublicIp: '無公網 IP',
+ location: '位置',
+ packageName: '方案名稱',
+ specConfig: '規格設定',
+ specConfigHint: 'CPU / 記憶體 / 磁碟空間',
+ noPackage: '無方案資訊',
+ snapshotQuota: '快照配額',
+ backupQuota: '備份配額',
+ portQuota: '連接埠配額',
+ quotaUsage: '{used} / {total}',
+ quotaUnlimited: '無限制',
+ sshAccess: 'SSH 連接',
+ sshAccessHint: '透過 SSH 連接到實例',
+ sshCommand: 'SSH 指令',
+ sshCopied: 'SSH 指令已複製',
+ ssh: {
+ title: 'SSH 連接',
+ hint: '使用 SSH 連接到實例',
+ command: 'SSH 指令',
+ port: 'SSH 連接埠',
+ noPublicIp: '無公網 IP,無法使用 SSH 連接',
+ noSshPort: '未設定 SSH 連接埠映射',
+ copyCommand: '複製指令',
+ copied: 'SSH 指令已複製',
+ user: 'SSH 使用者',
+ defaultUser: '預設使用者',
+ userHint: '根據映像檔,預設使用者可能是 root、ubuntu 或 debian 等',
+ connectWith: '連接方式',
+ },
+ deletePending: '實例已標記為刪除,正在處理中...',
+ cancelDelete: '取消刪除',
+ cancelDeleteSuccess: '已取消刪除實例',
+ cancelDeleteFailed: '取消刪除失敗',
+ cancelDeleteConfirm: '確認取消刪除',
+ cancelDeleteConfirmText: '確定要取消刪除此實例嗎?',
+ reinstallConfirmTitle: '確認重裝系統',
+ reinstallConfirmText: '重裝系統將清除實例所有資料,此操作不可復原!',
+ reinstallSelectImage: '選擇新系統',
+ reinstallCurrentImage: '目前系統:{image}',
+ reinstallKeepImage: '保持目前系統',
+ reinstallNewPassword: '新 root 密碼',
+ reinstallPasswordPlaceholder: '留空則由系統自動產生',
+ reinstallButton: '開始重裝',
+ reinstalling: '重裝中...',
+ deletionScheduled: '刪除已排程,{relativeTime}後執行',
+ siteQuota: '站點配額',
+ },
+ // 站點管理
+ sites: {
+ title: '建站',
+ addSite: '新增站點',
+ editSite: '編輯站點',
+ addFirstSite: '新增第一個站點',
+ empty: '暫無反代站點,點擊上方按鈕新增。',
+ description: '設定網域反向代理到實例連接埠',
+ add: '新增站點',
+ domain: '網域',
+ domainPlaceholder: '例如:example.com 或 sub.example.com',
+ domainRequired: '請輸入網域',
+ domainHint: '輸入要綁定的網域,不支援泛網域',
+ wildcardNotAllowed: '不支援泛網域(如 *.example.com)',
+ targetPort: '目標連接埠',
+ targetPortPlaceholder: '實例內部連接埠,例如:80',
+ portHint: '實例內部運行的 Web 服務連接埠(如 80, 3000, 8080)',
+ targetPortRequired: '請輸入目標連接埠',
+ invalidTargetPort: '請輸入有效的連接埠(1-65535)',
+ noSites: '暫無站點',
+ deleteSite: '刪除站點',
+ confirmDelete: '確定要刪除站點「{domain}」嗎?',
+ createSuccess: '站點建立成功',
+ updateSuccess: '站點更新成功',
+ deleteSuccess: '站點刪除成功',
+ createFailed: '建立站點失敗',
+ updateFailed: '更新站點失敗',
+ deleteFailed: '刪除站點失敗',
+ caddyNotEnabled: '主機未啟用 Caddy',
+ caddyNotEnabledHint: '目前實例所在主機未啟用 Caddy 反代服務,無法使用建站功能。',
+ status: '狀態',
+ statusActive: '已生效',
+ statusPending: '等待解析',
+ statusError: '設定失敗',
+ statusDisabled: '已停用',
+ statusRetrying: '重試中',
+ sslStatus: 'SSL 狀態',
+ sslActive: '已啟用',
+ sslPending: '申請中',
+ sslError: '錯誤',
+ refresh: '重新整理設定',
+ loadFailed: '載入站點列表失敗',
+ addSuccess: '站點新增成功',
+ addFailed: '新增站點失敗',
+ deleteConfirm: '確定要刪除站點 {domain} 嗎?',
+ refreshSuccess: '設定已重新整理',
+ refreshFailed: '重新整理設定失敗',
+ addedSuccess: '網域新增成功',
+ dnsHintDesc: '請前往您的 DNS 服務商新增以下解析記錄:',
+ dnsType: '類型',
+ dnsHost: '主機',
+ dnsValue: '值',
+ sslAutoHint: 'DNS 解析生效後,Caddy 將自動申請 SSL 憑證。',
+ quotaInfo: '已使用 {used} / {limit} 個站點',
+ quotaFull: '已達上限',
+ quotaExceeded: '站點配額已達上限',
+ disabled: '已停用',
+ enable: '啟用',
+ disable: '停用',
+ enableSite: '啟用站點',
+ disableSite: '停用站點',
+ enabled: '站點已啟用',
+ toggleFailed: '切換狀態失敗',
+ enableFailed: '啟用站點失敗',
+ disableFailed: '停用站點失敗',
+ enableHttps: '啟用 HTTPS',
+ httpsHint: '自動申請 Let\'s Encrypt 憑證,存取 HTTP 會跳轉到 HTTPS',
+ httpsEnabled: '已啟用 HTTPS',
+ httpOnly: '僅 HTTP',
+ checkCert: '檢查憑證狀態',
+ certCheckFailed: '檢查憑證狀態失敗',
+ checkDns: '檢測 DNS',
+ dnsActivated: 'DNS 驗證通過,站點已激活',
+ dnsResolved: 'DNS 已正確解析',
+ dnsCheckFailed: 'DNS 檢測失敗',
+ dnsHintWithCheck: 'DNS 設定完成後,點擊「檢測 DNS」激活站點',
+ remark: '備註',
+ remarkPlaceholder: '選填,如:部落格網站、API 服務等',
+ errorMessage: '錯誤訊息',
+ noError: '無錯誤',
+ visitSite: '存取站點',
+ helpTitle: '使用說明',
+ helpDomain: '網域需要先將 DNS 解析到主機的公網 IP',
+ helpPort: '目標連接埠是您的應用程式在實例內監聽的連接埠',
+ helpSSL: 'SSL 憑證會自動申請和續期',
+ hostIp: '主機 IP',
+ copyIp: '複製 IP',
+ ipCopied: 'IP 已複製',
+ dnsHelp: '請將以上網域的 DNS A 記錄指向:{ip}',
+ caddyNotReady: 'Caddy 反向代理尚未準備就緒',
+ caddyNotReadyHint: '請聯繫主機管理員設定 Caddy',
+ quotaUsed: '已使用 {used} / {total}',
+ unlimited: '無限制',
+ cert: {
+ title: '憑證狀態',
+ valid: '憑證有效',
+ disabled: 'HTTPS 未啟用',
+ pending: '待激活',
+ certPending: '憑證申請中',
+ },
+ },
+ list: {
+ title: '我的實例',
+ empty: '無實例',
+ emptyHint: '您還沒有任何實例,快去建立一個吧!',
+ createFirst: '建立第一個實例',
+ searchPlaceholder: '搜尋實例名稱、映像檔或主機...',
+ filterAll: '全部',
+ filterRunning: '運行中',
+ filterStopped: '已停止',
+ filterError: '錯誤',
+ sortName: '名稱',
+ sortCreated: '建立時間',
+ sortStatus: '狀態',
+ viewGrid: '方格檢視',
+ viewList: '清單檢視',
+ },
+ createForm: {
+ title: '建立實例',
+ selectHost: '選擇主機',
+ selectHostHint: '選擇您要在哪個主機上建立實例',
+ selectPackage: '選擇方案',
+ selectPackageHint: '選擇實例的規格方案',
+ selectImage: '選擇映像檔',
+ selectImageHint: '選擇實例的作業系統映像檔',
+ instanceName: '實例名稱',
+ instanceNameHint: '輸入實例的名稱',
+ instanceNamePlaceholder: '輸入實例名稱',
+ rootPassword: 'Root 密碼',
+ rootPasswordHint: '設定實例的 root 密碼',
+ rootPasswordPlaceholder: '輸入密碼(留空則自動產生)',
+ sshKey: 'SSH 公鑰',
+ sshKeyHint: '可選:添加 SSH 公鑰以便 SSH 登入',
+ sshKeyPlaceholder: '輸入 SSH 公鑰(可選)',
+ createButton: '建立實例',
+ creating: '建立中...',
+ createSuccess: '實例建立成功',
+ createFailed: '實例建立失敗',
+ validation: {
+ hostRequired: '請選擇主機',
+ packageRequired: '請選擇方案',
+ imageRequired: '請選擇映像檔',
+ nameRequired: '請輸入實例名稱',
+ nameInvalid: '實例名稱只能包含小寫字母、數字和連字號,且長度在 2-30 之間',
+ },
+ nameTip: '名稱在此方案下必須唯一,且使用小寫字母、數字和連字號',
+ passwordSecurityHint: '建議使用強密碼,密碼在提交後將被加密儲存',
+ passwordGenerateHint: '留空則系統會自動產生隨機強密碼',
+ step1: '選擇主機',
+ step2: '選擇方案',
+ step3: '選擇映像檔',
+ step4: '設定實例',
+ stepHint: '第 {current} 步 / 共 {total} 步',
+ noHosts: '無可用主機',
+ noHostsHint: '目前沒有可以建立實例的主機',
+ noPackages: '無可用方案',
+ noPackagesHint: '此主機沒有可用的方案',
+ noImages: '無可用映像檔',
+ noImagesHint: '此主機沒有可用的映像檔',
+ loadingHosts: '載入主機中...',
+ loadingPackages: '載入方案中...',
+ loadingImages: '載入映像檔中...',
+ selectedHost: '已選主機',
+ selectedPackage: '已選方案',
+ selectedImage: '已選映像檔',
+ showAllImages: '顯示所有映像檔',
+ showPopularImages: '只顯示常用映像檔',
+ advancedOptions: '進階選項',
+ startAfterCreate: '建立後自動啟動',
+ autoGeneratePassword: '自動產生密碼',
+ showPassword: '顯示密碼',
+ hidePassword: '隱藏密碼',
+ passwordStrength: '密碼強度',
+ passwordWeak: '弱',
+ passwordMedium: '中等',
+ passwordStrong: '強',
+ searchImages: '搜尋映像檔...',
+ allDistros: '全部發行版',
+ popularDistros: '常用發行版',
+ otherDistros: '其他發行版',
+ selectImageVersion: '選擇版本',
+ imageSize: '映像檔大小',
+ imageType: '映像檔類型',
+ imageDescription: '映像檔說明',
+ instanceTypeTip: '容器輕量快速,虛擬機器提供完整隔離',
+ instanceTypeContainer: '容器',
+ instanceTypeVM: '虛擬機器',
+ packageSpecTip: '方案規格:{cpu} 核心 / {memory} 記憶體 / {disk} 磁碟',
+ specsCpu: '{cores} 核心',
+ specsMemory: '{size}',
+ specsDisk: '{size} 磁碟',
+ specsTraffic: '{size}/月流量',
+ noTrafficLimit: '無流量限制',
+ container: '容器',
+ vm: '虛擬機器',
+ packageLimit: '方案限制',
+ packageLimitHint: '此方案剩餘 {remaining}/{total} 配額',
+ hostLocation: '主機位置',
+ hostPublicIp: '主機 IP',
+ noLocation: '未設定位置',
+ creatingInstance: '正在建立實例...',
+ creationTip: '建立過程可能需要幾分鐘,請耐心等待',
+ summaryTitle: '確認資訊',
+ estimatedTime: '預計建立時間',
+ estimatedTimeValue: '1-5 分鐘',
+ createdPasswordHint: '實例建立成功,密碼為',
+ createdPasswordCopy: '點擊複製密碼',
+ autoStartEnabled: '將自動啟動',
+ noAvailableDistros: '無可用發行版',
+ noAvailableVersions: '此發行版無可用版本',
+ },
+ card: {
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '磁碟',
+ network: '網路',
+ uptime: '運行時間',
+ noUptime: '未啟動',
+ quickActions: '快速操作',
+ moreActions: '更多操作',
+ viewDetails: '查看詳情',
+ openTerminal: '開啟終端機',
+ specs: '規格',
+ },
+ copy: {
+ title: '複製實例',
+ description: '建立此實例的副本',
+ targetHost: '目標主機',
+ targetHostHint: '選擇複製到哪個主機',
+ sameHost: '同一主機',
+ newName: '新實例名稱',
+ newNamePlaceholder: '輸入新實例的名稱',
+ keepName: '使用原名稱',
+ copyButton: '開始複製',
+ copying: '複製中...',
+ copySuccess: '實例複製成功',
+ copyFailed: '實例複製失敗',
+ copyStarted: '實例複製已開始,請稍候...',
+ validation: {
+ targetHostRequired: '請選擇目標主機',
+ nameRequired: '請輸入新實例名稱',
+ },
+ },
+ },
+
+ // 新增連接埠映射彈窗
+ portModal: {
+ title: '新增連接埠映射',
+ protocol: '協定',
+ bothHint: '將同時建立 TCP 和 UDP 映射,佔用 2 個配額',
+ privatePort: '內部連接埠',
+ privatePortRequired: '*',
+ privatePortPlaceholder: '容器內部服務連接埠,如 80、22、3306',
+ privatePortPlaceholderRange: '如 80 或 80-85',
+ publicPort: '公網連接埠',
+ publicPortOptional: '(選填)',
+ publicPortPlaceholder: '留空自動分配',
+ publicPortPlaceholderRange: '如 20000 或 20000-20005',
+ publicPortHint: '留空將自動從連接埠池分配可用連接埠',
+ publicPortHintWithRange: '可選範圍: {start}-{end},留空將自動分配',
+ remark: '備註',
+ remarkOptional: '(選填)',
+ remarkPlaceholder: '如:Web服務、資料庫、SSH等',
+ cancel: '取消',
+ adding: '新增中...',
+ add: '新增',
+ // 新增:範圍輸入支援
+ rangeHint: '支援範圍輸入,如 80-85',
+ invalidPortFormat: '連接埠格式無效,請輸入單一連接埠或連接埠範圍(如 80-85)',
+ ipv6OnlySshPortHint: '22 連接埠不用映射,直接用公網 IPv6 登入 SSH 就好,正門口已經亮燈了',
+ rangeMismatch: '內網連接埠數 ({private} 個) 與公網連接埠數 ({public} 個) 不匹配',
+ publicPortOutOfRange: '公網連接埠超出允許範圍 ({start}-{end})',
+ quotaPreview: '將建立 {count} 個映射,佔用 {quota} 個配額',
+ quotaRemaining: '剩餘 {remain} 個',
+ quotaInsufficient: '配額不足,需要 {need} 個,剩餘 {remain} 個',
+ },
+
+ // 連接埠衝突解決彈窗
+ portConflict: {
+ title: '部分連接埠已被佔用',
+ subtitle: '共 {count} 個連接埠衝突',
+ description: '以下連接埠已被其他實例佔用,您可以修改為新的連接埠或使用系統建議。',
+ originalPort: '原連接埠',
+ newPort: '新連接埠',
+ occupied: '已佔用',
+ suggested: '建議',
+ rangeHint: '可用範圍: {start}-{end}',
+ useSuggested: '使用全部建議',
+ cancel: '取消',
+ confirm: '確認修改',
+ },
+
+ // 重裝系統彈窗
+ rebuildModal: {
+ title: '重裝系統',
+ dangerWarning: '危險操作',
+ warningList: {
+ dataLoss: '重裝系統將清除實例內的所有資料',
+ snapshotLoss: '所有快照將被永久刪除',
+ irreversible: '此操作不可回復!',
+ },
+ preserveInfo: '重裝系統會保留連接埠映射。',
+ manualStartHint: '重裝完成後需手動啟動實例。',
+ selectImage: '選擇新映像檔',
+ imageHint: '只能選擇該節點目前允許的映像檔',
+ selectSshKey: '選擇 SSH 金鑰',
+ noSshKey: '暫無可用金鑰',
+ addSshKeyHint: '請先在設定中新增 SSH 金鑰',
+ passwordHint: '重裝後將自動產生新的 root 密碼,可在實例詳情頁查看。',
+ cancel: '取消',
+ rebuilding: '重裝中...',
+ confirmRebuild: '確認重裝',
+ },
+
+ // 重建實例彈窗
+ recreateModal: {
+ title: '重建實例',
+ dangerWarning: '危險操作',
+ warningList: {
+ dataLoss: '重建將清除實例內的所有資料',
+ snapshotLoss: '所有快照將被永久刪除',
+ portMappingLoss: '所有連接埠映射將被刪除',
+ backupLoss: '所有備份記錄和備份策略將被刪除',
+ proxySiteLoss: '所有反代站點和快照策略將被刪除',
+ irreversible: '此操作不可回復!',
+ },
+ differenceHint: '重建與重裝不同:不需要先關機,會建立全新的實例替換舊實例。',
+ preserveInfo: '重建只保留計費狀態和配額。',
+ selectImage: '選擇新映像檔',
+ selectSshKey: '選擇 SSH 金鑰',
+ noSshKey: '暫無可用金鑰',
+ addSshKeyHint: '請先在設定中新增 SSH 金鑰',
+ cancel: '取消',
+ recreating: '重建中...',
+ confirmRecreate: '確認重建',
+ },
+
+ // 快照管理
+ snapshot: {
+ title: '快照',
+ autoPolicy: '自動快照已啟用',
+ autoPolicyEnabled: '已啟用自動快照',
+ currentPolicy: '目前策略',
+ disableAutoPolicy: '取消自動',
+ minutes: '分鐘',
+ manual: '手動管理',
+ autoSettings: '自動快照設定',
+ create: '建立',
+ noQuota: '請先分配快照配額',
+ quotaFull: '快照配額已滿',
+ createSnapshot: '建立快照',
+ noSnapshots: '暫無快照',
+ noQuotaAllocated: '未分配快照配額',
+ allocateQuotaHint: '請先在「配額」標籤頁中分配快照配額',
+ statefulSnapshot: '狀態快照',
+ restore: '還原',
+ stopInstanceFirst: '請先停止實例',
+ delete: '刪除',
+ createModal: {
+ title: '建立快照',
+ name: '名稱',
+ nameRequired: '*',
+ namePlaceholder: 'snapshot-01',
+ description: '描述',
+ descriptionPlaceholder: '選填描述',
+ stateful: '儲存記憶體狀態(狀態快照)',
+ cancel: '取消',
+ creating: '建立中...',
+ create: '建立',
+ },
+ policyModal: {
+ title: '自動快照設定',
+ enable: '啟用自動快照',
+ interval: '快照間隔',
+ intervalOptions: {
+ min10: '每 10 分鐘',
+ hour1: '每 1 小時',
+ hour6: '每 6 小時',
+ hour24: '每 24 小時',
+ day3: '每 3 天',
+ },
+ quotaFromPackage: '配額繼承自方案,目前限制為 {limit} 個。滿額後將自動刪除最早的自動快照。',
+ cancel: '取消',
+ saving: '儲存中...',
+ save: '儲存',
+ },
+ messages: {
+ createSuccess: '快照建立成功',
+ createFailed: '建立失敗',
+ deleteConfirm: '確定刪除快照「{name}」?此操作不可回復。',
+ deleteSuccess: '快照已刪除',
+ deleteFailed: '刪除失敗',
+ restoreConfirm: '確定將實例還原到快照「{name}」?目前資料將被覆蓋。',
+ restoreSuccess: '快照還原成功',
+ restoreFailed: '還原失敗',
+ stopInstanceFirst: '請先停止實例再還原快照',
+ policySaved: '自動快照策略已更新',
+ policyDisabled: '自動快照已關閉',
+ policySaveFailed: '儲存失敗',
+ },
+ },
+
+ // 備份管理
+ backup: {
+ title: '備份',
+ autoPolicy: '自動備份已啟用',
+ autoPolicyEnabled: '已啟用自動備份',
+ currentPolicy: '目前策略',
+ disableAutoPolicy: '取消自動',
+ minutes: '分鐘',
+ manual: '手動管理',
+ autoSettings: '自動備份設定',
+ create: '建立',
+ noQuota: '請先分配備份配額',
+ quotaFull: '備份配額已滿',
+ createBackup: '建立備份',
+ noBackups: '暫無備份',
+ noQuotaAllocated: '未分配備份配額',
+ allocateQuotaHint: '請先在「配額」標籤頁中分配備份配額',
+ status: {
+ creating: '建立中',
+ ready: '就緒',
+ error: '失敗',
+ },
+ export: '匯出',
+ preparing: '準備中...',
+ clickToDownload: '點擊下載',
+ downloading: '下載中...',
+ retry: '重試',
+ delete: '刪除',
+ createModal: {
+ title: '建立備份',
+ name: '名稱',
+ nameRequired: '*',
+ namePlaceholder: 'backup-01',
+ description: '描述',
+ descriptionPlaceholder: '選填描述',
+ expiresIn: '過期天數(選填)',
+ neverExpire: '永不過期',
+ days7: '7 天',
+ days14: '14 天',
+ days30: '30 天',
+ days90: '90 天',
+ year1: '1 年',
+ createHint: '備份建立可能需要幾分鐘,請耐心等待。',
+ cancel: '取消',
+ creating: '建立中...',
+ create: '建立',
+ },
+ policyModal: {
+ title: '自動備份設定',
+ enable: '啟用自動備份',
+ interval: '備份間隔',
+ intervalOptions: {
+ hour1: '每 1 小時',
+ hour6: '每 6 小時',
+ hour24: '每 24 小時',
+ day3: '每 3 天',
+ },
+ quotaFromPackage: '配額繼承自方案,目前限制為 {limit} 個。滿額後將自動刪除最早的自動備份。',
+ cancel: '取消',
+ saving: '儲存中...',
+ save: '儲存',
+ },
+ restore: '還原',
+ restoring: '還原中...',
+ rollback: '回滾',
+ restoreModal: {
+ title: '⚠️ 危險操作 - 還原備份',
+ warning: '此操作將覆蓋目前實例!',
+ warningDetail: '還原操作會停止目前實例,並用備份內容替換。如果還原失敗,您可以選擇回滾到原實例。',
+ dataLossWarning: '以下資料將被永久刪除:',
+ dataLossItems: {
+ backups: '該實例的所有其他備份',
+ snapshots: '該實例的所有快照',
+ },
+ nameChangeNotice: '還原成功後,實例名稱將變更為:{name} | restored:{backup}',
+ backupName: '備份名稱',
+ instanceName: '目標實例',
+ cancel: '取消',
+ confirm: '確認還原',
+ },
+ messages: {
+ createSuccess: '備份建立中...',
+ createFailed: '建立失敗',
+ deleteConfirm: '確定刪除備份「{name}」?此操作不可回復。',
+ deleteSuccess: '備份已刪除',
+ deleteFailed: '刪除失敗',
+ exportFailed: '準備匯出失敗',
+ downloadStarted: '下載已開始',
+ downloadFailed: '下載失敗',
+ policySaved: '自動備份策略已更新',
+ policyDisabled: '自動備份已關閉',
+ policySaveFailed: '儲存失敗',
+ restoreStarted: '正在還原備份「{name}」,請稍候...',
+ restoreInProgress: '已有還原任務正在進行中',
+ restoreCompleted: '備份還原成功!',
+ restoreFailed: '還原失敗',
+ rollbackCompleted: '回滾成功,原實例已恢復',
+ rollbackFailed: '回滾失敗',
+ uploadStarted: '上傳任務已建立',
+ },
+ },
+
+ // 主機相關
+ host: {
+ title: '主機',
+ titlePlural: '主機',
+ create: '新增主機',
+ edit: '編輯主機',
+ delete: '刪除主機',
+ status: {
+ online: '線上',
+ offline: '離線',
+ maintenance: '維護中',
+ error: '錯誤',
+ },
+ detail: {
+ title: '主機詳情',
+ overview: '總覽',
+ instances: '實例',
+ packages: '方案',
+ stats: '統計',
+ settings: '設定',
+ hostId: '主機 ID',
+ hostName: '主機名稱',
+ publicIp: '公網 IP',
+ location: '位置',
+ description: '描述',
+ owner: '擁有者',
+ createdAt: '建立時間',
+ updatedAt: '更新時間',
+ instanceCount: '實例數量',
+ packageCount: '方案數量',
+ totalResources: '總資源',
+ usedResources: '已使用資源',
+ availableResources: '可用資源',
+ cpuCores: 'CPU 核心',
+ memory: '記憶體',
+ disk: '磁碟',
+ network: '網路',
+ noLocation: '未設定位置',
+ noDescription: '無描述',
+ resourceUsage: '資源使用量',
+ resourceUsageTitle: '資源使用概況',
+ imageTitle: '映像檔',
+ imageCount: '映像檔數量',
+ tabs: {
+ overview: '總覽',
+ instances: '實例',
+ packages: '方案',
+ images: '映像檔',
+ settings: '設定',
+ },
+ },
+ list: {
+ title: '主機列表',
+ empty: '無主機',
+ emptyHint: '目前沒有任何主機',
+ searchPlaceholder: '搜尋主機...',
+ },
+ form: {
+ name: '主機名稱',
+ namePlaceholder: '輸入主機名稱',
+ publicIp: '公網 IP',
+ publicIpPlaceholder: '輸入公網 IP',
+ location: '位置',
+ locationPlaceholder: '例如:中國台灣台北',
+ description: '描述',
+ descriptionPlaceholder: '輸入主機描述',
+ apiUrl: 'API URL',
+ apiUrlPlaceholder: '例如:https://host.example.com:8443',
+ authSecret: '認證金鑰',
+ authSecretPlaceholder: '輸入認證金鑰',
+ },
+ caddy: {
+ title: 'Caddy 反向代理',
+ description: '透過 Caddy 為您的實例提供網域反向代理服務,自動申請 SSL 憑證。',
+ enabled: '已啟用',
+ disabled: '未啟用',
+ notInstalled: 'Caddy 尚未安裝,請點擊下方按鈕產生安裝指令。',
+ generateCommand: '產生安裝指令',
+ installCommand: '安裝指令',
+ commandLabel: '在主機上執行以下指令',
+ confirmInstalled: '確認已安裝',
+ viewCommand: '查看安裝指令',
+ resetCredentials: '重設憑據',
+ resetConfirm: '重設憑據後,您需要在主機上重新執行安裝指令。確定要繼續嗎?',
+ resetSuccess: '憑據已重設,請在主機上重新執行安裝指令',
+ resetFailed: '重設憑據失敗',
+ testConnection: '測試連線',
+ apiPort: 'API 連接埠',
+ username: '使用者名稱',
+ password: '密碼',
+ publicIp: '公網 IP',
+ sitesCount: '站點數量',
+ loadFailed: '載入 Caddy 狀態失敗',
+ generateFailed: '產生安裝指令失敗',
+ confirmSuccess: 'Caddy 已確認安裝',
+ confirmFailed: '確認失敗',
+ testSuccess: '連線成功',
+ testFailed: '連線失敗',
+ installHint: '安裝步驟',
+ step1: '複製上方指令,在主機上以 root 權限執行',
+ step2: '等待安裝完成,應看到「Caddy Reverse Proxy Ready」提示',
+ step3: '返回此頁面,點擊「確認已安裝」按鈕',
+ // 站點列表
+ sitesList: '反向代理站點列表',
+ sitesTotalCount: '共 {count} 個站點',
+ loadSitesFailed: '載入站點列表失敗',
+ noSites: '暫無反向代理站點',
+ instance: '實例',
+ targetPort: '目標連接埠',
+ siteActive: '已啟用',
+ sitePending: '待啟用',
+ siteError: '錯誤',
+ siteDisabled: '已停用',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ pageInfo: '第 {current}/{total} 頁,共 {count} 筆',
+ },
+ // 節點創建實例
+ createInstance: {
+ title: '創建實例',
+ selfMode: '給自己創建',
+ giftMode: '贈送給使用者',
+ selfModeHint: '使用自己的 SSH 金鑰和初始化指令,在當前節點上創建免費實例。',
+ giftModeHint: '在當前節點上為其他使用者創建實例。付費實例僅支援贈送免費時長,不會扣費。',
+ adminModeHint: '管理員可在目前節點上為指定使用者建立免費實例,或贈送付費實例首期時長。',
+ selectPackage: '選擇套餐',
+ noPackages: '該節點暫無可用套餐',
+ noPackagesHint: '請先在套餐管理中創建並綁定套餐到此節點',
+ instanceName: '實例名稱',
+ giftDaysLabel: '免費贈送天數',
+ giftDaysHint: '僅贈送免費時長,不會從被贈送使用者餘額扣費。',
+ giftDaysRange: '範圍:1-365 天',
+ giftDuration: '贈送時長',
+ giftDurationValue: '免費 {days} 天',
+ giftOnlyFreeHint: '免費期結束後,將按所選方案價格正常續費。',
+ creating: '正在創建...',
+ success: '實例創建成功',
+ giftSuccess: '實例已為使用者 {username} 創建',
+ userInactive: '該使用者未處於可用狀態',
+ cannotGiftToSelf: '如果要給自己創建實例,請切換到「給自己創建」模式',
+ },
+ // 節點擁有者通知實例使用者
+ notify: {
+ title: '通知使用者',
+ sendToUsers: '通知使用者',
+ hint: '站內信會立即發送給本節點上所有實例對應的使用者,系統會自動去重複',
+ hintSelected: '站內信會立即發送給選中的 {count} 個實例對應的使用者,系統會自動去重複',
+ deliveryHint: '勾選郵件通知後,單一收件人會立即寄信;多位收件人則會進入佇列,並以每分鐘 1 封的節奏發送。',
+ messageTitle: '訊息標題',
+ titlePlaceholder: '輸入訊息標題',
+ titleRequired: '請輸入訊息標題',
+ messageContent: '訊息內容',
+ contentPlaceholder: '輸入訊息內容',
+ contentRequired: '請輸入訊息內容',
+ sendEmail: '同時發送郵件通知',
+ sendEmailHint: '僅會寄送給已設定電子郵件地址的使用者。批量郵件會自動排隊,避免短時間內集中發信。',
+ send: '發送通知',
+ sendSuccess: '已成功發送給 {count} 個使用者',
+ sendSuccessBase: '站內信已發送給 {count} 個使用者',
+ emailDirectSuccess: '郵件已立即發送 {count} 封',
+ emailQueuedSuccess: '郵件佇列已新增 {count} 封',
+ emailSkipped: '{count} 位使用者未設定電子郵件,已略過郵件發送',
+ emailFailed: '{count} 封郵件發送或入佇列失敗',
+ sendFailed: '發送失敗',
+ // 發送給單個實例使用者
+ sendToUser: '發送站內信',
+ sendToUserTitle: '發送站內信給 {username}',
+ sendToUserHint: '訊息將發送給實例「{instance}」的擁有者',
+ sendToUserSuccess: '訊息已發送',
+ },
+ // 修改續費價格
+ price: {
+ editPrice: '修改續費價格',
+ modalTitle: '修改續費價格',
+ hint: '修改實例「{instance}」的續費價格,新價格將於下次續費時生效,本月不受影響。',
+ currentPrice: '當前價格',
+ newPrice: '新價格',
+ placeholder: '輸入新的續費價格',
+ effectHint: '新價格將於下次續費時生效,不影響本月剩餘時間',
+ minPriceError: '價格不能為負數',
+ samePriceError: '新價格與原價格相同',
+ updateSuccess: '續費價格已更新,已通知用戶',
+ updateFailed: '更新失敗',
+ },
+ // 批量修改配置
+ batchConfig: {
+ title: '批量修改配置',
+ button: '批量配置',
+ targetAll: '應用範圍:主機全部 {count} 個實例',
+ targetSelected: '應用範圍:已選中的 {count} 個實例',
+ enableFieldHint: '勾選核取方塊以啟用對應欄位的修改',
+ // 分類
+ section: {
+ resources: '資源配置',
+ quota: '配額限制',
+ permissions: '容器權限',
+ advanced: '高級配置',
+ io: '儲存 I/O 限制',
+ network: '網路限制',
+ process: '程序與調度',
+ boot: '啟動設定',
+ },
+ // 欄位
+ cpu: 'CPU 核心數',
+ memory: '記憶體',
+ disk: '磁碟',
+ traffic: '流量',
+ swapEnabled: 'SWAP 開關',
+ swapSize: 'SWAP 大小',
+ portLimit: '連接埠數量',
+ snapshotLimit: '快照數量',
+ backupLimit: '備份數量',
+ siteLimit: '站點數量',
+ nested: '巢狀虛擬化',
+ privileged: '特權容器',
+ limitsRead: '讀取限制',
+ limitsWrite: '寫入限制',
+ limitsIngress: '入站限制',
+ limitsEgress: '出站限制',
+ limitsProcesses: '最大程序數',
+ limitsCpuPriority: 'CPU 優先級',
+ bootPriority: '啟動優先級',
+ bootAutostart: '開機自啟',
+ bootDelay: '啟動延遲',
+ shutdownTimeout: '關機逾時',
+ // 佔位符
+ placeholder: {
+ cpu: '如 1, 2, 4',
+ memory: '如 512, 1024, 2048',
+ disk: '如 10, 20, 50',
+ traffic: '如 100, 500, 1000',
+ swapSize: '如 512, 1024, 2048',
+ limit: '如 5, 10, 20',
+ ioLimit: '如 100MB',
+ priority: '如 5',
+ processLimit: '如 500, 1000',
+ bootPriority: '如 0, 1, 2',
+ },
+ // 狀態
+ processing: '正在批量修改配置...',
+ processed: '已處理 {current} / {total}',
+ // 結果
+ success: '成功',
+ failed: '失敗',
+ successAll: '批量修改成功,共 {count} 個實例',
+ partial: '部分成功:{success} 成功,{failed} 失敗',
+ allFailed: '全部失敗',
+ submitFailed: '提交失敗',
+ retrySuccess: '重試成功,共 {count} 個實例',
+ retryPartial: '重試部分成功:{success} 成功,{failed} 失敗',
+ retryFailed: '重試失敗項',
+ failedDetails: '失敗詳情',
+ instanceName: '實例名稱',
+ incusId: 'Incus ID',
+ errorReason: '錯誤原因',
+ copyIncusIds: '複製失敗的 Incus ID',
+ copiedIncusIds: '已複製 {count} 個 Incus ID',
+ // 操作
+ submit: '應用配置 ({count})',
+ close: '關閉',
+ noFieldsEnabled: '請至少啟用一個配置欄位',
+ noChanges: '沒有需要修改的配置',
+ },
+ // 批量遷移實例
+ migrate: {
+ title: '遷移實例到其他節點',
+ button: '改節點',
+ selectedCount: '已選擇 {count} 個實例',
+ targetNode: '目標節點',
+ selectTarget: '請選擇目標節點',
+ targetImage: '目標系統',
+ selectImage: '請選擇目標系統',
+ selectImageRequired: '請選擇目標系統',
+ loadImagesFailed: '載入系統列表失敗',
+ noImageAvailable: '目標節點無可用系統',
+ imageHint: '遷移會使用該系統重建實例,不再沿用實例舊映像',
+ instances: '個實例',
+ warning: '遷移注意事項',
+ warningCloudInit: '將重新執行 cloud-init 初始化',
+ warningImage: '實例將使用所選系統重建',
+ warningIp: '實例將獲得新的 IP 位址',
+ warningNotify: '遷移完成後將通知用戶',
+ confirm: '確認遷移',
+ migrating: '正在遷移...',
+ resultSummary: '成功 {success} 個,失敗 {failed} 個',
+ failedInstances: '失敗的實例',
+ loadHostsFailed: '載入節點列表失敗',
+ selectTargetRequired: '請選擇目標節點',
+ noInstancesSelected: '請先選擇要遷移的實例',
+ failed: '遷移失敗',
+ // 付費實例方案選擇
+ targetPlan: '目標方案',
+ selectPlan: '請選擇目標方案',
+ selectPlanRequired: '請選擇目標方案',
+ loadPlansFailed: '載入方案列表失敗',
+ noPlanAvailable: '目標節點無可用方案',
+ planHint: '付費實例將使用新方案的續費價格,保留原到期時間和優惠碼',
+ },
+ // 批量贈送時長
+ giftDays: {
+ title: '贈送時長',
+ button: '贈送時長',
+ hint: '為選中的付費實例免費延長到期時間,不扣除任何費用。',
+ confirm: '將為 {count} 個付費實例贈送時長',
+ daysLabel: '贈送天數',
+ daysRange: '範圍:1-365 天',
+ confirmButton: '確認贈送',
+ success: '成功為 {count} 個實例贈送 {days} 天',
+ partial: '成功 {success} 個,失敗 {failed} 個',
+ failed: '贈送失敗',
+ skipped: '跳過了 {count} 個免費實例',
+ noPaidInstances: '請選擇付費實例',
+ },
+ },
+
+ // 方案相關
+ package: {
+ title: '方案',
+ titlePlural: '方案',
+ create: '建立方案',
+ edit: '編輯方案',
+ delete: '刪除方案',
+ detail: {
+ title: '方案詳情',
+ specs: '規格',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '磁碟',
+ traffic: '流量',
+ bandwidth: '頻寬',
+ price: '價格',
+ instances: '使用此方案的實例',
+ },
+ list: {
+ title: '方案列表',
+ empty: '無方案',
+ emptyHint: '目前沒有任何方案',
+ searchPlaceholder: '搜尋方案...',
+ },
+ form: {
+ name: '方案名稱',
+ namePlaceholder: '輸入方案名稱',
+ cpu: 'CPU 核心數',
+ cpuPlaceholder: '例如:2',
+ memory: '記憶體',
+ memoryPlaceholder: '例如:2048',
+ disk: '磁碟空間',
+ diskPlaceholder: '例如:20',
+ traffic: '月流量',
+ trafficPlaceholder: '例如:1000',
+ bandwidth: '頻寬',
+ bandwidthPlaceholder: '例如:100',
+ price: '價格',
+ pricePlaceholder: '例如:10',
+ },
+ specs: {
+ cpu: '{cores} 核心',
+ memory: '{size}',
+ disk: '{size} 磁碟',
+ traffic: '{size}/月流量',
+ bandwidth: '{size} 頻寬',
+ noLimit: '無限制',
+ },
+ // 套餐標籤(創建實例頁)
+ shared: '好友',
+ globalShared: '可用',
+ friendPrefix: '好友:',
+ myPackage: '我的方案',
+ soldOut: '售罄',
+ },
+
+ // 個人資料
+ profile: {
+ title: '個人設定',
+ basicInfo: '基本資料',
+ security: '安全設定',
+ changePassword: '變更密碼',
+ currentPassword: '目前密碼',
+ newPassword: '新密碼',
+ confirmNewPassword: '確認新密碼',
+ twoFactor: '兩步驗證',
+ enableTwoFactor: '啟用兩步驗證',
+ disableTwoFactor: '停用兩步驗證',
+ // 帳號部分
+ account: {
+ title: '帳號',
+ username: '使用者名稱',
+ uid: 'UID',
+ role: '角色',
+ email: '電子郵件',
+ notSet: '未設定',
+ admin: '管理員',
+ user: '使用者',
+ changeEmail: '修改',
+ bindEmail: '綁定',
+ emailDialog: {
+ titleChange: '修改電子郵件',
+ titleBind: '綁定電子郵件',
+ stepCurrent: '驗證目前電子郵件',
+ stepCurrentSkipped: '目前電子郵件未綁定',
+ stepCurrentHint: '請先驗證目前電子郵件,確認這次操作由您本人發起。',
+ stepNew: '驗證新電子郵件',
+ stepNewHint: '輸入新的電子郵件地址,並完成驗證碼驗證。',
+ noCurrentEmailHint: '目前帳戶尚未綁定電子郵件,可直接進入新電子郵件驗證並完成綁定。',
+ verifyCurrentTitle: '目前電子郵件驗證',
+ verifyCurrentDesc: '驗證碼將發送到目前電子郵件 {email},通過驗證後才能繼續修改。',
+ currentCode: '目前電子郵件驗證碼',
+ currentCodePlaceholder: '輸入收到的 6 位驗證碼',
+ sendCurrentCode: '發送驗證碼',
+ resendCurrentCode: '重新發送',
+ currentCodeSent: '目前電子郵件驗證碼已發送',
+ currentCodeRequired: '請輸入目前電子郵件驗證碼',
+ verifyCurrentAction: '驗證目前電子郵件',
+ currentVerifiedSuccess: '目前電子郵件驗證成功',
+ currentVerificationExpired: '目前電子郵件驗證已失效,請重新驗證。',
+ verifyNewTitle: '新電子郵件驗證',
+ verifyNewDesc: '請驗證新的電子郵件地址,完成後將立即更新為新的登入電子郵件。',
+ bindEmailDesc: '請驗證新的電子郵件地址,完成後將綁定到目前帳戶。',
+ newEmail: '新電子郵件',
+ newEmailPlaceholder: '輸入新的電子郵件地址',
+ newEmailRequired: '請輸入新的電子郵件地址',
+ newEmailInvalid: '請輸入有效的電子郵件地址',
+ newEmailSame: '新電子郵件不能與目前電子郵件相同',
+ newCode: '新電子郵件驗證碼',
+ newCodePlaceholder: '輸入新電子郵件收到的 6 位驗證碼',
+ newCodeRequired: '請輸入新電子郵件驗證碼',
+ sendNewCode: '發送驗證碼',
+ resendNewCode: '重新發送',
+ newCodeSent: '新電子郵件驗證碼已發送',
+ resendIn: '{seconds}s 後可重新發送',
+ confirmAction: '確認提交',
+ updateSuccess: '電子郵件已更新',
+ verifying: '驗證中...',
+ submitting: '提交中...'
+ }
+ },
+ // 頭像
+ avatar: {
+ title: '頭像風格',
+ saveSuccess: '頭像風格已更新',
+ saveFailed: '更新頭像風格失敗',
+ styles: {
+ adventurer: '冒險者',
+ adventurerNeutral: '冒險者素雅',
+ avataaars: '扁平插畫',
+ avataaarsNeutral: '扁平插畫純淨',
+ bigEars: '大耳萌',
+ bigEarsNeutral: '大耳萌純淨',
+ bigSmile: '開懷大笑',
+ bottts: '機器人',
+ botttsNeutral: '機器人純淨',
+ croodles: '抽象塗鴉',
+ croodlesNeutral: '抽象塗鴉純淨',
+ dylan: '迪倫風',
+ funEmoji: '趣味表情',
+ glass: '毛玻璃',
+ icons: '常用圖示',
+ identicon: '幾何雜湊',
+ initials: '首字母',
+ lorelei: '洛蕾萊',
+ loreleiNeutral: '洛蕾萊純淨',
+ micah: '邁卡極簡',
+ miniavs: '迷你小人',
+ notionists: 'Notion風格',
+ notionistsNeutral: 'Notion風格純淨',
+ openPeeps: '手繪眾生相',
+ personas: '人物誌',
+ pixelArt: '像素藝術',
+ pixelArtNeutral: '像素藝術純淨',
+ rings: '同心圓',
+ shapes: '幾何圖形',
+ thumbs: '拇指人',
+ },
+ },
+ // 資源配額
+ resourceQuota: {
+ title: '資源配額',
+ hosts: '主機',
+ instances: '實例',
+ friends: '好友',
+ unit: '個',
+ },
+ // 餘額充值
+ userBilling: {
+ title: '餘額充值',
+ balance: '目前餘額',
+ frozen: '凍結金額',
+ recharge: '充值',
+ rechargeTitle: '帳戶充值',
+ selectAmount: '選擇金額',
+ customAmount: '自訂金額',
+ selectProvider: '選擇支付方式',
+ noProviders: '暫無可用支付渠道',
+ confirmRecharge: '確認充值',
+ fee: '手續費',
+ actual: '實際到帳',
+ balanceLogs: '餘額明細',
+ viewLogs: '查看明細',
+ rechargeRecords: '充值紀錄',
+ viewRecords: '查看紀錄',
+ noLogs: '暫無餘額紀錄',
+ noRecords: '暫無充值紀錄',
+ amountRequired: '請輸入充值金額',
+ providerRequired: '請選擇支付方式',
+ recharging: '充值中...',
+ rechargeSuccess: '充值成功',
+ rechargeFailed: '充值失敗',
+ loadFailed: '載入失敗',
+ },
+ // 增加配額
+ increaseQuota: {
+ title: '增加配額',
+ description: '當配額使用率達到50%時,您可以自行增加配額',
+ type: '配額類型',
+ selectType: '選擇要增加的配額類型',
+ hosts: '主機',
+ instances: '實例',
+ friends: '好友',
+ amount: '增加數量',
+ hostsAmount: '每次可增加 5 個',
+ instancesAmount: '每次可增加 50 個',
+ friendsAmount: '每次可增加 10 名',
+ submit: '提交',
+ submitting: '提交中...',
+ success: '配額增加成功',
+ failed: '配額增加失敗',
+ notEligible: '目前使用率未達到50%,無法增加配額',
+ usageTooLow: '目前使用率:{percent}%,需要達到50%才能增加配額',
+ selectTypeFirst: '請先選擇配額類型',
+ invalidAmount: '增加數量不正確',
+ hostsInvalid: '主機每次只能增加 5 個',
+ instancesInvalid: '實例每次只能增加 50 個',
+ friendsInvalid: '好友每次只能增加 10 名',
+ },
+ // 密碼部分
+ password: {
+ title: '變更密碼',
+ current: '目前密碼',
+ currentPlaceholder: '輸入目前密碼',
+ new: '新密碼',
+ newPlaceholder: '至少6位',
+ confirm: '確認新密碼',
+ confirmPlaceholder: '再次輸入新密碼',
+ mismatch: '兩次輸入的密碼不一致',
+ tooShort: '密碼長度至少6位',
+ updated: '密碼已更新',
+ updateFailed: '更新失敗',
+ updating: '更新中...',
+ update: '更新密碼',
+ },
+ // 雙重驗證
+ twoFactorAuth: {
+ title: '雙重驗證 (2FA)',
+ status: '狀態',
+ enabled: '已啟用',
+ notEnabled: '未啟用',
+ enable: '啟用 2FA',
+ disable: '停用 2FA',
+ loading: '載入中...',
+ description: '啟用雙重驗證後,登入時需要輸入驗證器應用程式產生的動態驗證碼,提高帳號安全性。',
+ setup: '設定雙重驗證',
+ scanQrCode: '使用 Google Authenticator、Microsoft Authenticator 或其他 TOTP 應用程式掃描 QR 碼',
+ manualEntry: '或手動輸入金鑰',
+ saveRecoveryCodes: '請儲存以下恢復碼,用於在無法使用驗證器時恢復帳號',
+ enterCode: '輸入驗證器顯示的6位驗證碼',
+ codePlaceholder: '000000',
+ verifying: '驗證中...',
+ confirmEnable: '確認啟用',
+ cancel: '取消',
+ disableTitle: '停用雙重驗證',
+ disableDesc: '停用後登入將不再需要驗證碼,請確認操作。',
+ password: '目前密碼',
+ passwordPlaceholder: '輸入密碼',
+ verificationCode: '驗證碼',
+ processing: '處理中...',
+ confirmDisable: '確認停用',
+ recoveryCodesStatus: '恢復碼狀態',
+ regenerate: '重新產生',
+ remaining: '剩餘',
+ used: '已使用',
+ lowCodesWarning: '恢復碼即將用完,建議重新產生',
+ regenerateTitle: '重新產生恢復碼',
+ regenerateDesc: '重新產生後,舊的恢復碼將全部失效。',
+ newCodesGenerated: '新恢復碼已產生,請妥善儲存',
+ generating: '產生中...',
+ confirmGenerate: '確認產生',
+ done: '完成',
+ enabledSuccess: '雙重驗證已啟用',
+ disabledSuccess: '雙重驗證已停用',
+ codesRegenerated: '恢復碼已重新產生,請妥善儲存',
+ getStatusFailed: '取得狀態失敗',
+ initFailed: '初始化失敗',
+ verifyFailed: '驗證失敗',
+ disableFailed: '停用失敗',
+ regenerateFailed: '重新產生失敗',
+ enterCodeError: '請輸入6位驗證碼',
+ fillPasswordAndCode: '請填寫密碼和驗證碼',
+ },
+ // 會話管理
+ sessions: {
+ title: '登入會話',
+ logoutAll: '登出所有裝置',
+ processing: '處理中...',
+ loading: '載入中...',
+ noSessions: '暫無活躍會話',
+ current: '目前',
+ ip: 'IP',
+ lastActive: '最後活躍',
+ revoke: '撤銷',
+ revoked: '會話已撤銷',
+ confirmLogout: '確定要登出目前登入嗎?',
+ confirmLogoutAll: '確定要登出所有裝置嗎?您需要重新登入。',
+ loadFailed: '載入會話失敗',
+ revokeFailed: '撤銷會話失敗',
+ revokeAllFailed: '撤銷所有會話失敗',
+ unknownDevice: '未知裝置',
+ unknownBrowser: '未知瀏覽器',
+ justNow: '剛剛',
+ minutesAgo: '{n} 分鐘前',
+ hoursAgo: '{n} 小時前',
+ daysAgo: '{n} 天前',
+ },
+ // OAuth 關聯帳號
+ oauth: {
+ title: '關聯帳號',
+ description: '綁定後可使用快捷登入',
+ bound: '已綁定',
+ notBound: '未綁定',
+ bind: '綁定',
+ unbind: '解除綁定',
+ noProviders: '暫無可用的第三方登入方式',
+ bindSuccess: '{provider} 帳號綁定成功',
+ unbindSuccess: '{provider} 帳號已解除綁定',
+ unbindFailed: '解除綁定失敗',
+ confirmUnbind: '確定解除 {provider} 帳號綁定?解除後將無法使用該方式登入。',
+ errors: {
+ notLoggedIn: '請先登入後再綁定',
+ alreadyBoundOther: '該帳號已被其他使用者綁定',
+ invalidSession: '會話已過期,請重新登入',
+ bindFailed: '綁定失敗',
+ tokenError: '授權失敗,請重試',
+ providerDisabled: '該登入方式已被停用',
+ oauthError: '認證失敗,請重試',
+ missingCode: '授權資訊缺失,請重試',
+ },
+ },
+ // SSH 金鑰
+ sshKeys: {
+ title: 'SSH 公鑰',
+ description: '用於 SSH 連接到實例',
+ add: '新增',
+ generate: '產生',
+ name: '名稱',
+ namePlaceholder: '我的筆記型電腦',
+ publicKey: '公鑰內容',
+ publicKeyPlaceholder: 'ssh-ed25519 AAAA... 或 ssh-rsa AAAA...',
+ save: '儲存',
+ cancel: '取消',
+ noKeys: '暫無公鑰',
+ addSuccess: '公鑰新增成功',
+ addFailed: '新增失敗',
+ deleteSuccess: '公鑰已刪除',
+ deleteFailed: '刪除失敗',
+ confirmDelete: '確定刪除此公鑰?',
+ invalidName: '金鑰名稱格式不正確',
+ generateSuccess: '金鑰產生成功',
+ generateFailed: '產生失敗',
+ privateKeyTitle: '請儲存私鑰',
+ privateKeyWarning: '請立即儲存私鑰',
+ privateKeyWarningDesc: '系統不會儲存您的私鑰,關閉此彈窗後將無法再次查看。請將私鑰儲存到安全的地方。',
+ privateKeyContent: '私鑰內容',
+ download: '下載私鑰',
+ noPrivateKey: '沒有可下載的私鑰',
+ copyFailed: '複製失敗',
+ downloadFailed: '下載失敗,請手動複製私鑰',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ pageInfo: '第 {current}/{total} 頁,共 {count} 筆',
+ },
+ // 通知渠道
+ notifications: {
+ title: '通知渠道',
+ description: '接收實例、快照等事件通知',
+ add: '新增',
+ type: '類型',
+ name: '名稱',
+ namePlaceholder: '我的通知',
+ save: '新增',
+ saving: '新增中...',
+ cancel: '取消',
+ noChannels: '暫無通知渠道',
+ addSuccess: '通知渠道新增成功',
+ addFailed: '新增失敗',
+ deleteSuccess: '通知渠道已刪除',
+ deleteFailed: '刪除失敗',
+ confirmDelete: '確定刪除此通知渠道?',
+ enabled: '已啟用',
+ disabled: '已停用',
+ enable: '啟用',
+ disable: '停用',
+ toggleFailed: '操作失敗',
+ test: '測試',
+ testSuccess: '測試通知已發送',
+ testFailed: '測試失敗',
+ history: '歷史',
+ historyTitle: '通知歷史',
+ disabledSuffix: '(已停用)',
+ statsTotal: '總計: {count}',
+ statsSent: '成功: {count}',
+ statsFailed: '失敗: {count}',
+ filterAll: '全部',
+ filterSent: '成功',
+ filterFailed: '失敗',
+ loadingLogs: '載入中...',
+ noLogs: '暫無通知記錄',
+ statusSent: '成功',
+ statusFailed: '失敗',
+ statusPending: '等待',
+ errorPrefix: '錯誤: {error}',
+ eventTypes: {
+ snapshot_created: '快照建立',
+ snapshot_restored: '快照還原',
+ snapshot_deleted: '快照刪除',
+ backup_created: '備份建立',
+ backup_failed: '備份失敗',
+ backup_deleted: '備份刪除',
+ backup_restored: '備份還原',
+ backup_uploaded: '備份上傳',
+ instance_created: '實例建立',
+ instance_started: '實例啟動',
+ instance_stopped: '實例停止',
+ instance_deleted: '實例刪除',
+ auto_snapshot: '自動快照',
+ auto_backup: '自動備份',
+ traffic_warning: '流量預警',
+ traffic_throttled: '流量限速',
+ test: '測試通知',
+ },
+ telegram: {
+ botToken: 'Bot Token',
+ botTokenPlaceholder: '123456:ABC-...',
+ chatId: 'Chat ID',
+ chatIdPlaceholder: '-100123456789',
+ },
+ discord: {
+ webhookUrl: 'Webhook URL',
+ webhookUrlPlaceholder: 'https://discord.com/api/webhooks/...',
+ },
+ webhook: {
+ url: 'URL',
+ urlPlaceholder: 'https://example.com/webhook',
+ secret: 'Secret(可選)',
+ secretPlaceholder: '用於驗證簽名',
+ },
+ },
+ telegramBinding: {
+ title: 'Telegram 綁定',
+ description: '綁定後可用於私有使用者群准入等功能。',
+ refresh: '重新整理',
+ refreshing: '重新整理中',
+ unavailableTitle: '暫未啟用',
+ unavailableDescription: '管理員尚未啟用或尚未完整設定 Telegram 綁定。',
+ boundTitle: '已綁定 {name}',
+ telegramId: 'Telegram ID: {id}',
+ boundAt: '綁定時間: {date}',
+ joinHint: '如需申請私有群,私訊 {bot} 發送',
+ unlink: '解除綁定',
+ unlinking: '解除中',
+ unboundTitle: '未綁定 Telegram',
+ unboundDescription: '點擊產生連結後,會跳轉到 {bot}。在 Telegram 內點擊開始即可完成綁定。',
+ generate: '產生綁定連結',
+ generating: '產生中',
+ openTelegram: '打開 Telegram',
+ copyLink: '複製連結',
+ linkHint: '連結 10 分鐘內有效,完成綁定後回到本頁重新整理狀態。',
+ expiresAt: '過期時間: {date}',
+ generated: 'Telegram 綁定連結已產生',
+ generateFailed: '產生綁定連結失敗: {error}',
+ copied: '綁定連結已複製',
+ copyFailed: '複製失敗,請手動複製連結',
+ confirmUnlink: '確定解除 Telegram 綁定?',
+ unlinked: 'Telegram 綁定已解除',
+ unlinkFailed: '解除綁定失敗: {error}',
+ },
+ // 遠端儲存
+ storage: {
+ title: '遠端儲存',
+ description: '設定 WebDAV/FTP/SFTP 儲存,用於備份上傳',
+ add: '新增',
+ name: '名稱',
+ namePlaceholder: '我的 NAS',
+ type: '類型',
+ host: '主機位址',
+ port: '連接埠',
+ username: '使用者名稱',
+ password: '密碼',
+ passwordUnchanged: '留空則不修改',
+ basePath: '基礎路徑',
+ setAsDefault: '設為預設',
+ default: '預設',
+ setDefault: '設為預設',
+ test: '測試',
+ noConfigs: '暫無儲存設定',
+ nameHostRequired: '請填寫名稱和主機位址',
+ createSuccess: '儲存設定建立成功',
+ updateSuccess: '儲存設定更新成功',
+ deleteSuccess: '儲存設定已刪除',
+ saveFailed: '儲存失敗',
+ deleteFailed: '刪除失敗',
+ hasActiveTasks: '無法刪除:該儲存設定有上傳任務正在進行中',
+ confirmDelete: '確定刪除此儲存設定?',
+ testSuccess: '連線測試成功',
+ testFailed: '連線測試失敗',
+ setDefaultSuccess: '已設為預設',
+ setDefaultFailed: '設定失敗',
+ },
+ // 登入歷史
+ loginHistory: {
+ title: '登入歷史',
+ description: '查看您的帳號登入記錄',
+ empty: '暫無登入記錄',
+ },
+ },
+
+ // 好友系統
+ friends: {
+ title: '好友',
+ description: '管理您的好友列表,好友之間可以共享資源',
+ friendsList: '好友列表',
+ pendingRequests: '待處理的請求',
+ historyRequests: '歷史記錄',
+ myFriends: '我的好友',
+ friendRequests: '好友請求',
+ addFriend: '加好友',
+ addFriendHint: '輸入使用者名稱或電子郵件搜尋好友',
+ searchPlaceholder: '搜尋好友...',
+ noFriends: '暫無好友',
+ noFriendsHint: '您還沒有新增任何好友',
+ noSearchResult: '未找到符合的好友',
+ noHistorySearchResult: '未找到符合的歷史記錄',
+ noRequests: '暫無好友請求',
+ noRequestsHint: '目前沒有待處理的好友請求',
+ noPendingRequests: '暫無待處理的好友請求',
+ noHistoryRecords: '暫無歷史記錄',
+ noHistoryRecordsHint: '已處理的好友請求將顯示在這裡',
+ sendRequest: '發送請求',
+ cancelRequest: '取消請求',
+ acceptRequest: '接受',
+ accept: '接受',
+ rejectRequest: '拒絕',
+ reject: '拒絕',
+ removeFriend: '刪除好友',
+ removeFriendConfirm: '確定要刪除好友「{name}」嗎?',
+ confirmRemove: '確定要刪除好友 {name} 嗎?',
+ requestSent: '好友請求已發送',
+ requestAccepted: '已接受好友請求',
+ requestRejected: '已拒絕好友請求',
+ requestCancelled: '已取消好友請求',
+ friendRemoved: '好友已刪除',
+ searchUser: '搜尋使用者',
+ userNotFound: '找不到使用者',
+ alreadyFriend: '已經是好友了',
+ alreadyRequested: '已發送過好友請求',
+ cannotAddSelf: '無法新增自己為好友',
+ sentRequests: '已發送的請求',
+ receivedRequests: '收到的請求',
+ friendSince: '成為好友於',
+ addedOn: '新增於',
+ requestedOn: '請求於',
+ sentOn: '發送於',
+ sentTo: '發送給 {username}',
+ processedOn: '處理於',
+ statusAccepted: '已接受',
+ statusRejected: '已拒絕',
+ filterAll: '全部',
+ filterAccepted: '已接受',
+ filterRejected: '已拒絕',
+ hosts: '節點',
+ instances: '實例',
+ username: '使用者名稱',
+ usernamePlaceholder: '輸入對方使用者名稱',
+ usernameHint: '輸入您想新增的好友使用者名稱',
+ remark: '備註',
+ remarkPlaceholder: '請輸入備註資訊(選填)',
+ remarkHint: '例如:認識原因、用途說明等',
+ status: {
+ online: '線上',
+ offline: '離線',
+ },
+ tabs: {
+ friends: '好友列表',
+ received: '待處理的請求',
+ sent: '已發送',
+ history: '歷史記錄',
+ invites: '邀請碼',
+ },
+ userSearchPlaceholder: '輸入使用者名稱搜尋...',
+ searching: '搜尋中...',
+ searchResults: '搜尋結果',
+ noSearchResults: '找不到符合的使用者',
+ add: '新增',
+ adding: '新增中...',
+ added: '已新增',
+ pending: '等待中',
+ accepting: '接受中...',
+ rejecting: '拒絕中...',
+ removeConfirmTitle: '刪除好友',
+ removing: '刪除中...',
+ // 邀請碼
+ invites: '邀請碼',
+ generateInvite: '產生邀請碼',
+ generateInviteTitle: '產生邀請碼',
+ inviteCode: '邀請碼',
+ inviteStatus: '狀態',
+ inviteUsed: '已使用',
+ inviteExpired: '已過期',
+ inviteUnused: '未使用',
+ usedBy: '使用者',
+ createdAt: '建立時間',
+ expiresAt: '過期時間',
+ permanent: '永久有效',
+ noInvites: '暫無邀請碼',
+ noInvitesHint: '產生邀請碼後,其他使用者可以使用它們註冊帳號',
+ inviteCount: '產生數量',
+ inviteCountHint: '可以批量產生 1-10 個邀請碼',
+ expireDays: '過期天數',
+ expireDaysPlaceholder: '0 表示永不過期',
+ expireDaysHint: '設定邀請碼的過期時間,0 表示永不過期',
+ generate: '產生',
+ confirmDeleteInvite: '確定刪除邀請碼 {code}?',
+ inviteDeleted: '邀請碼已刪除',
+ inviteGenerated: '邀請碼已產生',
+ copyCode: '複製邀請碼',
+ copyLink: '複製邀請連結',
+ inviteCodeCopied: '邀請碼已複製',
+ inviteLinkCopied: '邀請連結已複製',
+ close: '關閉',
+ deleteInvite: '刪除',
+ requestAlreadyPending: '好友請求已在待處理中',
+ requestNotFound: '請求不存在或已處理',
+ friendshipNotFound: '好友關係不存在',
+ // 套餐共享
+ selectFriendHint: '請選擇一個好友',
+ selectFriendDesc: '點擊左側好友卡片來管理方案共享',
+ sharedPackages: '已共享的方案',
+ availablePackages: '可共享的方案',
+ noSharedPackages: '尚未共享任何方案',
+ addShare: '新增共享',
+ addFirstShare: '共享第一個方案',
+ removeShare: '取消共享',
+ editQuota: '編輯配額',
+ quotaMultiplier: '配額倍數',
+ quotaMultiplierHint: '例如 1、1.5、2 倍,設定好友可使用的資源比例',
+ maxInstances: '最大實例數',
+ maxInstancesHint: '限制好友可建立的實例數量',
+ noLimit: '無限制',
+ currentUsage: '目前使用',
+ shareAdded: '方案已共享',
+ shareRemoved: '已取消共享',
+ quotaUpdated: '配額已更新',
+ confirmRemoveShare: '確定要取消共享方案 {package} 嗎?',
+ addShareTitle: '共享方案',
+ shareToFriend: '共享給該好友',
+ selectPackage: '選擇方案',
+ selectPackagePlaceholder: '請選擇要共享的方案',
+ confirmShare: '確認共享',
+ editQuotaTitle: '編輯配額限制',
+ sharedTo: '共享給',
+ noPackagesToShare: '您還沒有可共享的方案',
+ createPackageFirst: '請先在「我的方案」中建立方案',
+ noPackageSearchResult: '未找到符合的方案',
+ },
+
+ // 移轉系統
+ transfers: {
+ title: '實例移轉',
+ description: '將您的實例移轉給好友',
+ myTransfers: '我的移轉',
+ receivedTransfers: '收到的移轉',
+ createTransfer: '發起移轉',
+ noTransfers: '暫無移轉記錄',
+ noTransfersHint: '您還沒有任何移轉記錄',
+ noReceivedTransfers: '暫無收到的移轉',
+ noReceivedTransfersHint: '目前沒有收到的移轉請求',
+ selectInstance: '選擇實例',
+ selectInstanceHint: '選擇要移轉的實例',
+ selectFriend: '選擇好友',
+ selectFriendHint: '選擇移轉接收者',
+ transferNote: '備註',
+ transferNotePlaceholder: '輸入備註資訊(可選)',
+ confirmTransfer: '確認移轉',
+ cancelTransfer: '取消移轉',
+ acceptTransfer: '接受移轉',
+ rejectTransfer: '拒絕移轉',
+ transferSuccess: '移轉請求已發送',
+ transferAccepted: '移轉已接受',
+ transferRejected: '移轉已拒絕',
+ transferCancelled: '移轉已取消',
+ status: {
+ pending: '待處理',
+ accepted: '已接受',
+ rejected: '已拒絕',
+ cancelled: '已取消',
+ expired: '已過期',
+ },
+ from: '來自',
+ to: '移轉給',
+ instance: '實例',
+ createdAt: '建立時間',
+ expiresAt: '過期時間',
+ noInstances: '無可移轉的實例',
+ noFriends: '無好友可選擇',
+ addFriendFirst: '請先新增好友',
+ tabs: {
+ sent: '已發送',
+ received: '已接收',
+ },
+ sentEmpty: '暫無發送的移轉請求',
+ receivedEmpty: '暫無收到的移轉請求',
+ viewInstance: '查看實例',
+ transferPending: '移轉待處理',
+ expire: '過期',
+ accept: '接受',
+ reject: '拒絕',
+ cancel: '取消',
+ note: '備註',
+ acceptConfirm: '確認接受移轉',
+ acceptConfirmText: '接受移轉後,此實例將歸您所有。確定要接受嗎?',
+ rejectConfirm: '確認拒絕移轉',
+ rejectConfirmText: '拒絕後,此移轉請求將被取消。確定要拒絕嗎?',
+ cancelConfirm: '確認取消移轉',
+ cancelConfirmText: '確定要取消此移轉請求嗎?',
+ expiresInDays: '{days} 天後過期',
+ expiresInHours: '{hours} 小時後過期',
+ expired: '已過期',
+ },
+
+ // 日誌
+ logs: {
+ title: '系統日誌',
+ module: '模組',
+ allModules: '全部模組',
+ search: '搜尋',
+ searchPlaceholder: '搜尋使用者名稱、操作或內容...',
+ reset: '重設',
+ time: '時間',
+ user: '使用者',
+ action: '操作',
+ content: '內容',
+ result: '結果',
+ system: '系統',
+ loading: '載入中...',
+ noLogs: '暫無日誌記錄',
+ loadFailed: '載入日誌失敗',
+ loadModulesFailed: '載入模組列表失敗',
+ totalRecords: '共 {total} 筆記錄,第 {page} / {totalPages} 頁',
+ success: '成功',
+ failed: '失敗',
+ expand: '展開',
+ collapse: '收起',
+ },
+
+ // 說明頁面
+ help: {
+ title: '說明中心',
+ search: '搜尋說明文件...',
+ description: '查看使用指南和常見問題',
+ backToHelp: '返回說明中心',
+ updatedAt: '更新於 {date}',
+ all: '全部',
+ noArticles: '暫無說明文件',
+ articleNotFound: '文件不存在或已被刪除',
+ totalArticles: '共 {count} 篇文件',
+ categories: {
+ general: '常規',
+ gettingStarted: '快速開始',
+ instances: '實例管理',
+ networking: '網路設定',
+ billing: '計費相關',
+ faq: '常見問題',
+ },
+ },
+
+ // 日誌模組翻譯
+ logModules: {
+ security: '安全事件',
+ instance: '實例操作',
+ snapshot: '快照操作',
+ backup: '備份操作',
+ image: '映像檔操作',
+ host: '節點操作',
+ package: '方案操作',
+ user: '使用者管理',
+ personal: '個人設定',
+ ssh_key: 'SSH 金鑰',
+ notification: '通知設定',
+ system: '系統設定',
+ auth: '認證操作',
+ storage: '遠端儲存',
+ // 相容舊資料中的中文模組名
+ '登入操作': '登入操作',
+ '安全事件': '安全事件',
+ '實例操作': '實例操作',
+ '快照操作': '快照操作',
+ '備份操作': '備份操作',
+ '映像檔操作': '映像檔操作',
+ '節點操作': '節點操作',
+ '節點組操作': '節點組操作',
+ '方案操作': '方案操作',
+ '使用者管理': '使用者管理',
+ '個人設定': '個人設定',
+ '通知設定': '通知設定',
+ '系統設定': '系統設定',
+ '認證操作': '認證操作',
+ '遠端儲存': '遠端儲存',
+ },
+
+ // 日誌操作翻譯
+ logActions: {
+ // 安全事件
+ 'login_success': '登入成功',
+ 'login_failed': '登入失敗',
+ 'logout': '登出',
+ 'register_success': '註冊成功',
+ 'rate_limit_exceeded': '登入嘗試過多',
+ 'invalid_invite_code': '無效邀請碼',
+ 'suspicious_activity': '可疑活動',
+ 'permission_denied': '權限不足',
+ 'unauthorized_access': '未授權存取',
+ 'admin_action': '管理員操作',
+ // 實例操作
+ 'instance.create': '建立實例',
+ 'instance.delete': '刪除實例',
+ 'instance.start': '啟動實例',
+ 'instance.stop': '停止實例',
+ 'instance.restart': '重新啟動實例',
+ 'instance.rebuild': '重裝系統',
+ 'instance.recreate': '重建實例',
+ 'instance.change_host': '改節點',
+ 'instance.cloud_init_manual_complete': '手動完成初始化檢測',
+ 'instance.update_quota': '更新實例配額',
+ 'instance.rename': '重新命名實例',
+ // 連接埠映射
+ 'port.add': '新增連接埠映射',
+ 'port.delete': '刪除連接埠映射',
+ // 快照操作
+ 'snapshot.create': '建立快照',
+ 'snapshot.delete': '刪除快照',
+ 'snapshot.restore': '還原快照',
+ // 備份操作
+ 'backup.create': '建立備份',
+ 'backup.delete': '刪除備份',
+ 'backup.export': '匯出備份',
+ // 映像檔操作
+ 'image.sync': '同步映像檔',
+ 'image.delete': '刪除映像檔',
+ 'image.create': '建立映像檔',
+ 'image.update': '更新映像檔',
+ // 節點操作
+ 'host.create': '新增節點',
+ 'host.update': '更新節點',
+ 'host.delete': '刪除節點',
+ 'host.test': '測試節點',
+ // 方案操作
+ 'package.create': '建立方案',
+ 'package.update': '更新方案',
+ 'package.delete': '刪除方案',
+ // 使用者管理
+ 'user.ban': '封禁使用者',
+ 'user.unban': '解除封禁',
+ 'user.delete': '刪除使用者',
+ 'user.update_quota': '更新使用者配額',
+ 'user.revoke_sessions': '撤銷使用者會話',
+ // 個人設定
+ 'profile.update': '更新個人資料',
+ 'password.change': '變更密碼',
+ // SSH 金鑰
+ 'ssh_key.add': '新增 SSH 金鑰',
+ 'ssh_key.delete': '刪除 SSH 金鑰',
+ // 通知設定
+ 'notification.add': '新增通知管道',
+ 'notification.delete': '刪除通知管道',
+ // 安全設定
+ '2fa.setup': '設定雙重驗證',
+ '2fa.enable': '啟用雙重驗證',
+ '2fa.disable': '停用雙重驗證',
+ '2fa.recovery_reset': '重設恢復碼',
+ // 認證操作
+ 'session.revoke': '撤銷會話',
+ 'session.revoke_all': '撤銷所有會話',
+ // 系統設定
+ 'system.config_update': '更新系統設定',
+ // 幫助文件
+ 'help.create': '建立幫助文件',
+ 'help.update': '更新幫助文件',
+ 'help.delete': '刪除幫助文件',
+ // 備份上傳操作
+ 'backup.upload': '上傳備份',
+ 'backup.upload.queue': '備份上傳排隊',
+ 'backup.upload.cancel': '取消備份上傳',
+ // 備份還原操作
+ 'backup.restore': '還原備份',
+ 'backup.restore.rollback': '回滾還原',
+ 'backup.restore.cancel': '取消還原',
+ // 儲存設定操作
+ 'storage.create': '建立儲存設定',
+ 'storage.update': '更新儲存設定',
+ 'storage.delete': '刪除儲存設定',
+ // AFF 推薦計劃
+ 'aff.create_code': '建立優惠碼',
+ 'aff.convert_request': '申請 AFF 轉化',
+ 'aff.approve_convert': '審核通過 AFF 轉化',
+ 'aff.reject_convert': '審核拒絕 AFF 轉化',
+ },
+
+ // 日誌結果翻譯
+ logResults: {
+ success: '成功',
+ failed: '失敗',
+ warning: '警告',
+ },
+
+ // 管理後台
+ admin: {
+ statistics: {
+ title: '統計',
+ description: '使用者、實例和計費資料',
+ timezone: '{timezone}',
+ refresh: '重新整理',
+ reload: '重新載入',
+ noData: '暫無統計資料',
+ loadFailed: '統計資料載入失敗:{message}',
+ unknownError: '未知錯誤',
+ tooltip: '{label} · {value}',
+ tabs: {
+ users: '使用者',
+ instances: '實例',
+ billing: '計費',
+ },
+ periods: {
+ daily: '每日',
+ monthly: '每月',
+ },
+ billingMetrics: {
+ recharge: '儲值',
+ consume: '消費',
+ aff: '返利',
+ destroyFee: '銷毀手續費',
+ },
+ ranges: {
+ last30Days: '近 30 天',
+ last12Months: '近 12 個月',
+ },
+ cards: {
+ totalUsers: '總使用者數',
+ recentDailyNewUsers: '近 30 天新增',
+ recentMonthlyNewUsers: '近 12 月新增',
+ averageNewUsers: '平均新增',
+ totalInstances: '系統總實例數',
+ availableInstances: '可用實例',
+ recentDailyCreatedInstances: '近 30 天建立',
+ recentMonthlyCreatedInstances: '近 12 月建立',
+ totalRecharge: '累計儲值',
+ totalConsume: '累計消費',
+ totalAff: '累計返利',
+ totalDestroyFee: '累計銷毀手續費',
+ },
+ captions: {
+ currentTotal: '目前累計',
+ dailyAggregate: '按日彙總',
+ monthlyAggregate: '按月彙總',
+ dailyAverage: '日均',
+ monthlyAverage: '月均',
+ nonDeletedInstances: '未刪除實例',
+ notDeletedOrSuspended: '非刪除 / 非封停',
+ completedOrders: '已完成訂單',
+ totalScope: '託管和直營合計',
+ affCommission: 'AFF 新購 / 續費',
+ userDestroyFee: '使用者銷毀實例手續費',
+ },
+ sections: {
+ newUsers: '新增使用者',
+ createdInstances: '建立實例',
+ paidFreeInstances: '付費 / 免費實例',
+ paidFreeDescription: '目前未刪除實例占比',
+ metricTrend: '{metric}趨勢',
+ billingScope: '{range},託管和直營合計',
+ },
+ labels: {
+ paidInstances: '付費實例',
+ freeInstances: '免費實例',
+ },
+ },
+ hosting: {
+ title: '託管',
+ description: '查看符合條件的託管機主和託管經營資料',
+ loadFailed: '載入託管資料失敗:{message}',
+ unknownError: '未知錯誤',
+ tabs: {
+ owners: '託管機主',
+ zones: '專區機主',
+ hostingVipLevels: '託管 VIP 等級',
+ },
+ cards: {
+ owners: '託管機主',
+ ownersCaption: '符合託管條件的使用者',
+ hosts: '節點數',
+ hostsCaption: '機主已新增的節點',
+ instances: '實例數',
+ instancesCaption: '機主節點上的未刪除實例',
+ totalIncome: '歷史總託管收入',
+ totalIncomeCaption: '收入日誌累計',
+ },
+ owners: {
+ title: '託管機主使用者列表',
+ description: '已新增節點、已上架公開方案且歷史總託管餘額不為 0 的使用者。',
+ searchPlaceholder: '搜尋使用者名稱、ID、信箱...',
+ empty: '暫無託管機主',
+ emptyHint: '使用者需要同時滿足節點、上架方案和歷史託管餘額條件。',
+ user: '使用者',
+ vipLevel: 'VIP 等級',
+ hostingBalance: '託管餘額',
+ frozenBalance: '凍結餘額',
+ totalIncome: '歷史總收入',
+ hostCount: '節點數',
+ packageCount: '上架方案數',
+ instanceCount: '實例數',
+ createdAt: '註冊時間',
+ noEmail: '未設定信箱',
+ },
+ zones: {
+ title: '專區機主列表',
+ description: '專區機主會在開通實例頁作為獨立分頁展示,其方案不再出現在託管分頁中。',
+ createTitle: '新增專區機主',
+ createDescription: '輸入專區名,選擇託管機主使用者,並填寫圖床 LOGO 連結。',
+ name: '專區名',
+ namePlaceholder: '例如:Tokyoo 專區',
+ owner: '託管機主使用者',
+ ownerSearchPlaceholder: '搜尋可選託管機主...',
+ selectOwner: '請選擇託管機主',
+ ownerHint: '已設定專區的機主不會出現在可選列表中。',
+ logo: 'LOGO 連結',
+ logoPlaceholder: 'https://example.com/logo.png',
+ logoHint: '僅支援 http 或 https 圖片連結,系統不會保存圖片檔案。',
+ logoPreview: 'LOGO 預覽',
+ noLogo: 'LOGO',
+ previewName: '專區預覽',
+ previewHint: '開通實例頁會以圓形 LOGO 展示。',
+ create: '新增專區',
+ formRequired: '請填寫專區名、選擇託管機主並填寫 LOGO 連結',
+ logoInvalid: '請填寫有效的 http 或 https LOGO 圖片連結',
+ createSuccess: '專區機主已新增',
+ createFailed: '新增專區機主失敗',
+ loadFailed: '載入專區機主失敗:{message}',
+ deleteConfirm: '確定刪除專區「{name}」嗎?刪除後該機主方案會重新顯示在託管分頁中。',
+ deleteSuccess: '專區機主已刪除',
+ deleteFailed: '刪除專區機主失敗',
+ empty: '暫無專區機主',
+ emptyHint: '新增後會顯示在開通實例頁的直營與託管之間。',
+ zone: '專區',
+ },
+ },
+ vipRules: {
+ userTitle: '使用者 VIP 等級',
+ userDescription: '全域選擇累計充值或累計消費作為使用者會員口徑,並按該口徑動態計算會員等級,最高 VIP10。',
+ hostingTitle: '託管 VIP 等級',
+ hostingDescription: '按累計託管收入、目前託管實例數動態計算託管等級,最高 VIP10。',
+ level: '等級',
+ badgeBgColor: '標籤背景色',
+ badgeTextColor: '標籤字體色',
+ enabled: '啟用',
+ mode: '條件關係',
+ modeAny: '滿足任一',
+ modeAll: '同時滿足',
+ userMetricTitle: '使用者 VIP 統計口徑',
+ userMetricHint: '全站使用者 VIP 只能使用一種升級口徑。切換後,每個等級只儲存並計算目前口徑對應的門檻。',
+ metricRecharge: '按累計充值',
+ metricConsume: '按累計消費',
+ minRecharge: '累計充值滿(元)',
+ minConsume: '累計消費滿(元)',
+ minHostingIncome: '累計託管收入滿(元)',
+ minHostingInstances: '目前託管實例數滿',
+ noLimit: '不限制',
+ save: '儲存等級規則',
+ saveSuccess: 'VIP 等級規則已儲存',
+ saveFailed: '儲存 VIP 等級規則失敗',
+ loadFailed: '載入 VIP 等級規則失敗:{message}',
+ unknownError: '未知錯誤',
+ conditionRequired: 'VIP{level} 至少需要配置一個條件',
+ moneyThresholdInvalid: 'VIP{level} 的金額條件必須是大於 0 的數字',
+ instanceThresholdInvalid: 'VIP{level} 的實例數條件必須是正整數',
+ colorInvalid: 'VIP{level} 的標籤顏色必須是 #RRGGBB 格式',
+ },
+ vipBenefits: {
+ title: '會員福利大廳設定',
+ description: '為已啟用的使用者 VIP 等級配置可領取福利。餘額和積分會自動發放,實例獎品會建立待發放記錄。',
+ save: '儲存福利設定',
+ saveSuccess: '會員福利設定已儲存',
+ saveFailed: '儲存會員福利設定失敗',
+ loadFailed: '載入會員福利設定失敗',
+ loadPlansFailed: '載入套餐方案失敗',
+ noEnabledLevels: '暫無已啟用的使用者 VIP 等級,請先在使用者 VIP 等級中啟用等級。',
+ levelTitle: 'VIP{level} 福利',
+ levelHint: '使用者達到該等級後,可在福利大廳領取對應獎勵。',
+ addReward: '新增獎品',
+ noRewardsForLevel: '該等級暫無獎品。',
+ rewardDefaultTitle: '福利',
+ rewardTitle: '獎品標題',
+ rewardType: '獎品類型',
+ rewardDescription: '獎品說明',
+ claimLimit: '領取次數',
+ sortOrder: '排序',
+ enabled: '啟用',
+ disabled: '停用',
+ types: {
+ balance: '餘額',
+ points: '積分',
+ instance: '實例',
+ },
+ balanceTitle: '領取贈金',
+ balanceDesc: '發放到使用者帳戶餘額,可用於實例續費或新購。',
+ balanceAmount: '贈金金額(元)',
+ pointsTitle: '領取積分',
+ pointsDesc: '發放到使用者積分帳戶,可用於福利和積分消耗。',
+ pointsAmount: '積分數量',
+ instanceTitle: '領取套餐實例',
+ instanceDesc: '指定一個套餐方案,使用者領取後進入待發放狀態,後續可擴展自動建立。',
+ package: '套餐',
+ plan: '方案',
+ selectPackage: '選擇套餐',
+ selectPlan: '選擇方案',
+ instanceDays: '贈送天數',
+ instanceQuantity: '數量',
+ balancePreview: '贈金 {amount}',
+ pointsPreview: '積分 {amount}',
+ instancePreview: '套餐實例',
+ instancePreviewWithPlan: '{plan} · {quantity} 台 · {days} 天',
+ titleRequired: 'VIP{level} 的獎品標題不能為空',
+ claimLimitInvalid: 'VIP{level} 的領取次數必須是正整數',
+ amountInvalid: 'VIP{level} 的餘額或積分獎品數量必須大於 0',
+ balanceInvalid: 'VIP{level} 的贈金金額必須大於 0',
+ pointsInvalid: 'VIP{level} 的積分數量必須是正整數',
+ instancePlanRequired: 'VIP{level} 的套餐實例福利需要選擇套餐和方案',
+ instanceDaysInvalid: 'VIP{level} 的贈送天數必須是正整數',
+ instanceQuantityInvalid: 'VIP{level} 的實例數量必須是正整數',
+ },
+ title: '管理後台',
+ description: '系統管理',
+ system: {
+ title: '系統設定',
+ description: '配置系統初始參數和預設值',
+ tabs: {
+ system: '系統設定',
+ popupAnnouncement: '彈窗公告',
+ telegram: 'Telegram 設定',
+ },
+ sections: {
+ access: {
+ title: '訪問與註冊',
+ description: '管理註冊入口、邀請碼生成、預設配額和實例轉移規則',
+ },
+ hosting: {
+ title: '託管與站點',
+ description: '管理託管入口、託管公告和免費站贈送策略',
+ },
+ brand: {
+ title: '品牌與外觀',
+ description: '管理系統名稱、Logo、頭像服務和底部聯絡方式',
+ },
+ security: {
+ title: '安全驗證',
+ description: '管理 Turnstile 人機驗證和註冊信箱網域白名單',
+ },
+ mail: {
+ title: '郵件服務',
+ description: '管理 SMTP 郵件發送配置和測試郵件',
+ },
+ tickets: {
+ title: '工單與附件',
+ description: '管理工單入口和工單圖片儲存配置',
+ },
+ },
+ popupAnnouncement: {
+ title: '彈窗公告',
+ description: '配置使用者訪問網站時彈出的全站公告。',
+ content: '公告內容',
+ placeholder: '在此輸入彈窗公告內容,留空並儲存表示刪除公告,不再廣播。',
+ hint: '使用者看到後可以選擇今日不見或再也不見;公告內容更新後會按新公告重新彈出。',
+ promoTitle: '圖片推廣彈窗',
+ promoDescription: '配置新機器推廣彈窗,前台會展示圖片並引導使用者購買指定方案。',
+ promoImageUrl: '圖片 URL',
+ promoImagePlaceholder: 'https://example.com/promo.jpg',
+ promoImageHint: '建議使用清晰橫幅圖片,前台會按原比例完整顯示。',
+ promoPackage: '目標方案',
+ promoPackagePlaceholder: '選擇要推廣的方案',
+ promoPackageLoading: '正在載入方案...',
+ promoPackageEmpty: '暫無可推廣方案',
+ promoPackageHint: '圖片 URL 和目標方案都填寫後才會顯示推廣彈窗。',
+ promoPreview: '前台預覽',
+ promoPreviewEmpty: '填寫圖片 URL 後顯示預覽',
+ promoNoPackage: '尚未選擇方案',
+ promoPackageFallback: '目標方案',
+ },
+ defaultQuota: '使用者預設配額',
+ defaultQuotaDesc: '新註冊使用者將自動獲得以下配額限制',
+ quotaHost: '預設主機配額',
+ quotaHostDesc: '新使用者預設可建立主機數量',
+ quotaFriend: '預設好友配額',
+ quotaFriendDesc: '新使用者預設可新增好友數量(0 = 未授權)',
+ quotaPackage: '預設方案配額',
+ quotaPackageDesc: '新使用者預設可建立方案數量(0 = 未授權)',
+ registration: '註冊設定',
+ registrationDesc: '配置使用者註冊相關選項',
+ registrationEnabled: '開放註冊',
+ registrationEnabledDesc: '關閉後,新使用者將無法註冊,現有使用者仍可正常登入',
+ registrationOpen: '開放註冊',
+ registrationClosed: '關閉註冊',
+ requireInviteCode: '邀請碼註冊',
+ requireInviteCodeDesc: '啟用後,使用者註冊時需要填寫邀請碼',
+ openRegistration: '開放註冊',
+ inviteOnly: '僅邀請',
+ hostingFeature: {
+ title: '託管功能',
+ description: '控制託管節點與收益入口是否向新使用者顯示,已建立過節點的使用者會持續保留入口。',
+ enable: '顯示託管節點入口',
+ enableDesc: '關閉後,從未建立過節點的使用者將看不到託管節點與收益入口;已有節點使用者仍可繼續管理。',
+ marketEntry: '顯示託管方案購買入口',
+ marketEntryDesc: '控制開通實例頁面是否顯示託管專區與託管方案購買入口;關閉後使用者只能從該頁面選擇官方方案。',
+ marketEntryVisible: '顯示入口',
+ marketEntryHidden: '隱藏入口',
+ notice: '託管公告',
+ noticePlaceholder: '在此輸入託管公告內容,留空則前台不顯示',
+ noticeHint: '顯示在託管收益頁面,支援換行,留空則隱藏。',
+ visibleToAll: '所有使用者可見',
+ hiddenForNewUsers: '新使用者隱藏',
+ },
+ brand: {
+ title: '品牌設定',
+ description: '配置系統名稱、副標題與 Logo,留空則使用預設品牌。',
+ name: '系統名稱',
+ nameDesc: '顯示在側邊欄、頂部列、登入註冊頁和 SEO 資訊中。',
+ subtitle: '網站副標題',
+ subtitlePlaceholder: 'Incus 驅動的 NAT VPS 平台',
+ subtitleDesc: '顯示在公開站頭部/底部、瀏覽器預設標題和 SEO 預設描述中。',
+ logo: 'Logo 位址',
+ logoDesc: '支援 http(s) 圖片位址或站內絕對路徑,留空則使用預設 Logo。',
+ },
+ ticket: {
+ title: '工單設定',
+ description: '控制一般使用者是否可以發起工單。',
+ enable: '開放工單',
+ enableDesc: '關閉後,使用者端隱藏工單入口,一般使用者也無法透過 API 建立工單。',
+ enabled: '已開放',
+ disabled: '已關閉',
+ },
+ freeSite: {
+ title: '白嫖站',
+ description: '控制使用者端餘額頁是否展示充值和推薦計畫相關功能。',
+ enable: '啟用白嫖站',
+ enableDesc: '開啟後,使用者端餘額頁隱藏充值按鈕、充值記錄和推薦計畫,API 也會拒絕建立或重新支付充值訂單。',
+ enabled: '已啟用',
+ disabled: '未啟用',
+ registerGift: '註冊自動贈送',
+ registerGiftDesc: '開啟後,新使用者註冊成功會自動收到餘額和積分到帳通知。',
+ giftEnabled: '贈送中',
+ giftDisabled: '不贈送',
+ giftBalance: '贈送餘額',
+ giftBalanceDesc: '註冊成功後自動進入帳戶餘額,單位為元。',
+ giftPoints: '贈送積分',
+ giftPointsDesc: '註冊成功後自動進入娛樂中心積分帳戶。',
+ giftRequiresFreeSite: '需要先啟用白嫖站,才能設定註冊自動贈送。'
+ },
+ unitCount: '個',
+ reset: '重設',
+ save: '儲存設定',
+ saving: '儲存中...',
+ loadFailed: '載入設定失敗',
+ saveSuccess: '設定已儲存',
+ saveFailed: '儲存失敗',
+ notes: '說明',
+ note1: '修改預設配額只會影響新註冊的使用者,不會影響既有使用者的配額。',
+ note2: '如需修改既有使用者的配額,請前往「使用者管理」頁面單獨調整。',
+ note3: '主機配額限制使用者可以建立的主機數量(0 = 功能未授權)。',
+ note4: '好友配額限制使用者可以新增的好友數量(0 = 功能未授權)。',
+ note5: '方案配額限制使用者可以建立的方案數量(0 = 功能未授權)。',
+ turnstile: {
+ title: 'Cloudflare Turnstile',
+ description: '配置人機驗證,保護登入、註冊與敏感操作',
+ enable: '啟用 Turnstile',
+ enableDesc: '啟用後,使用者在登入、註冊與敏感操作時需要完成人機驗證',
+ enabled: '已啟用',
+ disabled: '已停用',
+ siteKey: 'Site Key',
+ siteKeyPlaceholder: '輸入 Cloudflare Turnstile Site Key',
+ siteKeyDesc: '前端使用的站點金鑰',
+ secretKey: 'Secret Key',
+ secretKeyPlaceholder: '輸入 Cloudflare Turnstile Secret Key',
+ secretKeyDesc: '後端驗證使用的金鑰(請妥善保管)',
+ helpText: '前往 Cloudflare 控制台取得金鑰:',
+ },
+ avatar: {
+ title: '頭像服務',
+ description: '配置使用者頭像生成服務,預設使用 DiceBear 官方 API',
+ apiBase: 'API 位址',
+ apiBaseDesc: 'DiceBear 頭像 API 基礎位址,可自建服務以提高存取速度',
+ helpText: '了解如何自建 DiceBear 服務:',
+ },
+ smtp: {
+ title: 'SMTP 郵件服務',
+ description: '配置 SMTP 伺服器以啟用電子郵件驗證功能,使用者註冊時需要驗證信箱',
+ enable: '啟用電子郵件驗證',
+ enableDesc: '啟用後,使用者註冊時需要透過電子郵件驗證碼驗證',
+ enabled: '已啟用',
+ disabled: '已停用',
+ host: 'SMTP 伺服器',
+ hostPlaceholder: 'smtp.example.com',
+ port: 'SMTP 連接埠',
+ secure: '使用 SSL/TLS',
+ secureHint: '連接埠 465 通常需要啟用,連接埠 587 通常不需要',
+ username: '使用者名稱',
+ usernamePlaceholder: '信箱帳號或使用者名稱',
+ password: '密碼',
+ passwordPlaceholder: '授權碼或密碼',
+ fromEmail: '寄件者信箱',
+ fromEmailPlaceholder: "noreply{'@'}example.com",
+ fromName: '寄件者名稱',
+ testConnection: '測試連線',
+ testing: '測試中...',
+ testSuccess: 'SMTP 連線測試成功',
+ testFailed: 'SMTP 連線測試失敗',
+ sendTestEmail: '發送測試郵件',
+ sendTestEmailDesc: '發送一封測試郵件到指定信箱,確認郵件發送功能是否正常',
+ testEmailPlaceholder: '輸入收件者信箱地址',
+ sending: '發送中...',
+ send: '發送',
+ invalidEmail: '請輸入有效的信箱地址',
+ testEmailSent: '測試郵件已發送至 {email}',
+ testEmailFailed: '測試郵件發送失敗',
+ helpText: '請確認 SMTP 伺服器位址、連接埠、使用者名稱與密碼正確。常見郵件服務商需要使用授權碼而不是登入密碼。',
+ },
+ emailDomain: {
+ title: '信箱網域白名單',
+ description: '限制只允許特定信箱網域進行註冊,提高使用者品質',
+ enable: '啟用信箱網域白名單',
+ enableDesc: '啟用後,只有白名單中的信箱網域才能註冊',
+ enabled: '已啟用',
+ disabled: '已停用',
+ allowedDomains: '允許的信箱網域',
+ allowedDomainsPlaceholder: 'gmail.com,outlook.com,icloud.com\n每行一個網域或用逗號分隔\n留空則使用預設白名單',
+ allowedDomainsDesc: '輸入允許註冊的信箱網域,可用逗號分隔或每行一個。留空則使用預設白名單(包含 Gmail、Outlook、iCloud、Yahoo、Proton 等主流信箱服務)。',
+ helpText: '預設白名單包含:Gmail、Outlook/Hotmail、iCloud、Yahoo、Zoho、Proton、Fastmail、Tuta、Posteo、Disroot、Riseup 等主流信箱服務。',
+ },
+ transfer: {
+ title: '移轉設定',
+ description: '配置實例移轉功能的相關參數',
+ feeLabel: '移轉手續費',
+ feeUnit: '元/次',
+ feeDesc: '使用者發起移轉時需要支付的手續費(0 表示免費),對方拒絕接收時將自動退還',
+ feeRangeError: '移轉手續費必須在 0-{max} 元之間,最多支援兩位小數',
+ },
+ footerLinks: {
+ title: '底部聯絡方式',
+ description: '配置側邊欄底部的電子郵件按鈕',
+ email: '聯絡信箱',
+ emailPlaceholder: "support{'@'}example.com 或 mailto:support{'@'}example.com",
+ emailDesc: '留空則隱藏信箱按鈕;支援填寫信箱地址或完整 mailto: 連結。',
+ telegram: 'Telegram 群組連結',
+ telegramPlaceholder: 'https://t.me/your_group',
+ telegramDesc: '留空則隱藏 Telegram 按鈕。',
+ },
+ ticketImages: {
+ title: '工單圖片儲存',
+ description: '配置工單圖片上傳到蘭空圖床。面板只轉發上傳並記錄中繼資料,不在本地落盤。',
+ baseUrl: 'Lsky 位址',
+ baseUrlPlaceholder: 'https://img.example.com',
+ baseUrlDesc: '蘭空圖床站點根位址,不要追加 /api/v1/upload',
+ token: 'Lsky Token',
+ tokenPlaceholder: '輸入蘭空圖床 API Token',
+ tokenDesc: '僅後端使用,不會下發到前端',
+ apiVersion: 'API 版本',
+ apiVersionDesc: '根據你的蘭空版本選擇上傳接口版本',
+ targetId: '策略/儲存 ID',
+ targetIdPlaceholder: 'v1 填 strategy_id,v2 填 storage_id',
+ targetIdDesc: '可留空,留空時使用蘭空預設策略或預設儲存',
+ },
+ },
+ images: {
+ noImagesForArchitecture: '目前架構下沒有映像檔',
+ },
+ // 網域郵箱管理
+ mail: {
+ title: '郵箱管理',
+ description: '管理郵箱源、方案和使用者訂閱',
+ tabs: {
+ sources: '郵箱源',
+ plans: '方案',
+ subscriptions: '訂閱',
+ domains: '網域',
+ },
+ createSource: '新增郵箱源',
+ editSource: '編輯郵箱源',
+ sourceCreated: '郵箱源建立成功',
+ sourceUpdated: '郵箱源更新成功',
+ sourceDeleted: '郵箱源已刪除',
+ confirmDeleteSource: '確定要刪除郵箱源 {name} 嗎?請確保已無關聯方案。',
+ noSources: '暫無郵箱源,請先新增',
+ createPlan: '新增方案',
+ editPlan: '編輯方案',
+ planCreated: '方案建立成功',
+ planUpdated: '方案更新成功',
+ planDeleted: '方案已刪除',
+ confirmDeletePlan: '確定要刪除方案 {name} 嗎?',
+ noPlans: '暫無方案,請先新增',
+ noSubscriptions: '暫無訂閱記錄',
+ noDomains: '暫無網域記錄',
+ searchSubscriptions: '搜尋使用者名稱、電子郵件或ID...',
+ searchDomains: '搜尋網域、使用者名稱、電子郵件或ID...',
+ fillRequired: '請填寫必填欄位',
+ createSourceFirst: '請先建立郵箱源',
+ region: '地區',
+ apiEndpoint: 'API 端點',
+ apiKey: 'API 密鑰',
+ smtpHost: 'SMTP 伺服器',
+ smtpPort: 'SMTP 連接埠',
+ webmailUrl: 'Webmail 網址',
+ sourcePlaceholder: '例如:美國資料中心',
+ source: '郵箱源',
+ domainLimit: '網域限制',
+ diskLimit: '磁碟限制',
+ diskLimitGb: '磁碟限制(GB)',
+ price: '價格',
+ billingCycle: '計費週期',
+ planPlaceholder: '例如:基礎版',
+ user: '使用者',
+ plan: '方案',
+ expiresAt: '到期時間',
+ domain: '網域',
+ accounts: '帳戶數',
+ plans: '方案數',
+ unsub: {
+ button: '退訂',
+ title: '退訂郵箱',
+ refundType: '退款方式',
+ refundNone: '不退款',
+ refundNoneDesc: '直接取消訂閱,不退還任何費用',
+ refundFull: '全額退款',
+ refundFullDesc: '退還方案全價 {amount}',
+ refundRemaining: '剩餘價值退款',
+ refundRemainingDesc: '按剩餘有效期比例退款',
+ reason: '退款原因',
+ reasonPlaceholder: '請輸入退款原因,將記錄到餘額日誌...',
+ reasonRequired: '退款時必須填寫原因',
+ confirm: '確認退訂',
+ success: '訂閱已退訂',
+ successWithRefund: '訂閱已退訂,已退款 ¥{amount}',
+ },
+ },
+ // 管理員建立實例
+ instanceCreate: {
+ title: '建立實例',
+ description: '為使用者贈送實例,無需支付任何費用',
+ targetUser: '目標使用者',
+ usernamePlaceholder: '輸入使用者名稱',
+ usernameHint: '實例將建立到該使用者帳戶下',
+ userHint: '實例將建立到該使用者帳戶下',
+ checkUser: '查詢',
+ checking: '查詢中...',
+ userFound: '使用者找到 (ID: {id})',
+ userNotFound: '使用者不存在',
+ userNotFoundHint: '請檢查使用者名稱是否正確',
+ noSshKey: '該使用者未設定 SSH 金鑰',
+ noSshKeyHint: '請讓使用者先在個人設定中新增 SSH 金鑰',
+ orderSummary: '贈送摘要',
+ freeGift: '免費贈送',
+ freeGiftHint: '此實例由管理員免費建立,不計入收費統計',
+ createFor: '建立給',
+ create: '建立實例',
+ creating: '建立中...',
+ submit: '建立實例',
+ success: '實例建立成功',
+ selectUser: '請先選擇目標使用者',
+ createSuccess: '實例已建立,將顯示在使用者 {username} 的實例列表中',
+ createFailed: '建立實例失敗',
+ selectUserFirst: '請先查詢並確認目標使用者',
+ packageScope: {
+ official: '自營方案',
+ hosted: '託管方案',
+ },
+ // 付費實例相關
+ instanceType: '實例類型',
+ freeInstance: '免費實例',
+ paidInstance: '付費實例',
+ selectPlan: '選擇方案',
+ noPlanHint: '該套餐暫無可用方案',
+ chargeFirstMonth: '扣除首月費用',
+ chargeFirstMonthHint: '從使用者餘額扣除首月費用',
+ noChargeFirstMonthHint: '首月免費贈送,次月起正常計費',
+ planPrice: '方案月費',
+ setupFee: '開通費',
+ totalCharge: '本次扣費',
+ freeFirstMonth: '首月免費',
+ userBalance: '使用者餘額',
+ insufficientBalance: '使用者餘額不足',
+ paidSummary: '付費摘要',
+ paidInstanceHint: '建立付費實例,按方案計費',
+ },
+ dashboard: '儀表板',
+ hosts: {
+ title: '節點管理',
+ description: '管理宿主機和節點組',
+ create: '新增宿主機',
+ address: '地址',
+ status: '狀態',
+ online: '線上',
+ offline: '離線',
+ maintenance: '維護中',
+ hostsTab: '宿主機',
+ searchPlaceholder: '搜尋宿主機...',
+ noHosts: '暫無宿主機',
+ name: '名稱',
+ resources: '資源',
+ instances: '實例',
+ actions: '操作',
+ cpu: 'CPU',
+ cpuQuota: 'CPU 配額',
+ memory: '記憶體',
+ memoryQuota: '記憶體配額',
+ disk: '硬碟',
+ diskUsage: '硬碟使用情況',
+ cores: '核',
+ allowanceLimit: '額配上限',
+ memoryLimit: '記憶體上限',
+ instanceType: '類型',
+ typeContainer: '容器',
+ typeVm: '虛擬機',
+ typeBoth: '兩者',
+ edit: '編輯',
+ test: '測試',
+ delete: '刪除',
+ testSuccess: '連線成功',
+ testFailed: '連線失敗',
+ confirmDelete: '確定刪除宿主機 "{name}"?',
+ hostDeleted: '宿主機已刪除',
+ deleteFailed: '刪除失敗',
+ // 新增/編輯宿主機
+ addHost: '新增宿主機',
+ editHost: '編輯宿主機',
+ hostName: '名稱',
+ hostNameHint: '只能包含英文字母、數字、- 和 _',
+ hostNameRequired: '請輸入節點名稱',
+ hostDesc: '描述',
+ apiUrl: 'API URL',
+ ipAddress: '連線地址',
+ ipAddressHint: '支持 IPv4、IPv6 裸地址或域名',
+ ipAddressRequired: '請輸入伺服器地址',
+ apiPort: 'API 連接埠',
+ apiPortHint: '預設 8443',
+ tokenPrompt: '若安裝時提示輸入安全通訊 Token,請複製下方內容並貼上:',
+ copyToken: '複製 Token',
+ country: '國家或地區',
+ certPath: '憑證路徑',
+ keyPath: '金鑰路徑',
+ natPublicIp: '網卡IP',
+ natPublicIpPlaceholder: '輸入伺服器的網卡IP',
+ natConfig: 'NAT 設定',
+ natPublicIpv4: '公網 IPv4',
+ natPublicIpv4Placeholder: '請輸入使用者訪問時顯示的公網 IPv4',
+ natPublicIpv4Desc: '使用者訪問 IPv4 連接埠映射時看到的公網 IPv4 地址。',
+ natPublicIpv6: '公網 IPv6',
+ natPublicIpv6Placeholder: '例如 2600:1900:41a0:5bb::',
+ natPublicIpv6Desc: '用於向使用者展示的公網 IPv6 地址,不一定是實際監聽連接埠時使用的地址。',
+ natBindIpv4: '監聽 IPv4',
+ natBindIpv4Placeholder: '留空則自動識別,例如 0.0.0.0 或 10.170.0.3',
+ natBindIpv4Desc: '實際綁定 IPv4 連接埠時使用的地址。留空後由系統自動選擇。',
+ natBindIpv6: '監聽 IPv6',
+ natBindIpv6Placeholder: '留空則自動識別,例如 2600:1900:41a0:5bb::',
+ natBindIpv6Desc: '實際綁定 IPv6 連接埠時使用的地址。留空後由系統自動選擇。',
+ natPublicIpv6Invalid: '公網 IPv6 地址格式無效',
+ natBindIpv6Invalid: '監聽 IPv6 地址格式無效',
+ portRangeStart: '連接埠範圍起始',
+ portRangeEnd: '連接埠範圍結束',
+ portRangeEndMustBeGreater: '連接埠範圍結束不能小於起始',
+ cpuAllowanceMax: '總 CPU 時間片配額',
+ memoryMax: '記憶體最大值',
+ instanceTypeLabel: '實例類型',
+ networkModeLabel: '網路模式',
+ networkModeNat: 'IPv4 NAT',
+ networkModeNatIpv6: 'IPv4 NAT & IPv6',
+ networkModeNatIpv6Nat: 'IPv4 NAT & IPv6 NAT',
+ networkModeIpv6Only: 'IPv6 Only',
+ networkModeIpv6Nat: 'IPv6 NAT',
+ autoAssign: '自動分配',
+ hostAdded: '宿主機新增成功',
+ hostUpdated: '宿主機更新成功',
+ typeChangeWarning: '節點類型變更警告',
+ addFailed: '新增失敗',
+ updateFailed: '更新失敗',
+ // 初始化設定
+ initConfig: '初始化設定',
+ // 儲存設定
+ storageConfig: '儲存設定',
+ storageDriver: '儲存驅動',
+ storageDriverZfs: 'ZFS (推薦)',
+ storageDriverLvm: 'LVM',
+ storageType: '儲存類型',
+ storageTypeLoop: 'Loop 檔案',
+ storageTypeDisk: '實體磁碟',
+ storagePath: '裝置路徑',
+ storagePathHint: '如 /dev/sdb',
+ storageSize: '儲存大小',
+ // 網路設定
+ networkConfig: '網路設定',
+ networkOption: '網路選項',
+ networkOptionHint: '選擇容器實例的網路出口方式',
+ independentIpv6: '獨立 IPv6',
+ ipv6Mode: 'IPv6 模式',
+ ipv6Routed: 'Routed',
+ ipv6Nat: 'NAT',
+ ipv6Disabled: '停用',
+ ipv6Subnet: 'IPv6 子網路',
+ ipv6SubnetHint: '分配給容器的 IPv6 子網路段',
+ ipv6SubnetRequired: '請輸入 IPv6 網段',
+ ipv6SubnetInvalid: 'IPv6 網段格式無效,需要包含 CIDR 前綴(如 /48)',
+ ipv6Gateway: 'IPv6 閘道',
+ ipv6ParentInterface: 'IPv6 父介面',
+ ipv6ParentInterfaceHint: 'IPv6 routed 模式使用的宿主機實體網卡名(如 eth0)',
+ ipv6ParentInterfaceRequired: '請輸入 IPv6 父介面',
+ enableApi: '啟用 API',
+ // 核心參數
+ sysctlConfig: '核心參數',
+ sysctlConfigHint: '自訂 sysctl 設定,留空使用預設值',
+ resetSysctl: '恢復預設',
+ enableBBR: '啟用 BBR',
+ bbrEnabled: 'BBR 已啟用',
+ // 安裝腳本
+ installScript: '安裝腳本',
+ runOnHost: '請在宿主機上以 root 權限執行以下命令:',
+ copyCommand: '複製命令',
+ step1RunScript: '執行安裝腳本',
+ step2Verify: '驗證並連線',
+ verifyHint: '腳本執行完成後,點擊下方按鈕驗證連線',
+ verifyAndConnect: '驗證並連線',
+ verifying: '驗證中...',
+ verifySuccess: '納管成功!',
+ verifyFailed: '驗證失敗',
+ reinstall: '重新安裝',
+ reinstallScript: '重新安裝腳本',
+ reinstallFailed: '產生安裝命令失敗',
+ waitingInstall: '等待安裝完成...',
+ installSuccess: '安裝成功!',
+ tokenExpired: 'Token 已過期,請重新建立宿主機',
+ // 詳情頁
+ tabInfo: '資訊',
+ tabConfig: '設定',
+ tabInstances: '實例',
+ tabStorage: '儲存',
+ tabImages: '映像檔',
+ tabOps: '運維',
+ tabCreate: '創建',
+ basicInfo: '基本資訊',
+ config: '設定',
+ resourceLimits: '資源限制',
+ transferControl: '轉移控制',
+ transferEnabled: '允許轉移',
+ transferEnabledHint: '關閉後,該節點上的實例將無法發起轉移請求',
+ extraConfig: '額外設定',
+ trafficConfig: '流量設定',
+ trafficResetDay: '流量重置日',
+ trafficResetDayHint: '每月何日重置實例流量(可設定 1-28)',
+ enableResourcePool: '參與資源池玩法',
+ enableResourcePoolHint: '開啟後,該節點的實例可參與簽到/抽獎資源應用',
+ announcement: '節點公告',
+ announcementPlaceholder: '在此輸入公告內容,將顯示在該節點下所有實例的詳情頁',
+ announcementHint: '留空則不顯示公告,支援換行',
+ probeUrl: '探針地址',
+ probeUrlPlaceholder: '輸入節點探針監控頁面地址',
+ probeUrlHint: '可以輸入此節點的探針、腳本測試結果等連結,配置後,用戶在選擇節點時可點擊圖標跳轉查看',
+ portRange: '連接埠範圍',
+ portsUsed: '已用連接埠',
+ recalculateResources: '對齊已用',
+ recalculateResourcesTip: '重新計算資源使用量,並將配額對齊到已用配額',
+ recalculateSuccess: '資源校對完成,配額已對齊',
+ recalculateNoChanges: '資源數據正確,無需修正',
+ recalculateFailed: '資源校對失敗',
+ ops: {
+ title: '宿主機運維中心',
+ description: '優先執行非破壞性盤點、基線同步與網路修復,用於舊節點納管補齊與狀態校準。',
+ discover: '實例盤點',
+ baselineSync: '基線同步',
+ networkRepair: '網路修復',
+ refresh: '刷新結果',
+ managed: '已納管實例',
+ orphaned: '未納管遺留實例',
+ missing: '資料庫異常實例',
+ summary: '摘要',
+ totalIncus: '宿主機實例',
+ totalDb: '資料庫實例',
+ managedCount: '已納管',
+ orphanedCount: '未納管',
+ missingCount: '異常缺失',
+ runSuccess: '操作執行成功',
+ runFailed: '操作執行失敗',
+ lastRunAt: '最近執行時間',
+ empty: '暫無結果,請先執行上方運維操作。',
+ sectionInventory: '宿主機實例盤點',
+ sectionRepair: '安全修復動作',
+ sectionReport: '執行結果',
+ resultInventory: '盤點結果',
+ resultBaseline: '基線同步結果',
+ resultNetwork: '網路修復結果',
+ resultPreview: '實例預檢結果',
+ resultInstanceSync: '單實例同步結果',
+ resultInstanceRestart: '單實例重啟結果',
+ resultDanger: '高風險動作結果',
+ instanceName: '實例名',
+ instanceType: '類型',
+ incusStatus: '宿主機狀態',
+ dbStatus: '資料庫狀態',
+ dbInstance: '資料庫實例',
+ changes: '變更數',
+ synced: '已同步',
+ failed: '失敗',
+ total: '總數',
+ ipv4: 'IPv4',
+ ipv6: 'IPv6',
+ details: '詳情',
+ noManaged: '未發現已納管實例',
+ noOrphaned: '未發現未納管遺留實例',
+ noMissing: '未發現資料庫異常實例',
+ baselineHint: '同步宿主機資源占用,並批次回填執行中實例的狀態與 IP。',
+ networkHint: '批次校準非刪除實例的狀態、IPv4 與 IPv6 記錄。',
+ discoverHint: '讀取宿主機當前所有 Incus 容器 / KVM,並與資料庫記錄對帳。',
+ selectInstanceHint: '從已納管實例中選擇一個對象,執行單實例同步、重啟或高風險動作。',
+ instancePanel: '單實例操作面板',
+ loadPreview: '載入預檢',
+ syncInstance: '同步實例',
+ safeRestart: '安全重啟',
+ forceRestart: '強制重啟',
+ dangerZone: '高風險操作區',
+ dangerHint: '這裡的動作會清空或替換實例系統資料,僅建議在盤點、同步與重啟均無法解決問題時使用。',
+ rebuild: '重裝目前實例',
+ recreate: '重建替換實例',
+ imageAlias: '映像別名',
+ imageAliasPlaceholder: '例如 ubuntu/22.04 或 debian/12',
+ selectImage: '映像選擇',
+ imagePlaceholder: '請選擇目標映像',
+ loadingImages: '正在載入可用映像...',
+ noImagesAvailable: '目前此節點暫無可用映像',
+ sshKeyId: 'SSH 金鑰 ID',
+ selectSshKey: 'SSH 金鑰',
+ sshKeyPlaceholder: '不指定 SSH 金鑰',
+ loadingSshKeys: '正在載入 SSH 金鑰...',
+ noSshKeysAvailable: '該實例使用者暫無 SSH 金鑰,不指定時將沿用後端預設處理',
+ sshKeyOptionalHint: '可選,不指定時使用實例使用者的預設金鑰策略',
+ customInitCommandIds: '自訂初始化命令 ID',
+ selectInitCommands: '初始化命令',
+ loadingInitCommands: '正在載入初始化命令...',
+ noInitCommandsAvailable: '目前映像沒有可選初始化命令',
+ optionalField: '可選項,可留空',
+ confirmDangerTitle: '執行前確認',
+ riskCheckbox: '我已知悉此操作可能導致實例系統或資料不可恢復,並確認這是預期行為。',
+ confirmTextHint: '請輸入實例真實名稱以確認,例如 u2-g65uoeo1',
+ ownerUserId: '使用者 ID',
+ fullInstanceName: '完整實例名稱',
+ selectedOnlyHint: '單實例操作僅對目前選取的實例生效,不會影響其他未選取的實例。',
+ localizedNone: '無需操作',
+ dangerActionType: '動作類型',
+ dangerConfirm1Title: '第一次確認高風險操作',
+ dangerConfirm1Hint: '請再次確認你即將執行不可逆的高風險動作。',
+ dangerConfirm1Btn: '確認繼續',
+ dangerConfirm2Title: '第二次確認高風險操作',
+ dangerConfirm2Hint: '這是最後一次確認,執行後將立即建立任務。',
+ dangerConfirm2Btn: '確認執行',
+ dangerConfirmStep: '確認步驟 {step} / {total}',
+ executeDangerAction: '執行高風險動作',
+ suggestedAction: '建議動作',
+ activeTask: '活躍任務',
+ latestInstanceAction: '最近單實例動作',
+ },
+ statusOnline: '線上',
+ statusOffline: '離線',
+ statusMaintenance: '維護中',
+ invalidId: '無效的節點 ID',
+ loadFailed: '載入失敗',
+ noInstances: '該節點上暫無實例',
+ search: '搜尋',
+ instanceSearchPlaceholder: '搜尋實例ID、名稱、使用者名稱、電郵、IP地址...',
+ imagesOnHost: '節點 {name} 上的映像檔',
+ noImagesOnHost: '該節點上暫無映像檔',
+ selectImagesToSync: '選擇要同步到節點 {name} 的映像檔',
+ allImagesSynced: '所有映像檔都已同步到該節點',
+ imagePolicy: {
+ title: '映像檔策略',
+ description: '設定節點「{name}」在開通與重裝時可選的映像檔範圍',
+ defaultMode: '使用面板預設映像檔',
+ defaultDesc: '不單獨限制此節點,開通與重裝時會依節點架構與實例類型載入全部可用映像檔。',
+ restrictedMode: '限制為以下映像檔',
+ restrictedDesc: '只有勾選的映像檔會出現在此節點的開通與重裝列表中。',
+ selectableImages: '可選映像檔',
+ defaultHint: '目前此節點未單獨限制映像檔。',
+ selectedCount: '目前已選擇 {count} 個映像檔',
+ searchPlaceholder: '搜尋映像檔名稱、別名或發行版...',
+ emptySelection: '限制模式下至少要選擇一個映像檔,否則請切回預設模式。',
+ noImages: '目前沒有符合此節點架構與實例類型的可見映像檔。',
+ loadFailed: '載入映像檔策略失敗',
+ saveSuccess: '映像檔策略已儲存',
+ saveFailed: '儲存映像檔策略失敗',
+ },
+ addHostDesc: '新增新的宿主機節點',
+ allocated: '已分配',
+ includesPageCache: '包含頁面快取',
+ syncTime: '同步時間',
+ deleteHost: '刪除宿主機',
+ deleteWarning: '此操作不可撤銷!刪除宿主機前,請確保該節點上沒有任何實例。',
+ deleteConfirmHint: '請輸入宿主機名稱 "{name}" 以確認刪除:',
+ enterHostName: '宿主機名稱',
+ confirmDeleteBtn: '確認刪除',
+ deleteNameMismatch: '輸入的名稱不匹配',
+ hasInstances: '該節點上還有 {count} 個實例,請先刪除或遷移實例',
+ checkFailed: '檢查失敗',
+ // 批次延期
+ batchExtend: '贈送時長',
+ extendHint: '該節點下共有 {count} 個付費實例將被延期',
+ extendDaysLabel: '延期天數',
+ extendDaysPlaceholder: '請輸入 1-365 的整數',
+ extendDaysInvalid: '請輸入有效的延期天數(1-365)',
+ confirmExtendBtn: '確認贈送',
+ extendSuccess: '已成功為 {count} 個付費實例延期 {days} 天',
+ extendFailed: '批次延期失敗',
+ // 批次刪除實例
+ selectedCount: '已選擇 {count} 個實例',
+ noInstanceSelected: '請勾選實例以使用批次操作',
+ batchDelete: '批次刪除',
+ batchDeleteTitle: '批次刪除實例',
+ batchDeleteWarning: '此操作不可撤銷!刪除後實例資料將無法復原。',
+ batchDeleteConfirm: '確定要刪除這 {count} 個實例嗎?',
+ confirmBatchDelete: '確認刪除',
+ databaseOnlyDelete: '僅資料庫刪除',
+ batchDeleteSuccess: '成功刪除 {count} 個實例',
+ batchDeletePartial: '成功刪除 {success} 個實例,{failed} 個失敗',
+ batchDeleteFailed: '批次刪除失敗',
+ batchDeleteRefundWarning: '刪除付費實例將自動退還剩餘價值給用戶,並從您的託管餘額扣除相應金額。',
+ batchDeleteRefundTotal: '總退款金額',
+ instanceName: '實例名稱',
+ instanceUser: '所屬用戶',
+ refundAmount: '退款金額',
+ // 批次同步實例狀態
+ batchSyncStatus: '同步狀態',
+ batchSyncSuccess: '成功同步 {synced} 個實例,{changed} 個狀態已更新',
+ batchSyncWithIpv4: '成功同步 {synced} 個實例,{changed} 個狀態更新,{ipv4Changed} 個內網 IP 更新',
+ batchSyncPartial: '同步 {synced} 個,更新 {changed} 個,失敗 {failed} 個',
+ batchSyncNoChange: '成功同步 {synced} 個實例,狀態無變化',
+ batchSyncFailed: '同步狀態失敗',
+ // 批次封停實例
+ batchSuspend: '批次封停',
+ batchSuspendTitle: '批次封停實例',
+ batchSuspendWarning: '封停後,實例所有者將無法對實例進行任何操作,直到解除封停。',
+ batchSuspendConfirm: '確定要封停這 {count} 個實例嗎?',
+ confirmBatchSuspend: '確認封停',
+ batchSuspendSuccess: '成功封停 {count} 個實例',
+ batchSuspendPartial: '成功封停 {success} 個實例,{failed} 個失敗',
+ batchSuspendFailed: '批次封停失敗',
+ batchUnsuspend: '批次解封',
+ batchUnsuspendSuccess: '成功解封 {count} 個實例',
+ batchUnsuspendPartial: '成功解封 {success} 個實例,{failed} 個失敗',
+ batchUnsuspendNone: '選中的實例中沒有已封停的實例',
+ batchUnsuspendFailed: '批次解封失敗',
+ suspendReason: '封停原因(選填)',
+ suspendReasonPlaceholder: '填寫封停原因後,系統將透過站內信通知實例所有者...',
+ deleteReason: '刪除原因(選填)',
+ deleteReasonPlaceholder: '填寫刪除原因後,系統將透過站內信和通知管道告知使用者...',
+ deleteReasonHint: '如果填寫了刪除原因,被刪除實例的所有者將收到通知',
+ // 實例流量
+ trafficUsage: '流量使用',
+ trafficUnlimited: '不限制',
+ resetTraffic: '重置流量',
+ trafficResetSuccess: '流量已重置',
+ trafficResetFailed: '重置流量失敗',
+ resetTrafficTitle: '重置實例流量',
+ resetTrafficWarning: '如果是已超額的實例,重置流量不會自動恢復實例的頻寬限速(1Mbps),請手動為該實例恢復頻寬速率。',
+ resetTrafficDesc: '確定要重置實例 "{instance}" 的流量嗎?',
+ // 節點流量統計
+ trafficStats: '流量統計',
+ monthlyUsed: '本月已用',
+ hostTotalLimit: '節點總分配',
+ // 宿主機狀態
+ agentStatusTitle: '宿主機狀態',
+ agentStatusDesc: 'Agent 按 {seconds} 秒間隔上報宿主機即時資源與執行狀態',
+ agentStatusRefreshSuccess: '狀態介面已重新整理,請以最後心跳時間為準',
+ agentStatusLoadFailed: '載入宿主機狀態失敗',
+ agentNotInstalled: '未安裝',
+ agentDisabled: '已停用',
+ agentOnline: '線上',
+ agentOffline: '離線',
+ agentUnknown: '未知',
+ agentVersionLatest: '最新',
+ agentVersionOutdated: '可升級',
+ agentVersionUnknown: '版本未知',
+ agentLatestVersion: '最新版本:{version}',
+ agentUpgradeClickHint: '點擊請求升級到 {version},Agent 會在下次心跳執行',
+ agentUpgradeRequestSuccess: '已請求升級,Agent 會在約 {seconds} 秒內的下一次心跳執行',
+ agentUpgradeRequestFailed: '請求 Agent 升級失敗',
+ agentAlreadyLatest: 'Agent 已是最新版本',
+ agentInstallCommand: '安裝/重裝 Agent',
+ agentInstallCommandTitle: 'Agent 安裝命令',
+ agentInstallCommandHint: '可在宿主機執行完整命令,也可在 Agent 選單中貼上該命令或其中的 ait_ token。',
+ agentInstallCommandConfirm: '產生新的 Agent 安裝命令會輪換該宿主機的 Agent 憑據。舊 Agent 會在重新安裝前無法繼續上報,確定繼續嗎?',
+ agentInstallTokenExpiresAt: '有效期至 {time}',
+ agentInstallCommandSuccess: 'Agent 安裝命令已產生',
+ agentInstallCommandFailed: '產生 Agent 安裝命令失敗',
+ agentInstallCommandCopied: 'Agent 安裝命令已複製',
+ agentNoRecordHint: '該宿主機還沒有 Agent 心跳記錄。重新安裝或安裝 Agent 後會自動上報。',
+ agentId: 'Agent ID',
+ agentLastSeen: '最後心跳',
+ agentHeartbeatIp: '心跳 IP',
+ agentReportedAt: '上報時間',
+ agentIncus: 'Incus 檢測',
+ agentIncusAvailable: '可用',
+ agentIncusUnavailable: '不可用',
+ agentCpuTotal: 'CPU 核心',
+ agentMemoryTotal: '記憶體總量',
+ agentUptime: '宿主機執行時間',
+ agentSocket: 'Incus Socket',
+ agentCpuUsage: 'CPU 使用率',
+ agentCpuCores: '核心',
+ agentMemoryUsage: '記憶體使用',
+ agentSwapUsage: 'SWAP 使用',
+ agentDiskUsage: '磁碟使用',
+ agentLoadAverage: '負載',
+ agentLoadAverageHint: '1 / 5 / 15 分鐘',
+ agentProcessCount: '處理程序數',
+ // 儲存池管理
+ storage: {
+ title: '儲存池',
+ subtitle: '管理此宿主機上的 Incus 儲存池',
+ create: '新增儲存池',
+ createTitle: '建立儲存池',
+ empty: '暫無儲存池',
+ loadFailed: '載入儲存池失敗',
+ createSuccess: '儲存池建立成功',
+ createFailed: '建立儲存池失敗',
+ deleteSuccess: '儲存池已刪除',
+ deleteFailed: '刪除儲存池失敗',
+ deleteConfirm: '確定刪除儲存池 "{name}"?此操作不可撤銷。',
+ updateSuccess: '儲存池已更新',
+ updateFailed: '更新儲存池失敗',
+ editTitle: '編輯儲存池',
+ currentSize: '目前大小',
+ newSize: '新大小',
+ newSizeHint: '僅支援擴容,不支援縮容',
+ nameRequired: '請輸入儲存池名稱',
+ sourceRequired: '請輸入儲存源(裝置路徑)',
+ sizeRequired: '請輸入儲存大小',
+ pathRequired: '請輸入目錄路徑',
+ poolName: '儲存池名稱',
+ driver: '驅動類型',
+ description: '描述',
+ source: '儲存源',
+ size: '儲存大小',
+ usedBy: '使用者',
+ volumes: '個卷',
+ // 驅動描述
+ zfsDesc: '推薦:功能最全(快照、複製、壓縮、配額),效能極佳',
+ lvmDesc: 'Linux 標配,極其穩定,建議開啟 Thin Provisioning',
+ btrfsDesc: '類似 ZFS 特性,單碟或 RAID1/10 可用',
+ dirDesc: '目錄儲存,效能最差,僅用於測試',
+ // 通用
+ useLoop: '使用 Loop 檔案',
+ loopSizeHint: '將在 /var/lib/incus/ 下建立映像檔檔案',
+ // ZFS
+ zfsSourceHint: '實體碟或分割區路徑,如 /dev/disk/by-id/nvme-xxx',
+ zfsPoolName: 'ZFS 池名稱',
+ zfsPoolNameHint: '選填,不填則使用儲存池名稱',
+ // LVM
+ lvmSourceHint: '實體碟路徑,如 /dev/sdb',
+ lvmVgName: '卷組名稱',
+ lvmUseThinpool: '啟用精簡卷(Thin Provisioning)',
+ lvmThinpoolHint: '強烈建議開啟,否則快照效能極差',
+ // Btrfs
+ btrfsSourceHint: '實體碟路徑,如 /dev/sdb',
+ // DIR
+ dirPath: '目錄路徑',
+ dirPathHint: '指定一個存在的目錄,如 /mnt/data/incus-storage',
+ // 儲存用途
+ purpose: '儲存用途',
+ forInstances: '用於實例系統碟',
+ forInstancesHint: '建立實例時作為預設儲存',
+ forVolumes: '用於實例儲存碟',
+ forVolumesHint: '可手動掛載到實例',
+ purposeSystemDisk: '系統碟',
+ purposeStorageDisk: '儲存碟',
+ // 模式切換
+ modeCreate: '建立新儲存池',
+ modeExisting: '關聯已有儲存池',
+ modeImport: '匯入已有儲存',
+ modeCreateHint: '在宿主機上建立新的儲存池,需要設定驅動類型、儲存源等參數',
+ modeExistingHint: '關聯已在宿主機上建立的儲存池,只需輸入儲存池名稱即可',
+ modeImportHint: '匯入底層已存在但 Incus 還未知曉的儲存池(如手動建立的 ZFS 池、LVM VG)',
+ linkSuccess: '儲存池關聯成功',
+ importSuccess: '儲存池匯入成功',
+ // 匯入儲存池
+ importZfsSource: 'ZFS 池名稱',
+ importLvmSource: 'LVM 卷組名稱',
+ importBtrfsSource: 'Btrfs 裝置或子卷路徑',
+ importDirSource: '目錄路徑',
+ importZfsHint: '底層已存在的 ZFS 池名稱(透過 zpool list 查看)',
+ importLvmHint: '底層已存在的 LVM 卷組名稱(透過 vgs 查看)',
+ importBtrfsHint: '已格式化為 Btrfs 的裝置或子卷路徑',
+ importDirHint: '已存在的目錄路徑,如 /mnt/storage',
+ importSourceRequired: 'Btrfs 和 DIR 類型必須提供底層儲存源路徑',
+ },
+ },
+ packages: {
+ title: '方案管理',
+ loadFailed: '載入方案失敗',
+ },
+ instances: '實例管理',
+ settings: '系統設定',
+ logs: '系統日誌',
+ stats: '統計資料',
+ nav: {
+ users: '使用者',
+ hosts: '主機',
+ packages: '方案',
+ instances: '實例',
+ settings: '設定',
+ logs: '日誌',
+ stats: '統計',
+ images: '映像檔',
+ redeemCodes: '兌換碼',
+ },
+ tabs: {
+ overview: '總覽',
+ users: '使用者',
+ hosts: '主機',
+ packages: '方案',
+ },
+ overview: {
+ title: '系統總覽',
+ totalUsers: '總使用者數',
+ totalHosts: '總主機數',
+ totalInstances: '總實例數',
+ totalPackages: '總方案數',
+ activeUsers: '活躍使用者',
+ onlineHosts: '線上主機',
+ runningInstances: '運行中實例',
+ systemHealth: '系統健康度',
+ },
+ user: {
+ list: '使用者列表',
+ create: '建立使用者',
+ edit: '編輯使用者',
+ delete: '刪除使用者',
+ search: '搜尋使用者...',
+ noUsers: '無使用者',
+ role: '角色',
+ status: '狀態',
+ verified: '已驗證',
+ unverified: '未驗證',
+ active: '啟用',
+ inactive: '停用',
+ banned: '已封禁',
+ lastLogin: '最後登入',
+ createdAt: '建立時間',
+ actions: '操作',
+ ban: '封禁',
+ unban: '解除封禁',
+ resetPassword: '重設密碼',
+ banConfirm: '確定要封禁此使用者嗎?',
+ unbanConfirm: '確定要解除封禁此使用者嗎?',
+ deleteConfirm: '確定要刪除此使用者嗎?此操作不可復原。',
+ banSuccess: '使用者已封禁',
+ unbanSuccess: '使用者已解除封禁',
+ deleteSuccess: '使用者已刪除',
+ roles: {
+ admin: '管理員',
+ user: '一般使用者',
+ },
+ },
+ // 用戶管理頁面資料(用於UsersView.vue)
+ users: {
+ title: '使用者管理',
+ description: '管理平台使用者與邀請碼',
+ searchPlaceholder: '搜尋使用者名稱、ID、電子郵件...',
+ searchRange: '搜尋範圍',
+ searchFieldUsername: '使用者名稱',
+ searchFieldId: 'ID',
+ searchFieldEmail: '電子郵件',
+ exactMatch: '絕對匹配',
+ allInstances: '所有實例',
+ promoteAdmin: '設為管理員',
+ demoteAdmin: '取消管理員',
+ confirmPromoteAdmin: '確定將使用者 "{name}" 設為管理員?該使用者需要重新登入後生效。',
+ confirmDemoteAdmin: '確定取消使用者 "{name}" 的管理員權限?該使用者目前會話會被撤銷。',
+ userPromotedAdmin: '已設為管理員',
+ userDemotedAdmin: '已取消管理員權限',
+ onlyActiveCanBeAdmin: '只有正常狀態的使用者可以設為管理員',
+ userVipLevels: '使用者 VIP 等級',
+ vipBenefits: '會員福利大廳設定',
+ // 邀請碼
+ invites: '邀請碼',
+ inviteCode: '邀請碼',
+ inviteStatus: '狀態',
+ createdBy: '建立者',
+ usedBy: '使用者',
+ usedExpireAt: '使用/過期時間',
+ noInvites: '暫無邀請碼',
+ noMatchingInvites: '暫無符合條件的邀請碼',
+ inviteFilterAll: '全部',
+ inviteFilterUsed: '已用',
+ inviteFilterUnused: '未用',
+ inviteUsed: '已使用',
+ inviteExpired: '已過期',
+ inviteUnused: '未使用',
+ permanent: '永久',
+ deleteInvite: '刪除',
+ confirmDeleteInvite: '確定刪除邀請碼 {code}?',
+ inviteDeleted: '邀請碼已刪除',
+ deleteFailed: '刪除失敗',
+ // 發送站內信
+ sendMessage: '發送站內信',
+ sendMessageTo: '發送站內信給 {username}',
+ messageTitle: '訊息標題',
+ messageTitlePlaceholder: '輸入訊息標題',
+ messageTitleRequired: '請輸入訊息標題',
+ messageContent: '訊息內容',
+ messageContentPlaceholder: '輸入訊息內容',
+ messageContentRequired: '請輸入訊息內容',
+ messageSent: '訊息已發送',
+ messageSendFailed: '發送失敗',
+ // 調整餘額
+ adjustBalance: '調整餘額',
+ adjustBalanceFor: '調整 {username} 的餘額',
+ adjustType: '調整類型',
+ addBalance: '增加餘額',
+ deductBalance: '扣除餘額',
+ amount: '金額',
+ amountPlaceholder: '請輸入金額',
+ adjustReason: '調整理由',
+ adjustReasonPlaceholder: '請輸入調整理由(必填)',
+ invalidAmount: '請輸入有效的金額',
+ reasonRequired: '請輸入調整理由',
+ balanceAdjusted: '餘額調整成功',
+ balanceAdjustFailed: '餘額調整失敗',
+ // 積分相關
+ points: '積分',
+ earned: '累計',
+ totalEarnedPoints: '累計獲得積分',
+ spent: '已用',
+ spentPoints: '已用積分',
+ adjustPoints: '調整積分',
+ currentPoints: '目前積分',
+ pointsAmount: '調整數量',
+ pointsAmountHint: '正數增加,負數扣除',
+ pointsAmountPlaceholder: '如:100 或 -50',
+ pointsReasonPlaceholder: '請輸入調整理由(必填)',
+ invalidPointsAmount: '請輸入有效的積分數量',
+ pointsAdjusted: '積分調整成功',
+ pointsAdjustFailed: '積分調整失敗',
+ // 托管餘額相關
+ hostingBalance: '托管餘額',
+ hostingBalanceDetails: '托管餘額詳情',
+ hostingBalanceOverview: '概覽',
+ hostingBalanceLogs: '明細',
+ adjustHostingBalance: '調整托管餘額',
+ viewHostingBalance: '查看托管餘額',
+ frozenHostingBalance: '凍結托管餘額',
+ availableBalance: '可用餘額',
+ frozenBalance: '凍結餘額',
+ frozen: '凍結中',
+ available: '可用',
+ operation: '操作類型',
+ operationAdd: '增加',
+ operationDeduct: '扣減',
+ hostingReasonPlaceholder: '請輸入調整理由(必填)',
+ hostingBalanceAdjusted: '托管餘額調整成功',
+ hostingBalanceAdjustFailed: '托管餘額調整失敗',
+ loadHostingLogsFailed: '載入托管餘額明細失敗',
+ logTime: '時間',
+ logType: '類型',
+ logAmount: '金額',
+ logStatus: '狀態',
+ logDescription: '描述',
+ hostingLogType: {
+ income: '收入',
+ deduction: '扣減',
+ unfreeze: '解凍',
+ withdraw: '提現',
+ admin_adjust: '管理員調整',
+ },
+ // 用戶餘額
+ balance: '餘額',
+ viewBalance: '查看餘額詳情',
+ consumed: '已消費',
+ totalConsumed: '已消費總金額',
+ balanceDetails: '餘額詳情',
+ balanceOverview: '帳戶概覽',
+ balanceLogs: '餘額明細',
+ rechargeRecords: '充值紀錄',
+ currentBalance: '目前餘額',
+ totalRecharge: '累計充值',
+ totalConsume: '累計消費',
+ noBalanceLogs: '暫無餘額變動',
+ noRechargeRecords: '暫無充值紀錄',
+ loadBalanceFailed: '載入餘額資訊失敗',
+ loadBalanceLogsFailed: '載入餘額明細失敗',
+ loadRechargeRecordsFailed: '載入充值紀錄失敗',
+ totalRecords: '共 {count} 條',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ balanceType: {
+ recharge: '充值',
+ consume: '消費',
+ refund: '退款',
+ admin_adjust: '管理員調整',
+ gift: '贈送',
+ transfer_fee: '移轉手續費',
+ transfer_refund: '手續費退還',
+ },
+ // 關聯帳號檢測
+ linkedAccounts: '關聯帳號檢測',
+ detectDays: '檢測範圍',
+ daysUnit: '天',
+ startDetect: '開始檢測',
+ detecting: '檢測中...',
+ detectingHint: '正在分析使用者資料,請稍候...',
+ clickToDetect: '點擊上方按鈕開始檢測關聯帳號',
+ loadLinkedAccountsFailed: '載入關聯帳號檢測失敗',
+ detectTime: '檢測時間',
+ detectDuration: '耗時',
+ detectRange: '檢測範圍',
+ ipGroupCount: '個 IP 關聯組',
+ emailGroupCount: '個郵箱相似組',
+ usernameGroupCount: '個使用者名稱相似組',
+ ipLinkedGroups: 'IP 關聯組',
+ emailSimilarGroups: '郵箱相似組',
+ usernameSimilarGroups: '使用者名稱相似組',
+ usersCount: '個使用者',
+ loginsCount: '次登入',
+ lastLoginAt: '最後登入',
+ noLinkedAccounts: '未檢測到關聯帳號,您的使用者非常純潔!',
+ registeredNew: '新',
+ registeredDays: '註冊 {days} 天',
+ // 用戶狀態
+ active: '正常',
+ banned: '已封禁',
+ createdAt: '建立時間',
+ },
+ // 支付渠道管理
+ paymentProviders: {
+ title: '支付渠道管理',
+ description: '設定和管理支付渠道',
+ add: '新增渠道',
+ create: '新增渠道',
+ empty: '暫無設定支付渠道',
+ noProviders: '暫無支付渠道',
+ createFirst: '新增第一個支付渠道',
+ providerName: '渠道名稱',
+ providerType: '渠道類型',
+ displayName: '顯示名稱',
+ description_label: '描述',
+ status: '狀態',
+ statusActive: '啟用',
+ statusDisabled: '停用',
+ statusTesting: '測試中',
+ feeRate: '費率',
+ feeFixed: '固定費用',
+ minAmount: '最小金額',
+ maxAmount: '最大金額',
+ sortOrder: '排序',
+ methods: '支付方式',
+ paymentMethods: '支援的支付方式',
+ configLabel: '設定',
+ configPlaceholder: 'JSON 格式的渠道設定',
+ edit: '編輯',
+ delete: '刪除',
+ name: '名稱',
+ nameRequired: '請輸入渠道名稱',
+ type: '類型',
+ confirmDelete: '確定刪除支付渠道 "{name}"?',
+ deleteWarning: '確定刪除支付渠道 "{name}"?此操作不可恢復。',
+ createSuccess: '支付渠道建立成功',
+ updateSuccess: '支付渠道更新成功',
+ deleteSuccess: '支付渠道已刪除',
+ statusUpdated: '狀態已更新',
+ statusUpdateSuccess: '狀態更新成功',
+ statusUpdateFailed: '狀態更新失敗',
+ saveFailed: '儲存失敗',
+ createFailed: '建立支付渠道失敗',
+ updateFailed: '更新支付渠道失敗',
+ deleteFailed: '刪除失敗',
+ loadFailed: '載入失敗',
+ createProvider: '新增支付渠道',
+ editProvider: '編輯支付渠道',
+ namePlaceholder: '輸入渠道名稱',
+ displayNamePlaceholder: '使用者看到的名稱',
+ descPlaceholder: '可選的渠道描述',
+ feeRateHint: '如 0.02 表示 2% 手續費',
+ notImplemented: '待實現',
+ providerTypes: {
+ yipay: '易支付',
+ heleket: 'Heleket',
+ stripe: 'Stripe',
+ alipayDirect: '支付寶直連',
+ wechatDirect: '微信直連',
+ manual: '人工充值',
+ },
+ deleteConfirm: '確認刪除',
+ types: {
+ alipay: '支付寶',
+ wechat: '微信支付',
+ stripe: 'Stripe',
+ paypal: 'PayPal',
+ usdt: 'USDT',
+ manual: '人工充值',
+ },
+ config: {
+ sdkVersion: 'SDK版本',
+ apiurl: '支付介面地址',
+ pid: '商戶ID',
+ key: '商戶密鑰',
+ platformPublicKey: '平台公鑰',
+ merchantPrivateKey: '商戶私鑰',
+ heleketMerchantUuid: 'Merchant UUID',
+ heleketApiKey: 'API Key',
+ heleketInvoiceCurrency: '發票法幣幣種',
+ heleketLifetime: '發票有效期(秒)',
+ heleketApiUrlHint: '預設使用 Heleket 官方介面地址,可按需切換到代理或私有網關。',
+ heleketCurrencyHint: '建立 Heleket 發票時使用的法幣幣種,常用為 CNY。',
+ heleketLifetimeHint: 'Heleket 發票有效期。預設 3600 秒,並會同步到本地訂單過期時間。',
+ yipayVersionV1: 'V1 (MD5簽名) - 傳統版本',
+ yipayVersionV2: 'V2 (RSA簽名) - 新版本',
+ yipayVersionV1Hint: 'V1 版本使用 MD5 簽名,適用於傳統易支付平台。',
+ yipayVersionV2Hint: 'V2 版本使用 RSA 簽名,適用於新版彩虹易支付。',
+ yipayApiUrlPlaceholder: '如: https://pay.example.com/',
+ yipayApiUrlHint: '支付介面地址,以 / 結尾。',
+ yipayPidPlaceholder: '商戶ID',
+ yipayKeyPlaceholder: '商戶密鑰',
+ yipayKeyHint: '易支付平台提供的商戶密鑰(一串字元)。',
+ platformPublicKeyPlaceholder: '平台公鑰(RSA)',
+ platformPublicKeyHint: '易支付平台提供的公鑰,用於驗證平台返回的簽名。',
+ merchantPrivateKeyPlaceholder: '商戶私鑰(RSA)',
+ merchantPrivateKeyHint: '您生成的 RSA 私鑰,用於對請求進行簽名。',
+ yipayMethodsHint: '選擇該渠道支援的支付方式,至少選擇一種。',
+ yipayMethodFeeHint: '手續費會依使用者選擇的支付方式加到應付金額中,充值本金仍按原金額入帳。',
+ yipayFeeFieldHint: '易支付手續費請在上方每個支付方式中設定。',
+ heleketMethods: '常見幣種展示',
+ heleketMethodsPlaceholder: 'USDT@TRON\nUSDT@BSC\nBTC\nETH',
+ heleketMethodsHint: '僅作為後台展示與紀錄參考,不會限制使用者在 Heleket 支付頁最終選擇的幣種和網路。',
+ instructions: '充值說明',
+ instructionsPlaceholder: '請輸入使用者充值時看到的說明資訊...',
+ },
+ },
+ // 計費管理
+ billingManage: {
+ title: '計費管理',
+ description: '管理使用者付費實例和計費紀錄',
+ tabOverview: '總覽',
+ tabInstances: '付費實例',
+ tabRecords: '扣費紀錄',
+ totalIncome: '總收入',
+ monthIncome: '本月收入',
+ todayIncome: '今日收入',
+ totalRefund: '總退款',
+ paidInstances: '付費實例數',
+ activeInstances: '活躍實例',
+ searchPlaceholder: '搜尋實例...',
+ instanceId: '實例ID',
+ instanceName: '名稱',
+ user: '使用者',
+ plan: '方案',
+ expiresAt: '到期時間',
+ status: '狀態',
+ actions: '操作',
+ noInstances: '暫無付費實例',
+ suspend: '封停',
+ unsuspend: '解封',
+ extend: '延期',
+ refund: '退款',
+ confirmSuspend: '確定封停實例 "{name}"?',
+ confirmUnsuspend: '確定解封實例 "{name}"?',
+ suspendSuccess: '實例已封停',
+ unsuspendSuccess: '實例已解封',
+ extendTitle: '延期實例',
+ extendDays: '延期天數',
+ extendReason: '延期原因',
+ freeExtend: '免費延期',
+ freeExtendHint: '勾選後不扣除使用者餘額',
+ extendSuccess: '實例已延期',
+ extendFailed: '延期失敗',
+ refundTitle: '退款實例',
+ refundAmount: '退款金額',
+ refundReason: '退款原因',
+ refundSuccess: '退款成功',
+ refundFailed: '退款失敗',
+ recordId: '紀錄ID',
+ recordType: '類型',
+ amount: '金額',
+ period: '帳期',
+ remark: '備註',
+ createdAt: '時間',
+ noRecords: '暫無扣費紀錄',
+ userBalance: '使用者餘額',
+ adjustBalance: '調整餘額',
+ giftBalance: '贈送餘額',
+ adjustTitle: '調整使用者餘額',
+ giftTitle: '贈送餘額',
+ adjustAmount: '調整金額',
+ giftAmount: '贈送金額',
+ adjustRemark: '調整原因',
+ giftRemark: '贈送備註',
+ adjustHint: '正數表示增加,負數表示扣除',
+ adjustSuccess: '餘額調整成功',
+ giftSuccess: '餘額贈送成功',
+ operationFailed: '操作失敗',
+ },
+ // 計費管理(新鍵名 billing,與元件對應)
+ billing: {
+ title: '計費管理',
+ description: '管理使用者付費實例和計費紀錄',
+ loadFailed: '載入計費資料失敗',
+ loadInstancesFailed: '載入實例列表失敗',
+ loadRecordsFailed: '載入扣費紀錄失敗',
+ // Tab 標籤
+ tabs: {
+ overview: '總覽',
+ instances: '付費實例',
+ records: '扣費紀錄',
+ rechargeRecords: '充值紀錄',
+ affConversions: 'AFF 轉化',
+ paymentProviders: '支付渠道',
+ },
+ // 總覽統計
+ totalRevenue: '總收入',
+ thisMonthRevenue: '本月收入',
+ todayRevenue: '今日收入',
+ totalRefunds: '總退款',
+ paidInstances: '付費實例數',
+ activeInstances: '活躍實例',
+ suspendedInstances: '已封停實例',
+ expiringInstances: '即將到期',
+ netRevenueLabel: '淨收入',
+ thisMonthVsLastMonth: '本月 / 上月',
+ hostedRevenueShare: '托管收入佔比',
+ revenueBreakdownTitle: '收入結構',
+ directRevenue: '自營收入',
+ hostedRevenue: '托管收入',
+ instanceHealthTitle: '實例狀態',
+ affOverviewTitle: 'AFF返利',
+ rechargeLabel: '充值',
+ revenueLabel: '收入',
+ overviewPeriods: {
+ total: '總覽',
+ thisMonth: '本月',
+ today: '今日',
+ },
+ // 實例列表
+ allStatus: '全部狀態',
+ allHosts: '全部節點',
+ showExpiring: '僅顯示即將到期',
+ showDateColumns: '顯示日期欄',
+ searchPlaceholder: '搜尋使用者/節點/實例名/方案/套餐...',
+ user: '使用者',
+ host: '節點',
+ plan: '方案',
+ package: '套餐',
+ price: '價格',
+ expiresAt: '到期時間',
+ purchaseDate: '購買日期',
+ remainingDays: '剩餘天數',
+ expired: '已過期',
+ days: '天',
+ instanceType: '實例類型',
+ instanceName: '實例名',
+ instanceStatus: '狀態',
+ noInstances: '暫無付費實例',
+ viewInstance: '查看',
+ autoRenew: '自動續費',
+ // 托管類型
+ hostingType: '托管類型',
+ direct: '直營',
+ hosted: '托管',
+ cycle: '週期',
+ cycleMonths: '{months}個月',
+ perPage: '每頁',
+ totalCount: '共 {count} 條',
+ // 操作
+ suspend: '封停',
+ unsuspend: '解封',
+ extend: '延期',
+ refund: '退款',
+ deleteRefund: '刪除並退款',
+ // 操作彈窗
+ suspendTitle: '封停實例',
+ unsuspendTitle: '解封實例',
+ extendTitle: '延期實例',
+ refundTitle: '退款',
+ deleteRefundTitle: '刪除並退款',
+ targetInstance: '目標實例',
+ reason: '原因',
+ reasonPlaceholder: '請輸入原因...',
+ extendDays: '延期天數',
+ freeExtend: '免費延期(不扣餘額)',
+ refundAmount: '退款金額',
+ refundReasonPlaceholder: '請輸入退款原因...',
+ refundReasonRequired: '請填寫退款原因',
+ // 刪除並退款相關
+ deleteRefundWarning: '警告:此操作將永久刪除實例及其所有資料(快照、備份、端口映射等),此操作不可撤銷!',
+ refundTypeLabel: '退款方式',
+ refundTypeRemaining: '按剩餘價值退款(根據剩餘天數計算)',
+ refundTypeFull: '全額退款(退還所有已消費金額)',
+ deleteRefundReasonPlaceholder: '請輸入刪除原因...',
+ deleteRefundReasonRequired: '請填寫刪除原因',
+ deleteRefundSuccess: '實例已刪除並退款',
+ databaseOnlyDelete: '僅數據庫刪除',
+ deleteRefundDatabaseOnlySuccess: '實例已從數據庫刪除',
+ // 應用折扣相關
+ applyDiscount: '應用折扣',
+ applyDiscountTitle: '應用續費折扣',
+ applyDiscountHint: '輸入AFF優惠碼後,該實例後續續費時將自動享受5%折扣,同時優惠碼創建者也將獲得續費返利。',
+ affCodeLabel: 'AFF優惠碼',
+ affCodePlaceholder: '請輸入AFF優惠碼...',
+ affCodeRequired: '請輸入AFF優惠碼',
+ applyDiscountSuccess: '已成功應用續費折扣',
+ // 修改價格相關
+ updatePrice: '修改價格',
+ owner: '擁有者',
+ currentPrice: '當前價格',
+ newPrice: '新價格',
+ enterNewPrice: '請輸入新價格',
+ priceHint: '此價格為單週期價格,修改後將影響續費費用',
+ settleBalance: '結算差價(根據剩餘天數補交/退還)',
+ needPay: '需補交',
+ willRefund: '將退還',
+ priceDiffHint: '根據剩餘 {days} 天計算,將自動處理用戶餘額',
+ noSettleHint: '不結算差價,僅修改後續續費價格,不影響當前週期餘額',
+ affDiscount: 'AFF折扣',
+ actualRenewPrice: '實際續費價',
+ affAppliedHint: '該實例已應用優惠碼,續費時享受 {discount}% 折扣。差價計算已基於折扣後價格。',
+ newActualPrice: '新實際續費價',
+ // 切換方案相關
+ upgradePlan: '切換',
+ upgradePlanTitle: '切換方案',
+ currentPlan: '當前方案',
+ planName: '方案名稱',
+ monthlyPrice: '月均價',
+ memoryLabel: '記憶體',
+ diskLabel: '磁碟',
+ selectNewPlan: '選擇新方案',
+ noAvailablePlans: '沒有可升級的方案(新方案月均價需高於當前方案)',
+ priceDifference: '補差價',
+ priceDifferenceHint: '根據剩餘天數計算,將從用戶餘額中扣除',
+ userBalance: '用戶餘額',
+ insufficientBalance: '用戶餘額不足,無法完成升級',
+ confirmUpgrade: '確認升級',
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ yearly: '年付',
+ month: '月',
+ months: '個月',
+ // 操作結果
+ suspendSuccess: '實例已封停',
+ unsuspendSuccess: '實例已解封',
+ extendSuccess: '實例已延期',
+ refundSuccess: '退款成功',
+ batchUpdatePrice: '批量修改價格',
+ batchSelectedCount: '已選擇 {count} 個實例',
+ selectAllCurrentPage: '選擇目前頁實例',
+ selectInstance: '選擇實例',
+ batchPriceNoSelection: '請先選擇實例',
+ batchSelected: '已選實例',
+ batchPreviewChanged: '將更新',
+ batchPreviewFailed: '失敗項',
+ batchPriceHint: '批量使用同一個單週期價格;預覽會按每個實例的週期、剩餘天數和AFF折扣分別計算。',
+ batchTotalCharge: '合計補交',
+ batchTotalRefund: '合計退還',
+ batchNetAmount: '淨影響',
+ batchPreviewBlocked: '目前預覽包含失敗項或用戶餘額不足,不能提交。',
+ batchUserImpact: '用戶餘額影響',
+ batchPreviewDetails: '預覽明細',
+ batchPreviewWaiting: '輸入價格後將自動產生預覽。',
+ batchPriceStatusReady: '可更新',
+ batchPriceStatusUnchanged: '無變化',
+ batchPriceStatusFailed: '失敗',
+ result: '結果',
+ // 扣費紀錄
+ recordType: '類型',
+ recordTypes: {
+ newPurchase: '新購',
+ renew: '續費',
+ upgrade: '升級',
+ downgrade: '降級',
+ refund: '退款',
+ transfer_fee: '移轉手續費',
+ },
+ amount: '金額',
+ instance: '實例',
+ remark: '備註',
+ time: '時間',
+ noRecords: '暫無扣費紀錄',
+ // 充值統計
+ totalRecharge: '總充值',
+ thisMonthRecharge: '本月充值',
+ todayRecharge: '今日充值',
+ orders: '筆',
+ // AFF 返利統計
+ totalAffCommission: 'AFF返利總額',
+ thisMonthAff: '本月AFF返利',
+ affConverted: '已轉化金額',
+ affPendingConvert: '待審批轉化',
+ // 充值紀錄
+ loadRechargeRecordsFailed: '載入充值紀錄失敗',
+ noRechargeRecords: '暫無充值紀錄',
+ rechargeOrderNo: '訂單號',
+ creditAmount: '到帳金額',
+ actualAmount: '實際到帳',
+ estimatedAmount: '預計到帳',
+ payChannel: '支付渠道',
+ paymentDetails: '支付詳情',
+ paymentUuid: 'UUID:',
+ paymentTxid: 'TxID:',
+ rechargeStatusLabel: '狀態',
+ tradeNo: '第三方單號',
+ sync: '同步',
+ syncSuccess: '同步成功,充值已到帳',
+ rechargeStatus: {
+ pending: '待支付',
+ completed: '已完成',
+ cancelled: '已取消',
+ failed: '失敗',
+ },
+ },
+ },
+
+ // 錯誤頁面
+ error: {
+ notFound: '頁面不存在',
+ notFoundMessage: '您尋找的頁面不存在或已被移動。',
+ serverError: '伺服器錯誤',
+ serverErrorMessage: '抱歉,伺服器發生錯誤,請稍後再試。',
+ unauthorized: '未授權',
+ unauthorizedMessage: '您沒有權限存取此頁面。',
+ forbidden: '禁止存取',
+ forbiddenMessage: '您沒有權限執行此操作。',
+ goHome: '返回首頁',
+ goBack: '返回上一頁',
+ tryAgain: '重試',
+ },
+
+ // 時間格式化
+ time: {
+ justNow: '剛剛',
+ minutesAgo: '{n} 分鐘前',
+ hoursAgo: '{n} 小時前',
+ daysAgo: '{n} 天前',
+ weeksAgo: '{n} 週前',
+ monthsAgo: '{n} 個月前',
+ yearsAgo: '{n} 年前',
+ inMinutes: '{n} 分鐘後',
+ inHours: '{n} 小時後',
+ inDays: '{n} 天後',
+ inWeeks: '{n} 週後',
+ inMonths: '{n} 個月後',
+ inYears: '{n} 年後',
+ },
+
+ // 單位
+ units: {
+ bytes: 'B',
+ kilobytes: 'KB',
+ megabytes: 'MB',
+ gigabytes: 'GB',
+ terabytes: 'TB',
+ bitsPerSecond: 'bps',
+ kilobitsPerSecond: 'Kbps',
+ megabitsPerSecond: 'Mbps',
+ gigabitsPerSecond: 'Gbps',
+ percentage: '%',
+ cores: '核心',
+ vcpu: 'vCPU',
+ },
+
+ // API 錯誤碼翻譯
+ errors: {
+ // 通用錯誤
+ INVALID_ID: '無效的 ID',
+ NOT_FOUND: '資源不存在',
+ UNAUTHORIZED: '未授權',
+ FORBIDDEN: '無權存取',
+ ADMIN_REQUIRED: '需要管理員權限',
+ // 使用者錯誤
+ USER_NOT_FOUND: '使用者不存在',
+ USER_EXISTS: '使用者名稱已存在',
+ CANNOT_MODIFY_SELF: '無法修改自己的狀態',
+ CANNOT_DELETE_SELF: '無法刪除自己',
+ CANNOT_BAN_ADMIN: '無法封禁管理員帳號',
+ CANNOT_DELETE_ADMIN: '無法刪除管理員帳號',
+ USER_HAS_INSTANCES: '該使用者還有實例,請先刪除實例',
+ // 認證錯誤
+ INVALID_CREDENTIALS: '使用者名稱或密碼錯誤',
+ ACCOUNT_BANNED: '帳號已被停用',
+ TOO_MANY_ATTEMPTS: '登入嘗試次數過多,請稍後再試',
+ REGISTRATION_DISABLED: '目前已關閉註冊',
+ INVALID_INVITE_CODE: '邀請碼無效或已使用',
+ INVITE_CODE_EXPIRED: '邀請碼已過期',
+ INVALID_2FA_CODE: '驗證碼或恢復碼錯誤',
+ TWO_FA_REQUIRED: '請輸入雙重驗證碼',
+ TWO_FA_ALREADY_ENABLED: '2FA 已啟用,請先停用後再重新設定',
+ TWO_FA_NOT_ENABLED: '2FA 未啟用',
+ REFRESH_TOKEN_MISSING: '缺少重新整理權杖',
+ REFRESH_TOKEN_INVALID: '重新整理權杖無效或已過期',
+ SESSION_NOT_FOUND: '會話不存在',
+ // 驗證錯誤
+ INVALID_EMAIL: '請輸入有效的電子郵件地址',
+ EMAIL_CONTAINS_ILLEGAL: '電子郵件包含非法字元',
+ USERNAME_CONTAINS_ILLEGAL: '使用者名稱包含非法字元',
+ PASSWORD_TOO_WEAK: '密碼強度不足',
+ INVALID_SSH_KEY: '無效的 SSH 公鑰格式',
+ SSH_KEY_EXISTS: '該公鑰已新增',
+ INVALID_NAME: '名稱格式不正確',
+ // 實例錯誤
+ INSTANCE_NOT_FOUND: '實例不存在',
+ INSTANCE_ALREADY_RUNNING: '實例已在運行',
+ INSTANCE_ALREADY_STOPPED: '實例已停止',
+ INSTANCE_STATUS_INVALID: '實例狀態不允許此操作',
+ INSTANCE_SUSPENDED: '實例已被封停,無法執行此操作',
+ INSTANCE_NOT_SUSPENDED: '實例未處於封停狀態',
+ INSTANCE_SUSPENDED_EXPIRED: '實例因到期被封停,請續費後解封',
+ INSTANCE_DESTROY_TRAFFIC_LIMIT_EXCEEDED: '當前月流量週期無法銷毀,已用流量達到或超過 5G',
+ // 主機錯誤
+ HOST_NOT_FOUND: '主機不存在',
+ HOST_OFFLINE: '主機離線',
+ HOST_HAS_INSTANCES: '主機上有實例,請先刪除',
+ HOST_ALREADY_OFFICIAL: '此節點已經是自營節點',
+ HOST_TAKEOVER_PACKAGE_BINDING_CONFLICT: '接管失敗,部分方案會失去全部綁定節點',
+ NO_AVAILABLE_HOSTS: '沒有可用的節點',
+ // 映像檔錯誤
+ IMAGE_NOT_FOUND: '映像檔不存在',
+ IMAGE_SYNCED_ON_HOSTS: '映像檔已在節點上同步,請先從節點刪除',
+ IMAGE_TYPE_MISMATCH: '所選映像檔與方案實例類型不相容',
+ // 方案錯誤
+ PACKAGE_NOT_FOUND: '方案不存在',
+ PACKAGE_IN_USE: '方案正在被實例使用',
+ // 配額錯誤
+ QUOTA_EXCEEDED: '配額不足',
+ QUOTA_CPU_EXCEEDED: 'CPU 配額不足',
+ QUOTA_MEMORY_EXCEEDED: '記憶體配額不足',
+ QUOTA_DISK_EXCEEDED: '磁碟配額不足',
+ QUOTA_INSTANCE_EXCEEDED: '實例數量已達上限',
+ QUOTA_PORT_EXCEEDED: '連接埠配額不足',
+ QUOTA_SNAPSHOT_EXCEEDED: '快照配額不足',
+ QUOTA_BACKUP_EXCEEDED: '備份配額不足',
+ QUOTA_NOT_ALLOCATED: '請先分配配額',
+ // 快照錯誤
+ SNAPSHOT_NOT_FOUND: '快照不存在',
+ SNAPSHOT_RESTORE_REQUIRES_STOP: '請先停止實例再還原快照',
+ // 備份錯誤
+ BACKUP_NOT_FOUND: '備份不存在',
+ BACKUP_NOT_READY: '備份尚未就緒,無法匯出',
+ EXPORT_TASK_NOT_FOUND: '匯出任務不存在或已過期',
+ EXPORT_TASK_EXPIRED: '匯出任務已過期',
+ // 連接埠映射錯誤
+ PORT_IN_USE: '該連接埠已被使用',
+ PORT_OUT_OF_RANGE: '連接埠超出允許範圍',
+ PORT_MAPPING_NOT_FOUND: '連接埠映射不存在',
+ // 節點組錯誤
+ NODE_GROUP_NOT_FOUND: '節點組不存在',
+ NODE_GROUP_HAS_HOSTS: '節點組下有主機,請先移除',
+ NODE_GROUP_HAS_PACKAGES: '節點組被方案使用中',
+ // 通知錯誤
+ NOTIFICATION_CHANNEL_NOT_FOUND: '通知管道不存在',
+ // 幫助錯誤
+ ARTICLE_NOT_FOUND: '文件不存在',
+ SLUG_EXISTS: 'Slug 已存在',
+ // 邀請碼錯誤
+ INVITE_CODE_USED: '邀請碼已被使用',
+ INVITE_CODE_NOT_FOUND: '邀請碼不存在',
+ // OAuth 錯誤
+ OAUTH_PROVIDER_DISABLED: '該登入方式已被停用',
+ OAUTH_ALREADY_BOUND: '該帳號已被其他使用者綁定',
+ OAUTH_NOT_BOUND: '帳號未綁定',
+ OAUTH_TOKEN_ERROR: '取得授權失敗',
+ // SSH 金鑰錯誤
+ SSH_KEY_NOT_FOUND: 'SSH 金鑰不存在',
+ SSH_KEY_REQUIRED: '必須選擇一個 SSH 金鑰,請先在個人設定中新增',
+ SSH_KEY_NOT_OWNED: 'SSH 金鑰不存在或不屬於該使用者',
+ // 方案錯誤(補充)
+ PACKAGE_UNAVAILABLE: '方案不可用或已下架',
+ CANNOT_CREATE_OWN_PAID_PACKAGE: '不能使用自己的付費方案建立實例',
+ // 節點資源錯誤
+ HOST_UNAVAILABLE: '選擇的主機不可用或資源不足',
+ HOST_NO_ONLINE: '沒有線上的主機,請先在節點管理中測試連線',
+ HOST_RESOURCES_NOT_SYNCED: '主機資源資訊未同步,請在節點管理中點擊「測試連線」',
+ HOST_NODE_GROUP_NO_HOSTS: '方案要求的節點組中沒有可用節點',
+ HOST_RESOURCES_INSUFFICIENT: '所有主機資源不足,請稍後再試或選擇其他方案',
+ HOST_NAME_EXISTS: '主機名稱已存在',
+ HOST_ADDRESS_EXISTS: '主機連線地址已存在',
+ HOST_ADDRESS_UNRESOLVABLE: '面板目前無法解析該主機連線地址',
+ HOST_CERT_NOT_CONFIGURED: '請先設定憑證路徑',
+ HOST_INVALID_CPU_MAX: 'CPU 最大額配不能為負數',
+ HOST_INVALID_MEMORY_MAX: '記憶體最大值不能為負數或低於 256MB',
+ HOST_INVALID_IPV6_MODE: 'IPv6 模式必須是 1(路由)、2(NAT) 或 3(停用)',
+ HOST_IPV6_ROUTE_REQUIRES_CONFIG: 'IPv6 路由模式需要提供子網路和父介面',
+ HOST_CPU_BELOW_USED: 'CPU 最大額配不能低於實例已使用的資源',
+ HOST_MEMORY_BELOW_USED: '記憶體最大值不能低於實例已使用的資源',
+ // 實例操作錯誤
+ INSTANCE_STOP_REQUIRED: '請先停止實例',
+ INSTANCE_IMAGE_REQUIRED: '請指定映像檔',
+ INSTANCE_IMAGE_UNAVAILABLE: '選擇的映像檔在目前節點不可用',
+ INSTANCE_REBUILD_FAILED: '重裝系統失敗',
+ INSTANCE_IPV6_NOT_SUPPORTED: '只有 NAT + IPv6 模式的實例才能重新分配 IPv6',
+ INSTANCE_IPV6_REASSIGN_FAILED: '重新分配 IPv6 位址失敗',
+ INSTANCE_NO_IPV4: '虛擬機實例需要分配 IPv4 位址才能新增連接埠映射',
+ HOST_NO_IPV6_SUBNET: '主機未設定 IPv6 子網路',
+ IPV6_POOL_EXHAUSTED: 'IPv6 位址池已耗盡,請稍後重試',
+ IPV6_REASSIGN_COOLDOWN: '每實例每天只能重新分配一次 IPv6,請稍後再試',
+ // 連接埠映射錯誤(補充)
+ PORT_MAPPING_NAT_ONLY: '僅 NAT 和雙棧模式支援連接埠映射',
+ PORT_NO_AVAILABLE: '沒有可用連接埠,請聯繫管理員',
+ PORT_CONFLICT: '連接埠已被佔用,請使用其他連接埠',
+ PORT_MAPPING_INVALID_ID: '無效的實例或連接埠映射 ID',
+ // 映像檔錯誤(補充)
+ IMAGE_CREATE_FAILED: '映像檔建立失敗',
+ IMAGE_UPDATE_FAILED: '映像檔更新失敗',
+ IMAGE_INVALID_HOST_ID: '無效的映像檔或節點 ID',
+ // 備份錯誤(補充)
+ BACKUP_CREATE_FAILED: '建立備份失敗',
+ BACKUP_DELETE_FAILED: '刪除備份失敗',
+ BACKUP_EXPORT_FAILED: '匯出備份失敗',
+ BACKUP_QUOTA_NOT_SET: '請先為該實例設定備份配額',
+ BACKUP_EXPORT_STATUS_INVALID: '匯出任務狀態異常',
+ // 設定錯誤
+ CONFIG_INVALID_KEY: '無效的設定鍵',
+ CONFIG_INVALID_VALUE: '設定值必須是非負整數',
+ // 快照錯誤(補充)
+ SNAPSHOT_QUOTA_NOT_SET: '請先為該實例設定快照配額',
+ // 方案錯誤(補充)
+ PACKAGE_HAS_INSTANCES: '該方案正在被實例使用,無法刪除',
+ // 資源限制錯誤
+ RESOURCE_CPU_EXCEEDS_PACKAGE: 'CPU 設定超出方案限制',
+ RESOURCE_MEMORY_EXCEEDS_PACKAGE: '記憶體設定超出方案限制',
+ RESOURCE_DISK_EXCEEDS_PACKAGE: '磁碟設定超出方案限制',
+ // 使用者配額錯誤(詳細)
+ QUOTA_CPU_INSUFFICIENT: 'CPU 配額不足',
+ QUOTA_MEMORY_INSUFFICIENT: '記憶體配額不足',
+ QUOTA_DISK_INSUFFICIENT: '磁碟配額不足',
+ QUOTA_INSTANCE_LIMIT_REACHED: '實例數量已達上限',
+ QUOTA_HOST_LIMIT_REACHED: '主機數量已達上限',
+ QUOTA_FRIEND_LIMIT_REACHED: '您的好友數量已達上限',
+ QUOTA_PORT_BELOW_USED: '連接埠配額不能小於目前已使用量',
+ QUOTA_SNAPSHOT_BELOW_USED: '快照配額不能小於目前已使用量',
+ QUOTA_BACKUP_BELOW_USED: '備份配額不能小於目前已使用量',
+ QUOTA_PORT_TOTAL_EXCEEDED: '連接埠配額超出使用者總配額',
+ QUOTA_SNAPSHOT_TOTAL_EXCEEDED: '快照配額超出使用者總配額',
+ QUOTA_BACKUP_TOTAL_EXCEEDED: '備份配額超出使用者總配額',
+ // 映像檔錯誤(補充)
+ IMAGE_NOT_SYNCED: '映像檔尚未同步到所選主機',
+ IMAGE_SYNCING_CANNOT_DELETE: '映像檔正在同步中,無法刪除',
+ IMAGE_NO_HOSTS: '沒有可用的節點,請先新增節點',
+ // 連接埠錯誤(補充)
+ PORT_RANGE_INVALID: '連接埠必須在允許範圍內',
+ // 快照策略錯誤
+ SNAPSHOT_MANUAL_FULL: '手動快照已達實例配額,無法啟用自動快照',
+ SNAPSHOT_RETENTION_EXCEEDS: '保留數量超出可用配額',
+ // 備份策略錯誤
+ BACKUP_MANUAL_FULL: '手動備份已達實例配額,無法啟用自動備份',
+ BACKUP_RETENTION_EXCEEDS: '保留數量超出可用配額',
+ // OAuth 錯誤(補充)
+ OAUTH_NOT_ENABLED: '該登入方式未啟用',
+ // 儲存池錯誤
+ STORAGE_POOL_NOT_CONFIGURED: '主機未設定系統磁碟儲存池,無法建立實例',
+ // 好友系統錯誤
+ CANNOT_ADD_SELF: '無法將自己加為好友',
+ ALREADY_FRIENDS: '你們已經是好友了',
+ FRIEND_REQUEST_PENDING: '好友請求已在待處理中',
+ FRIEND_REQUEST_NOT_FOUND: '好友請求不存在',
+ FRIEND_REQUEST_NOT_PENDING: '該請求已被處理',
+ FRIENDSHIP_NOT_FOUND: '好友關係不存在',
+ TARGET_FRIEND_QUOTA_FULL: '對方好友數量已達上限,無法新增',
+ // 方案共享錯誤
+ CANNOT_SHARE_TO_SELF: '無法共享給自己',
+ PACKAGE_ALREADY_SHARED: '方案已共享給該使用者',
+ SHARE_NOT_FOUND: '共享記錄不存在',
+ NOT_FRIENDS: '對方不是您的好友',
+ SHARE_QUOTA_CPU_EXCEEDED: '共享方案 CPU 配額已用完',
+ SHARE_QUOTA_MEMORY_EXCEEDED: '共享方案記憶體配額已用完',
+ SHARE_QUOTA_INSTANCES_EXCEEDED: '共享方案實例數量已達上限',
+ // 電子郵件驗證錯誤
+ EMAIL_VERIFICATION_DISABLED: '電子郵件驗證功能未啟用',
+ EMAIL_CODE_REQUIRED: '請輸入電子郵件驗證碼',
+ INVALID_EMAIL_CODE: '驗證碼無效或已過期',
+ TOO_MANY_VERIFICATION_REQUESTS: '驗證碼請求過於頻繁,請稍後再試',
+ EMAIL_SEND_FAILED: '驗證郵件發送失敗,請稍後再試',
+ EMAIL_ALREADY_REGISTERED: '該電子郵件地址已被使用',
+ EMAIL_DOMAIN_NOT_ALLOWED: '該電子郵件網域不允許註冊,請使用其他信箱',
+ // 移轉錯誤
+ TRANSFER_NOT_FOUND: '移轉請求不存在',
+ TRANSFER_TO_SELF: '無法移轉給自己',
+ TRANSFER_TO_BANNED: '無法移轉給被封禁的使用者',
+ TRANSFER_ALREADY_PENDING: '該實例已有待處理的移轉請求',
+ TRANSFER_NOT_PENDING: '移轉請求不在等待狀態',
+ TRANSFER_INVALID_STATUS: '實例狀態不允許移轉',
+ TRANSFER_QUOTA_NOT_FOUND: '接收方配額資訊不存在',
+ TRANSFER_QUOTA_INSUFFICIENT: '接收方配額不足',
+ TRANSFER_INSTANCE_LOCKED: '實例正在移轉中,暫時無法操作',
+ TRANSFER_HOST_DISABLED: '該節點已禁止移轉操作',
+ TRANSFER_INSUFFICIENT_BALANCE: '餘額不足,無法支付移轉手續費',
+ // 移轉過程錯誤
+ INSTANCE_MUST_BE_STOPPED: '實例必須在移轉前停止',
+ INCUS_RENAME_FAILED: '在主機上重新命名實例失敗',
+ DATABASE_ERROR: '資料庫操作失敗,請重試',
+ // 敏感操作二次驗證錯誤
+ VERIFICATION_REQUIRED: '此操作需要二次驗證',
+ INVALID_CODE: '驗證碼無效或已過期',
+ NO_NOTIFICATION_CHANNEL: '未綁定通知管道,無需二次驗證',
+ EMAIL_NOT_CONFIGURED: '未設定電子郵件,無法發送驗證碼',
+ SEND_FAILED: '驗證碼發送失敗',
+ // 簽到錯誤
+ CHECKIN_NO_INSTANCE: '您需要至少擁有一個實例才能簽到',
+ CHECKIN_ALREADY_TODAY: '今日已簽到',
+ REDEEM_ALREADY_TODAY: '今日已兌換過兌換碼',
+ REDEEM_CODE_NOT_FOUND: '兌換碼不存在',
+ REDEEM_CODE_USED: '兌換碼已被使用',
+ REDEEM_CODE_EXPIRED: '兌換碼已過期',
+ REDEEM_CODE_SELF_ONLY: '此兌換碼只能由擁有者自己使用',
+ REDEEM_CODE_DISABLED: '此兌換碼已被停用',
+ REDEEM_CODE_INVALID_FORMAT: '兌換碼格式錯誤,僅支援 h- 開頭的系統兌換碼',
+ REDEEM_CODE_EXHAUSTED: '此兌換碼已達到最大使用次數',
+ REDEEM_CODE_ALREADY_USED_BY_USER: '您已使用過此兌換碼',
+ REDEEM_CODE_HOST_MISMATCH: '此兌換碼只能用於同一節點的實例',
+ REDEEM_CODE_BATCH_LIMIT: '您已使用過該批次的其他兌換碼',
+ REDEEM_EXCEEDS_PACKAGE_QUOTA: '兌換後將超出實例方案配額限制',
+ REDEEM_ALREADY_AT_LIMIT: '實例資源已達到方案上限',
+ CHECKIN_CODE_PAID_INSTANCE: '簽到兑換碼只能用於免費實例',
+ PAID_INSTANCE_DELETION_NOT_ALLOWED: '付費實例不允許刪除',
+ // 餘額錯誤
+ INSUFFICIENT_BALANCE: '餘額不足,請先充值',
+ INTERNAL_ERROR: '伺服器內部錯誤',
+ },
+
+ // 流量統計
+ traffic: {
+ title: '流量統計',
+ monthlyUsage: '本月流量',
+ used: '已使用',
+ unlimited: '無限制',
+ total: '總計',
+ history30Days: '近 30 天流量',
+ historyPeriod: '週期流量',
+ noData: '暫無流量資料',
+ noHistoryData: '暫無歷史資料',
+ throttledHint: '已限速至 1Mbps',
+ resetHint: '每月 1 號重置流量',
+ periodResetHint: '每月 {date} 號重置流量',
+ status: {
+ normal: '正常',
+ warning: '預警',
+ limited: '已限速',
+ },
+ download: '下載',
+ upload: '上傳',
+ limit: '限額',
+ extraQuota: '額外配額',
+ resetDate: '重設日期',
+ nextReset: '下次重設',
+ },
+
+ // 實例移轉
+ transfer: {
+ title: '實例移轉',
+ sentTab: '移轉記錄',
+ receivedTab: '接收記錄',
+ pendingCount: '待處理',
+ noTransfers: '暫無移轉記錄',
+ noPendingTransfers: '暫無待接收的移轉',
+ searchPlaceholder: '搜尋實例名稱、接收方使用者名稱或備註...',
+ // 狀態
+ status: {
+ pending: '等待接收',
+ processing: '處理中',
+ accepted: '已接收',
+ rejected: '已拒絕',
+ cancelled: '已取消',
+ },
+ // 操作
+ actions: {
+ transfer: '移轉',
+ accept: '接受',
+ reject: '拒絕',
+ cancel: '取消',
+ push: '直接推送',
+ },
+ // 完成時間
+ completedAt: '完成時間',
+ rejectedAt: '拒絕時間',
+ cancelledAt: '取消時間',
+ // 移轉彈窗
+ modal: {
+ title: '移轉實例',
+ targetUser: '接收方使用者名稱',
+ targetUserPlaceholder: '輸入接收方使用者名稱',
+ searchUser: '搜尋使用者',
+ userNotFound: '使用者不存在',
+ userBanned: '該使用者已被封禁',
+ cannotTransferToSelf: '無法移轉給自己',
+ remark: '備註(選填)',
+ remarkPlaceholder: '輸入移轉備註',
+ quotaCheck: '配額檢查',
+ instance: '實例',
+ quotaSufficient: '配額充足',
+ quotaInsufficient: '配額不足',
+ canTransfer: '使用者狀態正常,可以進行移轉操作',
+ confirmTransfer: '確認移轉',
+ transferring: '移轉中...',
+ deleteWarning: '注意:如果對方接收移轉,系統將自動刪除該實例的連接埠映射、建站反向代理、快照、備份等關聯資源。',
+ feeLabel: '移轉手續費',
+ balanceLabel: '當前餘額',
+ insufficientBalance: '餘額不足,請先充值',
+ feeRefundHint: '確認移轉時將扣除手續費,對方拒絕接收將自動退還',
+ },
+ // 拒絕彈窗
+ rejectModal: {
+ title: '拒絕移轉',
+ reason: '拒絕原因(選填)',
+ reasonPlaceholder: '輸入拒絕原因',
+ },
+ // 設定詳情彈窗
+ configModal: {
+ title: '移轉時設定詳情',
+ instanceName: '實例名稱',
+ hostInfo: '主機資訊',
+ networkMode: '網路模式',
+ portMappings: '連接埠映射',
+ snapshots: '快照',
+ backups: '備份',
+ package: '方案',
+ },
+ hasRemark: '有備註',
+ // 詳情
+ detail: {
+ fromUser: '發起方',
+ toUser: '接收方',
+ instance: '實例',
+ snapshot: '移轉時設定',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '磁碟',
+ ports: '連接埠映射',
+ snapshots: '快照',
+ backups: '備份',
+ host: '節點',
+ package: '方案',
+ remark: '備註',
+ rejectReason: '拒絕原因',
+ createdAt: '發起時間',
+ acceptedAt: '接收時間',
+ rejectedAt: '拒絕時間',
+ cancelledAt: '取消時間',
+ },
+ // 提示訊息
+ messages: {
+ transferSuccess: '移轉請求已發送',
+ transferComplete: '移轉完成',
+ acceptSuccess: '已接受移轉',
+ rejectSuccess: '已拒絕移轉',
+ cancelSuccess: '已取消移轉',
+ pushSuccess: '已成功推送實例',
+ instanceLocked: '該實例正在移轉中,暫時無法操作',
+ },
+ // 錯誤
+ errors: {
+ TRANSFER_NOT_FOUND: '移轉請求不存在',
+ TRANSFER_TO_SELF: '無法移轉給自己',
+ TRANSFER_TO_BANNED: '無法移轉給被封禁的使用者',
+ TRANSFER_ALREADY_PENDING: '該實例已有待處理的移轉請求',
+ TRANSFER_NOT_PENDING: '移轉請求不在等待狀態',
+ TRANSFER_INVALID_STATUS: '實例狀態不允許移轉',
+ TRANSFER_QUOTA_NOT_FOUND: '接收方配額資訊不存在',
+ TRANSFER_QUOTA_INSUFFICIENT: '接收方配額不足',
+ TRANSFER_INSTANCE_LOCKED: '實例正在移轉中,暫時無法操作',
+ TRANSFER_HOST_DISABLED: '該節點已禁止移轉操作',
+ TRANSFER_INSUFFICIENT_BALANCE: '餘額不足,無法支付移轉手續費',
+ PUSH_NOT_HOST_OWNER: '只有主機擁有者才能直接推送',
+ },
+ },
+
+ // 忘記密碼
+ forgotPassword: {
+ title: '忘記密碼',
+ description: '輸入您的電子郵件地址,我們將發送重設密碼的連結。',
+ emailPlaceholder: '請輸入電子郵件地址',
+ submit: '發送重設連結',
+ sending: '發送中...',
+ success: '重設連結已發送到您的電子郵件',
+ backToLogin: '返回登入',
+ checkInbox: '請查看您的收件匣',
+ didntReceive: '沒有收到電子郵件?',
+ resend: '重新發送',
+ resendIn: '{seconds} 秒後可重新發送',
+ },
+
+ // 重設密碼
+ resetPassword: {
+ title: '重設密碼',
+ description: '輸入您的新密碼',
+ newPassword: '新密碼',
+ newPasswordPlaceholder: '請輸入新密碼',
+ confirmPassword: '確認密碼',
+ confirmPasswordPlaceholder: '請再次輸入新密碼',
+ submit: '重設密碼',
+ resetting: '重設中...',
+ success: '密碼已重設,請使用新密碼登入',
+ invalidToken: '重設連結無效或已過期',
+ tokenExpired: '重設連結已過期,請重新申請',
+ backToLogin: '返回登入',
+ },
+
+ // 管理後台 - 使用者管理
+ adminUsers: {
+ title: '使用者管理',
+ description: '管理系統中的所有使用者',
+ searchPlaceholder: '搜尋使用者名稱、電子郵件...',
+ filterAll: '全部',
+ filterAdmin: '管理員',
+ filterUser: '一般使用者',
+ filterBanned: '已封禁',
+ columns: {
+ user: '使用者',
+ email: '電子郵件',
+ role: '角色',
+ status: '狀態',
+ instances: '實例數',
+ createdAt: '建立時間',
+ lastLogin: '最後登入',
+ actions: '操作',
+ },
+ roles: {
+ admin: '管理員',
+ user: '使用者',
+ },
+ status: {
+ active: '啟用',
+ banned: '已封禁',
+ unverified: '未驗證',
+ },
+ noUsers: '無使用者',
+ noUsersHint: '系統中還沒有任何使用者',
+ loadFailed: '載入使用者失敗',
+ banSuccess: '使用者已封禁',
+ unbanSuccess: '使用者已解除封禁',
+ deleteSuccess: '使用者已刪除',
+ updateSuccess: '使用者已更新',
+ },
+
+ // 管理後台 - 主機管理
+ adminHosts: {
+ title: '主機管理',
+ description: '管理系統中的所有主機',
+ addHost: '新增主機',
+ editHost: '編輯主機',
+ deleteHost: '刪除主機',
+ searchPlaceholder: '搜尋主機名稱、IP...',
+ noHosts: '無主機',
+ status: {
+ online: '線上',
+ offline: '離線',
+ maintenance: '維護中',
+ },
+ columns: {
+ name: '名稱',
+ publicIp: '公網 IP',
+ location: '位置',
+ owner: '擁有者',
+ status: '狀態',
+ instances: '實例數',
+ createdAt: '建立時間',
+ actions: '操作',
+ },
+ createSuccess: '主機建立成功',
+ updateSuccess: '主機更新成功',
+ deleteSuccess: '主機已刪除',
+ loadFailed: '載入主機失敗',
+ },
+
+ // 管理後台 - 方案管理
+ adminPackages: {
+ title: '方案管理',
+ description: '管理系統中的所有方案',
+ addPackage: '新增方案',
+ editPackage: '編輯方案',
+ deletePackage: '刪除方案',
+ searchPlaceholder: '搜尋方案名稱...',
+ noPackages: '無方案',
+ columns: {
+ name: '名稱',
+ specs: '規格',
+ type: '類型',
+ host: '主機',
+ instances: '實例數',
+ actions: '操作',
+ },
+ types: {
+ container: '容器',
+ vm: '虛擬機器',
+ },
+ createSuccess: '方案建立成功',
+ updateSuccess: '方案更新成功',
+ deleteSuccess: '方案已刪除',
+ loadFailed: '載入方案失敗',
+ },
+
+ // 管理後台 - 實例管理
+ adminInstances: {
+ title: '實例管理',
+ description: '管理系統中的所有實例',
+ searchPlaceholder: '搜尋實例名稱、擁有者...',
+ noInstances: '無實例',
+ columns: {
+ name: '名稱',
+ owner: '擁有者',
+ host: '主機',
+ package: '方案',
+ status: '狀態',
+ createdAt: '建立時間',
+ actions: '操作',
+ },
+ filterAll: '全部',
+ filterRunning: '運行中',
+ filterStopped: '已停止',
+ loadFailed: '載入實例失敗',
+ },
+
+ // 管理後台 - 系統設定
+ adminSettings: {
+ title: '系統設定',
+ description: '管理系統設定',
+ general: '一般設定',
+ security: '安全設定',
+ email: '電子郵件設定',
+ notification: '通知設定',
+ saveSuccess: '設定已儲存',
+ saveFailed: '儲存設定失敗',
+ tabs: {
+ general: '一般',
+ security: '安全',
+ email: '電子郵件',
+ notification: '通知',
+ },
+ },
+
+ // 資源頁面(映像檔)
+ resources: {
+ hosts: {
+ title: '我的節點',
+ description: '管理您的節點,這些節點可以供您和您的好友使用',
+ create: '新增節點',
+ createDesc: '新增一個 Incus 節點',
+ ubuntuOnlyHint: '目前僅支援 Ubuntu 22.04+ 和 Debian 11+ 系統。',
+ installHintTitle: '填寫節點資訊並提交後,系統會生成包含面板地址和 Token 的安裝命令,複製到節點宿主機執行即可完成安裝。',
+ installHintIpv6: '提示:如需 IPv6 網路模式(NAT+IPv6、IPv6 Only),請先在宿主機執行安裝腳本,腳本會自動生成 IPv6 子網資訊供您填入下方表單。',
+ ipv6OptionalHint: '如不清楚以上資訊,可先留空。在節點宿主機執行安裝腳本後,腳本會自動檢測並輸出可用的 IPv6 子網,屆時回到面板編輯節點補充即可。',
+ storagePoolAfterConnectHint: '節點連線成功後,請記得前往節點詳細頁的「儲存」標籤頁建立儲存池。',
+ noHosts: '暫無節點',
+ noHostsHint: '新增節點後可以在其上建立實例',
+ calibrateAll: '對齊全部',
+ noOnlineHosts: '沒有線上的節點可對齊',
+ calibrateAllDone: '已對齊 {total} 個節點,其中 {changed} 個有差異已修正',
+ calibrateAllNoChange: '已對齊 {total} 個節點,無差異',
+ nameHint: '節點名稱以 PEER + 您的用戶ID 作為前綴,後面可自訂',
+ nameSuffixRequired: '請輸入節點名稱後綴',
+ // 管理員專用:節點切換器
+ mine: '我的節點',
+ hosted: '託管節點',
+ owner: '擁有者',
+ filterByUserId: '用戶ID',
+ takeoverOfficial: '接管為自營',
+ takeoverOfficialLoading: '接管中...',
+ takeoverOfficialConfirm: '確定要將節點「{name}」接管為自營嗎?此操作會轉移目前節點,並自動接管可安全轉移的方案;現有實例的使用者歸屬不變。',
+ takeoverOfficialSuccess: '已接管節點 {name},轉移 {packages} 個方案,保留 {instances} 個實例',
+ takeoverOfficialDetached: '其中 {count} 個方案仍綁定其他託管節點,已移除目前節點綁定:{names}',
+ takeoverOfficialBlocked: '無法接管:有 {count} 個方案在移除目前節點後將失去全部綁定節點,請先手動處理這些方案:{names}',
+ // 詳情頁
+ transferControl: '移轉控制',
+ transferEnabled: '允許移轉',
+ transferEnabledHint: '關閉後,該節點上的實例將無法發起移轉請求',
+ notificationSettings: '通知設定',
+ notificationSettingsHint: '僅透過你已啟用的 Telegram、Discord、Webhook 渠道發送,這裡不會發送 Email。',
+ notifyPurchase: '購買通知',
+ notifyPurchaseHint: '當有使用者在該節點購買付費實例時通知你。',
+ notifyRenew: '續費通知',
+ notifyRenewHint: '當有使用者在該節點續費付費實例時通知你。',
+ notifyDestroy: '銷毀通知',
+ notifyDestroyHint: '當有使用者銷毀該節點實例時通知你,並包含實際退款金額與手續費金額。',
+ extraConfig: '額外設定',
+ trafficConfig: '流量設定',
+ trafficResetDay: '流量重置日',
+ trafficResetDayHint: '每月何日重置實例流量(可設定 1-28)',
+ enableResourcePool: '參與資源池玩法',
+ enableResourcePoolHint: '開啟後,該節點的實例可參與簽到/抽獎資源應用',
+ },
+ packages: {
+ title: '我的方案',
+ description: '管理您的方案設定,這些方案可以供您和您的好友使用',
+ create: '建立方案',
+ noPackages: '暫無方案',
+ noPackagesHint: '建立方案後可以在建立實例時使用',
+ share: '共享方案',
+ viewShares: '查看共享列表',
+ selectFriend: '選擇好友',
+ selectFriendPlaceholder: '請選擇要共享的好友',
+ noFriends: '暫無好友,請先新增好友',
+ shareSuccess: '方案共享成功',
+ shareFailed: '方案共享失敗',
+ confirmUnshare: '確定要取消共享嗎?',
+ unshareSuccess: '已取消共享',
+ unshareFailed: '取消共享失敗',
+ sharesList: '共享列表',
+ sharesCount: '{count} 人',
+ noShares: '暫無共享記錄',
+ noSharesHint: '共享後好友可使用此方案建立實例',
+ sharedAt: '共享時間',
+ unshare: '取消共享',
+ searchFriend: '搜尋好友...',
+ noAvailableFriends: '所有好友都已共享此方案',
+ selectToShare: '選擇要共享的好友',
+ // 配額限制
+ quotaSettings: '配額限制',
+ quotaMultiplier: '資源配額倍數',
+ quotaMultiplierHint: '限制好友可使用的 CPU/記憶體佔方案比例',
+ maxInstances: '最大實例數',
+ maxInstancesHint: '限制好友最多可開通的實例數量',
+ noLimit: '無限制',
+ instanceUnit: '{n} 台',
+ quotaDisplay: '配額: {multiplier} · 實例: {instances}',
+ usageDisplay: '已用: {cpu}% CPU / {memory} MB 記憶體 / {instances} 台',
+ updateQuota: '修改配額',
+ updateQuotaSuccess: '配額已更新',
+ updateQuotaFailed: '更新配額失敗',
+ // 美化彈窗新增
+ shareToFriend: '分享方案給好友',
+ noFriendsHint: '先新增好友才能共享方案',
+ allFriendsShared: '已全部共享',
+ confirmShare: '確認共享',
+ editQuota: '編輯配額',
+ editQuotaFor: '正在編輯 {username} 的配額',
+ quotaUpdated: '配額已更新',
+ quotaUpdateFailed: '更新配額失敗',
+ usageStatus: '使用情況',
+ instanceCount: '實例數量',
+ packageInstanceCount: '套餐下實例數',
+ networkModeColumn: '網路模式',
+ instanceTypeColumn: '實例類型',
+ trafficMultiplierColumn: '流量倍率',
+ hostColumn: '宿主機',
+ instanceColumn: '實例數',
+ publicBadge: '已公開',
+ currentUsage: '目前已用: {cpu}% CPU / {memory} MB 記憶體 / {instances} 台實例',
+ currentUsageInfo: '目前已使用 {cpu}% CPU、{memory} MB 記憶體、{instances} 台實例',
+ addShare: '新增共享',
+ searchPlaceholder: '搜尋方案名、節點名、描述...',
+ noSearchResults: '未找到符合的方案',
+ clearSearch: '清除搜尋',
+ // 通知渠道設定
+ notifyChannel: '通知渠道',
+ notifyChannelTitle: '資源釋放通知渠道',
+ notifyChannelDesc: '當使用者刪除實例或主機所有者釋放配額時,系統會透過此渠道發送通知',
+ // 分享連結
+ copyShareLink: '複製分享連結',
+ shareLinkCopied: '分享連結已複製,使用者訪問該連結可直達開通實例頁面',
+ // 管理員專用:套餐切換器
+ mine: '我的套餐',
+ hosted: '託管套餐',
+ owner: '擁有者',
+ filterByUserId: '用戶ID',
+ },
+ // 套餐方案管理
+ plans: {
+ title: '方案管理',
+ manage: '管理方案',
+ noPlans: '暫無方案',
+ noPlansHint: '建立方案後,使用者可以購買此套餐的付費實例',
+ add: '新增方案',
+ create: '建立方案',
+ edit: '編輯方案',
+ name: '方案名稱',
+ namePlaceholder: '如:基礎版、專業版、企業版',
+ description: '方案描述',
+ descriptionPlaceholder: '可選,用於向使用者展示方案特點',
+ resourceConfig: '資源設定',
+ portLimit: '連接埠數',
+ snapshotLimit: '快照數',
+ backupLimit: '備份數',
+ siteLimit: '站點數',
+ swapSize: 'SWAP 大小',
+ trafficLimit: '流量限額',
+ trafficSpeed: '頻寬限制',
+ unlimitedHint: '留空表示無限制',
+ billingConfig: '計費設定',
+ price: '價格',
+ billingCycle: '計費週期',
+ setupFee: '開通費',
+ slaGuarantee: 'SLA保證',
+ stock: '庫存',
+ isActive: '啟用方案',
+ status: '方案狀態',
+ statusActive: '可售',
+ statusActiveHint: '正常展示並允許使用者選擇和開通',
+ statusSoldOut: '售罄',
+ statusSoldOutHint: '繼續展示,但禁止新開通和改方案',
+ statusInactive: '下架',
+ statusInactiveHint: '不在開通入口展示,也不能被選擇',
+ sortOrder: '排序',
+ daily: '按天',
+ weekly: '按週',
+ monthly: '月付',
+ quarterly: '季付',
+ semiAnnual: '半年付',
+ yearly: '年付',
+ days: '天',
+ months: '個月',
+ createSuccess: '方案建立成功',
+ updateSuccess: '方案更新成功',
+ saveFailed: '儲存方案失敗',
+ priceRangeHint: '最高 ¥{max}',
+ priceRangeError: '方案價格必須在 0-{max} 元之間,最多支援兩位小數',
+ confirmDelete: '確定要刪除方案「{name}」嗎?',
+ deleteSuccess: '方案已刪除',
+ deleteFailed: '刪除方案失敗',
+ },
+ images: {
+ title: '我的映像檔',
+ description: '管理您的映像檔設定,這些映像檔可以供您和您的好友使用',
+ create: '新增映像檔',
+ noImages: '暫無映像檔',
+ noImagesHint: '新增映像檔後可以在建立實例時使用',
+ },
+ title: '資源',
+ description: '管理主機的映像檔資源',
+ tabs: {
+ images: '映像檔',
+ },
+ },
+
+ // 實例設定標籤頁
+ instanceConfig: {
+ title: '進階設定',
+ sections: {
+ swap: 'SWAP',
+ storageIO: '儲存 I/O 限制',
+ networkLimits: '網路限制',
+ processScheduling: '處理程序與排程',
+ bootSettings: '啟動設定',
+ },
+ swap: {
+ size: '大小',
+ enabled: '已啟用',
+ disabled: '已關閉',
+ enableButton: '啟用 SWAP',
+ disableButton: '關閉 SWAP',
+ toggleHint: 'SWAP 可依需求啟用或關閉,重裝或重建後會保留目前狀態。',
+ runningRequired: '虛擬機需要先啟動後才能啟用 SWAP。',
+ vmHint: '虛擬機會透過實例內的 swapfile 持久啟用。',
+ containerHint: '容器會透過 Incus 的記憶體交換限制套用 SWAP。',
+ enableConfirmTitle: '啟用 SWAP',
+ enableConfirmText: '確定要為此實例啟用 {size} 的 SWAP 嗎?',
+ disableConfirmTitle: '關閉 SWAP',
+ disableConfirmText: '確定要為此實例關閉 SWAP 嗎?',
+ enableSuccess: 'SWAP 已啟用',
+ enableFailed: '啟用 SWAP 失敗',
+ disableSuccess: 'SWAP 已關閉',
+ disableFailed: '關閉 SWAP 失敗',
+ },
+ changeHost: {
+ title: '改節點',
+ description: '將實例重新建立到同方案的其他節點,實例 ID 與計費資訊會保留。',
+ currentHost: '目前節點',
+ availableCount: '{count} 個可選節點',
+ button: '改節點',
+ loadFailed: '載入可用節點失敗',
+ submitFailed: '提交改節點失敗',
+ taskQueued: '改節點任務已提交,請稍候...',
+ modalTitle: '選擇目標節點',
+ modalSubtitle: '只能選擇同方案下 CPU 和記憶體容量充足的節點。',
+ warning: '此操作會重新建立實例系統碟,舊實例資料、快照、備份、連接埠映射和反代站點會被清空。',
+ selectSshKey: 'SSH 金鑰',
+ noSshKey: '沒有可用 SSH 金鑰',
+ confirm: '確認改節點',
+ current: '目前',
+ available: '可用',
+ memory: '記憶體',
+ reasons: {
+ current_host: '目前節點',
+ host_offline: '離線',
+ host_type_mismatch: '類型不匹配',
+ cpu_full: '已滿',
+ memory_full: '已滿',
+ resource_unconfigured: '未配置配額',
+ image_unavailable: '映像檔不可用',
+ },
+ },
+ overridden: '已覆寫',
+ resetToDefault: '重設為方案預設值',
+ saveSuccess: '設定已儲存',
+ saveFailed: '儲存設定失敗',
+ boostProcesses: {
+ button: '提高上限',
+ title: '提高處理程序數上限',
+ confirm: '確定要為該 {type} 實例提高處理程序數限制嗎?操作後處理程序數上限將提升至 {limit}。',
+ hint: '此操作僅提高處理程序數上限,不會影響實例的其他設定',
+ success: '處理程序數上限已提升至 {limit}',
+ failed: '提升處理程序數上限失敗',
+ },
+ },
+
+ // 方案表單頁面
+ packageForm: {
+ createTitle: '建立方案',
+ editTitle: '編輯方案',
+ description: '設定方案的資源限制和進階選項',
+ sections: {
+ basicInfo: '基本資訊',
+ resourceLimits: '資源限制',
+ storageIO: '儲存 I/O 限制',
+ networkLimits: '網路限制',
+ processScheduling: '處理程序與排程',
+ bootSettings: '啟動設定',
+ prerequisite: '前置方案',
+ visibility: '可見性',
+ instancePermissions: '實例操作權限',
+ advancedOptions: '進階選項',
+ instanceQuota: '實例配額',
+ },
+ fields: {
+ networkMode: '網路模式',
+ instanceType: '實例類型',
+ packageCreationMode: '方案用途',
+ ioLimitMode: 'IO 限制模式',
+ limitsRead: '讀取速率限制',
+ limitsWrite: '寫入速率限制',
+ limitsReadIops: '讀取 IOPS 限制',
+ limitsWriteIops: '寫入 IOPS 限制',
+ limitsIngress: '入站頻寬限制',
+ limitsEgress: '出站頻寬限制',
+ limitsProcesses: '最大處理程序數',
+ limitsCpuPriority: 'CPU 優先級',
+ bootAutostart: '隨主機自動啟動',
+ bootAutostartPriority: '啟動優先級',
+ bootAutostartDelay: '啟動延遲',
+ bootHostShutdownTimeout: '關機逾時時間',
+ portLimit: '連接埠映射數量限制',
+ snapshotLimit: '快照數量限制',
+ backupLimit: '備份數量限制',
+ siteLimit: '站點數量限制',
+ hostStoragePools: '節點系統碟儲存池',
+ hostTrafficMultiplier: '節點流量倍率',
+ requiredPackage: '前置方案',
+ publicAccess: '公開方案',
+ globalMaxInstances: '最大實例數',
+ allowInstanceDeletion: '允許使用者刪除實例',
+ },
+ hostSelector: {
+ official: '自營節點',
+ searchPlaceholder: '搜尋節點名、地區、地址、擁有者...',
+ selectedCount: '已選擇 {count} 台宿主機',
+ noSearchResults: '未找到符合的宿主機',
+ detailUnavailable: '目前視圖未載入此宿主機詳情,儲存時仍會保留綁定',
+ },
+ creationModes: {
+ free: {
+ title: '免費實例方案',
+ description: '實例直接使用方案裡的資源、配額與頻寬限制',
+ },
+ paid: {
+ title: '付費實例方案',
+ description: '建立後透過方案設定資源、配額、流量與價格',
+ },
+ },
+ ioMode: {
+ throughput: '讀寫速率限制',
+ iops: 'IOPS 限制',
+ },
+ hints: {
+ ioLimitMode: 'Incus 僅支援其中一種 IO 限制模式,請選擇一種',
+ cpuPriority: '0 為最低優先級,10 為最高優先級',
+ bootPriority: '數值越小越先啟動',
+ startupDelay: '實例啟動前等待的秒數 (5-600)',
+ shutdownTimeout: '主機關機時等待實例關閉的秒數 (30-600)',
+ instanceQuota: '限制使用者在此方案實例上可建立的資源數量',
+ portLimit: '每個實例可建立的連接埠映射數量上限',
+ snapshotLimit: '每個實例可建立的快照數量上限(0表示無配額)',
+ backupLimit: '每個實例可建立的備份數量上限(0表示無配額)',
+ siteLimit: '每個實例可建立的反代站點數量上限(0表示無配額)',
+ hostStoragePools: '為每個已綁定節點指定預設系統碟儲存池;留空時按節點預設規則自動選擇',
+ hostTrafficMultiplier: '實例月流量 = 方案或套餐流量 × 此倍率,預設 1',
+ requiredPackage: '選擇後,使用者需要先擁有該方案的實例,才能建立此方案實例',
+ noSystemStoragePools: '此節點目前沒有可用於實例系統碟的儲存池',
+ instanceType: '容器輕量快速,虛擬機器提供完整隔離',
+ publicAccess: '開啟後,方案將對所有使用者可見,他們可以使用此方案建立實例;關閉後方案將被隱藏/歸檔',
+ globalMaxInstances: '限制使用者最多可開通的實例數量,必須填寫 1-5 之間的整數',
+ allowInstanceDeletion: '關閉後,使用此方案建立的實例將不允許使用者刪除',
+ freePackageCreationMode: '免費實例無需建立方案,實例會直接繼承本頁設定。若需要門檻或想按付費實例流程開通,請選擇付費實例方案,建立後新增方案並將價格設為 0 元。',
+ paidPackageCreationMode: '付費實例會使用方案裡的資源、實例配額、流量與價格。本頁會隱藏這些會被方案覆蓋的項目,並使用預設值儲存方案。',
+ },
+ units: {
+ seconds: '秒',
+ },
+ placeholders: {
+ unlimited: '留空表示無限制',
+ autoStoragePool: '自動選擇(未指定)',
+ noPrerequisite: '無前置方案',
+ },
+ validation: {
+ cpuPriorityRange: 'CPU 優先級必須在 0-10 之間',
+ bootPriorityRange: '啟動優先級必須在 0-100 之間',
+ startupDelayRange: '啟動延遲必須在 5-600 秒之間',
+ shutdownTimeoutRange: '關機逾時時間必須在 30-600 秒之間',
+ portLimitMin: '連接埠映射數量至少為 1',
+ globalMaxInstancesRange: '公開方案最大實例數必須是 1-5 之間的整數',
+ },
+ typeHelp: {
+ containerFast: '啟動快速,秒級回應',
+ containerLight: '資源佔用小,適合大多數 Linux 應用程式',
+ containerDocker: '支援 Docker(需啟用嵌套虛擬化)',
+ vmIsolation: '完整隔離,獨立核心',
+ vmKernel: '支援自訂核心和核心模組',
+ vmWindows: '可運行 Windows 等非 Linux 系統',
+ },
+ },
+
+ // 敏感操作二次驗證
+ sensitiveVerification: {
+ title: '敏感操作驗證',
+ description: '此操作需要二次驗證以確保帳戶安全',
+ operationLabel: '操作類型',
+ sendCode: '發送驗證碼',
+ resendCode: '重新發送',
+ resendIn: '{seconds} 秒後可重新發送',
+ codeSent: '驗證碼已發送',
+ enterCode: '請輸入驗證碼',
+ verify: '驗證',
+ verifySuccess: '驗證成功,請重新執行操作',
+ verifyFailed: '驗證失敗',
+ operationTypes: {
+ change_password: '變更密碼',
+ disable_2fa: '停用雙重驗證',
+ delete_instance: '刪除實例',
+ reinstall_instance: '重裝實例',
+ recreate_instance: '重建實例',
+ transfer_instance: '移轉實例',
+ delete_snapshot: '刪除快照',
+ delete_backup: '刪除備份',
+ },
+ },
+
+ // 站內信
+ inbox: {
+ title: '通知中心',
+ description: '查看您的所有系統通知',
+ notifications: '通知',
+ unread: '未讀',
+ all: '全部',
+ allCategories: '全部類型',
+ markAllRead: '全部已讀',
+ markRead: '標記已讀',
+ clearRead: '清除已讀',
+ noMessages: '暫無通知',
+ noUnread: '沒有未讀通知',
+ viewAll: '查看全部',
+ justNow: '剛剛',
+ minutesAgo: '{n} 分鐘前',
+ hoursAgo: '{n} 小時前',
+ daysAgo: '{n} 天前',
+ categories: {
+ instance: '實例',
+ snapshot: '快照',
+ backup: '備份',
+ social: '好友',
+ transfer: '移轉',
+ package: '方案',
+ security: '安全',
+ quota: '配額',
+ ticket: '工單',
+ system: '系統',
+ },
+ },
+
+ // 配額釋放
+ quotaRelease: {
+ title: '釋放配額',
+ packageQuota: '方案配額',
+ noHosts: '無可用主機',
+ noHostsHint: '此方案未綁定任何主機',
+ selectHosts: '選擇主機',
+ selectAll: '全選',
+ deselectAll: '取消全選',
+ available: '可用',
+ quotaToAdd: '增加配額',
+ preview: '將為 {count} 台主機各增加 {cpu}% CPU 和 {memory} 記憶體',
+ notificationChannel: '通知管道',
+ noNotification: '不發送通知',
+ notificationHint: '釋放配額後,系統會透過此管道發送通知',
+ selectChannel: '選擇通知管道',
+ bindChannelHint: '僅顯示停用狀態的通知管道,用於資源釋放通知',
+ unbindChannel: '解除綁定',
+ sendNotification: '發送通知',
+ noDisabledChannel: '暫無可用的通知管道',
+ noDisabledChannelHint: '請先在「個人設定 - 通知」中新增一個管道並停用它',
+ channelEnabledWarning: '此管道已啟用,將同時接收系統事件通知',
+ noGlobalChannel: '暫無全域通知管道',
+ noGlobalChannelHint: '請聯繫管理員在系統設定中建立全域通知管道',
+ confirm: '釋放配額',
+ success: '成功為 {count} 台主機釋放配額',
+ failed: '釋放配額失敗',
+ loadFailed: '載入主機資訊失敗',
+ selectHost: '請至少選擇一台主機',
+ enterQuota: '請輸入要增加的配額',
+ channelUpdated: '通知管道已更新',
+ channelUpdateFailed: '更新通知管道失敗',
+ },
+
+ // Web 終端機
+ terminal: {
+ title: '終端機',
+ connecting: '正在連線...',
+ connected: '已連線',
+ disconnected: '已中斷連線',
+ failed: '連線失敗',
+ connectionFailed: '連線失敗',
+ connectionError: '連線錯誤',
+ reconnecting: '正在重新連線...',
+ reconnect: '重新連線',
+ requiresRunning: '需要實例處於運行狀態',
+ clear: '清除畫面',
+ fullscreen: '全螢幕',
+ exitFullscreen: '離開全螢幕',
+ fontSize: '字型大小',
+ fontSizeIncrease: '放大字型',
+ fontSizeDecrease: '縮小字型',
+ close: '關閉終端機',
+ disconnect: '中斷連線',
+ modeExec: 'Shell',
+ modeConsole: '控制台',
+ modeBootConsole: '啟動控制台',
+ modeSwitching: '切換到 Shell',
+ modeUnknown: '未知模式',
+ consoleFallback: '目前已回退到控制台模式',
+ consoleFallbackHint: '此虛擬機未能進入 Shell 模式,目前使用的是串口控制台。若體驗異常,請檢查 qemu-guest-agent、cloud-init 與串口登入設定。',
+ switchingToShell: '正在切換到 Shell...',
+ switchingToShellHint: 'Shell 已就緒,終端機正在從啟動控制台切換到互動式 Shell。',
+ shellReadyNotice: '已進入 Shell',
+ shellReadyHint: '啟動控制台輸出已完成,目前終端機已切換到互動式 Shell。',
+ consoleOnlyHint: '目前仍處於控制台模式,Shell 尚未就緒或正在重新連線。',
+ instanceInfo: '實例:{name}',
+ statusConnecting: '連線中',
+ statusConnected: '已連線',
+ statusDisconnected: '未連線',
+ statusError: '錯誤',
+ escToClose: '按 Esc 關閉',
+ pressCtrlShiftFToSearch: '按 Ctrl+Shift+F 搜尋',
+ searchPlaceholder: '搜尋...',
+ searchNext: '下一個',
+ searchPrevious: '上一個',
+ restore: '恢復終端機',
+ exportLog: '匯出日誌',
+ tab: '標籤',
+ newTab: '新增標籤',
+ closeTab: '關閉標籤',
+ maxTabsReached: '已達到最大標籤數',
+ contextMenu: {
+ copy: '複製',
+ paste: '貼上',
+ selectAll: '全選',
+ },
+ cloudInitChecking: '正在檢查實例初始化狀態...',
+ cloudInitInProgress: '實例正在初始化中',
+ cloudInitInProgressHint: '實例正在運行 Cloud-init 初始化,可能需要等待 10-60 秒。請稍後點擊「重新檢查」嘗試連線。',
+ cloudInitUnknown: 'Cloud-init 狀態待確認',
+ cloudInitUnknownHint: '目前無法可靠檢測此 KVM 實例內的 Cloud-init 狀態。你可以繼續連線,或手動標記為已完成。',
+ cloudInitRetry: '重新檢查',
+ cloudInitSkip: '忽略並連線',
+ cloudInitManualComplete: '手動標記完成',
+ cloudInitManualCompleteSuccess: '已手動標記此實例初始化為完成',
+ mobileKeyboardHint: '請切換至英文鍵盤以取得最佳體驗',
+ help: '說明',
+ helpTitle: '終端機使用說明',
+ helpShortcuts: '快速鍵',
+ helpShortcutSearch: '搜尋內容',
+ helpShortcutCopy: '複製選取',
+ helpShortcutPaste: '貼上',
+ helpShortcutFontIncrease: '放大字型',
+ helpShortcutFontDecrease: '縮小字型',
+ helpShortcutFontReset: '重設字型',
+ helpShortcutExport: '匯出日誌',
+ helpShortcutNewTab: '新增標籤',
+ helpShortcutCloseTab: '關閉標籤',
+ helpMouseOps: '滑鼠操作',
+ helpMouseSelect: '拖曳選取文字',
+ helpMouseCopy: '右鍵選單複製/貼上',
+ helpMouseScroll: '滾輪捲動歷史',
+ helpTouchOps: '觸控操作',
+ helpTouchPinchZoom: '雙指捏合調整字型大小',
+ helpTouchSwipeScroll: '單指滑動捲動歷史',
+ settings: '設定',
+ settingBell: '終端機提示音',
+ settingBellDesc: '程式發出提醒時播放聲音(如指令完成、錯誤警告等)',
+ settingAutoCopy: '選取自動複製',
+ settingAutoCopyDesc: '選取文字後自動複製到剪貼簿',
+ settingLinkPreview: '連結預覽',
+ settingLinkPreviewDesc: '滑鼠懸停在連結上時顯示 URL',
+ settingTouch: '觸控最佳化',
+ settingTouchDesc: '啟用行動裝置手勢支援(雙指縮放等)',
+ settingThemeDesc: '選擇終端機的配色方案',
+ settingTheme: '終端機佈景主題',
+ themeDark: '深色',
+ themeLight: '淺色',
+ themeHighContrast: '高對比度',
+ currentStatus: '目前狀態',
+ latency: '延遲',
+ savedCommands: {
+ cloud: '雲端同步',
+ title: '快捷命令',
+ subtitle: '保存常用命令,隨時送到目前終端機',
+ add: '新增',
+ synced: '雲端保存',
+ encrypted: '加密保存',
+ count: '共 {count} 條',
+ collapse: '收合快捷命令',
+ expand: '展開快捷命令',
+ open: '打開快捷命令',
+ short: '命令',
+ new: '新增命令',
+ edit: '編輯命令',
+ name: '名稱',
+ namePlaceholder: '例如:更新軟體來源',
+ command: '命令內容',
+ commandPlaceholder: '輸入要保存到雲端的終端機命令',
+ description: '備註',
+ descriptionPlaceholder: '可選備註,說明這條命令的用途',
+ loadFailed: '載入快捷命令失敗',
+ createSuccess: '快捷命令已保存',
+ updateSuccess: '快捷命令已更新',
+ saveFailed: '保存快捷命令失敗',
+ deleteSuccess: '快捷命令已刪除',
+ deleteFailed: '刪除快捷命令失敗',
+ deleteConfirm: '確定刪除「{name}」嗎?',
+ emptyTitle: '還沒有快捷命令',
+ emptyDescription: '點擊右上角「新增」保存常用命令。',
+ selected: '已選擇:{name}',
+ notSelected: '選擇一條快捷命令後即可執行或刪除',
+ execute: '執行',
+ runHint: '會直接送到目前啟用的終端機並立即執行。',
+ disconnectedHint: '目前終端機未連線,連線成功後才可執行命令',
+ shellRequiredHint: '目前尚未進入 Shell,需等 Shell 就緒後才能執行快捷命令',
+ },
+ paste: '貼上',
+ hideKeyboard: '收起鍵盤',
+ },
+
+ // 終端機管理頁面
+ terminalPage: {
+ newConnection: '新增連線',
+ selectInstance: '選擇實例',
+ runningCount: '運行中實例 {count} 台',
+ selectedInstance: '目前選擇',
+ selectionHint: '請選擇一個運行中的實例,終端機會直接連入該實例。',
+ directShell: '控制台優先連線',
+ directShellHint: '終端機會先附著啟動控制台,並在互動式 Shell 就緒後自動切換。',
+ host: '節點',
+ package: '套餐',
+ instanceId: '實例 ID',
+ statusRunning: '運行中',
+ noConnections: '暫無終端機連線',
+ noRunningInstances: '沒有運行中的實例',
+ noRunningInstancesHint: '請先啟動一個實例,或回到實例詳情頁確認運行狀態。',
+ connect: '連線',
+ description: '管理所有實例的終端機連線',
+ searchInstances: '搜尋實例名稱或映像檔...',
+ noMatchingInstances: '沒有符合的實例',
+ noMatchingInstancesHint: '請嘗試其他關鍵字,或清空搜尋條件查看全部運行中的實例。',
+ },
+
+ // 工單系統
+ tickets: {
+ title: '工單中心',
+ myTickets: '我的工單',
+ hostTickets: '收到的工單',
+ createTicket: '建立工單',
+ newTicket: '新增工單',
+ ticketDetails: '工單詳情',
+ noTickets: '暫無工單',
+ noTicketsHint: '您還沒有建立任何工單',
+ noHostTickets: '暫無收到的工單',
+ noHostTicketsHint: '您的主機沒有收到任何工單',
+ noUserTickets: '暫無使用者工單',
+ noUserTicketsHint: '目前沒有未綁定實例、直接發給管理員的使用者工單',
+ noOfficialTickets: '暫無自營工單',
+ noOfficialTicketsHint: '目前沒有來自自營節點的工單',
+ noHostedTickets: '暫無託管工單',
+ noHostedTicketsHint: '目前沒有來自託管節點的工單',
+ status: {
+ open: '待處理',
+ in_progress: '處理中',
+ resolved: '已解決',
+ closed: '已關閉',
+ },
+ priority: {
+ low: '低',
+ normal: '普通',
+ high: '高',
+ urgent: '緊急',
+ },
+ category: {
+ general: '一般諮詢',
+ billing: '帳務問題',
+ technical: '技術支援',
+ abuse: '濫用檢舉',
+ },
+ subject: '主題',
+ subjectPlaceholder: '簡要描述問題',
+ content: '內容',
+ contentPlaceholder: '請至少寫 10 個字,詳細描述您的問題,或上傳圖片附件',
+ selectInstance: '選擇實例',
+ selectInstanceHint: '選擇實例(可選)',
+ noInstancesHint: '您還沒有實例,可直接提交工單',
+ hostedInstanceHint: '如需針對託管實例提交工單,請務必選擇對應實例,工單將發送至該節點負責人處理。',
+ hostedInstanceHintTitle: '託管實例提示',
+ selectCategory: '選擇分類',
+ selectPriority: '選擇優先級',
+ reply: '回覆',
+ replyPlaceholder: '輸入回覆內容...',
+ close: '關閉工單',
+ reopen: '重新開啟',
+ updateStatus: '更新狀態',
+ markResolved: '標記為已解決',
+ markInProgress: '標記為處理中',
+ confirmClose: '確認關閉工單?',
+ confirmCloseHint: '關閉後將無法繼續回覆',
+ createSuccess: '工單建立成功',
+ replySuccess: '回覆成功',
+ closeSuccess: '工單已關閉',
+ statusUpdated: '狀態已更新',
+ host: '主機',
+ instance: '實例',
+ from: '來自',
+ assignedTo: '分配給',
+ createdAt: '建立時間',
+ lastReply: '最後回覆',
+ messages: '訊息',
+ viewDetails: '查看詳情',
+ pendingCount: '待處理',
+ allHosts: '所有主機',
+ filterByHost: '按主機篩選',
+ filterByStatus: '按狀態篩選',
+ sourceFilter: {
+ all: '全部工單',
+ user: '使用者工單',
+ official: '自營工單',
+ hosted: '託管工單',
+ },
+ activeStatus: '活躍',
+ allStatus: '全部',
+ ownerReply: '客服回覆',
+ userReply: '使用者回覆',
+ ticketClosed: '工單已關閉,無法回覆',
+ mustSelectInstance: '請先選擇實例',
+ noInstancesAvailable: '沒有可用的實例',
+ instanceDetails: '實例詳情',
+ instanceStatus: '狀態',
+ instanceId: '實例 ID',
+ incusId: 'Incus ID',
+ packageName: '方案',
+ cores: '核',
+ memory: '記憶體',
+ disk: '硬碟',
+ image: '映像檔',
+ loadMoreMessages: '載入更多訊息',
+ remaining: '條剩餘',
+ needsReply: '需要回覆',
+ searchPlaceholder: '搜尋工單 ID、主題、使用者名稱...',
+ perPage: '每頁',
+ totalCount: '共 {count} 條',
+ images: {
+ label: '圖片附件',
+ hint: '支援 JPG、PNG、WebP、GIF、AVIF,最多 {count} 張,每張不超過 {size}MB',
+ add: '新增圖片',
+ remove: '移除圖片',
+ selected: '已選擇 {count}/{max} 張圖片',
+ maxReached: '最多只能上傳 {count} 張圖片',
+ invalidType: '僅支援 JPG、PNG、WebP、GIF、AVIF 圖片',
+ fileTooLarge: '單張圖片不能超過 {size}MB',
+ loadFailed: '圖片載入失敗',
+ zoomIn: '放大',
+ zoomOut: '縮小',
+ resetZoom: '重設縮放',
+ },
+ },
+
+ // 簽到系統
+ checkin: {
+ title: '每日簽到',
+ checkinTab: '簽到',
+ redeemTab: '兌換',
+ recordsTab: '記錄',
+ notCheckedIn: '今日尚未簽到',
+ alreadyCheckedIn: '今日已簽到',
+ checkinButton: '簽到',
+ checkinSuccess: '簽到成功',
+ noInstance: '您需要至少擁有一個實例才能簽到',
+ clickToOpen: '點擊禮盒抽取今日獎勵',
+ opening: '開啟中',
+ revealing: '揭曉獎勵',
+ congratulations: '恭喜您獲得以下獎勵',
+ redeemCode: '兌換碼',
+ codeExpired: '已過期',
+ codeUsed: '已使用',
+ expiresIn: '剩餘時間',
+ copyCode: '複製兌換碼',
+ codeCopied: '兌換碼已複製',
+ resourceType: '資源類型',
+ resourceValue: '資源數值',
+ cpu: 'CPU',
+ memory: '記憶體',
+ disk: '硬碟',
+ traffic: '流量',
+ points: '積分',
+ redeemTitle: '兌換兌換碼',
+ inputCode: '請輸入兌換碼',
+ selectInstance: '選擇實例',
+ selectInstanceHint: '選擇要兌換資源的免費實例',
+ redeemButton: '兌換',
+ redeemSuccess: '兌換成功',
+ cappedFromPackageLimit: '已達方案上限',
+ redeemHint: '簽到兌換碼僅可用於免費實例',
+ alreadyRedeemed: '今日簽到碼已兌換過',
+ noInstancesForRedeem: '沒有可用的免費實例',
+ checkinRecords: '簽到記錄',
+ redeemRecords: '兌換記錄',
+ noRecords: '暫無記錄',
+ showingRecent: '顯示最近 {count} 條,共 {total} 條',
+ usedBy: '使用者',
+ usedFor: '兌換實例',
+ self: '自己',
+ others: '他人',
+ unused: '未使用',
+ selfOnlyMode: '僅限自用',
+ selfOnlyHint: '您的兌換碼已連續兩天被他人使用,從今天起只能自己使用,直到您自己使用一次',
+ rulesTitle: '簽到說明',
+ rulesCheckin: '簽到規則',
+ rulesCheckin1: '每日 1 次,0 點重置',
+ rulesCheckin2: '需擁有至少 1 個實例',
+ rulesCheckin3: '隨機獲得 CPU / 記憶體 / 硬碟 / 流量 獎勵',
+ rulesRedeem: '兌換規則',
+ rulesRedeem1: '兌換碼有效期 3 小時',
+ rulesRedeem2: '每人每日限兌換 1 次',
+ rulesRedeem3: '資源不能超過方案上限',
+ rulesShare: '分享',
+ rulesShare1: '兌換碼可分享給好友使用',
+ rulesShare2: '連續 2 天被他人使用將限制為僅自己可用,直到再次自己使用後解除',
+ percent: '%',
+ mb: 'MB',
+ gb: 'GB',
+ // 資源池系統新增
+ tabCheckin: '簽到',
+ tabRedeem: '兌換',
+ tabPool: '資源池',
+ tabLogs: '記錄',
+ clickToCheckin: '點擊簽到領取獎勵',
+ noInstances: '您需要至少擁有一個實例才能簽到',
+ bonusPoints: '額外獲得 {points} 積分',
+ savedToPool: '已存入資源池',
+ redeemSystemCode: '系統兌換碼',
+ enterCode: '輸入兌換碼',
+ systemCodePlaceholder: '輸入 h- 開頭的系統兌換碼',
+ systemCodeHint: '系統兌換碼(h-前綴)需選擇目標實例,資源將直接應用到該實例',
+ redeem: '兌換',
+ redeemToInstanceSuccess: '{type} +{value}{unit} 已應用到 {instance}',
+ applyToInstance: '應用資源到實例',
+ amount: '數量',
+ targetInstance: '目標實例',
+ kvmCpuHint: 'CPU參數兑換到KVM實例時,必須是100的整數倍',
+ kvmHint: 'KVM實例限制:CPU必須是100的倍數,記憶體必須是128MB的倍數,硬碟必須是1GB的倍數,且調整記憶體/硬碟需先停止實例。LXC實例無此限制。',
+ apply: '應用',
+ applySuccess: '{type} +{value}{unit} 已應用到 {instance}',
+ insufficientPool: '資源池餘額不足',
+ enterAmount: '請輸入數量',
+ allActions: '所有操作',
+ allResources: '所有資源',
+ noLogs: '暫無記錄',
+ action: '操作',
+ instance: '實例',
+ time: '時間',
+ actionCheckin: '簽到獲得',
+ actionRedeem: '兌換獲得',
+ actionAdminGrant: '管理員贈送',
+ actionSystemGrant: '系統獎勵',
+ actionLottery: '抽獎獲得',
+ actionApply: '應用消耗',
+ actionSystemRedeem: '系統兑換碼',
+ },
+
+ // 兌換碼管理
+ redeemCodes: {
+ title: '兌換碼管理',
+ create: '建立兌換碼',
+ createTitle: '建立兌換碼',
+ createBatch: '批次建立',
+ codeType: '資源類型',
+ codeValue: '資源數值',
+ resourceType: '資源類型',
+ resourceValue: '資源數值',
+ maxUses: '最大使用次數',
+ usedCount: '已使用',
+ expiresAt: '過期時間',
+ expiresAtHint: '留空表示永不過期',
+ neverExpires: '永不過期',
+ enabled: '已啟用',
+ disabled: '已停用',
+ enable: '啟用',
+ disable: '停用',
+ remark: '備註',
+ remarkPlaceholder: '可選備註資訊',
+ batchCount: '產生數量',
+ batchCountHint: '數量大於 1 時將批次產生一次性兌換碼',
+ createSuccess: '兌換碼建立成功',
+ batchCreateSuccess: '成功建立 {count} 個兌換碼',
+ createFailed: '建立失敗',
+ deleteConfirm: '確認刪除',
+ deleteSuccess: '成功刪除 {count} 個兌換碼',
+ deleteFailed: '刪除失敗',
+ confirmDelete: '確認刪除',
+ confirmDeleteMessage: '確定要刪除選取的 {count} 個兌換碼嗎?此操作不可撤銷。',
+ deleteSelected: '刪除選取 ({count})',
+ updateSuccess: '更新成功',
+ copyCode: '複製兌換碼',
+ copyAll: '複製全部',
+ copyCodes: '複製所有兌換碼',
+ codesCopied: '兌換碼已複製',
+ exhausted: '已用完',
+ expired: '已過期',
+ active: '有效',
+ paused: '已暫停',
+ usages: '使用記錄',
+ usageRecords: '使用記錄',
+ noUsages: '暫無使用記錄',
+ user: '使用者',
+ instance: '實例',
+ usedAt: '使用時間',
+ selectType: '選擇資源類型',
+ selectValue: '請選擇資源數值',
+ empty: '暫無兌換碼',
+ emptyList: '暫無兌換碼,點擊上方按鈕建立',
+ filterAll: '全部',
+ filterEnabled: '已啟用',
+ filterDisabled: '已停用',
+ batchDelete: '批次刪除',
+ batchDeleteConfirm: '確定要刪除選取的 {count} 個兌換碼嗎?',
+ usesHint: '設為 1 表示一次性兌換碼',
+ batchHint: '批次建立只能產生一次性兌換碼',
+ batchResult: '批次建立結果',
+ code: '兌換碼',
+ type: '類型',
+ usage: '使用情況',
+ status: '狀態',
+ actions: '操作',
+ loadFailed: '載入失敗',
+ batchId: '批次 ID',
+ batchLimitHint: '同一批次的兑換碼,每個使用者只能使用一張',
+ batch: '批次',
+ valueRange: '範圍:{min} - {max}',
+ valueOutOfRange: '數值必須在 {min} 到 {max} 之間',
+ valueMustBeInteger: '數值必須為整數',
+ },
+
+ // 擴充
+ extensions: {
+ title: '腳本',
+ description: '管理擴充功能',
+ initCommands: {
+ title: '自訂初始化指令',
+ description: '建立指令範本,在實例建立/重裝時執行',
+ add: '新增指令',
+ addFirst: '新增第一個指令',
+ edit: '編輯指令',
+ view: '查看詳情',
+ viewDetail: '指令詳情',
+ empty: '暫無自訂指令',
+ emptyHint: '點擊上方按鈕建立第一個初始化指令範本',
+ name: '名稱',
+ namePlaceholder: '輸入指令名稱',
+ command: '指令內容',
+ commandPlaceholder: '輸入 Shell 指令,每行一條\n範例:\napt update\napt install -y nginx',
+ commandHint: '每行一條指令,實例初始化時按順序執行',
+ distros: '適配發行版',
+ distrosHint: '選擇此指令相容的 Linux 發行版',
+ remark: '備註',
+ remarkPlaceholder: '可選描述',
+ createdAt: '建立時間',
+ actions: '操作',
+ status: '狀態',
+ statusEnabled: '已啟用',
+ statusDisabled: '已停用',
+ enabled: '指令已啟用',
+ disabled: '指令已停用',
+ clickToEnable: '點擊啟用',
+ clickToDisable: '點擊停用',
+ toggleFailed: '切換狀態失敗',
+ confirmDelete: '確定要刪除指令「{name}」嗎?此操作不可撤銷。',
+ createSuccess: '指令建立成功',
+ updateSuccess: '指令更新成功',
+ deleteSuccess: '指令已刪除',
+ createFailed: '建立失敗',
+ updateFailed: '更新失敗',
+ deleteFailed: '刪除失敗',
+ loadFailed: '載入指令失敗',
+ loadDetailFailed: '載入指令詳情失敗',
+ noContent: '無內容',
+ lineCount: '{count} 行',
+ addTitle: '新增初始化指令',
+ editTitle: '編輯初始化指令',
+ modalDesc: '指令將在實例初始化時執行',
+ selectTitle: '初始化指令',
+ optional: '(可選)',
+ noAvailable: '暫無可用的初始化指令',
+ goToManage: '前往擴充頁面建立',
+ selectHint: '已選指令將在實例初始化完成後執行',
+ selectedCount: '已選擇 {count} 個',
+ showAll: '展開全部 {count} 條',
+ collapse: '收合',
+ distroAll: '所有發行版',
+ distroNames: {
+ all: '所有發行版',
+ ubuntu: 'Ubuntu',
+ debian: 'Debian',
+ rhel: 'RHEL/CentOS/Fedora',
+ alpine: 'Alpine',
+ arch: 'Arch Linux',
+ suse: 'openSUSE/SLES',
+ },
+ },
+ },
+
+ // 計費管理
+ billing: {
+ // 通用
+ balance: '餘額',
+ frozen: '凍結',
+ totalRecharge: '累計充值',
+ totalConsume: '累計消費',
+ yuan: '元',
+ months: '個月',
+ month: '月',
+ days: '天',
+ save: '節省',
+ freeInstance: '免費實例',
+ paidInstance: '付費實例',
+ traffic: '月流量',
+ trafficBidirectional: '雙向',
+ soldOut: '售罄',
+
+ // 實例計費資訊
+ billingInfo: '計費資訊',
+ currentPlan: '當前方案',
+ expiresAt: '到期時間',
+ neverExpires: '永不過期',
+ autoRenew: '自動續費',
+ autoRenewOn: '自動開',
+ autoRenewOff: '自動關',
+ autoRenewEnabled: '已開啟自動續費',
+ autoRenewDisabled: '已關閉自動續費',
+ autoRenewing: '自動續費',
+ enableAutoRenew: '開啟自動續費',
+ disableAutoRenew: '關閉自動續費',
+ autoRenewHint: '到期前 24 小時將自動從餘額扣款續費',
+ autoRenewDesc: '開啟自動續費後,實例將在到期前自動按 {cycle} 週期續費,每次續費 ¥{price}',
+ currentStatus: '當前狀態',
+
+ // 續費
+ renew: '續費',
+ renewInstance: '實例續費',
+ renewTitle: '續費實例',
+ selectRenewPeriod: '選擇續費時長',
+ renewMonths: '{months} 個月',
+ renewPrice: '續費金額',
+ originalPrice: '原價',
+ affDiscount: '優惠碼折扣',
+ actualPrice: '實付金額',
+ newExpiresAt: '續費後到期',
+ currentBalance: '當前餘額',
+ balanceAfterRenew: '續費後餘額',
+ insufficientBalance: '餘額不足',
+ goRecharge: '前往充值',
+ renewing: '續費中...',
+ renewSuccess: '續費成功',
+ renewFailed: '續費失敗',
+ freeInstanceNoRenew: '免費實例無需續費',
+ hostingRenewTooEarly: '託管實例僅可在到期前 7 天內續費(當前剩餘 {days} 天)',
+
+ // 升降級
+ changePlan: '升級',
+ changePlanTitle: '變更套餐方案',
+ upgrade: '升級',
+ selectNewPlan: '選擇新方案',
+ currentPlanLabel: '當前方案',
+ newPlanLabel: '新方案',
+ remainingDays: '剩餘天數',
+ remainingValue: '剩餘價值',
+ newPlanCost: '新方案費用',
+ priceDiff: '差價',
+ needPay: '需要支付',
+ noPriceChange: '無需補差價',
+ isUpgrade: '升級',
+ planNotActive: '方案已下架',
+ planNoStock: '方案已售罄',
+ changePlanInProgress: '升級方案中...',
+ changePlanSuccess: '方案升級成功',
+ changePlanFailed: '方案升級失敗',
+ needRestart: '請重啟實例以套用新配置',
+ newConfig: '新配置',
+ freeInstanceNoChange: '免費實例不支援升級',
+ samePlan: '不能切換到相同方案',
+ // 變更規則
+ viewRules: '變更規則',
+ hideRules: '收起規則',
+ changePlanRulesTitle: '方案升級規則',
+ changePlanRule1: '按日價計算差價,升級補差價',
+ changePlanRule2: '剩餘天數 ≥ 15 天才可升級方案',
+ changePlanRule3: '到期時間保持不變',
+ changePlanRule4: '優惠碼折扣繼續享受',
+ // 已是最高方案
+ alreadyHighestPlan: '當前已是最高方案',
+ contactForCustomPlan: '更高配置可提交工單定制',
+ // KVM/LXC 重啟提示
+ kvmRestartHint: 'KVM 實例升級後需重啟以套用新配置;若使用過重裝腳本,可能需自行擴容分割區',
+ lxcInstantHint: 'LXC 實例升級後配置即時生效',
+ kvmRestartRequired: 'KVM 實例方案已變更,請重啟實例以套用新配置',
+ // 不能變更原因
+ cannotChange: '暫時無法升級',
+ cannotChangeRemainingDays: '剩餘天數不足 {days} 天,無法升級方案',
+ cannotChangeInstanceStatus: '當前實例狀態不允許升級方案,僅運行中或已停止的實例可以操作',
+ cannotChangeUnknown: '當前無法升級方案',
+ // 計算詳情
+ oldDailyPrice: '原方案日價',
+ newDailyPrice: '新方案日價',
+ day: '天',
+ newPlanCostOriginal: '新方案剩餘費用',
+ newPlanCostFinal: '新方案剩餘費用(折後)',
+ discountAmount: '優惠碼折扣',
+
+ // 計費記錄
+ records: '計費記錄',
+ billingRecords: '計費記錄',
+ recordType: '類型',
+ recordAmount: '金額',
+ recordPeriod: '帳期',
+ recordRemark: '備註',
+ recordTypes: {
+ purchase: '購買',
+ renewal: '續費',
+ upgrade: '升級',
+ downgrade: '降級',
+ admin_extension: '管理員延期',
+ },
+
+ // 充值
+ recharge: '充值',
+ rechargeTitle: '帳戶充值',
+ rechargeAmount: '充值金額',
+ actualAmount: '實際到帳',
+ fee: '手續費',
+ minAmount: '最低充值',
+ maxAmount: '最高充值',
+ selectPaymentMethod: '選擇支付方式',
+ noPaymentProviders: '暫無可用支付渠道',
+ createOrder: '建立訂單',
+ creatingOrder: '建立訂單中...',
+ orderCreated: '訂單已建立',
+ orderNo: '訂單號',
+ orderStatus: '訂單狀態',
+ orderExpiredAt: '支付截止時間',
+ cancelOrder: '取消訂單',
+ orderCancelled: '訂單已取消',
+ orderStatus_pending: '待支付',
+ orderStatus_paid: '已支付',
+ orderStatus_completed: '已完成',
+ orderStatus_failed: '已失敗',
+ orderStatus_cancelled: '已取消',
+ orderStatus_expired: '已過期',
+ orderStatus_refunded: '已退款',
+
+ // 充值記錄
+ rechargeRecords: '充值記錄',
+ noRecords: '暫無記錄',
+ viewDetails: '查看詳情',
+
+ // 餘額記錄
+ balanceLogs: '餘額明細',
+ balanceLogTypes: {
+ recharge: '充值',
+ purchase: '購買',
+ renewal: '續費',
+ upgrade: '升級',
+ downgrade: '降級',
+ refund: '退款',
+ admin_adjust: '管理員調整',
+ },
+
+ // 套餐方案
+ plan: '方案',
+ planName: '方案名稱',
+ planPrice: '價格',
+ planBillingCycle: '帳期',
+ planConfig: '配置',
+ planStock: '庫存',
+ planSoldOut: '已售罄',
+ planInactive: '已下架',
+ billingCycleMonthly: '月付',
+ billingCycleQuarterly: '季付',
+ billingCycleYearly: '年付',
+ perMonth: '/月',
+ perQuarter: '/季',
+ perYear: '/年',
+ },
+
+ // 託管準入
+ hosting: {
+ accessDenied: {
+ title: '暫不滿足託管條件',
+ description: '您需要滿足以下條件才能使用託管功能:',
+ condition: '至少擁有過 1 台實例',
+ currentInstances: '當前擁有 {count} 台實例',
+ hint: '實例包括免費和付費實例,官方直營和託管節點均可。',
+ featureHiddenCondition: '託管功能暫未對新使用者開放',
+ featureHiddenCurrent: '您還沒有建立過節點,目前入口已被系統隱藏。',
+ featureHiddenHint: '如需使用託管功能,請聯絡管理員開放,或等待功能重新開放。',
+ },
+ },
+
+ // 託管收益
+ hostingWallet: {
+ title: '託管收益',
+ description: '查看您的節點託管收益和提現記錄',
+ hostingMember: '託管者會員',
+ notice: {
+ title: '託管公告',
+ },
+ balance: {
+ available: '可用餘額',
+ frozen: '凍結中',
+ frozenNote: '收入凍結30天後解凍',
+ totalIncome: '累計收入',
+ },
+ stats: {
+ myHostsCount: '託管節點',
+ instancesOnMyHosts: '節點實例',
+ uniqueCustomersCount: '客戶數',
+ monthIncome: '本月收入',
+ },
+ withdraw: {
+ button: '申請提現',
+ minAmountNote: '可用餘額達到 {amount} 後可提現',
+ },
+ tabs: {
+ overview: '概覽',
+ logs: '收支明細',
+ withdrawals: '提現記錄',
+ blocks: '黑名單',
+ },
+ overview: {
+ title: '提現說明',
+ howItWorks: '收益流程',
+ step1Title: '用戶購買實例',
+ step1Desc: '用戶在您的節點上購買/續費實例',
+ step2Title: '收入凍結',
+ step2Desc: '收益計入託管餘額,凍結30天',
+ step3Title: '解凍提現',
+ step3Desc: '凍結期滿後可申請提現',
+ minAmountTitle: '最低提現',
+ minAmount: '最低提現金額 {amount}',
+ feeTitle: '提現手續費',
+ feeDesc: '提現到面板餘額手續費 {rate}%',
+ feeDescNew: '提現到面板餘額手續費 5%,提現到指定方式手續費 10%',
+ manualTitle: '手動提現',
+ manualDesc: '如需提現到其他方式(手續費 10%),請提交工單申請',
+ withdrawMethodTitle: '提現方式',
+ withdrawMethodDesc: '自助提現至餘額或發送工單申請其他提現方式',
+ },
+ logs: {
+ noRecords: '暫無收支記錄',
+ emptyHint: '當用戶在您的節點上購買實例後,收益將顯示在這裡',
+ searchPlaceholder: '搜尋用戶名/郵箱/實例名...',
+ filterAll: '全部類型',
+ freePlan: '免費',
+ columns: {
+ type: '類型',
+ amount: '金額',
+ status: '狀態',
+ buyer: '購買者',
+ instance: '實例',
+ host: '節點',
+ package: '套餐',
+ plan: '方案',
+ remark: '備註',
+ time: '時間',
+ },
+ types: {
+ income: '收入',
+ unfreeze: '解凍',
+ withdraw: '提現',
+ deduction: '扣除',
+ },
+ actionTypes: {
+ purchase: '開通',
+ renew: '續費',
+ upgrade: '升級',
+ destroy: '銷毀',
+ unfreeze: '解凍',
+ withdraw: '提現',
+ admin_adjust: '管理員調整',
+ },
+ status: {
+ frozen: '凍結中',
+ unfrozen: '已解凍',
+ },
+ unknownInstance: '未知實例',
+ unknownUser: '未知用戶',
+ unknownHost: '未知節點',
+ },
+ perPage: '條/頁',
+ prevPage: '上一頁',
+ nextPage: '下一頁',
+ withdrawals: {
+ noRecords: '暫無提現記錄',
+ emptyTitle: '還沒有提現記錄',
+ emptyHint: '當您的可用餘額達到最低提現金額後,可以申請提現',
+ startEarning: '立即提現',
+ columns: {
+ amount: '金額',
+ actualAmount: '實際到賬',
+ target: '提現方式',
+ status: '狀態',
+ time: '申請時間',
+ },
+ target: {
+ balance: '面板餘額',
+ },
+ status: {
+ pending: '待審核',
+ approved: '已通過',
+ rejected: '已拒絕',
+ completed: '已完成',
+ },
+ },
+ blocks: {
+ title: '用戶黑名單',
+ description: '被拉黑用戶無法在開通實例處看到您的託管套餐和方案,也無法新開通您的實例。',
+ total: '共 {count} 位用戶',
+ searchLabel: '搜尋站內用戶',
+ searchPlaceholder: '輸入 UID、用戶名或郵箱,至少 2 個字元',
+ noSearchResults: '沒有找到匹配的用戶',
+ blockedUsers: '已拉黑用戶',
+ effectHint: '這些用戶無法新開通您的託管套餐,已有實例和續費不受影響。',
+ emptyTitle: '暫無拉黑用戶',
+ emptyHint: '可以透過上方搜尋添加。',
+ block: '拉黑',
+ unblock: '解除拉黑',
+ blockSuccess: '已加入黑名單',
+ unblockSuccess: '已移出黑名單',
+ },
+ modal: {
+ title: '申請提現',
+ amount: '提現金額',
+ availableNote: '可用餘額:{amount}',
+ targetBalance: '提現到面板餘額(手續費 {rate}%)',
+ manualWithdrawNote: '如需手動提現到指定方式(手續費 10%),請提交工單申請提現託管餘額',
+ summary: {
+ amount: '提現金額',
+ fee: '手續費',
+ actual: '實際到賬',
+ },
+ cancel: '取消',
+ confirm: '確認提現',
+ submitting: '提交中...',
+ },
+ errors: {
+ minAmount: '最低提現金額為 {amount}',
+ exceedBalance: '提現金額超過可用餘額',
+ },
+ },
+
+ vipBenefits: {
+ commonMember: '普通會員',
+ overviewLabel: 'VIP 福利大廳',
+ overviewTitle: '當前等級 {level}',
+ overviewDesc: '達到最高 {level},即可領取下方已解鎖等級的全部獎勵。請先領完低等級福利,再領取更高等級福利。',
+ noVipDesc: '升級 VIP 後可領取等級福利,所有獎品會按等級從低到高展示。',
+ availableSummary: '已解鎖獎勵匯總',
+ availableSummaryDesc: '這裡匯總當前等級已解鎖的全部獎勵額度。',
+ remainingSummary: '剩餘可領取',
+ noAvailableReward: '暫無已解鎖獎勵',
+ empty: '暫無 VIP 福利,請稍後再來。',
+ levelRewards: 'VIP{level} 獎勵',
+ levelUnlocked: '已達到該等級',
+ levelLocked: '升級後可領取',
+ rewardCount: '{count} 個獎品',
+ rewardValue: '獎勵內容',
+ claimProgress: '領取進度',
+ claim: '領取',
+ claimAll: '一鍵領取剩餘獎勵',
+ claimingAll: '正在領取...',
+ claimed: '已領取',
+ pendingDelivery: '待發放',
+ upgradeRequired: '待升級',
+ claimLowerFirst: '先領 VIP{level}',
+ claimSuccess: '領取成功',
+ noClaimableReward: '暫無可領取獎勵',
+ rewardReceived: 'VIP 福利獎勵',
+ pointsUnit: '積分',
+ instanceReward: '套餐實例',
+ instanceValue: '{plan} · {quantity} 台 · {days} 天',
+ feedback: {
+ delivered: '已到帳',
+ pending: '待發放',
+ },
+ types: {
+ balance: '餘額',
+ points: '積分',
+ instance: '實例',
+ },
+ summary: {
+ unlocked: '已解鎖',
+ claimable: '可領取',
+ claimed: '已領取',
+ pending: '待發放',
+ },
+ status: {
+ claimable: '可領取',
+ claimed: '已領取',
+ locked: '待升級',
+ blocked: '先領取 VIP{level}',
+ pending: '待發放',
+ },
+ },
+
+ // 福利系統
+ entertainment: {
+ title: '福利',
+ description: '領取會員福利,管理積分和徽章獎勵',
+ currentPoints: '當前積分',
+ convertPoints: '兑換積分',
+ noPointsToConvert: '暫無可兑換積分',
+ convertSuccess: '成功兑換 {points} 積分',
+ convertFailed: '兑換失敗',
+ pointsUnit: '分',
+ tabs: {
+ lottery: '抽獎',
+ records: '抽獎記錄',
+ points: '積分明細',
+ },
+ mainTabs: {
+ vipBenefits: 'VIP 福利',
+ lottery: '抽獎',
+ badge: '徽章',
+ blindbox: '盲盒',
+ checkin: '簽到',
+ },
+ comingSoon: '敬請期待',
+ blindbox: {
+ title: '盲盒',
+ },
+ checkinSection: {
+ title: '每日簽到',
+ },
+ // 抽獎
+ spin: '抽獎',
+ spinCost: '消耗 {points} 積分',
+ spinFailed: '抽獎失敗',
+ selectLottery: '請選擇抽獎活動',
+ notEnoughPoints: '積分不足',
+ noActiveLotteries: '暫無可用的抽獎活動',
+ prizeList: '獎品列表',
+ probability: '概率',
+ remaining: '剩餘',
+ // 十連抽
+ multiDraw: '十連抽',
+ multiDrawAgain: '再次十連',
+ multiDrawFailed: '十連抽失敗',
+ multiDrawResults: '十連抽結果',
+ multiDrawStopped: '抽獎提前結束',
+ notEnoughPointsForMulti: '積分不足,需要 {required} 積分,當前僅有 {current} 積分',
+ totalDraws: '抽獎次數',
+ totalPointsSpent: '消耗積分',
+ badgeUnit: '枚',
+ instanceUnit: '台',
+ multiDrawBadgesTitle: '本次十連抽中了徽章',
+ multiDrawBadgesSubtitle: '本輪十連共獲得 {count} 枚徽章,先看看這次的新收穫。',
+ continueToMultiResults: '繼續查看十連結果',
+ // 獎品類型
+ prizeTypes: {
+ nothing: '再接再勵',
+ points: '積分',
+ balance: '餘額',
+ badge: '隨機徽章',
+ instance: '實例',
+ cpu: 'CPU資源',
+ memory: '記憶體資源',
+ disk: '硬碟資源',
+ traffic: '流量資源',
+ },
+ // 中獎結果
+ congratulations: '恭喜中獎!',
+ betterLuckNextTime: '再接再勵',
+ wonPoints: '獲得 {points} 積分',
+ wonBalance: '獲得 ¥{amount} 餘額',
+ wonBadge: '獲得徽章:{badge}',
+ wonInstance: '請提交工單領取實例獎勵',
+ wonCpu: '獲得 {value}% CPU,已存入資源池',
+ wonMemory: '獲得 {value}MB 記憶體,已存入資源池',
+ wonDisk: '獲得 {value}MB 硬碟,已存入資源池',
+ wonTraffic: '獲得 {value}GB 流量,已存入資源池',
+ // 抽獎記錄
+ lotteryName: '活動名稱',
+ prize: '獎品',
+ prizeType: '獎品類型',
+ value: '獎勵值',
+ time: '時間',
+ noRecords: '暫無抽獎記錄',
+ loadRecordsFailed: '載入抽獎記錄失敗',
+ loadLotteriesFailed: '載入抽獎活動失敗',
+ // 積分明細
+ pointsLogType: '類型',
+ pointsChange: '變動',
+ pointsAfter: '變動後',
+ remark: '備註',
+ noPointsLogs: '暫無積分記錄',
+ loadPointsLogsFailed: '載入積分記錄失敗',
+ pointsLogTypes: {
+ convert: '消費兑換',
+ lotteryWin: '抽獎獲得',
+ lotterySpend: '抽獎消耗',
+ badgeDrawSpend: '徽章隨機抽取消耗',
+ badgeSelectSpend: '徽章自選消耗',
+ adminAdjust: '管理員調整',
+ checkin: '簽到獎勵',
+ },
+ badges: {
+ drawTab: '抽卡',
+ myTab: '我的徽章',
+ randomTitle: '隨機抽取',
+ randomHint: '等概率,必中一個徽章',
+ randomDescription: '消耗 {points} 積分,所有徽章等概率,必定獲得一個徽章副本。',
+ randomButton: '隨機抽一次({points} 積分)',
+ randomMultiButton: '隨機十連({points} 積分)',
+ selectTitle: '自選領取',
+ selectHint: '直接選擇一個指定徽章',
+ selectDescription: '消耗 {points} 積分,從下方直接選擇一個想要的徽章。',
+ selectButton: '領取目前選中徽章({points} 積分)',
+ multiDrawTitle: '十連抽獲得的徽章',
+ multiDrawSubtitle: '本次十連共獲得 {count} 枚徽章。',
+ myTitle: '我的徽章',
+ summary: '可用 {available},已套用 {applied}',
+ filterAll: '全部類型',
+ ownedCount: '已擁有 {count}',
+ empty: '還沒有獲得任何徽章。',
+ statusAvatar: '已套用到頭像',
+ statusInstance: '已套用到實例',
+ statusUnused: '未套用',
+ sourceLabel: '來源',
+ sourceDraw: '隨機抽取',
+ sourceLottery: '抽獎獲得',
+ sourceSelect: '自選領取',
+ sourceAdminGrant: '管理員發放',
+ obtainedAt: '獲得時間',
+ currentInstance: '目前實例',
+ applyAvatar: '套用到頭像',
+ applyInstance: '套用到實例圖示',
+ applyInstanceButton: '套用到實例',
+ selectInstance: '選擇一個實例',
+ unapply: '取消套用',
+ selectRequired: '請先選擇一個徽章',
+ instanceRequired: '請先選擇實例',
+ drawSuccess: '獲得徽章:{badge}',
+ selectSuccess: '領取成功:{badge}',
+ rewardTitleDraw: '抽卡成功',
+ rewardTitleSelect: '領取成功',
+ rewardSubtitle: '新徽章已經加入你的收藏,現在就可以前往「我的徽章」套用到頭像或實例。',
+ rewardSeriesLabel: '所屬系列',
+ rewardRemainingPoints: '剩餘積分',
+ rewardDrawAgain: '再抽一次',
+ rewardViewMine: '查看我的徽章',
+ applyAvatarSuccess: '已套用到頭像',
+ applyInstanceSuccess: '已套用到實例圖示',
+ unapplySuccess: '已取消套用',
+ },
+ // 管理端
+ admin: {
+ title: '娛樂管理',
+ description: '管理抽獎活動、獎品和用戶積分',
+ tabs: {
+ lotteries: '抽獎活動',
+ records: '中獎記錄',
+ users: '用戶積分',
+ badges: '徽章',
+ },
+ createLottery: '建立抽獎',
+ editLottery: '編輯抽獎',
+ lotteryName: '活動名稱',
+ enterLotteryName: '請輸入活動名稱',
+ lotteryDesc: '描述',
+ enterDescription: '請輸入描述(可選)',
+ costPoints: '消耗積分',
+ startAt: '開始時間',
+ endAt: '結束時間',
+ isActive: '啟用',
+ enterName: '請輸入名稱',
+ invalidCostPoints: '消耗積分必須大於0',
+ createSuccess: '建立成功',
+ updateSuccess: '更新成功',
+ saveFailed: '儲存失敗',
+ deleteSuccess: '刪除成功',
+ deleteFailed: '刪除失敗',
+ noLotteries: '暫無抽獎活動',
+ loadLotteriesFailed: '載入抽獎活動失敗',
+ prizes: '獎品',
+ totalDraws: '抽獎次數',
+ status: '狀態',
+ active: '已啟用',
+ inactive: '已停用',
+ // 獎品管理
+ managePrizes: '管理獎品',
+ addPrize: '新增獎品',
+ prizeName: '獎品名稱',
+ prizeType: '獎品類型',
+ prizeValue: '獎勵值',
+ balanceValue: '餘額(分)',
+ balanceCents: '輸入分,如100=1元',
+ cpuPercent: '輸入CPU百分比',
+ memoryMB: '輸入記憶體(MB)',
+ diskMB: '輸入硬碟(MB)',
+ trafficGB: '輸入流量(GB)',
+ probability: '權重',
+ quantity: '數量',
+ unlimited: '無限',
+ noQuantityForType: '此類型獎品不能設定數量限制',
+ replenish: '補充庫存',
+ remaining: '剩餘',
+ replenishPlaceholder: '輸入補充數量',
+ instanceDesc: '實例描述',
+ instanceDescPlaceholder: '如:1核/1G/10G SSD',
+ noPrizes: '暫無獎品,點擊上方按鈕新增',
+ enterPrizeName: '請輸入獎品名稱',
+ invalidProbability: '權重必須大於0',
+ savePrizesSuccess: '獎品儲存成功',
+ savePrizesFailed: '獎品儲存失敗',
+ // 中獎記錄
+ user: '用戶',
+ searchUser: '搜尋用戶名',
+ noRecords: '暫無中獎記錄',
+ loadRecordsFailed: '載入中獎記錄失敗',
+ prize: '獎品',
+ value: '獎勵值',
+ // 用戶積分
+ currentPoints: '當前積分',
+ totalEarned: '累計獲得',
+ totalSpent: '累計消耗',
+ lastConvertedAt: '最後兑換',
+ noUsers: '暫無用戶積分資料',
+ loadUsersFailed: '載入用戶積分失敗',
+ // 徽章目錄
+ badgeCatalog: {
+ loadFailed: '載入徽章目錄失敗',
+ fillSeriesRequired: '請填寫系列 ID、標題、名稱和說明',
+ seriesUpdated: '系列已更新',
+ seriesCreated: '系列已建立',
+ saveSeriesFailed: '儲存系列失敗',
+ seriesDeleted: '系列已刪除',
+ deleteSeriesFailed: '刪除系列失敗',
+ fillBadgeRequired: '請填寫徽章 ID、名稱、標籤、系列和預設圖片地址',
+ badgeUpdated: '徽章已更新',
+ badgeCreated: '徽章已建立',
+ saveBadgeFailed: '儲存徽章失敗',
+ badgeDeleted: '徽章已刪除',
+ deleteBadgeFailed: '刪除徽章失敗',
+ addSeries: '新增系列',
+ addBadge: '新增徽章',
+ series: {
+ title: '系列',
+ description: '控制前台篩選分組和整組啟用狀態',
+ add: '新增',
+ all: '全部系列',
+ enabledCount: '{active} / {total} 個啟用',
+ empty: '暫無系列',
+ editTitle: '編輯系列',
+ createTitle: '新增系列',
+ id: '系列 ID',
+ sort: '排序',
+ nameZh: '中文名',
+ nameEn: '英文名',
+ titleLabel: '標題',
+ titlePlaceholder: 'SUPREME 尊貴系列',
+ descriptionLabel: '說明',
+ sourceId: '來源 ID',
+ sourceLabel: '來源名稱',
+ enable: '啟用該系列',
+ },
+ badges: {
+ title: '徽章',
+ currentFilter: '當前篩選:{name}',
+ empty: '暫無徽章',
+ tableBadge: '徽章',
+ tableSeries: '系列',
+ tableAssetUrl: '圖片地址',
+ tableStatus: '狀態',
+ tableUsage: '使用',
+ drawable: '可抽取',
+ notDrawable: '不可抽取',
+ usage: '擁有 {ownership} / 頭像 {avatar} / 實例 {instance}',
+ editTitle: '編輯徽章',
+ createTitle: '新增徽章',
+ id: '徽章 ID',
+ series: '系列',
+ name: '名稱',
+ nameEn: '英文名',
+ fullLabel: '完整標籤',
+ fullLabelPlaceholder: '精英 (Elite)',
+ sourceId: '來源 ID',
+ sourceLabel: '來源名稱',
+ assetUrl: '預設圖片地址',
+ assetUrlPlaceholder: '/badges/dark/elite.svg 或 https://example.com/badge.svg',
+ assetUrlDark: '深色圖片地址',
+ assetUrlLight: '淺色圖片地址',
+ sort: '排序',
+ enable: '啟用該徽章',
+ preview: '預覽',
+ previewName: '徽章名稱',
+ previewLabel: '完整標籤',
+ },
+ },
+ // 通知配置
+ notification: {
+ title: '中獎通知',
+ enabled: '啟用通知',
+ type: '通知方式',
+ conditions: '通知條件',
+ notifyBalance: '中餘額時通知',
+ notifyInstance: '中實例時通知',
+ secret: '簽名密鑰',
+ secretPlaceholder: '用於驗證 Webhook 請求(可選)',
+ fillTelegram: '請填寫 Telegram Bot Token 和 Chat ID',
+ fillDiscord: '請填寫 Discord Webhook URL',
+ fillWebhook: '請填寫 Webhook URL',
+ saveSuccess: '通知配置保存成功',
+ saveFailed: '通知配置保存失敗',
+ },
+ },
+ },
+
+ // 郵箱模組
+ mail: {
+ title: '郵箱',
+ description: '管理您的專業郵箱服務',
+ tabs: {
+ my: '我的郵箱',
+ buy: '購買郵箱',
+ accounts: '郵箱帳戶',
+ dns: 'DNS 配置',
+ settings: '設定',
+ },
+ noSubscription: '您還沒有郵箱服務',
+ buyNowHint: '立即購買專業的郵箱服務',
+ buyNow: '立即購買',
+ subscriptionOverview: '訂閱概覽',
+ expiresAt: '到期時間',
+ domainsUsed: '已用網域',
+ diskUsed: '已用空間',
+ totalSpace: '總空間',
+ accounts: '帳戶',
+ plan: '方案',
+ domains: '個網域',
+ month: '月',
+ year: '年',
+ renew: '續費',
+ myDomains: '我的網域',
+ addDomain: '新增網域',
+ noDomains: '暫無網域,點擊上方按鈕新增',
+ used: '已用',
+ status: {
+ active: '活躍',
+ expired: '已過期',
+ suspended: '已暫停',
+ },
+ domainStatus: {
+ pending: '待驗證',
+ verified: '已驗證',
+ suspended: '已暫停',
+ },
+ selectRegion: '選擇服務地區',
+ nodeStatus: {
+ available: '節點狀態: 充足',
+ limited: '庫存緊張',
+ },
+ plans: '個方案',
+ planDetails: '方案配置詳情',
+ planTag: '正式版方案',
+ allFeaturesIncluded: '包含所有基礎及進階功能',
+ pureStorage: '純淨儲存空間',
+ feature: {
+ domains: '支援 {count} 個自訂網域綁定',
+ domainStorage: '{count}主網域 {storage}G儲存空間',
+ unlimitedAliases: '每個網域無限個別名',
+ unlimitedMailboxes: '每個網域無限個郵箱',
+ emailLimit: '每個網域 600 封電子郵件/小時',
+ emClientPro: '免費的 eM Client Pro 許可證',
+ catchAll: '支援 Catch All',
+ antispam: '自研反垃圾郵件閘道',
+ protocols: '支援 SMTP/IMAP/POP3 全協議',
+ aliases: '無限郵箱別名設定',
+ },
+ otherOptions: '其他選項',
+ verify: '驗證',
+ checkout: {
+ title: '結算匯總',
+ region: '選定地區',
+ serviceStatus: '服務狀態',
+ instant: '即時開通',
+ amount: '應付金額',
+ confirm: '立即開通',
+ securePayment: '加密支付保障,支援隨時申請退款',
+ balanceRequired: '需要先充值餘額,才能購買',
+ },
+ help: {
+ title: '遇到問題?',
+ desc: '如果您在購買過程中遇到任何技術問題,請提交工單聯系支援人員。',
+ },
+ selectPlan: '選擇方案',
+ storage: '儲存空間',
+ unlimitedAccounts: '無限郵箱帳戶',
+ orderConfirm: '訂單確認',
+ billingCycle: '計費週期',
+ monthly: '月付',
+ yearly: '年付',
+ totalPrice: '總價',
+ confirmRenew: '確認續費',
+ confirmPurchase: '確認購賣',
+ renewSubscription: '續費訂閱',
+ renewMonths: '續費時長',
+ monthlyPrice: '月單價',
+ yearlyPrice: '年單價',
+ alreadyPurchased: '您已購買該地區的郵箱服務',
+ alreadyPurchasedDesc: '每個用戶僅可購買一個地區的郵箱服務。如需更換方案,請先到「我的郵箱」進行管理。',
+ viewMySubscription: '查看我的訂閱',
+ renewDuration: '續費時長',
+ months: '個月',
+ renewSuccess: '續費成功',
+ selectPlanFirst: '請先選擇一個方案',
+ purchaseSuccess: '購賣成功',
+ domainName: '網域',
+ domainPlaceholder: '例如:example.com',
+ domainHint: '請輸入您擁有的網域,新增後需要配置 DNS 記錄驗證',
+ domainRequired: '請輸入網域',
+ domainAdded: '網域新增成功,請配置 DNS 記錄',
+ accountsDescription: '管理此網域下的郵箱帳戶',
+ createAccount: '建立帳戶',
+ verifyFirst: '請先驗證網域 DNS 配置後再建立郵箱帳戶',
+ completeDnsFirst: '請先完成 DNS 配置驗證,郵箱服務才能正常使用',
+ goDnsConfig: '前往配置',
+ adminAccount: '管理員帳號',
+ adminAccountDesc: '此帳號在新增網域時自動建立,可用於登入 Webmail 管理郵箱',
+ webmailUrl: '登入地址',
+ helpDoc: '查看幫助文檔',
+ noAdminAccount: '管理員帳號資訊不可用',
+ refreshStatus: '重新整理狀態',
+ noAccounts: '暫無郵箱帳戶',
+ admin: '管理員',
+ resetPassword: '重設密碼',
+ deleteAccountConfirm: '確定要刪除郵箱帳戶 {email} 嗎?此操作不可復原。',
+ accountDeleted: '帳戶已刪除',
+ dnsDescription: '請在您的網域 DNS 管理面板中新增以下記錄',
+ recordType: '記錄類型',
+ hostRecord: '主機記錄',
+ recordValue: '記錄值',
+ emailAddress: '郵箱地址',
+ txtVerification: 'TXT 驗證記錄',
+ pending: '待驗證',
+ verified: '已驗證',
+ mxRecords: 'MX 記錄',
+ cnameRecords: 'CNAME 記錄',
+ spfRecord: 'SPF 記錄',
+ dkimRecord: 'DKIM 記錄',
+ optional: '可選',
+ required: '必需',
+ recommended: '推薦',
+ dnsHint: {
+ txt: '網域驗證記錄,新增後點擊「刷新狀態」驗證',
+ mx: '郵件交換記錄,數字表示優先級(數字越小優先級越高)',
+ cname: '別名記錄,用於 Webmail 和自動發現功能',
+ spf: '寄件人策略框架,防止郵件偽造,提高送達率',
+ dkim: '網域密鑰認證,提高郵件可信度',
+ dmarc: '網域郵件認證策略,防止釣魚和欺詐郵件',
+ },
+ domainInfo: '網域資訊',
+ createdAt: '建立時間',
+ verifiedAt: '驗證時間',
+ dangerZone: '危險區域',
+ deleteDomainWarning: '刪除網域將同時刪除該網域下所有郵箱帳戶和資料,此操作不可復原。',
+ deleteDomain: '刪除網域',
+ deleteDomainConfirm: '確定要刪除網域 {domain} 嗎?此操作將刪除所有相關資料且不可復原。',
+ domainDeleted: '網域已刪除',
+ domainVerified: '網域驗證成功',
+ domainNotVerified: 'DNS 記錄尚未生效,請稍後重試',
+ username: '使用者名稱',
+ password: '密碼',
+ displayName: '顯示名稱',
+ diskLimit: '空間配額',
+ setAsAdmin: '設為網域管理員',
+ usernamePlaceholder: '例如:admin',
+ passwordPlaceholder: '至少8位字元',
+ passwordHint: '密碼至少8位,建議包含大小寫字母和數字',
+ displayNamePlaceholder: '例如:張三',
+ accountFieldsRequired: '請填寫使用者名稱和密碼',
+ accountCreated: '帳戶建立成功',
+ accountUpdated: '帳戶更新成功',
+ passwordMinLength: '密碼至少8位字元',
+ passwordReset: '密碼重設成功',
+ editAccount: '編輯帳戶',
+ newPassword: '新密碼',
+ newPasswordPlaceholder: '輸入新密碼',
+ },
+}
diff --git a/client/src/main.ts b/client/src/main.ts
new file mode 100644
index 0000000..bd0ba87
--- /dev/null
+++ b/client/src/main.ts
@@ -0,0 +1,81 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import router from './router'
+import i18n, { getLocale } from './locales'
+import App from './App.vue'
+import './styles/main.css'
+import 'flag-icons/css/flag-icons.min.css'
+
+const app = createApp(App)
+const pinia = createPinia()
+
+app.use(pinia)
+app.use(router)
+app.use(i18n)
+
+document.documentElement.lang = getLocale()
+
+// 全局错误处理
+app.config.errorHandler = (err, _instance, info) => {
+ console.error('Vue应用错误:', err, info)
+ // 如果是组件加载错误,尝试重新加载页面
+ if (err && typeof err === 'object' && 'message' in err) {
+ const errorMessage = String(err.message)
+ if (errorMessage.includes('Failed to fetch dynamically imported module') ||
+ errorMessage.includes('Loading chunk') ||
+ errorMessage.includes('ChunkLoadError')) {
+ console.warn('检测到代码块加载失败,尝试重新加载页面')
+ setTimeout(() => {
+ window.location.reload()
+ }, 1000)
+ return
+ }
+ }
+}
+
+// Initialize theme (after pinia is mounted)
+import { useThemeStore } from './stores/theme'
+const themeStore = useThemeStore()
+themeStore.init()
+
+// Load public config
+import { useConfigStore } from './stores/config'
+const configStore = useConfigStore()
+configStore.loadPublicConfig().then(() => {
+ const logoUrl = configStore.brandLogoUrl?.trim() || '/incudal_logo.webp'
+ const icon = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null
+ const appleTouchIcon = document.querySelector('link[rel="apple-touch-icon"]') as HTMLLinkElement | null
+ if (icon) {
+ icon.href = logoUrl
+ }
+ if (appleTouchIcon) {
+ appleTouchIcon.href = logoUrl
+ }
+})
+
+app.mount('#app')
+
+// 注册 Service Worker(仅生产环境)
+if ('serviceWorker' in navigator && import.meta.env.PROD) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js')
+ .then(registration => {
+ console.log('Service Worker 注册成功:', registration.scope)
+
+ // 检测更新
+ registration.addEventListener('updatefound', () => {
+ const newWorker = registration.installing
+ if (newWorker) {
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ console.log('有新版本可用,刷新页面后生效')
+ }
+ })
+ }
+ })
+ })
+ .catch(error => {
+ console.warn('Service Worker 注册失败:', error)
+ })
+ })
+}
diff --git a/client/src/router/index.ts b/client/src/router/index.ts
new file mode 100644
index 0000000..0b33a86
--- /dev/null
+++ b/client/src/router/index.ts
@@ -0,0 +1,551 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+import { useConfigStore } from '@/stores/config'
+import type { RouteLocationNormalized, NavigationGuardNext, RouteRecordRaw } from 'vue-router'
+import api from '@/api'
+
+// OAuth 登录码处理状态
+let oauthProcessing = false
+let oauthProcessed = false
+const hiddenHostingRouteNames = new Set([
+ 'my-hosts',
+ 'my-host-create',
+ 'my-host-detail',
+ 'my-packages',
+ 'my-package-create',
+ 'my-package-edit',
+ 'hosting-wallet'
+])
+const hiddenMailRouteNames = new Set(['mail', 'mail-domain'])
+
+// 处理 OAuth 登录码的函数
+async function handleOAuthCode(): Promise {
+ if (oauthProcessed) return true
+
+ const urlParams = new URLSearchParams(window.location.search)
+ const oauthCode = urlParams.get('oauth_code')
+
+ if (!oauthCode) return false
+
+ // 防止重复处理
+ if (oauthProcessing) {
+ // 等待处理完成
+ while (oauthProcessing) {
+ await new Promise(resolve => setTimeout(resolve, 50))
+ }
+ return oauthProcessed
+ }
+
+ oauthProcessing = true
+
+ try {
+ const response = await api.oauth.exchangeCode(oauthCode)
+ // 保存 token 到 localStorage
+ localStorage.setItem('token', response.token)
+ // 同步到 auth store
+ const authStore = useAuthStore()
+ authStore.syncToken()
+ // 获取用户信息
+ await authStore.fetchCurrentUser()
+
+ // 清除 URL 中的 oauth_code 参数
+ urlParams.delete('oauth_code')
+ const newSearch = urlParams.toString()
+ const newUrl = window.location.pathname + (newSearch ? '?' + newSearch : '')
+ window.history.replaceState({}, '', newUrl)
+
+ oauthProcessed = true
+ return true
+ } catch (err) {
+ console.error('OAuth login code exchange failed:', err)
+ // 清除 URL 参数,避免重复尝试
+ urlParams.delete('oauth_code')
+ const newSearch = urlParams.toString()
+ const newUrl = window.location.pathname + (newSearch ? '?' + newSearch : '')
+ window.history.replaceState({}, '', newUrl)
+ return false
+ } finally {
+ oauthProcessing = false
+ }
+}
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: '/login',
+ name: 'login',
+ component: () => import('@/views/LoginView.vue'),
+ meta: { guest: true }
+ },
+ {
+ path: '/register/:code?',
+ name: 'register',
+ component: () => import('@/views/RegisterView.vue'),
+ meta: { guest: true }
+ },
+ {
+ path: '/forgot-password',
+ name: 'forgot-password',
+ component: () => import('@/views/ForgotPasswordView.vue'),
+ meta: { guest: true }
+ },
+ {
+ path: '/',
+ redirect: { name: 'dashboard' }
+ },
+ {
+ path: '/dashboard',
+ name: 'dashboard',
+ component: () => import('@/views/DashboardView.vue'),
+ meta: { requiresAuth: true, requiresUser: true, titleKey: 'nav.dashboard', title: '概览' }
+ },
+ {
+ path: '/market',
+ redirect: (to) => ({
+ name: 'instance-create',
+ query: to.query
+ })
+ },
+ {
+ path: '/instances',
+ name: 'instances',
+ component: () => import('@/views/InstancesView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.instances', title: '实例' }
+ },
+ {
+ path: '/instances/create',
+ name: 'instance-create',
+ component: () => import('@/views/InstanceCreateView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.createInstance', title: '创建实例' }
+ },
+ {
+ path: '/instances/:id',
+ name: 'instance-detail',
+ component: () => import('@/views/InstanceDetailView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.instanceDetail', title: '实例详情' }
+ },
+ // 域名邮箱
+ {
+ path: '/mail',
+ name: 'mail',
+ component: () => import('@/views/MailView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.mail', title: '邮箱' }
+ },
+ {
+ path: '/mail/domains/:id',
+ name: 'mail-domain',
+ component: () => import('@/views/MailDomainView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.mailDomain', title: '邮箱域名' }
+ },
+ {
+ path: '/profile',
+ name: 'profile',
+ component: () => import('@/views/ProfileView.vue'),
+ meta: { requiresAuth: true, titleKey: 'auth.profile', title: '个人设置' }
+ },
+ {
+ path: '/wallet',
+ name: 'wallet',
+ component: () => import('@/views/WalletView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.wallet', title: '钱包' }
+ },
+ {
+ path: '/invites',
+ name: 'invites',
+ component: () => import('@/views/InvitesView.vue'),
+ meta: { requiresAuth: true, requiresUser: true, titleKey: 'nav.invites', title: '邀请码' }
+ },
+ {
+ path: '/extensions',
+ name: 'extensions',
+ component: () => import('@/views/ExtensionsView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.extensions', title: '扩展' }
+ },
+ {
+ path: '/logs',
+ name: 'logs',
+ component: () => import('@/views/LogsView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.logs', title: '日志' }
+ },
+ {
+ path: '/transfers',
+ name: 'transfers',
+ component: () => import('@/views/TransfersView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.transfers', title: '转移' }
+ },
+ // 用户资源管理路由(开放给满足条件的用户)
+ {
+ path: '/resources/hosts',
+ name: 'my-hosts',
+ component: () => import('@/views/resources/MyHostsView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.myHosts', title: '我的节点' }
+ },
+ {
+ path: '/resources/hosts/create',
+ name: 'my-host-create',
+ component: () => import('@/views/resources/MyHostCreateView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.myHostCreate', title: '创建节点' }
+ },
+ {
+ path: '/resources/hosts/:id',
+ name: 'my-host-detail',
+ component: () => import('@/views/resources/MyHostDetailView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.myHostDetail', title: '节点详情' }
+ },
+ {
+ path: '/resources/packages',
+ name: 'my-packages',
+ component: () => import('@/views/resources/MyPackagesView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.myPackages', title: '我的套餐' }
+ },
+ {
+ path: '/resources/packages/create',
+ name: 'my-package-create',
+ component: () => import('@/views/resources/PackageFormView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.myPackageCreate', title: '创建套餐' }
+ },
+ {
+ path: '/resources/packages/:id/edit',
+ name: 'my-package-edit',
+ component: () => import('@/views/resources/PackageFormView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.myPackageEdit', title: '编辑套餐' }
+ },
+ // 托管余额页面
+ {
+ path: '/hosting-wallet',
+ name: 'hosting-wallet',
+ component: () => import('@/views/HostingWalletView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.hostingWallet', title: '托管收益' }
+ },
+
+ // 工单系统路由
+ {
+ path: '/tickets',
+ name: 'tickets',
+ component: () => import('@/views/TicketsView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.tickets', title: '工单' }
+ },
+ // Admin routes
+ {
+ path: '/admin/users',
+ name: 'admin-users',
+ component: () => import('@/views/admin/UsersView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.users', title: '用户' }
+ },
+ {
+ path: '/admin/hosting',
+ name: 'admin-hosting',
+ component: () => import('@/views/admin/HostingView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.hosting', title: '托管' }
+ },
+ {
+ path: '/admin/statistics',
+ name: 'admin-statistics',
+ component: () => import('@/views/admin/StatisticsView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.statistics', title: '统计' }
+ },
+ {
+ path: '/admin/oauth',
+ name: 'admin-oauth',
+ component: () => import('@/views/admin/OAuthConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.oauth', title: 'OAuth' }
+ },
+ {
+ path: '/admin/help',
+ name: 'admin-help',
+ component: () => import('@/views/admin/HelpManageView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.helpManage', title: '帮助' }
+ },
+ {
+ path: '/admin/settings',
+ name: 'admin-settings',
+ redirect: (to) => ({
+ path: '/admin/settings/access',
+ query: to.query,
+ hash: to.hash
+ }),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.settings', title: '设置' }
+ },
+ {
+ path: '/admin/settings/access',
+ name: 'admin-settings-access',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.sections.access.title', title: '访问与注册' }
+ },
+ {
+ path: '/admin/settings/hosting',
+ name: 'admin-settings-hosting',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.sections.hosting.title', title: '托管与站点' }
+ },
+ {
+ path: '/admin/settings/brand',
+ name: 'admin-settings-brand',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.sections.brand.title', title: '品牌与外观' }
+ },
+ {
+ path: '/admin/settings/security',
+ name: 'admin-settings-security',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.sections.security.title', title: '安全验证' }
+ },
+ {
+ path: '/admin/settings/mail',
+ name: 'admin-settings-mail',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.sections.mail.title', title: '邮件服务' }
+ },
+ {
+ path: '/admin/settings/tickets',
+ name: 'admin-settings-tickets',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.sections.tickets.title', title: '工单与附件' }
+ },
+ {
+ path: '/admin/settings/popup-announcement',
+ name: 'admin-settings-popup-announcement',
+ component: () => import('@/views/admin/SystemConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'admin.system.popupAnnouncement.title', title: '弹窗公告' }
+ },
+ {
+ path: '/admin/settings/telegram',
+ name: 'admin-settings-telegram',
+ component: () => import('@/views/admin/TelegramConfigView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.telegramSettings', title: 'Telegram 设置' }
+ },
+ {
+ path: '/admin/images',
+ name: 'admin-images',
+ component: () => import('@/views/admin/ImagesView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.images', title: '镜像' }
+ },
+ {
+ path: '/admin/broadcast',
+ name: 'admin-broadcast',
+ component: () => import('@/views/admin/BroadcastView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.broadcast', title: '公告' }
+ },
+ {
+ path: '/admin/payment-providers',
+ name: 'admin-payment-providers',
+ redirect: { path: '/admin/billing', query: { tab: 'paymentProviders' } },
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.paymentProviders', title: '支付' }
+ },
+ {
+ path: '/admin/billing',
+ name: 'admin-billing',
+ component: () => import('@/views/admin/BillingView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.billing', title: '计费' }
+ },
+ {
+ path: '/admin/aff',
+ name: 'admin-aff',
+ redirect: { path: '/admin/billing', query: { tab: 'affConversions' } },
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.aff', title: '推荐' }
+ },
+ {
+ path: '/admin/instances/create',
+ name: 'admin-instance-create',
+ component: () => import('@/views/admin/AdminInstanceCreateView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.adminCreateInstance', title: '管理员创建实例' }
+ },
+ {
+ path: '/admin/mail',
+ name: 'admin-mail',
+ component: () => import('@/views/admin/AdminMailView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'nav.mail', title: '邮箱' }
+ },
+ // Help articles (public access)
+ {
+ path: '/help',
+ name: 'help',
+ component: () => import('@/views/HelpView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.help', title: '帮助' }
+ },
+ {
+ path: '/help/:slug',
+ name: 'help-article',
+ component: () => import('@/views/HelpView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.help', title: '帮助' }
+ },
+ // Inbox (notifications)
+ {
+ path: '/inbox',
+ name: 'inbox',
+ component: () => import('@/views/InboxView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.inbox', title: '通知' }
+ },
+ // 娱乐系统
+ {
+ path: '/entertainment',
+ name: 'entertainment',
+ component: () => import('@/views/EntertainmentView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.entertainment', title: '娱乐' }
+ },
+ // 管理端娱乐管理
+ {
+ path: '/admin/entertainment',
+ name: 'admin-entertainment',
+ component: () => import('@/views/admin/EntertainmentView.vue'),
+ meta: { requiresAuth: true, requiresAdmin: true, titleKey: 'entertainment.admin.title', title: '娱乐管理' }
+ },
+ // 集中式终端管理
+ {
+ path: '/terminal',
+ name: 'terminal',
+ component: () => import('@/views/TerminalView.vue'),
+ meta: { requiresAuth: true, titleKey: 'nav.terminal', title: '终端' }
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'not-found',
+ component: () => import('@/views/NotFoundView.vue'),
+ meta: { titleKey: 'error.notFound', title: '页面不存在' }
+ }
+]
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes,
+ // 页面切换时滚动到顶部,解决页面切换后空白问题
+ scrollBehavior(_to, _from, savedPosition) {
+ if (_to.path === _from.path && _to.hash === _from.hash) {
+ return false
+ }
+ if (savedPosition) {
+ return savedPosition
+ }
+ return { top: 0, behavior: 'instant' }
+ }
+})
+
+// 路由错误处理 - 捕获组件加载失败
+router.onError((error) => {
+ console.error('路由错误:', error)
+ console.error('错误详情:', {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ url: window.location.href
+ })
+ // 如果是组件加载失败,尝试重新加载页面
+ if (error.message?.includes('Failed to fetch dynamically imported module') ||
+ error.message?.includes('Loading chunk') ||
+ error.message?.includes('ChunkLoadError') ||
+ error.name === 'ChunkLoadError') {
+ console.warn('检测到代码块加载失败,尝试重新加载页面')
+ // 延迟一下,避免快速重载循环
+ setTimeout(() => {
+ window.location.reload()
+ }, 1000)
+ }
+})
+
+// Route guard
+router.beforeEach(async (to: RouteLocationNormalized, _from: RouteLocationNormalized, next: NavigationGuardNext) => {
+ const authStore = useAuthStore()
+ const configStore = useConfigStore()
+
+ // 检查是否有 OAuth 登录码需要处理
+ const urlParams = new URLSearchParams(window.location.search)
+ if (urlParams.has('oauth_code')) {
+ // 先处理 OAuth 登录码,等待完成
+ await handleOAuthCode()
+ }
+
+ // 如果有 token 但用户信息还没加载,先等待加载完成
+ if (authStore.isAuthenticated && !authStore.user) {
+ try {
+ await authStore.fetchCurrentUser()
+ } catch {
+ // 加载失败,token 可能无效,会被 logout 清除
+ }
+ }
+
+ // 如果配额信息未加载,尝试重新获取
+ if (authStore.isAuthenticated && !authStore.quota && authStore.user) {
+ try {
+ await authStore.fetchCurrentUser()
+ } catch {
+ // 静默失败
+ }
+ }
+
+ // Pages requiring authentication
+ if (to.meta.requiresAuth && !authStore.isAuthenticated) {
+ next({ name: 'login', query: { redirect: to.fullPath } })
+ return
+ }
+
+ // Pages requiring admin permission
+ if (to.meta.requiresAdmin && !authStore.isAdmin) {
+ // 如果不是管理员,重定向到 dashboard(普通用户)
+ next({ name: 'dashboard' })
+ return
+ }
+
+ if (to.name === 'tickets' && authStore.isAuthenticated && !authStore.isAdmin) {
+ await configStore.loadPublicConfig()
+ if (!configStore.ticketEnabled) {
+ next({ name: 'dashboard' })
+ return
+ }
+ }
+
+ if (
+ authStore.isAuthenticated &&
+ !authStore.isAdmin &&
+ typeof to.name === 'string' &&
+ hiddenMailRouteNames.has(to.name)
+ ) {
+ await configStore.loadPublicConfig()
+ if (!configStore.mailAvailable) {
+ next({ name: 'dashboard' })
+ return
+ }
+ }
+
+ // Pages requiring user (non-admin) permission
+ if (to.meta.requiresUser && authStore.isAdmin) {
+ // 如果是管理员访问用户专属页面,重定向到用户管理页面
+ next({ name: 'admin-users' })
+ return
+ }
+
+ if (
+ !authStore.isAdmin &&
+ typeof to.name === 'string' &&
+ hiddenHostingRouteNames.has(to.name) &&
+ authStore.user?.canAccessHostingFeature === false
+ ) {
+ next({ name: 'dashboard' })
+ return
+ }
+
+ // 配额检查:普通用户不再需要配额检查(好友、节点、套餐功能已移除)
+ // 节点和套餐路由已设置 requiresAdmin,不需要额外配额检查
+
+ // Authenticated users accessing login/register pages
+ if (to.meta.guest && authStore.isAuthenticated) {
+ // 根据用户角色跳转
+ const redirectName = authStore.isAdmin ? 'admin-users' : 'dashboard'
+ next({ name: redirectName })
+ return
+ }
+
+ next()
+})
+
+// 预加载常用页面,提升切换速度
+router.isReady().then(() => {
+ // 延迟预加载,避免影响首屏加载
+ setTimeout(() => {
+ // 预加载核心页面
+ import('@/views/DashboardView.vue')
+ import('@/views/InstancesView.vue')
+ import('@/views/InstanceDetailView.vue')
+ import('@/views/ProfileView.vue')
+ }, 1000)
+})
+
+export default router
diff --git a/client/src/stores/auth.ts b/client/src/stores/auth.ts
new file mode 100644
index 0000000..7f8bac4
--- /dev/null
+++ b/client/src/stores/auth.ts
@@ -0,0 +1,149 @@
+import { defineStore } from 'pinia'
+import { ref, computed, type Ref } from 'vue'
+import api from '@/api'
+import type { AuthUser } from '@/types/store.js'
+import type { RegisterRequest } from '@/types/api.js'
+
+export const useAuthStore = defineStore('auth', () => {
+ const user: Ref = ref(null)
+ const token: Ref = ref(localStorage.getItem('token') || null)
+ const quota = ref(null)
+
+ // 同步 token 的方法(用于 token 刷新后同步)
+ function syncToken() {
+ token.value = localStorage.getItem('token')
+ }
+
+ // 监听 localStorage 变化(跨窗口同步)
+ if (typeof window !== 'undefined') {
+ window.addEventListener('storage', (e) => {
+ if (e.key === 'token') {
+ token.value = e.newValue
+ }
+ })
+ }
+
+ const isAuthenticated = computed(() => !!token.value)
+ const isAdmin = computed(() => user.value?.role === 'admin')
+
+ function applyAuthUser(rawUser: any) {
+ user.value = {
+ id: rawUser.id,
+ username: rawUser.username,
+ email: rawUser.email || '',
+ role: rawUser.role,
+ avatarStyle: rawUser.avatarStyle || 'bigSmile',
+ avatarBadgeId: rawUser.avatarBadgeId || null,
+ hasCreatedHostBefore: rawUser.hasCreatedHostBefore || false,
+ canAccessHostingFeature: rawUser.canAccessHostingFeature ?? true
+ }
+ }
+
+ async function login(username: string, password: string, totpCode?: string, recoveryCode?: string, turnstileToken?: string) {
+ const response = await api.auth.login(username, password, totpCode, recoveryCode, turnstileToken)
+
+ token.value = response.token
+ applyAuthUser(response.user)
+ localStorage.setItem('token', response.token)
+ return response
+ }
+
+ async function register(data: RegisterRequest) {
+ const response = await api.auth.register(data)
+
+ // 注册成功后自动登录
+ token.value = response.token
+ applyAuthUser(response.user)
+ localStorage.setItem('token', response.token)
+
+ return response
+ }
+
+ async function fetchCurrentUser(): Promise {
+ if (!token.value) return null
+ try {
+ const response = await api.auth.me()
+ applyAuthUser(response.user)
+ // 保存配额信息
+ quota.value = (response.user as any).quota || null
+ return user.value
+ } catch (error: any) {
+ if (error?.code === 'UNAUTHORIZED' || error?.response?.status === 401) {
+ // 只清除本地状态,不调用后端logout(因为token可能已经无效)
+ clearLocalAuth()
+ }
+ throw error
+ }
+ }
+
+ // 清除本地认证状态(不调用后端API)
+ function clearLocalAuth() {
+ token.value = null
+ user.value = null
+ quota.value = null
+ localStorage.removeItem('token')
+ }
+
+ async function logout() {
+ // 先调用后端登出 API(清除服务端会话和记录日志)
+ // 必须在清除本地状态之前调用,否则请求拦截器无法获取 token
+ if (token.value) {
+ try {
+ await api.auth.logout()
+ } catch (error) {
+ // 后端调用失败不影响本地登出
+ console.warn('登出 API 调用失败:', error)
+ }
+ }
+
+ // 最后清除本地状态
+ clearLocalAuth()
+ }
+
+ // 初始化:如果存在 token 则获取用户信息
+ // 使用 setTimeout 延迟执行,避免在 store 初始化时立即触发请求
+ if (token.value && typeof window !== 'undefined') {
+ setTimeout(() => {
+ fetchCurrentUser().catch(() => {
+ // 初始化时静默失败,如果 token 无效会自动清除
+ })
+ }, 0)
+ }
+
+ // 检查会话是否有效(用于页面可见性变化时检查)
+ async function checkSession(): Promise {
+ if (!token.value) {
+ return false
+ }
+ try {
+ const response = await api.auth.me()
+ applyAuthUser(response.user)
+ quota.value = (response.user as any).quota || null
+ return true
+ } catch (error: any) {
+ // 如果是401,说明会话已过期
+ if (error?.code === 'UNAUTHORIZED' || error?.response?.status === 401) {
+ clearLocalAuth()
+ return false
+ }
+ // 其他错误(如网络错误),不清除状态
+ return true
+ }
+ }
+
+ return {
+ user,
+ token,
+ quota,
+ isAuthenticated,
+ isAdmin,
+ login,
+ register,
+ fetchCurrentUser,
+ logout,
+ syncToken,
+ clearLocalAuth,
+ checkSession
+ }
+})
+
diff --git a/client/src/stores/badges.ts b/client/src/stores/badges.ts
new file mode 100644
index 0000000..ef4a1db
--- /dev/null
+++ b/client/src/stores/badges.ts
@@ -0,0 +1,62 @@
+import { computed, ref, shallowRef } from 'vue'
+import { defineStore } from 'pinia'
+import api from '@/api'
+import type { BadgeCatalogItem, BadgeSeriesItem } from '@/types/api'
+
+export const useBadgeStore = defineStore('badges', () => {
+ const series = ref([])
+ const badges = ref([])
+ const loaded = ref(false)
+ const loading = ref(false)
+ const loadingPromise = shallowRef | null>(null)
+ const missingReloads = new Set()
+
+ const badgeMap = computed(() => new Map(badges.value.map(badge => [badge.id, badge])))
+
+ async function loadCatalog(force = false) {
+ if (loading.value && loadingPromise.value) return loadingPromise.value
+ if (loaded.value && !force) return
+
+ loading.value = true
+ loadingPromise.value = (async () => {
+ try {
+ const res = await api.entertainment.getBadgeCatalog()
+ series.value = res.series || []
+ badges.value = res.badges || []
+ loaded.value = true
+ } catch (error) {
+ console.warn('Failed to load badge catalog:', error)
+ } finally {
+ loading.value = false
+ loadingPromise.value = null
+ }
+ })()
+
+ return loadingPromise.value
+ }
+
+ function getBadge(id: string | null | undefined): BadgeCatalogItem | null {
+ if (!id) return null
+ return badgeMap.value.get(id) || null
+ }
+
+ async function ensureBadge(id: string | null | undefined) {
+ if (!id) return
+ await loadCatalog()
+ if (!getBadge(id) && !missingReloads.has(id)) {
+ missingReloads.add(id)
+ await loadCatalog(true)
+ }
+ }
+
+ return {
+ series,
+ badges,
+ loaded,
+ loading,
+ badgeMap,
+ loadCatalog,
+ ensureBadge,
+ getBadge
+ }
+})
diff --git a/client/src/stores/config.ts b/client/src/stores/config.ts
new file mode 100644
index 0000000..11e95a4
--- /dev/null
+++ b/client/src/stores/config.ts
@@ -0,0 +1,104 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import api from '@/api'
+
+type PopupPromoPackage = {
+ id: number
+ name: string
+ description: string | null
+ source: 'official' | 'market'
+ plans: Array<{
+ id: number
+ name: string
+ description: string | null
+ cpu: number
+ memory: number
+ disk: number
+ trafficLimit: string
+ price: number
+ billingCycle: number
+ isSoldOut: boolean
+ }>
+}
+
+export const useConfigStore = defineStore('config', () => {
+ const avatarApiBase = ref('https://api.dicebear.com/9.x')
+ const brandName = ref('Incudal')
+ const brandSubtitle = ref('基于 Incus 的低价 NAT VPS')
+ const brandLogoUrl = ref('/incudal_logo.webp')
+ const registrationEnabled = ref(true)
+ const requireInviteCode = ref(true)
+ const ticketEnabled = ref(true)
+ const freeSiteMode = ref(false)
+ const mailAvailable = ref(true)
+ const turnstileEnabled = ref(false)
+ const turnstileSiteKey = ref(null)
+ const transferFee = ref(0)
+ const footerContactEmail = ref('incudal@sent.com')
+ const footerTelegramLink = ref('https://t.me/incudal_com')
+ const hostingMarketEntryEnabled = ref(true)
+ const hostingNotice = ref(null)
+ const popupAnnouncement = ref(null)
+ const popupAnnouncementUpdatedAt = ref(null)
+ const popupPromoImageUrl = ref(null)
+ const popupPromoPackage = ref(null)
+ const popupPromoUpdatedAt = ref(null)
+ const loaded = ref(false)
+
+ async function loadPublicConfig(force = false) {
+ if (loaded.value && !force) return
+ try {
+ const config = await api.systemConfig.getPublic()
+ registrationEnabled.value = config.registrationEnabled ?? true
+ requireInviteCode.value = config.requireInviteCode
+ ticketEnabled.value = config.ticketEnabled ?? true
+ freeSiteMode.value = config.freeSiteMode ?? false
+ mailAvailable.value = config.mailAvailable ?? true
+ turnstileEnabled.value = config.turnstileEnabled || false
+ turnstileSiteKey.value = config.turnstileSiteKey || null
+ avatarApiBase.value = config.avatarApiBase || 'https://api.dicebear.com/9.x'
+ brandName.value = config.brandName?.trim() || 'Incudal'
+ brandSubtitle.value = config.brandSubtitle?.trim() || '基于 Incus 的低价 NAT VPS'
+ brandLogoUrl.value = config.brandLogoUrl?.trim() || '/incudal_logo.webp'
+ transferFee.value = config.transferFee || 0
+ footerContactEmail.value = config.footerContactEmail ?? null
+ footerTelegramLink.value = config.footerTelegramLink ?? null
+ hostingMarketEntryEnabled.value = config.hostingMarketEntryEnabled ?? true
+ hostingNotice.value = config.hostingNotice ?? null
+ popupAnnouncement.value = config.popupAnnouncement ?? null
+ popupAnnouncementUpdatedAt.value = config.popupAnnouncementUpdatedAt ?? null
+ popupPromoImageUrl.value = config.popupPromoImageUrl ?? null
+ popupPromoPackage.value = config.popupPromoPackage ?? null
+ popupPromoUpdatedAt.value = config.popupPromoUpdatedAt ?? null
+ loaded.value = true
+ } catch (error) {
+ console.error('Failed to load public config:', error)
+ }
+ }
+
+ return {
+ avatarApiBase,
+ brandName,
+ brandSubtitle,
+ brandLogoUrl,
+ registrationEnabled,
+ requireInviteCode,
+ ticketEnabled,
+ freeSiteMode,
+ mailAvailable,
+ turnstileEnabled,
+ turnstileSiteKey,
+ transferFee,
+ footerContactEmail,
+ footerTelegramLink,
+ hostingMarketEntryEnabled,
+ hostingNotice,
+ popupAnnouncement,
+ popupAnnouncementUpdatedAt,
+ popupPromoImageUrl,
+ popupPromoPackage,
+ popupPromoUpdatedAt,
+ loaded,
+ loadPublicConfig
+ }
+})
diff --git a/client/src/stores/inbox.ts b/client/src/stores/inbox.ts
new file mode 100644
index 0000000..87c2b90
--- /dev/null
+++ b/client/src/stores/inbox.ts
@@ -0,0 +1,100 @@
+/**
+ * 站内信状态管理
+ */
+
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import api from '@/api'
+
+export const useInboxStore = defineStore('inbox', () => {
+ // 未读消息数量
+ const unreadCount = ref(0)
+
+ // 轮询定时器
+ let pollTimer: number | null = null
+
+ // 是否已初始化
+ const initialized = ref(false)
+
+ /**
+ * 获取未读消息数量
+ */
+ async function fetchUnreadCount(): Promise {
+ try {
+ const res = await api.inbox.getUnreadCount()
+ unreadCount.value = res.count
+ } catch {
+ // 静默失败,不影响用户体验
+ }
+ }
+
+ /**
+ * 启动轮询(登录后调用)
+ */
+ function startPolling(): void {
+ if (initialized.value) return
+
+ initialized.value = true
+ fetchUnreadCount() // 立即获取一次
+
+ // 每 15 秒轮询一次
+ pollTimer = window.setInterval(fetchUnreadCount, 15000)
+
+ // 监听页面可见性变化
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+ }
+
+ /**
+ * 停止轮询(登出时调用)
+ */
+ function stopPolling(): void {
+ if (pollTimer) {
+ clearInterval(pollTimer)
+ pollTimer = null
+ }
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ initialized.value = false
+ unreadCount.value = 0
+ }
+
+ /**
+ * 主动刷新(用户操作后调用)
+ */
+ function refresh(): void {
+ fetchUnreadCount()
+ }
+
+ /**
+ * 页面可见性变化时刷新
+ */
+ function handleVisibilityChange(): void {
+ if (document.visibilityState === 'visible') {
+ refresh()
+ }
+ }
+
+ /**
+ * 减少未读数量(标记已读后调用)
+ */
+ function decrementUnread(count: number = 1): void {
+ unreadCount.value = Math.max(0, unreadCount.value - count)
+ }
+
+ /**
+ * 清零未读数量(全部标记已读后调用)
+ */
+ function clearUnread(): void {
+ unreadCount.value = 0
+ }
+
+ return {
+ unreadCount,
+ initialized,
+ fetchUnreadCount,
+ startPolling,
+ stopPolling,
+ refresh,
+ decrementUnread,
+ clearUnread
+ }
+})
diff --git a/client/src/stores/restoreTask.ts b/client/src/stores/restoreTask.ts
new file mode 100644
index 0000000..2d4fb48
--- /dev/null
+++ b/client/src/stores/restoreTask.ts
@@ -0,0 +1,219 @@
+/**
+ * 全局恢复任务状态管理
+ * 用于在页面切换时保持恢复任务的轮询状态
+ */
+import { ref, type Ref } from 'vue'
+import api from '@/api'
+import { useToast } from './toast'
+
+export type RestoreTaskStatus =
+ | 'idle'
+ | 'pending'
+ | 'processing'
+ | 'stopping'
+ | 'restoring'
+ | 'replacing'
+ | 'completed'
+ | 'failed'
+ | 'rolled_back'
+
+export interface RestoreTask {
+ instanceId: number
+ backupId: number
+ backupName: string
+ taskId: string
+ status: RestoreTaskStatus
+ error?: string
+ notificationId: number | null
+ queuePosition?: number
+ duration?: number | null
+ createdAt?: string
+ startedAt?: string | null
+ finishedAt?: string | null
+}
+
+// 全局状态 - 在模块级别定义,所有组件共享
+const activeTask: Ref = ref(null)
+let pollInterval: ReturnType | null = null
+
+export function useRestoreTask() {
+ const toast = useToast()
+
+ /**
+ * 开始恢复任务
+ */
+ async function startRestore(
+ instanceId: number,
+ backupId: number,
+ backupName: string,
+ t: (key: string, params?: Record) => string
+ ): Promise {
+ // 检查是否已有进行中的任务
+ if (activeTask.value && !['completed', 'failed', 'rolled_back', 'idle'].includes(activeTask.value.status)) {
+ toast.warning(t('backup.messages.restoreInProgress'))
+ return false
+ }
+
+ try {
+ const res = await api.instances.restoreBackup(instanceId, backupId)
+
+ // 显示持久通知
+ const notificationId = toast.show(
+ t('backup.messages.restoreStarted', { name: backupName }),
+ 'info',
+ 0 // 不自动消失
+ )
+
+ activeTask.value = {
+ instanceId,
+ backupId,
+ backupName,
+ taskId: res.taskId,
+ status: 'pending',
+ notificationId
+ }
+
+ // 开始轮询
+ startPolling(t)
+ return true
+ } catch (err: any) {
+ toast.error(t('backup.messages.restoreFailed') + ': ' + (err?.message || String(err)))
+ return false
+ }
+ }
+
+ /**
+ * 开始轮询任务状态
+ */
+ function startPolling(t: (key: string, params?: Record) => string): void {
+ stopPolling()
+
+ pollInterval = setInterval(async () => {
+ if (!activeTask.value || !activeTask.value.taskId) {
+ stopPolling()
+ return
+ }
+
+ try {
+ const res = await api.instances.getRestoreStatus(
+ activeTask.value.instanceId,
+ activeTask.value.taskId
+ )
+
+ // 后端返回大写状态,转为小写
+ const status = res.status.toLowerCase() as RestoreTaskStatus
+ activeTask.value.status = status
+ if (res.error) {
+ activeTask.value.error = res.error
+ }
+ // 更新任务时间信息
+ if (res.queuePosition !== undefined) activeTask.value.queuePosition = res.queuePosition
+ if (res.duration !== undefined) activeTask.value.duration = res.duration
+ if (res.createdAt) activeTask.value.createdAt = res.createdAt
+ if (res.startedAt !== undefined) activeTask.value.startedAt = res.startedAt
+ if (res.finishedAt !== undefined) activeTask.value.finishedAt = res.finishedAt
+
+ // 处理完成状态
+ if (status === 'completed') {
+ if (activeTask.value.notificationId !== null) {
+ toast.remove(activeTask.value.notificationId)
+ }
+ toast.success(t('backup.messages.restoreCompleted'))
+ stopPolling()
+ // 刷新页面以显示恢复后的实例
+ setTimeout(() => {
+ window.location.reload()
+ }, 1500)
+ } else if (status === 'failed') {
+ if (activeTask.value.notificationId !== null) {
+ toast.remove(activeTask.value.notificationId)
+ }
+ toast.error(t('backup.messages.restoreFailed') + ': ' + (res.error || ''))
+ stopPolling()
+ }
+ } catch (err) {
+ console.error('Failed to poll restore status:', err)
+ }
+ }, 2000)
+ }
+
+ /**
+ * 停止轮询
+ */
+ function stopPolling(): void {
+ if (pollInterval) {
+ clearInterval(pollInterval)
+ pollInterval = null
+ }
+ }
+
+ /**
+ * 回滚恢复操作
+ */
+ async function rollback(t: (key: string, params?: Record) => string): Promise {
+ if (!activeTask.value || activeTask.value.status !== 'failed') {
+ return false
+ }
+
+ try {
+ await api.instances.rollbackRestore(activeTask.value.instanceId, activeTask.value.taskId)
+ activeTask.value.status = 'rolled_back'
+ toast.success(t('backup.messages.rollbackCompleted'))
+ return true
+ } catch (err: any) {
+ toast.error(t('backup.messages.rollbackFailed') + ': ' + (err?.message || String(err)))
+ return false
+ }
+ }
+
+ /**
+ * 清除任务状态
+ */
+ function clearTask(): void {
+ stopPolling()
+ if (activeTask.value && activeTask.value.notificationId !== null) {
+ toast.remove(activeTask.value.notificationId)
+ }
+ activeTask.value = null
+ }
+
+ /**
+ * 获取指定实例的恢复状态
+ */
+ function getTaskForInstance(instanceId: number): RestoreTask | null {
+ if (activeTask.value && activeTask.value.instanceId === instanceId) {
+ return activeTask.value
+ }
+ return null
+ }
+
+ /**
+ * 检查指定备份是否正在恢复
+ */
+ function isBackupRestoring(instanceId: number, backupId: number): boolean {
+ if (!activeTask.value) return false
+ return activeTask.value.instanceId === instanceId &&
+ activeTask.value.backupId === backupId &&
+ ['pending', 'processing', 'stopping', 'restoring', 'replacing'].includes(activeTask.value.status)
+ }
+
+ /**
+ * 检查指定备份是否恢复失败(可回滚)
+ */
+ function canRollbackBackup(instanceId: number, backupId: number): boolean {
+ if (!activeTask.value) return false
+ return activeTask.value.instanceId === instanceId &&
+ activeTask.value.backupId === backupId &&
+ activeTask.value.status === 'failed'
+ }
+
+ return {
+ activeTask,
+ startRestore,
+ rollback,
+ clearTask,
+ getTaskForInstance,
+ isBackupRestoring,
+ canRollbackBackup
+ }
+}
diff --git a/client/src/stores/terminal.ts b/client/src/stores/terminal.ts
new file mode 100644
index 0000000..94439d0
--- /dev/null
+++ b/client/src/stores/terminal.ts
@@ -0,0 +1,332 @@
+/**
+ * 终端设置 Store
+ *
+ * 管理终端的所有可配置项,并持久化到 localStorage
+ * 被 TerminalModal.vue 和 TerminalView.vue 共享
+ */
+
+import { defineStore } from 'pinia'
+import { ref, computed, watch } from 'vue'
+
+// 终端主题类型
+export type TerminalThemeType = 'dark' | 'light' | 'highContrast'
+
+// 终端主题定义
+export const TERMINAL_THEMES = {
+ // Vercel 极简风格 - 纯黑背景(默认)
+ dark: {
+ name: 'dark',
+ background: '#0a0a0a',
+ foreground: '#ededed',
+ cursor: '#ffffff',
+ cursorAccent: '#0a0a0a',
+ selectionBackground: '#444444',
+ selectionForeground: '#ffffff',
+ black: '#0a0a0a',
+ red: '#ff6369',
+ green: '#52c41a',
+ yellow: '#faad14',
+ blue: '#1890ff',
+ magenta: '#eb2f96',
+ cyan: '#13c2c2',
+ white: '#ededed',
+ brightBlack: '#666666',
+ brightRed: '#ff8a8a',
+ brightGreen: '#73d13d',
+ brightYellow: '#ffc53d',
+ brightBlue: '#40a9ff',
+ brightMagenta: '#f759ab',
+ brightCyan: '#36cfc9',
+ brightWhite: '#ffffff'
+ },
+ // 亮色主题
+ light: {
+ name: 'light',
+ background: '#ffffff',
+ foreground: '#1a1a1a',
+ cursor: '#1a1a1a',
+ cursorAccent: '#ffffff',
+ selectionBackground: '#b4d7ff',
+ selectionForeground: '#1a1a1a',
+ black: '#1a1a1a',
+ red: '#c41a16',
+ green: '#007400',
+ yellow: '#aa5500',
+ blue: '#0451a5',
+ magenta: '#bc05bc',
+ cyan: '#0598bc',
+ white: '#e0e0e0',
+ brightBlack: '#666666',
+ brightRed: '#ff6b6b',
+ brightGreen: '#5cb85c',
+ brightYellow: '#f0ad4e',
+ brightBlue: '#5bc0de',
+ brightMagenta: '#d63384',
+ brightCyan: '#17a2b8',
+ brightWhite: '#ffffff'
+ },
+ // 高对比度主题
+ highContrast: {
+ name: 'highContrast',
+ background: '#000000',
+ foreground: '#ffffff',
+ cursor: '#00ff00',
+ cursorAccent: '#000000',
+ selectionBackground: '#ffff00',
+ selectionForeground: '#000000',
+ black: '#000000',
+ red: '#ff0000',
+ green: '#00ff00',
+ yellow: '#ffff00',
+ blue: '#0080ff',
+ magenta: '#ff00ff',
+ cyan: '#00ffff',
+ white: '#ffffff',
+ brightBlack: '#808080',
+ brightRed: '#ff8080',
+ brightGreen: '#80ff80',
+ brightYellow: '#ffff80',
+ brightBlue: '#80c0ff',
+ brightMagenta: '#ff80ff',
+ brightCyan: '#80ffff',
+ brightWhite: '#ffffff'
+ }
+} as const
+
+// localStorage 存储键
+const STORAGE_KEY = 'incudal_terminal_settings'
+
+// 默认设置
+const DEFAULT_SETTINGS = {
+ fontSize: 14,
+ theme: 'dark' as TerminalThemeType,
+ bellEnabled: false, // 终端铃声(默认关闭)
+ autoCopyOnSelect: false, // 选中自动复制(默认关闭)
+ linkPreview: true, // 链接预览(默认开启)
+ touchEnabled: true, // 触控优化(默认开启)
+ commandHistory: [] as string[], // 命令历史
+ commandHistoryLimit: 100, // 历史命令数量限制
+}
+
+type TerminalSettings = typeof DEFAULT_SETTINGS
+
+/**
+ * 从 localStorage 读取设置
+ */
+function loadSettings(): TerminalSettings {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored) {
+ const parsed = JSON.parse(stored)
+ // 合并默认值,确保新增字段有默认值
+ return { ...DEFAULT_SETTINGS, ...parsed }
+ }
+ } catch (err) {
+ console.warn('[Terminal Store] Failed to load settings:', err)
+ }
+ return { ...DEFAULT_SETTINGS }
+}
+
+/**
+ * 保存设置到 localStorage
+ */
+function saveSettings(settings: TerminalSettings): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
+ } catch (err) {
+ console.warn('[Terminal Store] Failed to save settings:', err)
+ }
+}
+
+export const useTerminalStore = defineStore('terminal', () => {
+ // 从 localStorage 加载初始设置
+ const initialSettings = loadSettings()
+
+ // 响应式设置
+ const fontSize = ref(initialSettings.fontSize)
+ const theme = ref(initialSettings.theme)
+ const bellEnabled = ref(initialSettings.bellEnabled)
+ const autoCopyOnSelect = ref(initialSettings.autoCopyOnSelect)
+ const linkPreview = ref(initialSettings.linkPreview)
+ const touchEnabled = ref(initialSettings.touchEnabled)
+ const commandHistory = ref(initialSettings.commandHistory)
+ const commandHistoryLimit = ref(initialSettings.commandHistoryLimit)
+
+ // 计算属性:当前主题配置
+ const currentTheme = computed(() => TERMINAL_THEMES[theme.value])
+
+ // 监听变化并自动保存
+ watch(
+ [fontSize, theme, bellEnabled, autoCopyOnSelect, linkPreview, touchEnabled, commandHistory, commandHistoryLimit],
+ () => {
+ saveSettings({
+ fontSize: fontSize.value,
+ theme: theme.value,
+ bellEnabled: bellEnabled.value,
+ autoCopyOnSelect: autoCopyOnSelect.value,
+ linkPreview: linkPreview.value,
+ touchEnabled: touchEnabled.value,
+ commandHistory: commandHistory.value,
+ commandHistoryLimit: commandHistoryLimit.value,
+ })
+ },
+ { deep: true }
+ )
+
+ /**
+ * 设置字体大小
+ */
+ function setFontSize(size: number) {
+ fontSize.value = Math.max(8, Math.min(32, size))
+ }
+
+ /**
+ * 增大字体
+ */
+ function increaseFontSize() {
+ setFontSize(fontSize.value + 2)
+ }
+
+ /**
+ * 减小字体
+ */
+ function decreaseFontSize() {
+ setFontSize(fontSize.value - 2)
+ }
+
+ /**
+ * 重置字体大小
+ */
+ function resetFontSize() {
+ setFontSize(DEFAULT_SETTINGS.fontSize)
+ }
+
+ /**
+ * 设置主题
+ */
+ function setTheme(newTheme: TerminalThemeType) {
+ theme.value = newTheme
+ }
+
+ /**
+ * 切换终端铃声
+ */
+ function toggleBell() {
+ bellEnabled.value = !bellEnabled.value
+ }
+
+ /**
+ * 切换选中自动复制
+ */
+ function toggleAutoCopy() {
+ autoCopyOnSelect.value = !autoCopyOnSelect.value
+ }
+
+ /**
+ * 切换触控优化
+ */
+ function toggleTouch() {
+ touchEnabled.value = !touchEnabled.value
+ }
+
+ /**
+ * 添加命令到历史
+ */
+ function addToHistory(command: string) {
+ if (!command.trim()) return
+
+ // 去除重复
+ const index = commandHistory.value.indexOf(command)
+ if (index !== -1) {
+ commandHistory.value.splice(index, 1)
+ }
+
+ // 添加到开头
+ commandHistory.value.unshift(command)
+
+ // 限制数量
+ if (commandHistory.value.length > commandHistoryLimit.value) {
+ commandHistory.value = commandHistory.value.slice(0, commandHistoryLimit.value)
+ }
+ }
+
+ /**
+ * 清空命令历史
+ */
+ function clearHistory() {
+ commandHistory.value = []
+ }
+
+ /**
+ * 播放终端铃声
+ */
+ function playBell() {
+ if (!bellEnabled.value) return
+
+ try {
+ // 使用 Web Audio API 生成简短的铃声
+ const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
+ const oscillator = audioContext.createOscillator()
+ const gainNode = audioContext.createGain()
+
+ oscillator.connect(gainNode)
+ gainNode.connect(audioContext.destination)
+
+ oscillator.frequency.value = 800 // Hz
+ oscillator.type = 'sine'
+
+ gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1)
+
+ oscillator.start(audioContext.currentTime)
+ oscillator.stop(audioContext.currentTime + 0.1)
+
+ // 铃声结束后关闭 AudioContext 释放资源
+ oscillator.onended = () => {
+ audioContext.close().catch(() => {})
+ }
+ } catch {
+ // 音频 API 不可用,静默失败
+ }
+ }
+
+ /**
+ * 重置所有设置
+ */
+ function resetAllSettings() {
+ fontSize.value = DEFAULT_SETTINGS.fontSize
+ theme.value = DEFAULT_SETTINGS.theme
+ bellEnabled.value = DEFAULT_SETTINGS.bellEnabled
+ autoCopyOnSelect.value = DEFAULT_SETTINGS.autoCopyOnSelect
+ linkPreview.value = DEFAULT_SETTINGS.linkPreview
+ touchEnabled.value = DEFAULT_SETTINGS.touchEnabled
+ // 保留命令历史
+ }
+
+ return {
+ // 状态
+ fontSize,
+ theme,
+ bellEnabled,
+ autoCopyOnSelect,
+ linkPreview,
+ touchEnabled,
+ commandHistory,
+ commandHistoryLimit,
+ currentTheme,
+
+ // 方法
+ setFontSize,
+ increaseFontSize,
+ decreaseFontSize,
+ resetFontSize,
+ setTheme,
+ toggleBell,
+ toggleAutoCopy,
+ toggleTouch,
+ addToHistory,
+ clearHistory,
+ playBell,
+ resetAllSettings,
+ }
+})
diff --git a/client/src/stores/theme.ts b/client/src/stores/theme.ts
new file mode 100644
index 0000000..472d03d
--- /dev/null
+++ b/client/src/stores/theme.ts
@@ -0,0 +1,81 @@
+/**
+ * 主题管理 Store
+ * 支持浅色/深色/跟随系统模式
+ */
+import { defineStore } from 'pinia'
+import { ref, watch, computed, type Ref } from 'vue'
+
+type ThemeMode = 'light' | 'dark' | 'system'
+
+export const useThemeStore = defineStore('theme', () => {
+ // 主题模式: 'light' | 'dark' | 'system'
+ const mode: Ref = ref((localStorage.getItem('theme') as ThemeMode) || 'system')
+
+ // 实际应用的主题
+ const resolvedTheme = computed(() => {
+ if (mode.value === 'system') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ }
+ return mode.value
+ })
+
+ // 是否为深色主题
+ const isDark = computed(() => resolvedTheme.value === 'dark')
+
+ // 将主题应用到 DOM
+ function applyTheme() {
+ const theme = resolvedTheme.value
+ document.documentElement.classList.remove('light', 'dark')
+ document.documentElement.classList.add(theme)
+
+ // 更新 meta 主题色
+ const metaThemeColor = document.querySelector('meta[name="theme-color"]')
+ if (metaThemeColor) {
+ metaThemeColor.setAttribute('content', theme === 'dark' ? '#0a0a0a' : '#ffffff')
+ }
+ }
+
+ // 设置主题模式
+ function setTheme(newMode: ThemeMode) {
+ mode.value = newMode
+ localStorage.setItem('theme', newMode)
+ applyTheme()
+ }
+
+ // 切换主题 (dark -> light -> system -> dark)
+ function toggleTheme() {
+ const modes: ThemeMode[] = ['dark', 'light', 'system']
+ const currentIndex = modes.indexOf(mode.value)
+ const nextIndex = (currentIndex + 1) % modes.length
+ setTheme(modes[nextIndex])
+ }
+
+ // 监听系统主题变化
+ function setupSystemThemeListener() {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ mediaQuery.addEventListener('change', () => {
+ if (mode.value === 'system') {
+ applyTheme()
+ }
+ })
+ }
+
+ // 初始化
+ function init() {
+ applyTheme()
+ setupSystemThemeListener()
+ }
+
+ // 监听模式变化
+ watch(mode, applyTheme)
+
+ return {
+ mode,
+ resolvedTheme,
+ isDark,
+ setTheme,
+ toggleTheme,
+ init
+ }
+})
+
diff --git a/client/src/stores/toast.ts b/client/src/stores/toast.ts
new file mode 100644
index 0000000..a86c0b2
--- /dev/null
+++ b/client/src/stores/toast.ts
@@ -0,0 +1,59 @@
+import { ref, type Ref } from 'vue'
+import type { Toast } from '@/types/store.js'
+
+const toasts: Ref = ref([])
+let nextId = 0
+
+type ToastType = 'success' | 'error' | 'warning' | 'info'
+
+export function useToast() {
+ function show(message: string, type: ToastType = 'info', duration: number = 3000): number {
+ const id = nextId++
+ toasts.value.push({ id, message, type, duration, visible: true })
+
+ if (duration > 0) {
+ setTimeout(() => {
+ remove(id)
+ }, duration)
+ }
+
+ return id
+ }
+
+ function remove(id: number) {
+ const index = toasts.value.findIndex(t => t.id === id)
+ if (index > -1) {
+ toasts.value[index].visible = false
+ setTimeout(() => {
+ toasts.value = toasts.value.filter(t => t.id !== id)
+ }, 200)
+ }
+ }
+
+ function success(message: string, duration?: number): number {
+ return show(message, 'success', duration)
+ }
+
+ function error(message: string, duration?: number): number {
+ return show(message, 'error', duration)
+ }
+
+ function warning(message: string, duration?: number): number {
+ return show(message, 'warning', duration)
+ }
+
+ function info(message: string, duration?: number): number {
+ return show(message, 'info', duration)
+ }
+
+ return {
+ toasts,
+ show,
+ remove,
+ success,
+ error,
+ warning,
+ info
+ }
+}
+
diff --git a/client/src/styles/main.css b/client/src/styles/main.css
new file mode 100644
index 0000000..ab944d7
--- /dev/null
+++ b/client/src/styles/main.css
@@ -0,0 +1,1433 @@
+/* Geist 字体 - 必须在 @tailwind 之前 */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+
+ /* 暗色主题变量 (默认) */
+ :root {
+ --bg-primary: #0a0a0a;
+ --bg-secondary: #171717;
+ --bg-tertiary: #262626;
+ --bg-elevated: #0a0a0a;
+ --border-color: #262626;
+ --border-hover: #404040;
+ --text-primary: #ededed;
+ --text-secondary: #a1a1a1;
+ --text-tertiary: #737373;
+ --accent: #3b82f6;
+ --success: #22c55e;
+ --warning: #eab308;
+ --error: #ef4444;
+ }
+
+ /* 亮色主题变量 */
+ .light {
+ --bg-primary: #ffffff;
+ --bg-secondary: #fafafa;
+ --bg-tertiary: #f4f4f5;
+ --bg-elevated: #ffffff;
+ --border-color: #e4e4e7;
+ --border-hover: #d4d4d8;
+ --text-primary: #18181b;
+ --text-secondary: #52525b;
+ --text-tertiary: #a1a1aa;
+ --accent: #2563eb;
+ --success: #16a34a;
+ --warning: #ca8a04;
+ --error: #dc2626;
+ }
+
+ html {
+ @apply scroll-smooth antialiased;
+ font-feature-settings: 'rlig' 1, 'calt' 1;
+ }
+
+ body {
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ transition: background-color 0.2s ease, color 0.2s ease;
+ }
+
+ /* 极简滚动条 */
+ ::-webkit-scrollbar {
+ @apply w-2 h-2;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .dark ::-webkit-scrollbar-thumb {
+ @apply bg-gray-700 rounded-full;
+ }
+
+ .dark ::-webkit-scrollbar-thumb:hover {
+ @apply bg-gray-600;
+ }
+
+ .light ::-webkit-scrollbar-thumb {
+ @apply bg-gray-300 rounded-full;
+ }
+
+ .light ::-webkit-scrollbar-thumb:hover {
+ @apply bg-gray-400;
+ }
+
+ /* 选择文本 */
+ ::selection {
+ background-color: rgba(59, 130, 246, 0.3);
+ }
+}
+
+@layer components {
+
+ /* ============ 按钮 - Vercel 风格 ============ */
+ .btn {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none;
+ }
+
+ /* 主按钮 */
+ .btn-primary {
+ @apply btn;
+ }
+
+ .dark .btn-primary {
+ @apply bg-white text-gray-900 hover:bg-gray-100 shadow-sm;
+ --tw-ring-offset-color: #0a0a0a;
+ }
+
+ .light .btn-primary {
+ @apply bg-gray-900 text-white hover:bg-gray-700 shadow-sm;
+ --tw-ring-offset-color: #ffffff;
+ }
+
+ /* 次要按钮 */
+ .btn-secondary {
+ @apply btn;
+ }
+
+ .dark .btn-secondary {
+ @apply bg-transparent text-gray-100 border border-gray-800 hover:border-gray-700 hover:bg-gray-900;
+ }
+
+ .light .btn-secondary {
+ @apply bg-transparent text-gray-900 border border-gray-300 hover:border-gray-400 hover:bg-gray-100;
+ }
+
+ /* 幽灵按钮 */
+ .btn-ghost {
+ @apply btn;
+ }
+
+ .dark .btn-ghost {
+ @apply bg-transparent text-gray-400 hover:text-gray-100 hover:bg-gray-800;
+ }
+
+ .light .btn-ghost {
+ @apply bg-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-100;
+ }
+
+ /* 危险按钮 */
+ .btn-danger {
+ @apply btn bg-red-500/10 text-red-500 border border-red-500/20 hover:bg-red-500/20;
+ }
+
+ /* 警告按钮 */
+ .btn-warning {
+ @apply btn bg-amber-500/10 text-amber-500 border border-amber-500/20 hover:bg-amber-500/20;
+ }
+
+ .btn-sm {
+ @apply px-3 py-1.5 text-xs;
+ }
+
+ .btn-lg {
+ @apply px-5 py-2.5 text-base;
+ }
+
+ /* ============ 输入框 ============ */
+ .input {
+ @apply w-full px-3 py-2 text-sm bg-transparent rounded transition-colors duration-150;
+ }
+
+ .dark .input {
+ @apply border border-gray-800 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-gray-600 focus:ring-1 focus:ring-gray-600;
+ }
+
+ .light .input {
+ @apply bg-white border border-gray-300 text-gray-900 placeholder-gray-400 focus:outline-none focus:border-gray-400 focus:ring-1 focus:ring-gray-400;
+ }
+
+ /* 下拉选择框 */
+ select.input {
+ @apply cursor-pointer appearance-none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23737373'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 1.25rem;
+ padding-right: 2.5rem;
+ }
+
+ .dark select.input {
+ @apply bg-gray-950;
+ }
+
+ .light select.input {
+ @apply bg-white;
+ }
+
+ .dark select.input option {
+ @apply bg-gray-900 text-gray-100 py-2;
+ }
+
+ .light select.input option {
+ @apply bg-white text-gray-900 py-2;
+ }
+
+ .input-error {
+ @apply border-red-500/50 focus:border-red-500 focus:ring-red-500/50;
+ }
+
+ /* ============ 卡片 - 极简边框 ============ */
+ .card {
+ @apply rounded-lg;
+ }
+
+ .dark .card {
+ @apply bg-gray-950 border border-gray-800;
+ }
+
+ .light .card {
+ @apply bg-white border border-gray-200 shadow-sm;
+ }
+
+ .card-hover {
+ @apply rounded-lg transition-all duration-150;
+ }
+
+ .dark .card-hover {
+ @apply bg-gray-950 border border-gray-800 hover:border-gray-700;
+ }
+
+ .light .card-hover {
+ @apply bg-white border border-gray-200 hover:border-gray-300 hover:shadow;
+ }
+
+ /* 统计卡片 - 移动端优化 */
+ .stat-card {
+ @apply p-3 sm:p-4 flex items-center gap-3 sm:gap-4;
+ }
+
+ .stat-card-icon {
+ @apply w-10 h-10 sm:w-12 sm:h-12 rounded-xl flex items-center justify-center flex-shrink-0;
+ }
+
+ .stat-card-value {
+ @apply text-xl sm:text-2xl font-semibold;
+ }
+
+ .stat-card-label {
+ @apply text-xs sm:text-sm truncate;
+ }
+
+ .dark .stat-card-label {
+ @apply text-gray-500;
+ }
+
+ .light .stat-card-label {
+ @apply text-gray-500;
+ }
+
+ /* ============ 徽章 ============ */
+ .badge {
+ @apply inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full;
+ }
+
+ .badge-success {
+ @apply text-green-500;
+ }
+
+ .dark .badge-success {
+ @apply bg-green-500/10;
+ }
+
+ .light .badge-success {
+ @apply bg-green-100 text-green-700;
+ }
+
+ .badge-warning {
+ @apply text-yellow-500;
+ }
+
+ .dark .badge-warning {
+ @apply bg-yellow-500/10;
+ }
+
+ .light .badge-warning {
+ @apply bg-yellow-100 text-yellow-700;
+ }
+
+ .badge-error {
+ @apply text-red-500;
+ }
+
+ .dark .badge-error {
+ @apply bg-red-500/10;
+ }
+
+ .light .badge-error {
+ @apply bg-red-100 text-red-700;
+ }
+
+ .badge-default {
+ /* base styles */
+ }
+
+ .dark .badge-default {
+ @apply bg-gray-800 text-gray-400;
+ }
+
+ .light .badge-default {
+ @apply bg-gray-100 text-gray-600;
+ }
+
+ /* ============ 表格 ============ */
+ .table {
+ @apply w-full text-sm;
+ }
+
+ .table th {
+ @apply text-left py-3 px-4 text-xs font-medium text-gray-500 uppercase tracking-wider;
+ }
+
+ .dark .table th {
+ @apply border-b border-gray-800;
+ }
+
+ .light .table th {
+ @apply border-b border-gray-200;
+ }
+
+ .table td {
+ @apply py-3 px-4;
+ }
+
+ .dark .table td {
+ @apply border-b border-gray-900;
+ }
+
+ .light .table td {
+ @apply border-b border-gray-100;
+ }
+
+ .dark .table tr:hover td {
+ @apply bg-gray-900/50;
+ }
+
+ .light .table tr:hover td {
+ @apply bg-gray-50;
+ }
+
+ /* ============ 分隔线 ============ */
+ .divider {
+ @apply border-t;
+ }
+
+ .dark .divider {
+ @apply border-gray-800;
+ }
+
+ .light .divider {
+ @apply border-gray-200;
+ }
+
+ /* ============ 页面标题 ============ */
+ .page-header {
+ @apply flex items-start sm:items-center justify-between pb-4 sm:pb-6 mb-4 sm:mb-6 border-b;
+ }
+
+ .dark .page-header {
+ @apply border-gray-800;
+ }
+
+ .light .page-header {
+ @apply border-gray-200;
+ }
+
+ .page-title {
+ @apply text-lg sm:text-xl font-semibold;
+ }
+
+ .dark .page-title {
+ @apply text-gray-100;
+ }
+
+ .light .page-title {
+ @apply text-gray-900;
+ }
+
+ .page-description {
+ @apply text-xs sm:text-sm mt-1;
+ }
+
+ .dark .page-description {
+ @apply text-gray-500;
+ }
+
+ .light .page-description {
+ @apply text-gray-600;
+ }
+
+ /* ============ 空状态 ============ */
+ .empty-state {
+ @apply flex flex-col items-center justify-center py-12 text-center;
+ }
+
+ .empty-state-icon {
+ @apply w-12 h-12 mb-4;
+ }
+
+ .dark .empty-state-icon {
+ @apply text-gray-700;
+ }
+
+ .light .empty-state-icon {
+ @apply text-gray-400;
+ }
+
+ .empty-state-text {
+ @apply text-sm;
+ }
+
+ .dark .empty-state-text {
+ @apply text-gray-500;
+ }
+
+ .light .empty-state-text {
+ @apply text-gray-600;
+ }
+}
+
+/* ============ 动画 ============ */
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes scale-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-fade-in {
+ animation: fade-in 0.2s ease-out;
+}
+
+.animate-slide-up {
+ animation: slide-up 0.25s ease-out;
+}
+
+.animate-slide-down {
+ animation: slide-down 0.25s ease-out;
+}
+
+.animate-scale-in {
+ animation: scale-in 0.2s ease-out;
+}
+
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+/* 加载动画 Spinner */
+.loading-spinner {
+ @apply animate-spin;
+ border: 2px solid transparent;
+ border-top-color: currentColor;
+ border-radius: 50%;
+}
+
+/* ============ 页面过渡动画 ============ */
+.page-enter-active,
+.page-leave-active {
+ transition: opacity 0.15s ease, transform 0.15s ease;
+}
+
+.page-enter-from {
+ opacity: 0;
+ transform: translateY(8px);
+}
+
+.page-leave-to {
+ opacity: 0;
+ transform: translateY(-8px);
+}
+
+/* 快速淡入淡出动画(用于页面切换) */
+.fade-enter-active {
+ transition: opacity 0.08s ease-out;
+}
+
+.fade-leave-active {
+ transition: opacity 0.05s ease-in;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+/* 底部滑入动画(用于悬浮按钮) */
+.slide-up-enter-active {
+ transition: all 0.3s ease-out;
+}
+
+.slide-up-leave-active {
+ transition: all 0.2s ease-in;
+}
+
+.slide-up-enter-from {
+ opacity: 0;
+ transform: translateY(20px);
+}
+
+.slide-up-leave-to {
+ opacity: 0;
+ transform: translateY(10px);
+}
+
+/* ============ 模态框过渡动画 ============ */
+.modal-enter-active,
+.modal-leave-active {
+ transition: opacity 0.2s ease;
+}
+
+.modal-enter-active .modal-content,
+.modal-leave-active .modal-content {
+ transition: transform 0.2s ease, opacity 0.2s ease;
+}
+
+.modal-enter-from,
+.modal-leave-to {
+ opacity: 0;
+}
+
+.modal-enter-from .modal-content,
+.modal-leave-to .modal-content {
+ transform: scale(0.95);
+ opacity: 0;
+}
+
+/* ============ 列表项过渡动画 ============ */
+.list-enter-active,
+.list-leave-active {
+ transition: all 0.25s ease;
+}
+
+.list-enter-from {
+ opacity: 0;
+ transform: translateX(-15px);
+}
+
+.list-leave-to {
+ opacity: 0;
+ transform: translateX(15px);
+}
+
+.list-move {
+ transition: transform 0.25s ease;
+}
+
+/* 淡入淡出列表动画 */
+.fade-list-enter-active,
+.fade-list-leave-active {
+ transition: all 0.2s ease;
+}
+
+.fade-list-enter-from,
+.fade-list-leave-to {
+ opacity: 0;
+}
+
+.fade-list-move {
+ transition: transform 0.2s ease;
+}
+
+/* ============ Tab 切换动画 ============ */
+.tab-enter-active,
+.tab-leave-active {
+ transition: opacity 0.15s ease, transform 0.15s ease;
+}
+
+.tab-enter-from {
+ opacity: 0;
+ transform: translateY(10px);
+}
+
+.tab-leave-to {
+ opacity: 0;
+ transform: translateY(-10px);
+}
+
+/* ============ 弹窗样式 ============ */
+.modal-overlay {
+ @apply fixed inset-0 z-50 flex items-center justify-center p-4;
+}
+
+.modal-backdrop {
+ @apply absolute inset-0 backdrop-blur-sm;
+}
+
+.dark .modal-backdrop {
+ @apply bg-black/60;
+}
+
+.light .modal-backdrop {
+ @apply bg-black/30;
+}
+
+.modal-content {
+ @apply relative w-full max-w-md mx-4 sm:mx-auto rounded-xl shadow-2xl animate-scale-in max-h-[90vh] overflow-y-auto;
+}
+
+.dark .modal-content {
+ @apply bg-gray-900 border border-gray-800;
+}
+
+.light .modal-content {
+ @apply bg-white border border-gray-200;
+}
+
+.modal-header {
+ @apply flex items-center justify-between p-4 sm:p-5 border-b;
+}
+
+.dark .modal-header {
+ @apply border-gray-800;
+}
+
+.light .modal-header {
+ @apply border-gray-200;
+}
+
+.modal-title {
+ @apply text-sm sm:text-base font-medium;
+}
+
+.dark .modal-title {
+ @apply text-gray-100;
+}
+
+.light .modal-title {
+ @apply text-gray-900;
+}
+
+.modal-body {
+ @apply p-4 sm:p-5 space-y-4;
+}
+
+.modal-footer {
+ @apply flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3 p-4 sm:p-5 border-t;
+}
+
+.modal-footer .btn-primary,
+.modal-footer .btn-secondary,
+.modal-footer .btn-danger {
+ @apply w-full sm:w-auto;
+}
+
+.dark .modal-footer {
+ @apply border-gray-800;
+}
+
+.light .modal-footer {
+ @apply border-gray-200;
+}
+
+/* ============ Toast 通知 ============ */
+.toast-container {
+ @apply fixed bottom-4 sm:bottom-5 left-4 right-4 sm:left-auto sm:right-5 flex flex-col gap-2;
+ z-index: 9999;
+ /* 确保通知显示在所有弹窗之上 */
+}
+
+.toast {
+ @apply flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-2.5 sm:py-3 rounded-lg shadow-lg animate-slide-up text-xs sm:text-sm;
+}
+
+.dark .toast {
+ @apply bg-gray-900 border border-gray-800 text-gray-100;
+}
+
+.light .toast {
+ @apply bg-white border border-gray-200 text-gray-900;
+}
+
+.toast-success {
+ @apply border-green-500/30;
+}
+
+.dark .toast-success {
+ @apply bg-green-950/50;
+}
+
+.light .toast-success {
+ @apply bg-green-50;
+}
+
+.toast-error {
+ @apply border-red-500/30;
+}
+
+.dark .toast-error {
+ @apply bg-red-950/50;
+}
+
+.light .toast-error {
+ @apply bg-red-50;
+}
+
+.toast-warning {
+ @apply border-yellow-500/30;
+}
+
+.dark .toast-warning {
+ @apply bg-yellow-950/50;
+}
+
+.light .toast-warning {
+ @apply bg-yellow-50;
+}
+
+.toast-info {
+ @apply border-blue-500/30;
+}
+
+.dark .toast-info {
+ @apply bg-blue-950/50;
+}
+
+.light .toast-info {
+ @apply bg-blue-50;
+}
+
+/* ============ 工具提示 ============ */
+[data-tooltip] {
+ @apply relative;
+}
+
+[data-tooltip]::after {
+ content: attr(data-tooltip);
+ @apply absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs rounded whitespace-nowrap opacity-0 pointer-events-none transition-opacity duration-150;
+}
+
+.dark [data-tooltip]::after {
+ @apply text-gray-300 bg-gray-800;
+}
+
+.light [data-tooltip]::after {
+ @apply text-gray-700 bg-gray-100 border border-gray-200;
+}
+
+[data-tooltip]:hover::after {
+ @apply opacity-100;
+}
+
+/* ============ 进度条动画 ============ */
+.progress-bar {
+ @apply h-1.5 rounded-full overflow-hidden;
+}
+
+.dark .progress-bar {
+ @apply bg-gray-800;
+}
+
+.light .progress-bar {
+ @apply bg-gray-200;
+}
+
+.progress-bar-fill {
+ @apply h-full rounded-full transition-all duration-500 ease-out;
+}
+
+/* ============ 输入框聚焦效果 ============ */
+.dark .input:focus {
+ box-shadow: 0 0 0 1px rgba(82, 82, 91, 0.3);
+}
+
+.light .input:focus {
+ box-shadow: 0 0 0 1px rgba(156, 163, 175, 0.3);
+}
+
+/* ============ 按钮点击效果 ============ */
+.btn:active {
+ transform: scale(0.98);
+}
+
+/* ============ 链接悬停效果 ============ */
+.link {
+ @apply transition-colors duration-150;
+}
+
+.dark .link {
+ @apply text-gray-400 hover:text-gray-200;
+}
+
+.light .link {
+ @apply text-gray-600 hover:text-gray-900;
+}
+
+.link-primary {
+ @apply text-blue-500 hover:text-blue-400;
+}
+
+/* ============ 状态指示点动画 ============ */
+.status-dot {
+ @apply w-2 h-2 rounded-full;
+}
+
+.status-dot.online {
+ @apply bg-green-500;
+ animation: pulse-green 2s infinite;
+}
+
+.status-dot.offline {
+ @apply bg-gray-500;
+}
+
+.status-dot.warning {
+ @apply bg-yellow-500;
+ animation: pulse-yellow 1.5s infinite;
+}
+
+@keyframes pulse-green {
+
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
+ }
+
+ 50% {
+ box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
+ }
+}
+
+@keyframes pulse-yellow {
+
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.4);
+ }
+
+ 50% {
+ box-shadow: 0 0 0 4px rgba(251, 191, 36, 0);
+ }
+}
+
+/* ============ 卡片悬停效果增强 ============ */
+.card-interactive {
+ @apply rounded-lg cursor-pointer transition-all duration-200;
+}
+
+.dark .card-interactive {
+ @apply bg-gray-950 border border-gray-800 hover:border-gray-700 hover:bg-gray-900/50;
+}
+
+.light .card-interactive {
+ @apply bg-white border border-gray-200 hover:border-gray-300 hover:bg-gray-50;
+}
+
+.card-interactive:hover {
+ transform: translateY(-1px);
+}
+
+.light .card-interactive:hover {
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
+}
+
+/* ============ 数字徽章动画 ============ */
+.count-badge {
+ @apply inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-medium rounded-full;
+}
+
+.dark .count-badge {
+ @apply bg-gray-800 text-gray-400;
+}
+
+.light .count-badge {
+ @apply bg-gray-100 text-gray-600;
+}
+
+.count-badge.has-count {
+ animation: pop 0.2s ease-out;
+}
+
+@keyframes pop {
+ 0% {
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.1);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+/* ============ 侧边栏主题适配 ============ */
+.sidebar {
+ @apply border-r;
+}
+
+.dark .sidebar {
+ @apply bg-gray-950 border-gray-800;
+}
+
+.light .sidebar {
+ @apply bg-white border-gray-200;
+}
+
+.sidebar-link {
+ @apply transition-colors;
+}
+
+.dark .sidebar-link {
+ @apply text-gray-400 hover:text-gray-100 hover:bg-gray-800/50;
+}
+
+.light .sidebar-link {
+ @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100;
+}
+
+.sidebar-link.active {
+ /* base active */
+}
+
+.dark .sidebar-link.active {
+ @apply text-white bg-gray-800;
+}
+
+.light .sidebar-link.active {
+ @apply text-gray-900 bg-gray-100;
+}
+
+/* ============ 主题切换按钮 ============ */
+.theme-toggle {
+ @apply p-2 rounded-lg transition-colors duration-200;
+}
+
+.dark .theme-toggle {
+ @apply text-gray-400 hover:text-gray-100 hover:bg-gray-800;
+}
+
+.light .theme-toggle {
+ @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100;
+}
+
+/* ============ 通用文本颜色 ============ */
+.text-error {
+ @apply text-red-500;
+}
+
+.text-success {
+ @apply text-green-500;
+}
+
+.text-warning {
+ @apply text-yellow-500;
+}
+
+/* ============ 主题感知的文本颜色 ============ */
+.text-themed {
+ /* Default text - adapts to theme */
+}
+
+.dark .text-themed {
+ @apply text-gray-100;
+}
+
+.light .text-themed {
+ @apply text-gray-900;
+}
+
+.dark .text-themed-secondary {
+ @apply text-gray-400;
+}
+
+.light .text-themed-secondary {
+ @apply text-gray-600;
+}
+
+.dark .text-themed-muted {
+ @apply text-gray-500;
+}
+
+.light .text-themed-muted {
+ @apply text-gray-500;
+}
+
+.dark .text-themed-faint {
+ @apply text-gray-600;
+}
+
+.light .text-themed-faint {
+ @apply text-gray-400;
+}
+
+/* ============ 主题感知的背景颜色 ============ */
+.dark .bg-themed {
+ @apply bg-gray-900;
+}
+
+.light .bg-themed {
+ @apply bg-white;
+}
+
+.dark .bg-themed-secondary {
+ @apply bg-gray-800;
+}
+
+.light .bg-themed-secondary {
+ @apply bg-gray-100;
+}
+
+.dark .bg-themed-tertiary {
+ @apply bg-gray-800/50;
+}
+
+.light .bg-themed-tertiary {
+ @apply bg-gray-50;
+}
+
+.dark .bg-themed-hover {
+ @apply bg-gray-800;
+}
+
+.light .bg-themed-hover {
+ @apply bg-gray-100;
+}
+
+.dark .bg-themed-accent {
+ @apply bg-gray-800;
+}
+
+.light .bg-themed-accent {
+ @apply bg-gray-100;
+}
+
+/* ============ 主题感知的边框 ============ */
+.dark .border-themed {
+ @apply border-gray-800;
+}
+
+.light .border-themed {
+ @apply border-gray-200;
+}
+
+.dark .border-themed-secondary {
+ @apply border-gray-700;
+}
+
+.light .border-themed-secondary {
+ @apply border-gray-300;
+}
+
+.dark .divide-themed> :not([hidden])~ :not([hidden]) {
+ @apply border-gray-800;
+}
+
+.light .divide-themed> :not([hidden])~ :not([hidden]) {
+ @apply border-gray-200;
+}
+
+/* ============ 主题感知的图标颜色 ============ */
+.dark .icon-themed {
+ @apply text-gray-400;
+}
+
+.light .icon-themed {
+ @apply text-gray-500;
+}
+
+.dark .icon-themed-muted {
+ @apply text-gray-600;
+}
+
+.light .icon-themed-muted {
+ @apply text-gray-400;
+}
+
+/* ============ 表格主题适配 ============ */
+.table-themed {
+ @apply w-full;
+}
+
+.dark .table-themed thead {
+ @apply bg-gray-900/50 border-b border-gray-800;
+}
+
+.light .table-themed thead {
+ @apply bg-gray-50 border-b border-gray-200;
+}
+
+.dark .table-themed tbody {
+ @apply divide-y divide-gray-800;
+}
+
+.light .table-themed tbody {
+ @apply divide-y divide-gray-100;
+}
+
+.dark .table-themed tbody tr:hover {
+ @apply bg-gray-900/30;
+}
+
+.light .table-themed tbody tr:hover {
+ @apply bg-gray-50;
+}
+
+/* ============ 代码块主题适配 ============ */
+.dark .code-block {
+ @apply bg-gray-800 text-gray-300;
+}
+
+.light .code-block {
+ @apply bg-gray-100 text-gray-700;
+}
+
+/* ============ 滑块/进度条主题适配 ============ */
+.dark .slider-track {
+ @apply bg-gray-800;
+}
+
+.light .slider-track {
+ @apply bg-gray-200;
+}
+
+/* ============ 成功/警告/信息卡片主题 ============ */
+.dark .info-card-blue {
+ @apply bg-blue-900/30;
+}
+
+.light .info-card-blue {
+ @apply bg-blue-100;
+}
+
+.dark .info-card-green {
+ @apply bg-green-900/30;
+}
+
+.light .info-card-green {
+ @apply bg-green-100;
+}
+
+.dark .info-card-yellow {
+ @apply bg-yellow-900/30;
+}
+
+.light .info-card-yellow {
+ @apply bg-yellow-100;
+}
+
+.dark .info-card-purple {
+ @apply bg-purple-900/30;
+}
+
+.light .info-card-purple {
+ @apply bg-purple-100;
+}
+
+.dark .info-card-gray {
+ @apply bg-gray-800;
+}
+
+.light .info-card-gray {
+ @apply bg-gray-100;
+}
+
+/* ============ 弹窗/下拉菜单主题 ============ */
+.dropdown-menu {
+ @apply rounded-lg shadow-lg overflow-hidden;
+}
+
+.dark .dropdown-menu {
+ @apply bg-gray-900 border border-gray-800;
+}
+
+.light .dropdown-menu {
+ @apply bg-white border border-gray-200;
+}
+
+.dropdown-item {
+ @apply px-3 py-2 text-sm cursor-pointer transition-colors;
+}
+
+.dark .dropdown-item {
+ @apply text-gray-300 hover:bg-gray-800;
+}
+
+.light .dropdown-item {
+ @apply text-gray-700 hover:bg-gray-100;
+}
+
+.dropdown-item.active {
+ @apply font-medium;
+}
+
+.dark .dropdown-item.active {
+ @apply bg-gray-800 text-white;
+}
+
+.light .dropdown-item.active {
+ @apply bg-gray-100 text-gray-900;
+}
+
+/* ============ 骨架屏主题 ============ */
+.dark .skeleton-bg {
+ @apply bg-gray-800;
+}
+
+.light .skeleton-bg {
+ @apply bg-gray-200;
+}
+
+.dark .skeleton-bg-soft {
+ @apply bg-gray-800/50;
+}
+
+.light .skeleton-bg-soft {
+ background-color: #f4f4f5;
+}
+
+/* ============ 创建中状态呼吸灯效果 ============ */
+/* 桌面端表格行 - 左侧扫描线效果(应用在第一个 td 上) */
+@keyframes creating-scan {
+ 0% {
+ top: 0;
+ opacity: 0;
+ }
+ 10% {
+ opacity: 1;
+ }
+ 90% {
+ opacity: 1;
+ }
+ 100% {
+ top: calc(100% - 16px);
+ opacity: 0;
+ }
+}
+
+.creating-row > td:first-child {
+ position: relative;
+}
+
+.creating-row > td:first-child::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 2px;
+ height: 16px;
+ background: linear-gradient(180deg,
+ transparent 0%,
+ #3b82f6 20%,
+ #60a5fa 50%,
+ #3b82f6 80%,
+ transparent 100%
+ );
+ border-radius: 1px;
+ animation: creating-scan 1.8s ease-in-out infinite;
+ box-shadow: 0 0 8px rgba(59, 130, 246, 0.6), 0 0 16px rgba(59, 130, 246, 0.3);
+}
+
+/* 移动端卡片 - 边框流光效果 */
+@keyframes creating-border-flow {
+ 0% {
+ background-position: 0% 0%;
+ }
+ 50% {
+ background-position: 100% 100%;
+ }
+ 100% {
+ background-position: 0% 0%;
+ }
+}
+
+.creating-card {
+ position: relative;
+ overflow: visible !important;
+}
+
+.creating-card::before {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ transparent 30%,
+ #3b82f6 45%,
+ #60a5fa 50%,
+ #3b82f6 55%,
+ transparent 70%,
+ transparent 100%
+ );
+ background-size: 300% 100%;
+ animation: creating-border-flow 2.5s ease-in-out infinite;
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+}
+
+.dark .creating-card::before {
+ box-shadow: 0 0 12px rgba(59, 130, 246, 0.2);
+}
+
+.light .creating-card::before {
+ box-shadow: 0 0 8px rgba(59, 130, 246, 0.15);
+}
+
+/* Vercel Style Slider */
+.vercel-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 6px;
+ border-radius: 3px;
+ outline: none;
+ cursor: pointer;
+}
+
+.dark .vercel-slider {
+ background: linear-gradient(to right, #3b82f6 var(--slider-progress, 50%), #374151 var(--slider-progress, 50%));
+}
+
+.light .vercel-slider {
+ background: linear-gradient(to right, #3b82f6 var(--slider-progress, 50%), #e5e7eb var(--slider-progress, 50%));
+}
+
+.vercel-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #3b82f6;
+ cursor: grab;
+ transition: all 0.15s ease;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.vercel-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.1);
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
+}
+
+.vercel-slider::-webkit-slider-thumb:active {
+ cursor: grabbing;
+ transform: scale(1.05);
+ background: #3b82f6;
+ border-color: #3b82f6;
+}
+
+.vercel-slider::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #3b82f6;
+ cursor: grab;
+ transition: all 0.15s ease;
+}
+
+.vercel-slider::-moz-range-track {
+ height: 6px;
+ border-radius: 3px;
+}
+
+.dark .vercel-slider::-moz-range-track {
+ background: #374151;
+}
+
+.light .vercel-slider::-moz-range-track {
+ background: #e5e7eb;
+}
+
+.dark .vercel-slider::-moz-range-progress {
+ background: #3b82f6;
+ border-radius: 3px;
+}
+
+.light .vercel-slider::-moz-range-progress {
+ background: #3b82f6;
+ border-radius: 3px;
+}
+
+/* 隐藏滚动条工具类 */
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
\ No newline at end of file
diff --git a/client/src/types/api.ts b/client/src/types/api.ts
new file mode 100644
index 0000000..71b88e8
--- /dev/null
+++ b/client/src/types/api.ts
@@ -0,0 +1,1729 @@
+/**
+ * API 请求/响应类型定义
+ */
+
+export interface VipBadgeStyle {
+ backgroundColor: string
+ textColor: string
+}
+
+// ==================== 认证相关 ====================
+
+export interface LoginRequest {
+ username: string
+ password: string
+ turnstileToken?: string
+}
+
+export interface LoginResponse {
+ token: string
+ user: {
+ id: number
+ username: string
+ email: string
+ role: 'admin' | 'user'
+ avatarStyle?: string
+ avatarBadgeId?: string | null
+ hasCreatedHostBefore?: boolean
+ canAccessHostingFeature?: boolean
+ }
+}
+
+export interface RegisterRequest {
+ username: string
+ email: string
+ password: string
+ inviteCode?: string
+ turnstileToken?: string
+ emailCode?: string
+}
+
+export interface RegisterResponse {
+ token: string
+ user: {
+ id: number
+ username: string
+ email: string
+ role: 'admin' | 'user'
+ avatarStyle?: string
+ avatarBadgeId?: string | null
+ hasCreatedHostBefore?: boolean
+ canAccessHostingFeature?: boolean
+ }
+}
+
+export interface UpdateUserResponse {
+ message: string
+ reauthRequired?: boolean
+}
+
+export interface GenerateInviteRequest {
+ expiresAt?: string
+ count?: number
+}
+
+export interface InviteCode {
+ id: number
+ code: string
+ created_by: number
+ createdByUsername?: string | null
+ createdByEmail?: string | null
+ createdByAvatarStyle?: string | null
+ createdByAvatarBadgeId?: string | null
+ used_by: number | null
+ usedBy?: number | null
+ usedByUsername?: string | null
+ usedByEmail?: string | null
+ usedByAvatarStyle?: string | null
+ usedByAvatarBadgeId?: string | null
+ used_at: string | null
+ usedAt?: string | null
+ expires_at: string | null
+ expiresAt?: string | null
+ costSnapshot?: InviteGenerationCostSnapshot | null
+ cost_snapshot?: InviteGenerationCostSnapshot | null
+ created_at: string
+ createdAt?: string
+}
+
+export interface InviteListResponse {
+ invites: InviteCode[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+export interface InviteCostOption {
+ resource: string
+ amount: number
+ enabled: boolean
+ label: string
+ unit: string
+ displayAmount: string
+}
+
+export interface InviteGenerationCostSnapshot {
+ resource: string
+ amount: number
+ label: string
+ unit: string
+ displayAmount: string
+ chargedAt: string
+}
+
+export interface UserInvite {
+ id: number
+ code: string
+ createdBy: number
+ usedBy: number | null
+ usedAt: string | null
+ expiresAt: string | null
+ createdAt: string
+ costSnapshot: InviteGenerationCostSnapshot | null
+ registerUrl: string
+ usedByUser: {
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId: string | null
+ createdAt: string
+ } | null
+}
+
+export interface UserInviteSummary {
+ costOptions: InviteCostOption[]
+ balances: {
+ balance: number
+ points: number
+ }
+ stats: {
+ total: number
+ used: number
+ unused: number
+ usageRate: number
+ }
+}
+
+// ==================== 用户相关 ====================
+
+export interface User {
+ id: number
+ username: string
+ email: string | null
+ role: 'admin' | 'user'
+ status: 'active' | 'banned'
+ avatarStyle?: string
+ avatarBadgeId?: string | null
+ hasCreatedHostBefore?: boolean
+ canAccessHostingFeature?: boolean
+ twoFAEnabled?: boolean
+ instanceCount?: number
+ hasGithubBinding?: boolean
+ balance?: number
+ quota?: UserQuota
+ created_at: string
+ updated_at: string
+ createdAt?: string
+}
+
+export interface UserQuota {
+ id?: number
+ userId?: number
+ // 新配额系统:控制用户可拥有的资源数量
+ // 配额为 0 表示功能未授权,需要管理员开启
+ // 注意:不再限制实例配额,用户可以创建无限数量的实例
+ hostLimit: number
+ hostUsed: number
+ friendLimit: number
+ friendUsed: number
+ packageLimit: number
+ packageUsed: number
+}
+
+export interface UpdateUserRequest {
+ email?: string
+ role?: 'admin' | 'user'
+ status?: 'active' | 'banned'
+ avatarStyle?: string
+ password?: string
+ currentPassword?: string // 修改密码时需要提供当前密码
+ emailCode?: string
+}
+
+export interface TelegramBindingStatus {
+ enabled: boolean
+ configured: boolean
+ botUsername: string | null
+ binding: {
+ telegramUserId: string
+ telegramUsername: string | null
+ firstName: string | null
+ lastName: string | null
+ boundAt: string
+ } | null
+}
+
+export interface TelegramBindTokenResponse {
+ bindUrl: string
+ expiresAt: string
+}
+
+export interface TelegramWebhookInfo {
+ url: string
+ has_custom_certificate?: boolean
+ pending_update_count?: number
+ last_error_date?: number
+ last_error_message?: string
+ max_connections?: number
+ allowed_updates?: string[]
+}
+
+export interface TelegramWebhookInfoResponse {
+ info: TelegramWebhookInfo
+}
+
+export interface TelegramWebhookSetupResponse {
+ message: string
+ webhookUrl: string
+ commandsSynced?: boolean
+ commands?: Array<{
+ command: string
+ description: string
+ }>
+ result?: boolean
+}
+
+export interface TelegramWebhookDeleteResponse {
+ message: string
+ result?: boolean
+}
+
+export interface TelegramGroupEligibility {
+ eligible: boolean
+ status: 'eligible' | 'ineligible' | 'disabled' | 'unconfigured'
+ message: string
+ joinMode: 'any' | 'all'
+ minRecharge: number
+ minConsume: number
+}
+
+export interface TelegramAdminBinding {
+ id: number
+ userId: number
+ user: {
+ id: number
+ username: string
+ email: string | null
+ status: string
+ } | null
+ telegramUserId: string
+ telegramUsername: string | null
+ firstName: string | null
+ lastName: string | null
+ boundAt: string
+ updatedAt: string
+ stats: {
+ totalRecharge: number
+ totalConsume: number
+ totalRefund: number
+ totalDestroyedValue: number
+ }
+ eligibility: TelegramGroupEligibility
+ vipEligibility: TelegramGroupEligibility
+}
+
+export interface TelegramAdminBindingsResponse {
+ bindings: TelegramAdminBinding[]
+ total: number
+ page: number
+ pageSize: number
+ group: {
+ enabled: boolean
+ configured: boolean
+ joinMode: 'any' | 'all'
+ minRecharge: number
+ minConsume: number
+ }
+ vipGroup: {
+ enabled: boolean
+ configured: boolean
+ joinMode: 'any' | 'all'
+ minRecharge: number
+ minConsume: number
+ }
+}
+
+export interface BadgeCatalogItem {
+ id: string
+ name: string
+ nameEn: string | null
+ fullLabel: string
+ sourceId?: string
+ sourceLabel?: string
+ seriesId: string
+ seriesTitle: string
+ seriesNameZh?: string
+ seriesNameEn?: string | null
+ seriesDescription: string
+ assetUrl: string
+ assetUrlDark?: string
+ assetUrlLight?: string
+ displayOrder?: number
+ isActive?: boolean
+ seriesIsActive?: boolean
+ createdAt?: string
+ updatedAt?: string
+ ownershipCount?: number
+ avatarUseCount?: number
+ instanceUseCount?: number
+}
+
+export interface BadgeSeriesItem {
+ id: string
+ title: string
+ nameZh: string
+ nameEn: string | null
+ description: string
+ sourceId?: string | null
+ sourceLabel?: string | null
+ displayOrder: number
+ isActive: boolean
+ badgeCount?: number
+ activeBadgeCount?: number
+ createdAt?: string
+ updatedAt?: string
+}
+
+export interface BadgeOwnership {
+ id: number
+ badgeId: string
+ badgeName: string
+ badgeNameEn: string | null
+ badgeLabel: string
+ seriesId: string
+ seriesTitle: string
+ assetUrl: string
+ assetUrlDark?: string | null
+ assetUrlLight?: string | null
+ source: 'draw' | 'lottery' | 'select' | 'admin_grant'
+ applicationTarget: 'avatar' | 'instance' | null
+ appliedInstanceId: number | null
+ appliedInstanceName: string | null
+ appliedAt: string | null
+ createdAt: string
+}
+
+export interface BadgeOverview {
+ costs: {
+ randomDraw: number
+ select: number
+ }
+ currentPoints: number
+ avatarBadgeId: string | null
+ catalog: BadgeCatalogItem[]
+ ownerships: BadgeOwnership[]
+ instances: Array<{
+ id: number
+ name: string
+ status: string
+ iconBadgeId: string | null
+ packagePlanId: number | null
+ instanceType: 'container' | 'vm'
+ host: {
+ id: number
+ name: string
+ location: string | null
+ countryCode: string
+ }
+ }>
+}
+
+export interface BadgeMultiDrawResponse {
+ success: boolean
+ currentPoints: number
+ ownerships: BadgeOwnership[]
+}
+
+// ==================== 实例相关 ====================
+
+export interface Instance {
+ id: number
+ incus_id: string
+ name: string
+ user_id?: number // Only for admin
+ host_id?: number // Only for admin
+ package_id: number | null
+ displayOrder?: number
+ display_order?: number
+ image: string
+ imageName?: string | null // 镜像显示名称
+ status: 'creating' | 'running' | 'stopped' | 'suspended' | 'error' | 'deleted'
+ cpu: number
+ memory: number
+ disk: number
+ ipv4: string | null
+ ipv6: string | null
+ network_mode: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat'
+ networkMode?: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat' // camelCase 别名
+ ssh_port: number | null
+ root_password?: string | null // Removed from API responses for security
+ port_limit: number | null
+ snapshot_limit: number | null
+ backup_limit: number | null
+ site_limit: number | null
+ remaining_quota?: {
+ port: number
+ snapshot: number
+ backup: number
+ site: number
+ }
+ quota_usage?: {
+ port: number
+ snapshot: number
+ backup: number
+ site: number
+ }
+ effective_quota_limit?: {
+ port: number
+ snapshot: number
+ backup: number
+ site: number
+ }
+ swapEnabled?: boolean
+ swapSize?: number | null
+ monthlyTrafficLimit?: string | null // BigInt as string (Bytes)
+ limitsIngress?: string | null // 入栈带宽限制
+ limitsEgress?: string | null // 出栈带宽限制
+ planId?: number | null // 方案ID(用于变更方案)
+ packagePlanId?: number | null // 付费方案ID(列表接口返回,用于判断是否付费实例)
+ planName?: string | null
+ planPrice?: number | null
+ billingPrice?: number | null
+ billingCycle?: number | null
+ affDiscountRate?: number | null
+ hasAffBinding?: boolean
+ isHostedInstance?: boolean
+ instanceType?: 'container' | 'vm' // 实例类型:容器或虚拟机
+ iconBadgeId?: string | null
+ allow_instance_deletion?: boolean // 是否允许删除实例(来自套餐设置)
+ // 实例列表返回的宿主机信息
+ host?: {
+ name: string
+ country_code: string
+ nat_public_ip?: string | null
+ storageDriver?: string
+ }
+ hostCountryCode?: string
+ natPublicIp?: string | null
+ hostNatPublicIpv6?: string | null
+ hostIpv6Gateway?: string | null
+ hostIpAddress?: string | null
+ userAvatarBadgeId?: string | null
+ // 封停相关字段
+ expires_at?: string | null // 到期时间,null 表示免费实例
+ suspended_at?: string | null // 封停时间
+ suspended_by?: number | null // 封停操作者 ID
+ suspend_reason?: string | null // 封停原因
+ // 自动续费
+ autoRenew?: boolean
+ // 托管节点所有者信息(仅用户托管节点)
+ hostOwnerInfo?: {
+ id: number
+ username: string
+ email: string
+ avatarStyle: string
+ avatarBadgeId: string | null
+ hostCount: number
+ instanceCount: number
+ registeredDays: number
+ vipLevel: number
+ vipBadgeStyle?: VipBadgeStyle | null
+ } | null
+ created_at: string
+ createdAt?: string // camelCase 别名
+ updated_at: string
+}
+
+export interface InstanceWithDetails extends Omit {
+ user?: Pick
+ // 实例详情页面的宿主机信息(比列表更详细)
+ host?: {
+ id?: number
+ name: string
+ location?: string | null
+ country_code: string
+ nat_public_ip?: string | null
+ storageDriver?: string
+ }
+ package?: Pick
+ port_mappings?: PortMapping[]
+ nat_public_ip?: string
+ isHostOwner?: boolean // True if current user owns the host
+ isInstanceOwner?: boolean // True if current user owns the instance
+ isAdmin?: boolean // True if current user is an admin
+ enableResourcePool?: boolean // 节点是否启用资源池玩法
+}
+
+export interface CreateInstanceRequest {
+ name: string
+ packageId: number
+ planId?: number // 付费方案ID(付费套餐必填)
+ image: string
+ cpu: number
+ memory: number
+ disk: number
+ hostId: number
+ sshKeyId: number
+ networkMode?: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat'
+ portLimit?: number
+ snapshotLimit?: number
+ backupLimit?: number
+ customInitCommandIds?: number[] // 用户自定义初始化命令 ID 列表
+}
+
+export interface UpdateInstanceRequest {
+ name?: string
+ cpu?: number
+ memory?: number
+ disk?: number
+ portLimit?: number
+ snapshotLimit?: number
+ backupLimit?: number
+ siteLimit?: number | null // 0 = 不限制, null = 继承套餐
+}
+
+export interface InstanceStats {
+ cpu: {
+ usage: number
+ usagePercent: number
+ }
+ memory: {
+ usage: number
+ usagePercent: number
+ limit: number
+ }
+ disk: {
+ usage: number
+ usagePercent: number
+ limit: number
+ }
+ network: {
+ bytesReceived: number
+ bytesSent: number
+ }
+}
+
+export interface PortMapping {
+ id: number
+ instance_id?: number
+ host_id?: number
+ protocol: 'tcp' | 'udp'
+ publicPort: number
+ privatePort: number
+ public_port?: number // 兼容旧格式
+ private_port?: number // 兼容旧格式
+ remark?: string | null
+ device_name?: string
+ created_at?: string
+}
+
+export interface CreatePortMappingRequest {
+ protocol: 'tcp' | 'udp'
+ privatePort: number
+ remark?: string
+ deviceName?: string
+}
+
+// ==================== IP 地址 ====================
+
+export interface IpAddress {
+ id: number
+ address: string
+ type: 'inet4' | 'inet6'
+ isPrimary: boolean
+ isCustom?: boolean
+ device: string
+ createdAt: string
+}
+
+export interface Ipv6Subnet {
+ id: number
+ cidr: string
+ primaryIp: string
+ device: string
+ instanceId: number
+ createdAt: string
+}
+
+// ==================== 快照/备份 ====================
+
+export interface Snapshot {
+ id: number
+ instance_id: number
+ incus_name: string
+ name: string
+ description: string | null
+ stateful: number
+ size: number
+ created_at: string
+}
+
+export interface Backup {
+ id: number
+ instance_id: number
+ incus_name: string
+ name: string
+ description: string | null
+ size: number
+ status: 'creating' | 'ready' | 'error' | 'deleted'
+ created_at: string
+ expires_at: string | null
+}
+
+export interface CreateSnapshotRequest {
+ name: string
+ description?: string
+}
+
+export interface CreateBackupRequest {
+ name: string
+ description?: string
+}
+
+export interface SnapshotPolicy {
+ id: number
+ instance_id: number
+ enabled: number
+ interval_minutes: number
+ last_run_at: string | null
+ next_run_at: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface BackupPolicy {
+ id: number
+ instance_id: number
+ enabled: number
+ interval_minutes: number
+ last_run_at: string | null
+ next_run_at: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface UpdateSnapshotPolicyRequest {
+ enabled?: boolean
+ intervalMinutes?: number // 10, 60, 360, 1440
+}
+
+export interface UpdateBackupPolicyRequest {
+ enabled?: boolean
+ intervalMinutes?: number // 60, 360, 1440, 4320
+}
+
+// ==================== 节点相关 ====================
+
+export interface Host {
+ id: number
+ name: string
+ url: string
+ location: string | null
+ country_code: string
+ architecture?: 'x86_64' | 'aarch64'
+ status: 'online' | 'offline' | 'maintenance'
+ cert_path?: string | null // Removed from API responses for security
+ key_path?: string | null // Removed from API responses for security
+ nat_public_ip: string | null
+ nat_port_start: number | null
+ nat_port_end: number | null
+ cpu_used: number
+ memory_used: number
+ disk_used: number
+ cpu_allowance_max?: number
+ memory_max?: number
+ instance_type?: 'container' | 'vm' | 'both'
+ notify_purchase?: boolean
+ notify_renew?: boolean
+ notify_destroy?: boolean
+ created_at: string
+ updated_at: string
+}
+
+export interface HostWithDetails extends Host {
+ instanceCount?: number
+ tags?: string[]
+ certPath?: string // Removed from API responses for security
+ keyPath?: string // Removed from API responses for security
+ resources?: {
+ cpuUsed: number
+ memoryUsed: number
+ diskUsed: number
+ }
+ cpuAllowanceMax?: number
+ memoryMax?: number
+ instanceType?: 'container' | 'vm' | 'both'
+ notifyPurchase?: boolean
+ notifyRenew?: boolean
+ notifyDestroy?: boolean
+ architecture?: 'x86_64' | 'aarch64'
+ natConfig?: {
+ publicIp: string | null
+ publicIpv6?: string | null
+ bindIp?: string | null
+ bindIpv6?: string | null
+ portRangeStart: number | null
+ portRangeEnd: number | null
+ portsUsedCount?: number
+ }
+ // 托管节点所有者信息(管理员查看托管节点时返回)
+ owner?: {
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ }
+}
+
+export interface HostAgentStatus {
+ id: number
+ hostId: number
+ agentId: string
+ enabled: boolean
+ status: 'online' | 'offline' | string
+ version: string | null
+ latestVersion: string | null
+ versionStatus: 'latest' | 'outdated' | 'unknown'
+ capabilities: string[]
+ lastReport: {
+ incus?: {
+ available?: boolean
+ socket?: string
+ }
+ resources?: {
+ cpuTotal?: number
+ cpuUsagePercent?: number
+ memoryTotalMb?: number
+ memoryUsedMb?: number
+ memoryAvailableMb?: number
+ memoryUsagePercent?: number
+ swapTotalMb?: number
+ swapUsedMb?: number
+ swapUsagePercent?: number
+ diskMountpoint?: string
+ diskTotalBytes?: number
+ diskUsedBytes?: number
+ diskAvailableBytes?: number
+ diskUsagePercent?: number
+ processCount?: number
+ }
+ metrics?: {
+ reportedAt?: string
+ heartbeatIntervalSeconds?: number
+ uptimeSeconds?: number
+ load1?: number
+ load5?: number
+ load15?: number
+ }
+ [key: string]: unknown
+ }
+ lastSeenAt: string | null
+ lastHeartbeatIp: string | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface HostAgentStatusResponse {
+ host: {
+ id: number
+ name: string
+ }
+ agent: HostAgentStatus | null
+}
+
+export interface HostAgentUpgradeRequestResponse {
+ requested: boolean
+ currentVersion: string | null
+ latestVersion: string | null
+ versionStatus: 'latest' | 'outdated' | 'unknown'
+ nextHeartbeatSeconds: number
+ message: string
+}
+
+export interface HostAgentInstallCommandResponse {
+ host: {
+ id: number
+ name: string
+ }
+ agent: HostAgentStatus
+ installToken: string
+ installTokenExpiresAt: string
+ installScriptUrl: string
+ installCommand: string
+ warning: string
+}
+
+export interface CreateHostRequest {
+ name: string
+ url: string
+ location?: string
+ countryCode?: string
+ tags?: string[]
+ certPath?: string
+ keyPath?: string
+ natConfig?: {
+ publicIp?: string
+ publicIpv6?: string
+ bindIp?: string
+ bindIpv6?: string
+ portRangeStart?: number
+ portRangeEnd?: number
+ }
+ ipAddress?: string
+ // 存储配置
+ storageDriver?: 'zfs' | 'lvm'
+ storageType?: 'loop' | 'disk'
+ storagePath?: string
+ storageSize?: number
+ // 网络配置
+ ipv6Mode?: number
+ ipv6Subnet?: string
+ ipv6Gateway?: string
+ ipv6ParentInterface?: string
+}
+
+export interface UpdateHostRequest {
+ name?: string
+ url?: string
+ location?: string
+ countryCode?: string
+ status?: 'online' | 'offline' | 'maintenance'
+ certPath?: string
+ keyPath?: string
+ cpuAllowanceMax?: number
+ memoryMax?: number
+ instanceType?: 'container' | 'vm' | 'both'
+ ipv6ParentInterface?: string
+ ipv6Subnet?: string
+ transferEnabled?: boolean
+ trafficResetDay?: number // 流量重置日(1-28)
+ notifyPurchase?: boolean
+ notifyRenew?: boolean
+ notifyDestroy?: boolean
+ enableResourcePool?: boolean // 是否参与资源池玩法
+ announcement?: string | null // 节点公告
+ probeUrl?: string | null // 探针地址
+ natConfig?: {
+ publicIp?: string
+ publicIpv6?: string
+ bindIp?: string
+ bindIpv6?: string
+ portRangeStart?: number | null
+ portRangeEnd?: number | null
+ }
+}
+
+export interface AvailableHost {
+ id: number
+ name: string
+ location: string | null
+ countryCode: string
+ architecture?: 'x86_64' | 'aarch64'
+ probeUrl?: string | null
+ trafficMultiplier?: number
+ effectiveTrafficLimit?: string | null
+ resources: {
+ cpuUsed: number
+ cpuAllowanceMax: number
+ cpuEffectiveMax: number
+ cpuAvailable: number
+ memoryUsed: number
+ memoryMax: number
+ memoryAvailable: number
+ diskAvailable: number
+ }
+}
+
+export type ChangeHostUnavailableReason =
+ | 'current_host'
+ | 'host_offline'
+ | 'host_type_mismatch'
+ | 'cpu_full'
+ | 'memory_full'
+ | 'resource_unconfigured'
+ | 'image_unavailable'
+
+export interface ChangeHostOption {
+ id: number
+ name: string
+ location: string | null
+ countryCode: string
+ architecture: string
+ status: string
+ probeUrl?: string | null
+ trafficMultiplier?: number
+ effectiveTrafficLimit?: string | null
+ isCurrent: boolean
+ canChange: boolean
+ unavailableReason: ChangeHostUnavailableReason | null
+ resources: {
+ cpuUsed: number
+ cpuAllowanceMax: number
+ cpuAvailable: number
+ memoryUsed: number
+ memoryMax: number
+ memoryAvailable: number
+ }
+}
+
+export interface ChangeHostOptionsResponse {
+ packageId: number | null
+ packageName: string | null
+ currentHostId: number
+ required: {
+ cpu: number
+ memory: number
+ }
+ hosts: ChangeHostOption[]
+ sshKeys: Array<{
+ id: number
+ name: string
+ fingerprint?: string | null
+ }>
+ canChangeHost: boolean
+ unavailableReason?: 'no_package' | 'single_host' | 'no_ssh_key'
+}
+
+// ==================== 套餐相关 ====================
+
+export interface Package {
+ id: number
+ name: string
+ description: string | null
+ cpu_max: number
+ memory_max: number
+ disk_max: number
+ bandwidth_max: number | null
+ network_mode: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat'
+ networkMode?: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat' // camelCase 别名
+ instance_type?: 'container' | 'vm' // 实例类型
+ host_ids: number[] // 绑定的宿主机ID列表
+ host_storage_pools?: Record
+ host_traffic_multipliers?: Record
+ privileged: number
+ nested: number
+ active: number
+ instance_count?: number
+ port_limit?: number | null
+ snapshot_limit?: number | null
+ backup_limit?: number | null
+ site_limit?: number | null
+ node_selectors?: string | null
+ monthly_traffic_limit?: string | null // BigInt as string (Bytes)
+ // 存储 I/O 限制
+ io_limit_mode?: 'throughput' | 'iops'
+ limits_read?: string
+ limits_write?: string
+ limits_read_iops?: number
+ limits_write_iops?: number
+ // 网络限制
+ limits_ingress?: string
+ limits_egress?: string
+ // 进程与调度
+ limits_processes?: number
+ limits_cpu_priority?: number
+ // 启动配置
+ boot_autostart?: boolean
+ boot_autostart_priority?: number
+ boot_autostart_delay?: number
+ boot_host_shutdown_timeout?: number
+ created_at: string
+ // 套餐所有权标识(用于区分自己的套餐和共享的套餐)
+ isOwn?: boolean // true = 用户自己拥有的套餐,false = 共享给自己的套餐
+ isShared?: boolean // true = 共享给自己的套餐
+ isGlobalShared?: boolean // true = 全局共享的套餐
+ ownerId?: number // 套餐所有者ID
+ ownerUsername?: string // 套餐所有者用户名
+ sourceType?: 'official' | 'market' | 'zone' | 'friends' | 'own' // 套餐来源类型
+ hostingZoneId?: number
+ hostingZoneName?: string
+ hostingZoneLogoUrl?: string
+ soldOut?: boolean // 是否售罄(所有宿主机都无法满足最低配置)
+ // 全局共享配置(仅当 isGlobalShared = true 或套餐所有者查看时)
+ global_shared?: boolean
+ global_quota_multiplier?: null // 公开套餐不再使用资源配额倍数限制
+ global_max_instances?: number | null // 公开套餐最大实例数,开启公开时必须为 1-5
+ required_package_id?: number | null
+ required_package_name?: string | null
+ has_required_package_instance?: boolean
+ // 实例操作权限
+ allow_instance_deletion?: boolean // 是否允许用户删除实例
+ sharedAt?: string // 共享时间(共享套餐)
+ // 剩余配额信息
+ quotaInfo?: {
+ maxInstances: number | null
+ remainingInstances: number | null
+ maxCpu: number | null
+ remainingCpu: number | null
+ maxMemory: number | null
+ remainingMemory: number | null
+ ownerUsername: string | null
+ }
+ // 托管套餐所有者信息(管理员查看托管套餐时返回)
+ owner?: {
+ id: number
+ username: string
+ email: string | null
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ }
+}
+
+export interface CreatePackageRequest {
+ name: string
+ description?: string
+ cpuMax: number
+ memoryMax: number
+ diskMax: number
+ bandwidthMax?: number
+ networkMode?: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat'
+ instanceType?: 'container' | 'vm' // 实例类型
+ hostIds: number[] // 必须至少绑定一个宿主机
+ hostStoragePools?: Record
+ hostTrafficMultipliers?: Record
+ privileged?: boolean
+ nested?: boolean
+ active?: boolean
+ portLimit?: number
+ snapshotLimit?: number
+ backupLimit?: number
+ siteLimit?: number
+ nodeSelectors?: string[]
+ monthlyTrafficLimit?: string // BigInt as string (Bytes)
+ // 存储 I/O 限制
+ ioLimitMode?: 'throughput' | 'iops'
+ limitsRead?: string
+ limitsWrite?: string
+ limitsReadIops?: number
+ limitsWriteIops?: number
+ // 网络限制
+ limitsIngress?: string
+ limitsEgress?: string
+ // 进程与调度
+ limitsProcesses?: number
+ limitsCpuPriority?: number
+ // 启动配置
+ bootAutostart?: boolean
+ bootAutostartPriority?: number
+ bootAutostartDelay?: number
+ bootHostShutdownTimeout?: number
+ // 全局共享配置
+ globalShared?: boolean
+ globalQuotaMultiplier?: null // 公开套餐不再使用资源配额倍数限制
+ globalMaxInstances?: number | null // 公开套餐最大实例数,开启公开时必须为 1-5
+ requiredPackageId?: number | null
+ // 实例操作权限
+ allowInstanceDeletion?: boolean // 是否允许用户删除实例
+}
+
+export interface UpdatePackageRequest {
+ name?: string
+ description?: string
+ cpuMax?: number
+ memoryMax?: number
+ diskMax?: number
+ bandwidthMax?: number
+ networkMode?: 'nat' | 'nat_ipv6' | 'nat_ipv6_nat' | 'ipv6_only' | 'ipv6_nat'
+ instanceType?: 'container' | 'vm' // 实例类型
+ hostIds?: number[] // 如果提供,将更新绑定的宿主机(必须至少一个)
+ hostStoragePools?: Record
+ hostTrafficMultipliers?: Record
+ privileged?: boolean
+ nested?: boolean
+ active?: boolean
+ portLimit?: number
+ snapshotLimit?: number
+ backupLimit?: number
+ siteLimit?: number
+ nodeSelectors?: string[]
+ monthlyTrafficLimit?: string | null // BigInt as string (Bytes), null to clear
+ // 存储 I/O 限制
+ ioLimitMode?: 'throughput' | 'iops'
+ limitsRead?: string
+ limitsWrite?: string
+ limitsReadIops?: number
+ limitsWriteIops?: number
+ // 网络限制
+ limitsIngress?: string
+ limitsEgress?: string
+ // 进程与调度
+ limitsProcesses?: number
+ limitsCpuPriority?: number
+ // 启动配置
+ bootAutostart?: boolean
+ bootAutostartPriority?: number
+ bootAutostartDelay?: number
+ bootHostShutdownTimeout?: number
+ // 全局共享配置
+ globalShared?: boolean
+ globalQuotaMultiplier?: null // 公开套餐不再使用资源配额倍数限制
+ globalMaxInstances?: number | null // 公开套餐最大实例数,开启公开时必须为 1-5
+ requiredPackageId?: number | null
+ // 实例操作权限
+ allowInstanceDeletion?: boolean // 是否允许用户删除实例
+}
+
+// 实例配置类型
+export interface InstanceConfig {
+ limits_read: string
+ limits_write: string
+ limits_read_iops: number
+ limits_write_iops: number
+ limits_ingress: string
+ limits_egress: string
+ limits_processes: number
+ limits_cpu_priority: number
+ boot_autostart: boolean
+ boot_autostart_priority: number
+ boot_autostart_delay: number
+ boot_host_shutdown_timeout: number
+}
+
+export interface InstanceSwapConfig {
+ available: boolean
+ enabled: boolean
+ sizeMb: number
+ kind: 'container' | 'vm'
+ requiresRunning: boolean
+}
+
+export interface InstanceConfigResponse {
+ config: InstanceConfig
+ overrides: Record
+ packageDefaults: InstanceConfig
+ ioLimitMode: 'throughput' | 'iops'
+ swap: InstanceSwapConfig
+}
+
+export interface UpdateInstanceConfigRequest {
+ limitsRead?: string | null
+ limitsWrite?: string | null
+ limitsReadIops?: number | null
+ limitsWriteIops?: number | null
+ limitsIngress?: string | null
+ limitsEgress?: string | null
+ limitsProcesses?: number | null
+ limitsCpuPriority?: number | null
+ bootAutostart?: boolean | null
+ bootAutostartPriority?: number | null
+ bootAutostartDelay?: number | null
+ bootHostShutdownTimeout?: number | null
+}
+
+// ==================== SSH Keys ====================
+
+export interface SshKey {
+ id: number
+ user_id?: number
+ name: string
+ fingerprint: string
+ publicKeyPreview?: string // 截断的公钥预览
+ created_at?: string
+ createdAt?: string
+}
+
+export interface CreateSshKeyRequest {
+ name: string
+ publicKey: string
+}
+
+// ==================== 通知相关 ====================
+
+export interface NotificationChannel {
+ id: number
+ user_id: number
+ type: 'telegram' | 'discord' | 'email' | 'webhook'
+ name: string
+ config: string | Record
+ enabled: boolean | number
+ created_at: string
+}
+
+export interface CreateNotificationChannelRequest {
+ type: 'telegram' | 'discord' | 'email' | 'webhook'
+ name: string
+ config: Record
+}
+
+export interface UpdateNotificationChannelRequest {
+ name?: string
+ config?: Record
+ enabled?: boolean
+}
+
+export type CloudInitState =
+ | 'manual'
+ | 'done'
+ | 'done_with_errors'
+ | 'running'
+ | 'disabled'
+ | 'unsupported'
+ | 'agent_unavailable'
+ | 'unknown'
+
+export interface CloudInitStatusResponse {
+ ready: boolean
+ state: CloudInitState
+ source: 'manual' | 'status_json' | 'boot_finished' | 'image' | 'unknown'
+ manualOverride: boolean
+ message: string
+ detectedAt: string
+}
+
+export interface TerminalSavedCommand {
+ id: number
+ userId: number
+ name: string
+ command: string
+ description: string | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface CreateTerminalSavedCommandRequest {
+ name: string
+ command: string
+ description?: string | null
+}
+
+export interface UpdateTerminalSavedCommandRequest {
+ name?: string
+ command?: string
+ description?: string | null
+}
+
+// ==================== 实例任务相关 ====================
+
+export type InstanceTaskType = 'start' | 'stop' | 'restart' | 'rebuild' | 'recreate' | 'clone' | 'change_host'
+export type InstanceTaskStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'
+
+export interface InstanceTask {
+ id: number
+ instanceId?: number
+ instanceName?: string | null
+ taskType: InstanceTaskType
+ status: InstanceTaskStatus
+ progress?: string | null
+ error?: string | null
+ queuePosition: number
+ createdAt: string
+ startedAt?: string | null
+ finishedAt?: string | null
+ newInstanceId?: number | null // 用于 clone 操作
+}
+
+export interface InstanceTaskResponse {
+ message: string
+ taskId: number
+ status: InstanceTaskStatus
+}
+
+// ==================== 站内信相关 ====================
+
+export interface InboxMessage {
+ id: number
+ userId: number
+ eventType: string
+ title: string
+ content: string
+ isRead: boolean
+ data: Record | null
+ createdAt: string
+}
+
+export interface PaginatedInboxMessages {
+ messages: InboxMessage[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+// ==================== OAuth 相关 ====================
+
+export interface OAuthConfig {
+ id: number
+ provider: 'github' | 'google'
+ client_id: string
+ client_secret: string
+ enabled: number
+ created_at: string
+ updated_at: string
+}
+
+export interface UserOAuthBinding {
+ id: number
+ user_id: number
+ provider: 'github' | 'google'
+ provider_user_id: string
+ provider_username: string | null
+ provider_email: string | null
+ provider_avatar: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface UpdateOAuthConfigRequest {
+ clientId?: string
+ clientSecret?: string
+ enabled?: boolean
+}
+
+// ==================== 帮助文档 ====================
+
+export interface HelpArticle {
+ id: number
+ title: string
+ slug: string
+ content: string
+ category: string
+ sort_order: number
+ published: number
+ pinned?: number // 是否置顶(0 = 否,1 = 是)
+ created_by: number | null
+ created_at: string
+ updated_at: string
+}
+
+export interface CreateHelpArticleRequest {
+ title: string
+ slug: string
+ content: string
+ category: string
+ sortOrder?: number
+ published?: boolean
+ pinned?: boolean
+}
+
+export interface UpdateHelpArticleRequest {
+ title?: string
+ slug?: string
+ content?: string
+ category?: string
+ sortOrder?: number
+ published?: boolean
+ pinned?: boolean
+}
+
+// ==================== 镜像相关 ====================
+
+export interface SystemImage {
+ id: number
+ name: string
+ osType: string
+ remoteAlias: string
+ architecture: 'x86_64' | 'aarch64'
+ instanceType?: 'container' | 'vm' | 'both'
+ icon: string
+ sortOrder?: number
+ hidden?: boolean
+ createdAt?: string
+ updatedAt?: string
+}
+
+export interface CreateSystemImageRequest {
+ name: string
+ remoteAlias: string
+ osType?: string
+ architecture?: 'x86_64' | 'aarch64'
+ instanceType?: 'container' | 'vm' | 'both'
+ icon: string
+ sortOrder?: number
+ hidden?: boolean
+}
+
+export interface UpdateSystemImageRequest {
+ name?: string
+ remoteAlias?: string
+ osType?: string
+ architecture?: 'x86_64' | 'aarch64'
+ instanceType?: 'container' | 'vm' | 'both'
+ icon?: string
+ sortOrder?: number
+ hidden?: boolean
+}
+
+export interface HostImagePolicy {
+ success: boolean
+ host: {
+ id: number
+ name: string
+ architecture: 'x86_64' | 'aarch64'
+ instanceType: 'container' | 'vm' | 'both'
+ }
+ useDefault: boolean
+ allowedImageIds: number[]
+ images: SystemImage[]
+}
+
+// ==================== 日志 ====================
+
+export interface Log {
+ id: number
+ user_id: number | null
+ instance_id?: number | null
+ username?: string | null
+ module: string
+ action: string
+ content: string
+ result: string
+ created_at: string
+}
+
+// ==================== 工单相关 ====================
+
+export type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
+export type TicketPriority = 'low' | 'normal' | 'high' | 'urgent'
+export type TicketCategory = 'general' | 'billing' | 'technical' | 'abuse'
+
+export interface Ticket {
+ id: number
+ userId: number
+ hostId: number | null // 可为 null,表示直接发给管理员的工单
+ instanceId: number | null
+ subject: string
+ category: TicketCategory
+ priority: TicketPriority
+ status: TicketStatus
+ createdAt: string
+ updatedAt: string
+ resolvedAt: string | null
+ closedAt: string | null
+ user?: {
+ id: number
+ username: string
+ avatarStyle?: string
+ avatarBadgeId?: string | null
+ }
+ host?: {
+ id: number
+ name: string
+ userId: number
+ }
+ instance?: {
+ id: number
+ name: string
+ status?: string
+ iconBadgeId?: string | null
+ incusId?: string | null
+ ipv4?: string | null
+ ipv6?: string | null
+ cpu?: number
+ memory?: number
+ disk?: number
+ image?: string
+ packageName?: string | null
+ } | null
+ messageCount?: number
+ lastMessage?: {
+ content: string
+ isFromOwner: boolean
+ createdAt: string
+ } | null
+ // 是否需要回复:
+ // - 用户视角:宿主机主人回复了,需要用户回复
+ // - 宿主机视角:用户回复了,需要宿主机主人回复
+ needsReply?: boolean
+}
+
+export interface TicketMessage {
+ id: number
+ ticketId: number
+ senderId: number
+ content: string
+ isFromOwner: boolean
+ createdAt: string
+ attachments: TicketMessageAttachment[]
+ sender?: {
+ id: number
+ username: string
+ avatarStyle?: string
+ avatarBadgeId?: string | null
+ }
+}
+
+export interface TicketMessageAttachment {
+ id: number
+ ticketId: number
+ messageId: number
+ uploaderId: number
+ provider: string
+ providerVersion: string
+ providerFileId: string | null
+ filename: string
+ originalName: string
+ mimeType: string
+ sizeBytes: number
+ width: number | null
+ height: number | null
+ createdAt: string
+}
+
+export interface CreateTicketRequest {
+ instanceId?: number | null // 可选:不选实例时工单直接发给管理员
+ subject: string
+ category?: TicketCategory
+ priority?: TicketPriority
+ content: string
+ attachments?: File[]
+}
+
+export interface PaginatedTickets {
+ tickets: Ticket[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+export interface PaginatedTicketMessages {
+ messages: TicketMessage[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+// ==================== 计费相关 ====================
+
+// 套餐方案
+export interface PackagePlan {
+ id: number
+ packageId: number
+ name: string
+ description: string | null
+ price: number
+ billingCycle: number // 账期(月)
+ setupFee?: number // 开通费(分)
+ cpu: number
+ memory: number
+ disk: number
+ portLimit: number
+ snapshotLimit: number
+ backupLimit: number
+ siteLimit: number
+ monthlyTrafficLimit: string | null
+ isActive: boolean
+ isSoldOut: boolean
+ sortOrder: number
+ slaGuarantee: number | null // SLA保证百分比,1-100
+ createdAt: string
+ package?: {
+ id: number
+ name: string
+ }
+}
+
+// 计费信息
+export interface InstanceBillingInfo {
+ instanceId: number
+ instanceName: string
+ planId: number | null
+ planName: string | null
+ price: number | null
+ billingCycle: number | null
+ expiresAt: string | null
+ autoRenew: boolean
+ renewPreview: RenewPreview[] | null
+ affDiscount: AffDiscount | null
+ // 托管实例相关信息
+ isHostedInstance: boolean
+ daysUntilExpire: number | null
+ hostingRenewRestriction: { monthsOnly: number; daysBeforeExpire: number } | null
+}
+
+export interface RenewPreview {
+ months: number
+ price: number // 原价(元)
+ discountedPrice: number // 折扣价(元)
+ expiresAt: string
+}
+
+export interface AffDiscount {
+ discountRate: number // 折扣率,如 0.05
+ discountPercent: number // 百分比,如 5 表示 5%
+}
+
+// 升降级预览
+export interface ChangePlanPreview {
+ oldPlan: {
+ id: number
+ name: string
+ price: number
+ billingCycle: number
+ }
+ newPlan: {
+ id: number
+ name: string
+ price: number
+ billingCycle: number
+ isActive: boolean
+ isSoldOut: boolean
+ }
+ remainingDays: number
+ // 计算详情
+ oldDailyPrice: number // 原方案日价(元)
+ newDailyPrice: number // 新方案日价(元)
+ remainingValue: number // 剩余价值(元)
+ newPlanCost: number // 新方案费用(元)
+ // 折扣信息
+ discountRate: number // 折扣率(0-1)
+ discountAmount: number // 折扣金额(元)
+ // 最终费用
+ priceDiff: number // 差价(元,正数=补交,负数=退款)
+ isUpgrade: boolean
+ newExpiresAt: string
+ newConfig: {
+ cpu: number
+ memory: number
+ disk: number
+ }
+ resourceWarnings: string[] | null
+ // 可变更状态
+ canChange: boolean
+ cannotChangeReason?: string
+}
+
+// 计费记录
+export interface InstanceBillingRecord {
+ id: number
+ type: 'purchase' | 'renewal' | 'upgrade' | 'downgrade' | 'admin_extension'
+ amount: number
+ months: number | null
+ periodStart: string
+ periodEnd: string
+ remark: string | null
+ createdAt: string
+}
+
+// 支付渠道
+export interface PaymentProvider {
+ id: number
+ name: string
+ type: string
+ methods: string[]
+ methodFees?: Record
+ minAmount: number
+ maxAmount: number | null
+ feeRate: number
+ feeFixed: number
+}
+
+// 充值订单
+export interface RechargeOrder {
+ id?: number
+ orderNo: string
+ amount: number
+ payableAmount?: number
+ actualAmount: number | null
+ fee: number
+ status: 'pending' | 'paid' | 'completed' | 'failed' | 'cancelled' | 'expired' | 'refunded'
+ provider?: {
+ id: number
+ name: string
+ type: string
+ }
+ paymentMethod?: string | null
+ actualPaymentMethod?: string | null
+ paymentCurrency?: string | null
+ paymentNetwork?: string | null
+ paymentUuid?: string | null
+ paymentTxid?: string | null
+ invoiceCurrency?: string | null
+ gatewayStatus?: string | null
+ gatewayStatusDescription?: string | null
+ tradeNo?: string | null
+ failReason?: string | null
+ createdAt: string
+ expiredAt?: string | null
+ completedAt?: string | null
+}
+
+// 用户余额信息
+export interface UserBalance {
+ balance: number
+ frozen: number
+ totalRecharge: number
+ totalConsume: number
+}
+
+// 余额变动记录
+export interface BalanceLog {
+ id: number
+ type: string
+ amount: number
+ balanceBefore: number
+ balanceAfter: number
+ remark: string | null
+ createdAt: string
+}
+
+// ==================== 通用响应 ====================
+
+export interface ApiResponse {
+ success?: boolean
+ data?: T
+ error?: string
+ message?: string
+}
+
+export interface PaginatedResponse {
+ items: T[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
diff --git a/client/src/types/router.ts b/client/src/types/router.ts
new file mode 100644
index 0000000..e4aedef
--- /dev/null
+++ b/client/src/types/router.ts
@@ -0,0 +1,17 @@
+/**
+ * Router 类型定义
+ */
+
+import 'vue-router'
+
+declare module 'vue-router' {
+ interface RouteMeta {
+ requiresAuth?: boolean
+ requiresAdmin?: boolean
+ requiresUser?: boolean // 仅普通用户可访问(禁止管理员)
+ guest?: boolean
+ title?: string
+ titleKey?: string
+ }
+}
+
diff --git a/client/src/types/store.ts b/client/src/types/store.ts
new file mode 100644
index 0000000..2d4b9bb
--- /dev/null
+++ b/client/src/types/store.ts
@@ -0,0 +1,29 @@
+/**
+ * Store 类型定义
+ */
+
+export interface AuthUser {
+ id: number
+ username: string
+ email: string
+ role: 'admin' | 'user'
+ avatarStyle: string
+ avatarBadgeId?: string | null
+ hasCreatedHostBefore?: boolean
+ canAccessHostingFeature?: boolean
+}
+
+export interface ThemeMode {
+ mode: 'light' | 'dark' | 'system'
+ resolvedTheme: 'light' | 'dark'
+ isDark: boolean
+}
+
+export interface Toast {
+ id: number
+ message: string
+ type: 'success' | 'error' | 'warning' | 'info'
+ duration: number
+ visible: boolean
+}
+
diff --git a/client/src/utils/billing.ts b/client/src/utils/billing.ts
new file mode 100644
index 0000000..5125c67
--- /dev/null
+++ b/client/src/utils/billing.ts
@@ -0,0 +1,70 @@
+/**
+ * 前端计费计算工具函数
+ * 与后端 billing-calc.ts 保持一致
+ */
+
+// 周期天数常量(统一定义:1个月 = 31 天)
+const CYCLE_DAYS: Record = {
+ 1: 31, // 月付
+ 3: 93, // 季付
+ 6: 186, // 半年付
+ 12: 372 // 年付
+}
+
+/**
+ * 获取周期天数
+ */
+export function getCycleDays(billingCycle: number): number {
+ return CYCLE_DAYS[billingCycle] || (billingCycle * 31)
+}
+
+/**
+ * 计算日价
+ */
+export function calculateDailyPrice(price: number, billingCycle: number): number {
+ const cycleDays = getCycleDays(billingCycle)
+ return price / cycleDays
+}
+
+/**
+ * 计算折扣后价格
+ */
+export function calculateDiscountedPrice(originalPrice: number, discountRate: number): number {
+ return Number((originalPrice * (1 - discountRate)).toFixed(2))
+}
+
+/**
+ * 计算价格差价
+ * @param oldPrice 旧价格(元)
+ * @param oldBillingCycle 旧计费周期(月)
+ * @param newPrice 新价格(元)
+ * @param newBillingCycle 新计费周期(月)
+ * @param remainingDays 剩余天数
+ * @param discountRate 折扣率(0-1)
+ */
+export function calculatePriceDiff(
+ oldPrice: number,
+ oldBillingCycle: number,
+ newPrice: number,
+ newBillingCycle: number,
+ remainingDays: number,
+ discountRate: number = 0
+): number {
+ // 应用折扣后的实际价格
+ const actualOldPrice = discountRate > 0
+ ? calculateDiscountedPrice(oldPrice, discountRate)
+ : oldPrice
+ const actualNewPrice = discountRate > 0
+ ? calculateDiscountedPrice(newPrice, discountRate)
+ : newPrice
+
+ // 计算日价
+ const oldDailyPrice = calculateDailyPrice(actualOldPrice, oldBillingCycle)
+ const newDailyPrice = calculateDailyPrice(actualNewPrice, newBillingCycle)
+
+ // 差价 = (新日价 - 旧日价) × 剩余天数
+ const priceDiff = (newDailyPrice - oldDailyPrice) * remainingDays
+
+ // 最低金额门槛:低于 0.01 元按 0 处理
+ return Math.abs(priceDiff) < 0.01 ? 0 : Number(priceDiff.toFixed(2))
+}
diff --git a/client/src/utils/countryDisplay.ts b/client/src/utils/countryDisplay.ts
new file mode 100644
index 0000000..da049f0
--- /dev/null
+++ b/client/src/utils/countryDisplay.ts
@@ -0,0 +1,132 @@
+import flagCountryMetadata from 'flag-icons/country.json'
+
+interface FlagCountryMeta {
+ code: string
+ name: string
+}
+
+type TranslateCountryName = (key: string, fallback: string) => string
+
+const flagCountryCodePattern = /^[a-z]{2}$/
+const intlRegionCodePattern = /^([a-z]{2}|\d{3})$/i
+const flagCountries = (flagCountryMetadata as FlagCountryMeta[])
+ .filter(country => flagCountryCodePattern.test(country.code))
+ .map(country => ({
+ code: country.code.toLowerCase(),
+ englishName: country.name
+ }))
+
+const englishCountryNameMap = new Map(
+ flagCountries.map(country => [country.code, country.englishName])
+)
+
+const regionDisplayNameCache = new Map()
+
+const specialCountryNames: Record> = {
+ 'zh-cn': {
+ pc: '太平洋共同体',
+ xx: '未知地区'
+ },
+ 'zh-tw': {
+ pc: '太平洋共同體',
+ xx: '未知地區'
+ },
+ en: {
+ pc: 'Pacific Community',
+ xx: 'Unknown'
+ }
+}
+
+function getLocaleBucket(locale: string): keyof typeof specialCountryNames {
+ const normalizedLocale = locale.toLowerCase()
+ if (normalizedLocale.startsWith('zh-cn')) return 'zh-cn'
+ if (normalizedLocale.startsWith('zh-tw')) return 'zh-tw'
+ return 'en'
+}
+
+function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
+ const cacheKey = locale.toLowerCase()
+ const cached = regionDisplayNameCache.get(cacheKey)
+ if (cached !== undefined) {
+ return cached
+ }
+
+ if (typeof Intl.DisplayNames !== 'function') {
+ regionDisplayNameCache.set(cacheKey, null)
+ return null
+ }
+
+ const displayNames = new Intl.DisplayNames([locale], { type: 'region' })
+ regionDisplayNameCache.set(cacheKey, displayNames)
+ return displayNames
+}
+
+export const availableFlagCountryCodes = flagCountries
+ .map(country => country.code)
+ .filter(code => code !== 'cn')
+
+export function normalizeCountryCodeForFlag(code: string): string {
+ return code.toLowerCase()
+}
+
+export function getLocalizedCountryName(
+ code: string,
+ locale: string,
+ translate?: TranslateCountryName
+): string {
+ const normalizedCode = code.trim().toLowerCase()
+ if (!normalizedCode) {
+ return ''
+ }
+
+ const uppercaseCode = normalizedCode.toUpperCase()
+
+ if (translate) {
+ const translatedName = translate(`common.countries.${normalizedCode}`, uppercaseCode)
+ if (translatedName !== uppercaseCode) {
+ return translatedName
+ }
+ }
+
+ if (intlRegionCodePattern.test(normalizedCode)) {
+ const displayName = getRegionDisplayNames(locale)?.of(uppercaseCode)
+ if (displayName && displayName !== uppercaseCode) {
+ return displayName
+ }
+ }
+
+ const localeBucket = getLocaleBucket(locale)
+ const specialName = specialCountryNames[localeBucket][normalizedCode]
+ if (specialName) {
+ return specialName
+ }
+
+ return englishCountryNameMap.get(normalizedCode) || uppercaseCode
+}
+
+export function normalizeCountryName(
+ country: string | null | undefined,
+ localizedNames: { mainlandChina: string; hongKong: string; macau: string; taiwan: string }
+): string | null {
+ if (!country) return null
+
+ const normalized = country.trim()
+
+ if (normalized === 'China' || normalized === 'China Mainland' || normalized === 'Mainland China') {
+ return localizedNames.mainlandChina
+ }
+
+ if (normalized === 'Taiwan' || normalized === 'China Taiwan') {
+ return localizedNames.taiwan
+ }
+
+ if (normalized === 'Hong Kong' || normalized === 'China Hong Kong') {
+ return localizedNames.hongKong
+ }
+
+ if (normalized === 'Macau' || normalized === 'China Macau') {
+ return localizedNames.macau
+ }
+
+ return country
+}
diff --git a/client/src/utils/errorHandler.ts b/client/src/utils/errorHandler.ts
new file mode 100644
index 0000000..4ab94ab
--- /dev/null
+++ b/client/src/utils/errorHandler.ts
@@ -0,0 +1,61 @@
+import i18n from '@/locales'
+
+/**
+ * API error type from interceptor
+ */
+export interface ApiError {
+ message: string
+ code?: string | null
+ details?: string | null
+}
+
+/**
+ * Translate API error response
+ * If error has a code, translate it; otherwise return the error message as-is
+ */
+export function translateError(error: unknown): string {
+ const { t, te } = i18n.global
+
+ if (!error) return t('common.error') as string
+
+ // Handle API error object from interceptor
+ if (typeof error === 'object' && error !== null) {
+ const err = error as ApiError
+
+ // 优先使用 details(包含详细的错误信息)
+ if (err.details) {
+ return err.details
+ }
+
+ // Check for error code and translate
+ if (err.code) {
+ const key = `errors.${err.code}`
+ if (te(key)) {
+ return t(key) as string
+ }
+ }
+
+ // Fallback to error message
+ if (err.message) {
+ return err.message
+ }
+ }
+
+ if (typeof error === 'string') {
+ return error
+ }
+
+ return t('common.error') as string
+}
+
+/**
+ * Get translated error message from error code
+ */
+export function getErrorMessage(code: string): string {
+ const { t, te } = i18n.global
+ const key = `errors.${code}`
+ if (te(key)) {
+ return t(key) as string
+ }
+ return code
+}
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
new file mode 100644
index 0000000..48b76b8
--- /dev/null
+++ b/client/src/utils/formatters.ts
@@ -0,0 +1,166 @@
+/**
+ * 通用格式化工具函数
+ */
+
+/**
+ * 格式化内存大小 (MB -> 可读格式,1024进制)
+ */
+export function formatMemory(mb: number | null | undefined): string {
+ if (!mb) return '0'
+ if (mb >= 1024) {
+ const gb = mb / 1024
+ return gb % 1 === 0 ? gb.toFixed(0) + ' GB' : gb.toFixed(1) + ' GB'
+ }
+ return mb + ' MB'
+}
+
+/**
+ * 格式化硬盘大小 (MB -> 可读格式,1024进制)
+ */
+export function formatDisk(mb: number | null | undefined): string {
+ if (!mb) return '0'
+ if (mb >= 1024) {
+ const gb = mb / 1024
+ return gb % 1 === 0 ? gb.toFixed(0) + ' GB' : gb.toFixed(1) + ' GB'
+ }
+ return mb + ' MB'
+}
+
+/**
+ * 格式化字节大小
+ */
+export function formatBytes(bytes: number | null | undefined): string {
+ if (!bytes || bytes === 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(1024))
+ return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
+}
+
+/**
+ * 格式化日期
+ */
+export function formatDate(dateStr: string | null | undefined): string {
+ if (!dateStr) return '-'
+ return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+/**
+ * 格式化日期(简短格式)
+ */
+export function formatDateShort(dateStr: string | null | undefined): string {
+ if (!dateStr) return '-'
+ const date = new Date(dateStr)
+ return date.toLocaleDateString('zh-CN', {
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ })
+}
+
+/**
+ * 计算百分比
+ */
+export function getPercent(used: number | null | undefined, limit: number | null | undefined): number {
+ if (!limit || !used) return 0
+ return Math.min(100, Math.round((used / limit) * 100))
+}
+
+/**
+ * 根据百分比获取进度条颜色
+ */
+export function getProgressColor(percent: number): string {
+ if (percent >= 90) return 'bg-red-500'
+ if (percent >= 70) return 'bg-yellow-500'
+ return 'bg-green-500'
+}
+
+export interface StatusInfo {
+ label: string
+ class: string
+ dot: string
+}
+
+type TranslateFunction = (key: string) => string
+
+/**
+ * 获取实例状态信息
+ * @param status 状态字符串
+ * @param t 翻译函数(可选,不传则返回状态键名)
+ */
+export function getStatusInfo(status: string, t?: TranslateFunction): StatusInfo {
+ const normalizedStatus = status?.toLowerCase() || 'stopped'
+
+ const statusMap: Record = {
+ running: { labelKey: 'instance.status.running', class: 'badge-success', dot: 'bg-green-500' },
+ stopped: { labelKey: 'instance.status.stopped', class: 'badge-default', dot: 'bg-gray-500' },
+ suspended: { labelKey: 'instance.status.suspended', class: 'badge-error', dot: 'bg-red-500' },
+ starting: { labelKey: 'instance.status.starting', class: 'badge-warning', dot: 'bg-yellow-500 animate-pulse' },
+ stopping: { labelKey: 'instance.status.stopping', class: 'badge-warning', dot: 'bg-yellow-500 animate-pulse' },
+ restarting: { labelKey: 'instance.status.restarting', class: 'badge-warning', dot: 'bg-yellow-500 animate-pulse' },
+ creating: { labelKey: 'instance.status.creating', class: 'badge-warning', dot: 'bg-yellow-500 animate-pulse' },
+ error: { labelKey: 'instance.status.error', class: 'badge-error', dot: 'bg-red-500' }
+ }
+
+ const info = statusMap[normalizedStatus] || statusMap.stopped
+ return {
+ label: t ? t(info.labelKey) : info.labelKey,
+ class: info.class,
+ dot: info.dot
+ }
+}
+
+/**
+ * 获取时间问候语
+ */
+export function getTimeGreeting(): string {
+ const hour = new Date().getHours()
+ if (hour < 6) return '夜深了'
+ if (hour < 12) return '早上好'
+ if (hour < 18) return '下午好'
+ return '晚上好'
+}
+
+/**
+ * 格式化时长(秒 -> 可读格式)
+ */
+export function formatDuration(seconds: number | null | undefined): string {
+ if (!seconds && seconds !== 0) return '-'
+ if (seconds < 60) return `${seconds}秒`
+ if (seconds < 3600) {
+ const minutes = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return secs > 0 ? `${minutes}分${secs}秒` : `${minutes}分钟`
+ }
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const secs = seconds % 60
+ if (minutes === 0 && secs === 0) return `${hours}小时`
+ if (secs === 0) return `${hours}小时${minutes}分钟`
+ return `${hours}小时${minutes}分${secs}秒`
+}
+
+/**
+ * 格式化相对时间(用于站内信等场景)
+ * @param dateStr ISO 时间字符串
+ * @param t 翻译函数,接受 key 和参数
+ * @returns 格式化后的相对时间字符串
+ */
+export function formatRelativeTime(
+ dateStr: string,
+ t: (key: string, params?: Record) => string
+): string {
+ const date = new Date(dateStr)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return t('inbox.justNow')
+ if (diffMins < 60) return t('inbox.minutesAgo', { n: diffMins })
+ if (diffHours < 24) return t('inbox.hoursAgo', { n: diffHours })
+ if (diffDays < 7) return t('inbox.daysAgo', { n: diffDays })
+
+ return date.toLocaleDateString()
+}
\ No newline at end of file
diff --git a/client/src/utils/freeSiteFun.ts b/client/src/utils/freeSiteFun.ts
new file mode 100644
index 0000000..5d3be4c
--- /dev/null
+++ b/client/src/utils/freeSiteFun.ts
@@ -0,0 +1,112 @@
+import i18n from '@/locales'
+
+type TranslateParams = Record
+
+function tr(key: string, params?: TranslateParams): string {
+ const translate = i18n.global.t as (key: string, params?: TranslateParams) => string
+ return translate(key, params)
+}
+
+export function getFreeSiteBillingCycleLabel(months: number | null | undefined): string {
+ switch (months) {
+ case 1:
+ return tr('freeSite.billingCycleLabel.monthly')
+ case 3:
+ return tr('freeSite.billingCycleLabel.quarterly')
+ case 6:
+ return tr('freeSite.billingCycleLabel.semiAnnual')
+ case 12:
+ return tr('freeSite.billingCycleLabel.annual')
+ default:
+ return months
+ ? tr('freeSite.billingCycleLabel.custom', { months })
+ : tr('freeSite.billingCycleLabel.free')
+ }
+}
+
+export function getFreeSiteBillingCycleShort(months: number | null | undefined): string {
+ switch (months) {
+ case 1:
+ return tr('freeSite.billingCycleShort.monthly')
+ case 3:
+ return tr('freeSite.billingCycleShort.quarterly')
+ case 6:
+ return tr('freeSite.billingCycleShort.semiAnnual')
+ case 12:
+ return tr('freeSite.billingCycleShort.annual')
+ default:
+ return months ? tr('freeSite.billingCycleShort.custom', { months }) : ''
+ }
+}
+
+const freeSiteCopyKeys = [
+ 'finalPrice',
+ 'renewPrice',
+ 'billingCycle',
+ 'needPay',
+ 'originalPrice',
+ 'oldDailyPrice',
+ 'newDailyPrice',
+ 'remainingValue',
+ 'newPlanCost',
+ 'currentBalance',
+ 'balanceAfterRenew',
+ 'walletBalanceTab',
+ 'walletLogsTab',
+ 'walletCurrentBalance',
+ 'walletDescription',
+ 'walletLogsDescription',
+ 'walletTotalRecharge',
+ 'walletTotalConsume',
+ 'walletDestroyedValue',
+ 'dashboardNewInstance',
+ 'dashboardCreateInstance',
+ 'dashboardCreateFirst',
+ 'dashboardUserBalance',
+ 'dashboardBalanceValue',
+ 'dashboardNewContainer',
+ 'instanceCreate',
+ 'instanceCreateFirst',
+ 'instanceBatchRenewTitle',
+ 'instanceBatchRenewDescription',
+ 'instanceBatchTotalAmount',
+ 'instanceBatchBalanceAfter',
+ 'instanceBatchCurrentBalance',
+ 'instanceBatchRenewAction',
+ 'moneyJustForShow',
+ 'marketPriceFree',
+ 'marketPlanCount',
+ 'marketCreateNow',
+ 'marketLoginToOrder',
+ 'marketSelectedPlanTitle',
+ 'marketCycleMonthly',
+ 'marketMonthlyPrice',
+ 'createOrderSummary',
+ 'createPromoCode',
+ 'createPromoPlaceholder',
+ 'createPromoHostedDisabled',
+ 'createPromoValid',
+ 'createPromoUsing',
+ 'createPromoBenefit',
+ 'createCommissionEstimate',
+ 'createPlanFee',
+ 'createMonthlyEquivalent',
+ 'mailPrice',
+ 'mailCheckoutTitle',
+ 'mailBillingCycle',
+ 'mailCheckoutAmount',
+ 'mailCheckoutConfirm',
+ 'mailBalanceRequired',
+] as const
+
+type FreeSiteCopyKey = typeof freeSiteCopyKeys[number]
+type FreeSiteCopy = Record
+
+export const freeSiteCopy = {} as FreeSiteCopy
+
+for (const key of freeSiteCopyKeys) {
+ Object.defineProperty(freeSiteCopy, key, {
+ enumerable: true,
+ get: () => tr(`freeSite.copy.${key}`)
+ })
+}
diff --git a/client/src/utils/inboxHelper.ts b/client/src/utils/inboxHelper.ts
new file mode 100644
index 0000000..56b8467
--- /dev/null
+++ b/client/src/utils/inboxHelper.ts
@@ -0,0 +1,261 @@
+/**
+ * 站内信辅助工具
+ * 提供消息类型映射、分类、跳转逻辑
+ */
+
+import type { InboxMessage } from '@/types/api'
+
+/**
+ * 消息类别
+ */
+export type MessageCategory =
+ | 'instance' // 实例相关
+ | 'snapshot' // 快照相关
+ | 'social' // 社交互动(好友)
+ | 'transfer' // 实例转移
+ | 'package' // 套餐共享
+ | 'security' // 安全账户
+ | 'quota' // 配额资源
+ | 'ticket' // 工单相关
+ | 'system' // 系统通知
+
+/**
+ * 消息类别配置
+ */
+interface CategoryConfig {
+ label: string // i18n key
+ icon: string // 图标名称(用于前端渲染)
+ color: string // 颜色类名
+}
+
+/**
+ * 事件类型到类别的映射
+ */
+const EVENT_TYPE_CATEGORY_MAP: Record = {
+ // 实例相关
+ instance_created: 'instance',
+ instance_started: 'instance',
+ instance_stopped: 'instance',
+ instance_restarted: 'instance',
+ instance_rebuilt: 'instance',
+ instance_cloned: 'instance',
+ instance_deleted: 'instance',
+ instance_task_failed: 'instance',
+ instance_unexpected_stop: 'instance',
+
+ // 快照相关
+ snapshot_created: 'snapshot',
+ snapshot_restored: 'snapshot',
+ snapshot_deleted: 'snapshot',
+ snapshot_failed: 'snapshot',
+ auto_snapshot: 'snapshot',
+ auto_snapshot_rotated: 'snapshot',
+
+ // 历史备份消息归并到实例类,不再单独显示备份分类
+ backup_created: 'instance',
+ backup_failed: 'instance',
+ backup_deleted: 'instance',
+ backup_restored: 'instance',
+ backup_uploaded: 'instance',
+ auto_backup: 'instance',
+ auto_backup_rotated: 'instance',
+
+ // 社交互动
+ friend_request: 'social',
+ friend_accepted: 'social',
+ friend_rejected: 'social',
+ friend_removed: 'social',
+
+ // 套餐共享
+ package_shared: 'package',
+ package_share_revoked: 'package',
+
+ // 实例转移
+ transfer_received: 'transfer',
+ transfer_accepted: 'transfer',
+ transfer_rejected: 'transfer',
+ transfer_cancelled: 'transfer',
+
+ // 安全账户
+ login_new_device: 'security',
+ password_changed: 'security',
+ '2fa_enabled': 'security',
+ '2fa_disabled': 'security',
+
+ // 配额资源
+ quota_warning: 'quota',
+
+ // 工单相关
+ ticket_created: 'ticket',
+ ticket_replied: 'ticket',
+ ticket_status_changed: 'ticket',
+ ticket_closed: 'ticket',
+
+ // 系统公告
+ system_announcement: 'system',
+ host_announcement: 'system',
+ admin_message: 'system',
+ host_message: 'system',
+
+ // AFF 推荐计划
+ aff_convert_approved: 'system',
+ aff_convert_rejected: 'system'
+}
+
+/**
+ * 类别配置
+ */
+const CATEGORY_CONFIG: Record = {
+ instance: {
+ label: 'inbox.categories.instance',
+ icon: 'server',
+ color: 'blue'
+ },
+ snapshot: {
+ label: 'inbox.categories.snapshot',
+ icon: 'camera',
+ color: 'purple'
+ },
+ social: {
+ label: 'inbox.categories.social',
+ icon: 'users',
+ color: 'pink'
+ },
+ transfer: {
+ label: 'inbox.categories.transfer',
+ icon: 'arrow-right',
+ color: 'orange'
+ },
+ package: {
+ label: 'inbox.categories.package',
+ icon: 'gift',
+ color: 'cyan'
+ },
+ security: {
+ label: 'inbox.categories.security',
+ icon: 'shield',
+ color: 'red'
+ },
+ quota: {
+ label: 'inbox.categories.quota',
+ icon: 'chart',
+ color: 'yellow'
+ },
+ ticket: {
+ label: 'inbox.categories.ticket',
+ icon: 'ticket',
+ color: 'indigo'
+ },
+ system: {
+ label: 'inbox.categories.system',
+ icon: 'bell',
+ color: 'gray'
+ }
+}
+
+/**
+ * 获取消息类别
+ */
+export function getMessageCategory(eventType: string): MessageCategory {
+ return EVENT_TYPE_CATEGORY_MAP[eventType] || 'system'
+}
+
+/**
+ * 获取类别配置
+ */
+export function getCategoryConfig(category: MessageCategory): CategoryConfig {
+ return CATEGORY_CONFIG[category]
+}
+
+/**
+ * 获取所有类别列表
+ */
+export function getAllCategories(): { key: MessageCategory; config: CategoryConfig }[] {
+ return Object.entries(CATEGORY_CONFIG).map(([key, config]) => ({
+ key: key as MessageCategory,
+ config
+ }))
+}
+
+/**
+ * 获取类别对应的 CSS 颜色类
+ */
+export function getCategoryColorClass(category: MessageCategory, isDark: boolean): string {
+ const colorMap: Record = {
+ blue: { light: 'bg-blue-100 text-blue-700', dark: 'bg-blue-900/50 text-blue-300' },
+ purple: { light: 'bg-purple-100 text-purple-700', dark: 'bg-purple-900/50 text-purple-300' },
+ green: { light: 'bg-green-100 text-green-700', dark: 'bg-green-900/50 text-green-300' },
+ pink: { light: 'bg-pink-100 text-pink-700', dark: 'bg-pink-900/50 text-pink-300' },
+ orange: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-900/50 text-orange-300' },
+ cyan: { light: 'bg-cyan-100 text-cyan-700', dark: 'bg-cyan-900/50 text-cyan-300' },
+ red: { light: 'bg-red-100 text-red-700', dark: 'bg-red-900/50 text-red-300' },
+ yellow: { light: 'bg-yellow-100 text-yellow-700', dark: 'bg-yellow-900/50 text-yellow-300' },
+ indigo: { light: 'bg-indigo-100 text-indigo-700', dark: 'bg-indigo-900/50 text-indigo-300' },
+ gray: { light: 'bg-gray-100 text-gray-700', dark: 'bg-gray-700 text-gray-300' }
+ }
+
+ const config = getCategoryConfig(category)
+ const colors = colorMap[config.color] || colorMap.gray
+ return isDark ? colors.dark : colors.light
+}
+
+/**
+ * 根据消息数据生成跳转路由
+ * 返回 null 表示不支持跳转
+ */
+export function getMessageRoute(message: InboxMessage): string | null {
+ const { eventType, data } = message
+
+ if (!data) return null
+
+ const category = getMessageCategory(eventType)
+
+ switch (category) {
+ case 'instance':
+ case 'snapshot':
+ // 实例/快照相关 - 跳转到实例详情
+ if (data.instanceId) {
+ return `/instances/${data.instanceId}`
+ }
+ break
+
+ case 'social':
+ // 好友功能已禁用,不进行跳转
+ return null
+
+ case 'transfer':
+ // 转移相关 - 跳转到转移页面
+ return '/transfers'
+
+ case 'package':
+ // 套餐共享 - 跳转到套餐页面
+ if (data.packageId) {
+ return `/resources/packages/${data.packageId}`
+ }
+ return '/resources/packages'
+
+ case 'security':
+ // 安全相关 - 跳转到个人设置
+ return '/profile'
+
+ case 'quota':
+ // 配额相关 - 跳转到概览
+ return '/dashboard'
+
+ case 'ticket':
+ // 工单相关 - 跳转到工单页面
+ if (data.ticketId) {
+ return `/tickets?id=${data.ticketId}`
+ }
+ return '/tickets'
+ }
+
+ return null
+}
+
+/**
+ * 判断消息是否支持跳转
+ */
+export function canNavigate(message: InboxMessage): boolean {
+ return getMessageRoute(message) !== null
+}
diff --git a/client/src/utils/markdown.ts b/client/src/utils/markdown.ts
new file mode 100644
index 0000000..d64c98e
--- /dev/null
+++ b/client/src/utils/markdown.ts
@@ -0,0 +1,587 @@
+import { marked, Renderer } from 'marked'
+
+// 自定义 Alert 颜色配置
+const alertColors: Record = {
+ info: {
+ bg: 'bg-blue-900/30',
+ bgLight: 'bg-blue-50',
+ border: 'border-blue-500/50',
+ text: 'text-blue-400',
+ textLight: 'text-blue-600',
+ icon: ''
+ },
+ success: {
+ bg: 'bg-green-900/30',
+ bgLight: 'bg-green-50',
+ border: 'border-green-500/50',
+ text: 'text-green-400',
+ textLight: 'text-green-600',
+ icon: ''
+ },
+ warning: {
+ bg: 'bg-yellow-900/30',
+ bgLight: 'bg-yellow-50',
+ border: 'border-yellow-500/50',
+ text: 'text-yellow-400',
+ textLight: 'text-yellow-600',
+ icon: ''
+ },
+ danger: {
+ bg: 'bg-red-900/30',
+ bgLight: 'bg-red-50',
+ border: 'border-red-500/50',
+ text: 'text-red-400',
+ textLight: 'text-red-600',
+ icon: ''
+ },
+ note: {
+ bg: 'bg-gray-800/50',
+ bgLight: 'bg-gray-100',
+ border: 'border-gray-500/50',
+ text: 'text-gray-400',
+ textLight: 'text-gray-600',
+ icon: ''
+ }
+}
+
+// 颜色别名映射
+const colorAliases: Record = {
+ blue: 'info',
+ green: 'success',
+ yellow: 'warning',
+ red: 'danger',
+ gray: 'note',
+ grey: 'note'
+}
+
+/**
+ * 解析自定义 Alert 语法: ?{颜色}[内容]
+ * 支持的颜色: info/blue, success/green, warning/yellow, danger/red, note/gray
+ */
+function parseCustomAlerts(content: string): string {
+ // 匹配 ?{颜色}[内容] 格式,内容可以跨行
+ const alertRegex = /\?\{(\w+)\}\[([\s\S]*?)\]/g
+
+ return content.replace(alertRegex, (_, color: string, text: string) => {
+ const normalizedColor = colorAliases[color.toLowerCase()] || color.toLowerCase()
+ const colorConfig = alertColors[normalizedColor] || alertColors.info
+
+ // 处理内容中的换行
+ const processedText = text.trim().replace(/\n/g, '
')
+
+ return `
+
${colorConfig.icon}
+
${processedText}
+
`
+ })
+}
+
+
+/**
+ * 创建自定义 marked 渲染器
+ */
+function createCustomRenderer(): Renderer {
+ const renderer = new Renderer()
+
+ // 自定义链接渲染 - 外部链接新窗口打开
+ renderer.link = ({ href, title, text }) => {
+ const isExternal = href?.startsWith('http://') || href?.startsWith('https://')
+ const titleAttr = title ? ` title="${title}"` : ''
+ const targetAttr = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''
+ const externalIcon = isExternal
+ ? ' '
+ : ''
+ return `${text}${externalIcon}`
+ }
+
+ // 自定义图片渲染 - 支持图片标题和懒加载
+ renderer.image = ({ href, title, text }) => {
+ const titleAttr = title ? ` title="${title}"` : ''
+ const altAttr = text ? ` alt="${text}"` : ''
+ const figcaption = title ? `${title}` : ''
+ return `
+
+ ${figcaption}
+ `
+ }
+
+ // 自定义代码块渲染
+ renderer.code = ({ text, lang }) => {
+ const langLabel = lang ? `${lang}` : ''
+ const escapedText = text.replace(/&/g, '&').replace(//g, '>')
+
+ return `
+
${escapedText}
+ ${langLabel}
+
`
+ }
+
+ // 自定义表格渲染 - 添加响应式包装
+ renderer.table = (token: any) => {
+ const headerRows = token.header.map((row: any[]) =>
+ `${row.map((cell: any) => `| ${cell.text} | `).join('')}
`
+ ).join('')
+ const bodyRows = token.rows.map((row: any[]) =>
+ `${row.map((cell: any) => `| ${cell.text} | `).join('')}
`
+ ).join('')
+ return `
+
+ ${headerRows}
+ ${bodyRows}
+
+
`
+ }
+
+ // 自定义引用块渲染
+ renderer.blockquote = ({ text }) => {
+ return `${text}
`
+ }
+
+ // 自定义水平线
+ renderer.hr = () => {
+ return '
'
+ }
+
+ // 自定义复选框列表项
+ renderer.listitem = ({ text, task, checked }) => {
+ if (task) {
+ const checkboxClass = checked
+ ? 'text-green-500'
+ : 'text-themed-muted'
+ const checkIcon = checked
+ ? ''
+ : ''
+ return `${checkIcon}${text}`
+ }
+ return `${text}`
+ }
+
+ return renderer
+}
+
+/**
+ * 配置 marked 选项
+ */
+function configureMarked(): void {
+ marked.setOptions({
+ breaks: true,
+ gfm: true,
+ renderer: createCustomRenderer()
+ } as any)
+}
+
+// 初始化配置
+configureMarked()
+
+/**
+ * 解析 Markdown 内容为 HTML
+ * @param content Markdown 内容
+ * @returns 解析后的 HTML
+ */
+export function parseMarkdown(content: string): string {
+ if (!content) return ''
+
+ // 先处理自定义 Alert 语法
+ const processedContent = parseCustomAlerts(content)
+
+ // 使用 marked 解析
+ return marked(processedContent) as string
+}
+
+/**
+ * 获取 Markdown 样式类名
+ * 用于在组件中应用样式
+ */
+export const markdownStyles = `
+/* ============ Markdown 基础样式 ============ */
+.markdown-body {
+ color: var(--text-primary);
+ line-height: 1.7;
+}
+
+.markdown-body > *:first-child {
+ margin-top: 0 !important;
+}
+
+.markdown-body > *:last-child {
+ margin-bottom: 0 !important;
+}
+
+/* 标题 */
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4,
+.markdown-body h5,
+.markdown-body h6 {
+ color: var(--text-primary);
+ margin-top: 1.5em;
+ margin-bottom: 0.5em;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.markdown-body h1 { font-size: 1.75em; }
+.markdown-body h2 {
+ font-size: 1.5em;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0.3em;
+}
+.markdown-body h3 { font-size: 1.25em; }
+.markdown-body h4 { font-size: 1.1em; }
+.markdown-body h5 { font-size: 1em; }
+.markdown-body h6 { font-size: 0.9em; color: var(--text-secondary); }
+
+/* 段落 */
+.markdown-body p {
+ margin: 1em 0;
+}
+
+/* 链接 */
+.markdown-body a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: color 0.15s;
+}
+
+.markdown-body a:hover {
+ text-decoration: underline;
+}
+
+/* 图片 */
+.markdown-body img {
+ max-width: 100%;
+ border-radius: 8px;
+}
+
+.markdown-body figure {
+ margin: 1.5em 0;
+}
+
+/* 列表 */
+.markdown-body ul,
+.markdown-body ol {
+ padding-left: 1.5em;
+ margin: 1em 0;
+}
+
+.markdown-body ul {
+ list-style-type: disc;
+}
+
+.markdown-body ol {
+ list-style-type: decimal;
+}
+
+.markdown-body li {
+ margin: 0.5em 0;
+}
+
+.markdown-body li > ul,
+.markdown-body li > ol {
+ margin: 0.25em 0;
+}
+
+/* 任务列表 */
+.markdown-body ul:has(li > span:first-child > svg) {
+ list-style: none;
+ padding-left: 0;
+}
+
+/* 行内代码 */
+.markdown-body code {
+ background: var(--bg-tertiary);
+ padding: 0.2em 0.4em;
+ border-radius: 4px;
+ font-size: 0.9em;
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
+}
+
+/* 代码块 */
+.markdown-body pre {
+ background: var(--bg-tertiary);
+ padding: 1em;
+ border-radius: 8px;
+ overflow-x: auto;
+ margin: 1em 0;
+ border: 1px solid var(--border-color);
+}
+
+.markdown-body pre code {
+ background: none;
+ padding: 0;
+ font-size: 0.875em;
+ line-height: 1.6;
+}
+
+/* 引用块 */
+.markdown-body blockquote {
+ border-left: 4px solid var(--accent);
+ padding-left: 1em;
+ margin: 1em 0;
+ color: var(--text-secondary);
+}
+
+.markdown-body blockquote p {
+ margin: 0.5em 0;
+}
+
+/* 表格 */
+.markdown-body table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1em 0;
+ font-size: 0.9em;
+}
+
+.markdown-body th,
+.markdown-body td {
+ border: 1px solid var(--border-color);
+ padding: 0.6em 1em;
+ text-align: left;
+}
+
+.markdown-body th {
+ background: var(--bg-tertiary);
+ font-weight: 600;
+}
+
+.markdown-body tr:hover td {
+ background: var(--bg-secondary);
+}
+
+/* 水平线 */
+.markdown-body hr {
+ border: none;
+ border-top: 1px solid var(--border-color);
+ margin: 2em 0;
+}
+
+/* 删除线 */
+.markdown-body del {
+ color: var(--text-tertiary);
+}
+
+/* 强调 */
+.markdown-body strong {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.markdown-body em {
+ font-style: italic;
+}
+
+/* 键盘按键 */
+.markdown-body kbd {
+ display: inline-block;
+ padding: 0.2em 0.4em;
+ font-size: 0.85em;
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ box-shadow: 0 1px 0 var(--border-color);
+}
+
+/* ============ 自定义 Alert 样式 ============ */
+.md-alert {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 1rem;
+ margin: 1rem 0;
+ border-radius: 0.5rem;
+ border-width: 1px;
+}
+
+.md-alert svg {
+ flex-shrink: 0;
+ width: 1.25rem;
+ height: 1.25rem;
+ margin-top: 0.125rem;
+}
+
+.md-alert > div {
+ flex: 1;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+/* Alert 颜色变体 - 暗色主题 */
+.dark .md-alert-info {
+ background-color: rgba(59, 130, 246, 0.15);
+ border-color: rgba(59, 130, 246, 0.3);
+}
+.dark .md-alert-info svg,
+.dark .md-alert-info > div {
+ color: #60a5fa;
+}
+
+.dark .md-alert-success {
+ background-color: rgba(34, 197, 94, 0.15);
+ border-color: rgba(34, 197, 94, 0.3);
+}
+.dark .md-alert-success svg,
+.dark .md-alert-success > div {
+ color: #4ade80;
+}
+
+.dark .md-alert-warning {
+ background-color: rgba(234, 179, 8, 0.15);
+ border-color: rgba(234, 179, 8, 0.3);
+}
+.dark .md-alert-warning svg,
+.dark .md-alert-warning > div {
+ color: #facc15;
+}
+
+.dark .md-alert-danger {
+ background-color: rgba(239, 68, 68, 0.15);
+ border-color: rgba(239, 68, 68, 0.3);
+}
+.dark .md-alert-danger svg,
+.dark .md-alert-danger > div {
+ color: #f87171;
+}
+
+.dark .md-alert-note {
+ background-color: rgba(107, 114, 128, 0.15);
+ border-color: rgba(107, 114, 128, 0.3);
+}
+.dark .md-alert-note svg,
+.dark .md-alert-note > div {
+ color: #9ca3af;
+}
+
+/* Alert 颜色变体 - 亮色主题 */
+.light .md-alert-info {
+ background-color: #eff6ff;
+ border-color: rgba(59, 130, 246, 0.3);
+}
+.light .md-alert-info svg,
+.light .md-alert-info > div {
+ color: #2563eb;
+}
+
+.light .md-alert-success {
+ background-color: #f0fdf4;
+ border-color: rgba(34, 197, 94, 0.3);
+}
+.light .md-alert-success svg,
+.light .md-alert-success > div {
+ color: #16a34a;
+}
+
+.light .md-alert-warning {
+ background-color: #fefce8;
+ border-color: rgba(234, 179, 8, 0.3);
+}
+.light .md-alert-warning svg,
+.light .md-alert-warning > div {
+ color: #ca8a04;
+}
+
+.light .md-alert-danger {
+ background-color: #fef2f2;
+ border-color: rgba(239, 68, 68, 0.3);
+}
+.light .md-alert-danger svg,
+.light .md-alert-danger > div {
+ color: #dc2626;
+}
+
+.light .md-alert-note {
+ background-color: #f9fafb;
+ border-color: rgba(107, 114, 128, 0.3);
+}
+.light .md-alert-note svg,
+.light .md-alert-note > div {
+ color: #4b5563;
+}
+
+/* ============ 代码高亮主题适配 ============ */
+/* 暗色主题代码高亮 */
+.dark .markdown-body pre {
+ background: #0d1117;
+ border-color: #30363d;
+}
+
+.dark .markdown-body .hljs {
+ color: #c9d1d9;
+ background: transparent;
+}
+
+.dark .markdown-body .hljs-keyword,
+.dark .markdown-body .hljs-selector-tag,
+.dark .markdown-body .hljs-title {
+ color: #ff7b72;
+}
+
+.dark .markdown-body .hljs-string,
+.dark .markdown-body .hljs-attr {
+ color: #a5d6ff;
+}
+
+.dark .markdown-body .hljs-comment {
+ color: #8b949e;
+}
+
+.dark .markdown-body .hljs-number,
+.dark .markdown-body .hljs-literal {
+ color: #79c0ff;
+}
+
+.dark .markdown-body .hljs-function,
+.dark .markdown-body .hljs-built_in {
+ color: #d2a8ff;
+}
+
+.dark .markdown-body .hljs-variable,
+.dark .markdown-body .hljs-template-variable {
+ color: #ffa657;
+}
+
+/* 亮色主题代码高亮 */
+.light .markdown-body pre {
+ background: #f6f8fa;
+ border-color: #d0d7de;
+}
+
+.light .markdown-body .hljs {
+ color: #24292f;
+ background: transparent;
+}
+
+.light .markdown-body .hljs-keyword,
+.light .markdown-body .hljs-selector-tag,
+.light .markdown-body .hljs-title {
+ color: #cf222e;
+}
+
+.light .markdown-body .hljs-string,
+.light .markdown-body .hljs-attr {
+ color: #0a3069;
+}
+
+.light .markdown-body .hljs-comment {
+ color: #6e7781;
+}
+
+.light .markdown-body .hljs-number,
+.light .markdown-body .hljs-literal {
+ color: #0550ae;
+}
+
+.light .markdown-body .hljs-function,
+.light .markdown-body .hljs-built_in {
+ color: #8250df;
+}
+
+.light .markdown-body .hljs-variable,
+.light .markdown-body .hljs-template-variable {
+ color: #953800;
+}
+`
+
+export default parseMarkdown
diff --git a/client/src/utils/publicCatalog.ts b/client/src/utils/publicCatalog.ts
new file mode 100644
index 0000000..8d19b34
--- /dev/null
+++ b/client/src/utils/publicCatalog.ts
@@ -0,0 +1,136 @@
+export type PackageSource = 'official' | 'market' | `zone:${number}`
+export type PackageSourceRequest = {
+ source: 'official' | 'market' | 'zone'
+ zoneId?: number
+}
+
+export interface PublicPackagePlan {
+ id: number
+ name: string
+ description: string | null
+ cpu: number
+ memory: number
+ disk: number
+ portLimit: number
+ snapshotLimit: number
+ backupLimit: number
+ siteLimit: number
+ swapSize: number
+ trafficLimit: string
+ trafficLimitSpeed: string
+ price: number
+ billingCycle: number
+ setupFee: number
+ slaGuarantee: number | null
+ isSoldOut: boolean
+ monthlyPrice: number
+}
+
+export interface PublicPackage {
+ id: number
+ name: string
+ description: string | null
+ cpu_max: number
+ memory_max: number
+ disk_max: number
+ monthly_traffic_limit: string | null
+ network_mode: string
+ instance_type: string
+ host_ids: number[]
+ privileged: number
+ nested: number
+ sourceType: 'official' | 'market'
+ soldOut: boolean
+ isPaid: boolean
+ plans: PublicPackagePlan[]
+}
+
+export interface PublicRegion {
+ code: string
+ name: string
+ packageIds: number[]
+ hostCount: number
+}
+
+function getQueryString(value: unknown): string | undefined {
+ if (Array.isArray(value)) {
+ return typeof value[0] === 'string' ? value[0] : undefined
+ }
+ return typeof value === 'string' ? value : undefined
+}
+
+export function normalizePackageSourceQuery(value: unknown, zoneIdValue?: unknown): PackageSource {
+ const query = getQueryString(value)
+ if (query === 'market') return 'market'
+ if (query === 'zone') {
+ const zoneId = parsePackageIdQuery(zoneIdValue)
+ return zoneId ? `zone:${zoneId}` : 'official'
+ }
+ if (query?.startsWith('zone:')) {
+ const zoneId = Number(query.slice('zone:'.length))
+ return Number.isInteger(zoneId) && zoneId > 0 ? `zone:${zoneId}` : 'official'
+ }
+ return 'official'
+}
+
+export function toPackageSourceRequest(source: PackageSource): PackageSourceRequest {
+ if (source.startsWith('zone:')) {
+ const zoneId = Number(source.slice('zone:'.length))
+ return { source: 'zone', zoneId }
+ }
+ return { source: source === 'market' ? 'market' : 'official' }
+}
+
+export function parsePackageIdQuery(value: unknown): number | null {
+ const query = getQueryString(value)
+ if (!query) {
+ return null
+ }
+
+ const parsed = Number(query)
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null
+}
+
+export function parseTextQuery(value: unknown): string {
+ return getQueryString(value)?.trim() || ''
+}
+
+export function formatPublicTraffic(bytes: string | null, unlimitedLabel: string): string {
+ if (!bytes || bytes === '0') {
+ return unlimitedLabel
+ }
+
+ const value = BigInt(bytes)
+ const tb = BigInt(1024 * 1024 * 1024 * 1024)
+ const gb = BigInt(1024 * 1024 * 1024)
+
+ if (value >= tb) {
+ return `${(Number(value) / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`
+ }
+
+ if (value >= gb) {
+ return `${(Number(value) / (1024 * 1024 * 1024)).toFixed(0)} GB`
+ }
+
+ return `${(Number(value) / (1024 * 1024)).toFixed(0)} MB`
+}
+
+export function formatPublicPrice(cents: number): string {
+ return (cents / 100).toFixed(2)
+}
+
+export function getStartingMonthlyPrice(pkg: Pick): number | null {
+ if (!pkg.isPaid || pkg.plans.length === 0) {
+ return null
+ }
+
+ const availablePlans = pkg.plans.filter(plan => !plan.isSoldOut)
+ const pricePlans = availablePlans.length > 0 ? availablePlans : pkg.plans
+
+ return pricePlans.reduce((minPrice, plan) => {
+ if (plan.monthlyPrice < minPrice) {
+ return plan.monthlyPrice
+ }
+ return minPrice
+ }, pricePlans[0].monthlyPrice)
+}
diff --git a/client/src/utils/turnstile.ts b/client/src/utils/turnstile.ts
new file mode 100644
index 0000000..e187751
--- /dev/null
+++ b/client/src/utils/turnstile.ts
@@ -0,0 +1,52 @@
+/**
+ * Turnstile 工具函数
+ * 用于在 API 调用前执行隐式验证
+ */
+import { useTurnstile } from '@/composables/useTurnstile'
+import i18n from '@/locales'
+
+// 全局 Turnstile 实例
+let globalTurnstile: ReturnType | null = null
+
+/**
+ * 获取全局 Turnstile 实例
+ */
+export function getGlobalTurnstile() {
+ if (!globalTurnstile) {
+ globalTurnstile = useTurnstile()
+ }
+ return globalTurnstile
+}
+
+/**
+ * 执行 Turnstile 验证并返回 token
+ * 如果未启用 Turnstile,返回 undefined
+ */
+export async function getTurnstileToken(_action?: string): Promise {
+ const turnstile = getGlobalTurnstile()
+
+ if (!turnstile.isEnabled.value) {
+ return undefined
+ }
+
+ try {
+ return await turnstile.execute()
+ } catch (error) {
+ console.error('Turnstile verification failed:', error)
+ throw new Error(i18n.global.t('common.turnstileFailed'))
+ }
+}
+
+/**
+ * 包装 API 调用,自动添加 Turnstile token
+ */
+export async function withTurnstile>(
+ data: T,
+ action?: string
+): Promise {
+ const token = await getTurnstileToken(action)
+ return {
+ ...data,
+ turnstileToken: token
+ }
+}
diff --git a/client/src/utils/validation.ts b/client/src/utils/validation.ts
new file mode 100644
index 0000000..93b5c24
--- /dev/null
+++ b/client/src/utils/validation.ts
@@ -0,0 +1,403 @@
+/**
+ * 输入验证工具
+ * 用于前端表单验证,防止危险字符输入
+ */
+import i18n from '@/locales'
+
+type TranslateParams = Record
+
+function t(key: string, params?: TranslateParams): string {
+ const translate = i18n.global.t as (key: string, params?: TranslateParams) => string
+ return translate(key, params)
+}
+
+function validationField(key: string): string {
+ return t(`validation.fields.${key}`)
+}
+
+function validationMessage(key: string, fieldName: string, params?: TranslateParams): string {
+ return t(`validation.${key}`, { field: fieldName, ...(params || {}) })
+}
+
+/**
+ * 危险字符正则表达式
+ * 禁止: ' " \ / ? ] [ = + . < > ` ; : | ! @ # $ % ^ & * { } ~
+ * 允许: ( ) 空格 ,
+ */
+const DANGEROUS_CHARS_REGEX = /['"\\/?[\]=+.<>`;:|!@#$%^&*{}~]/g
+
+/**
+ * 安全名称正则(只允许字母、数字、连字符、下划线、空格、逗号、圆括号和中文)
+ */
+const SAFE_NAME_REGEX = /^[\u4e00-\u9fa5a-zA-Z0-9\-_ ,()]+$/
+
+/**
+ * 严格名称正则(只允许字母、数字、连字符、下划线,必须以字母开头)
+ */
+const STRICT_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9\-_]*$/
+
+/**
+ * 验证结果接口
+ */
+export interface ValidationResult {
+ valid: boolean
+ message?: string
+ sanitized?: string
+}
+
+/**
+ * 危险字符列表(用于显示提示)
+ */
+export const DANGEROUS_CHARS_DISPLAY = `' " \\ / ? [ ] = + . < > \` ; : | ! @ # $ % ^ & * { } ~`
+
+/**
+ * 检查字符串是否包含危险字符
+ */
+export function containsDangerousChars(input: string): boolean {
+ return DANGEROUS_CHARS_REGEX.test(input)
+}
+
+/**
+ * 移除危险字符
+ */
+export function removeDangerousChars(input: string): string {
+ return input.replace(DANGEROUS_CHARS_REGEX, '')
+}
+
+/**
+ * 验证通用名称输入(允许中文、字母、数字、连字符、下划线、空格)
+ * 适用于:实例名称、套餐名称、节点组名称等用户可见的名称
+ */
+export function validateName(
+ name: string,
+ fieldName: string = validationField('name'),
+ minLength: number = 1,
+ maxLength: number = 64
+): ValidationResult {
+ if (!name || typeof name !== 'string') {
+ return { valid: false, message: validationMessage('required', fieldName) }
+ }
+
+ const trimmed = name.trim()
+
+ if (trimmed.length < minLength) {
+ return { valid: false, message: validationMessage('minLength', fieldName, { min: minLength }) }
+ }
+
+ if (trimmed.length > maxLength) {
+ return { valid: false, message: validationMessage('maxLength', fieldName, { max: maxLength }) }
+ }
+
+ if (containsDangerousChars(trimmed)) {
+ return { valid: false, message: validationMessage('illegalChars', fieldName) }
+ }
+
+ if (!SAFE_NAME_REGEX.test(trimmed)) {
+ return { valid: false, message: validationMessage('safeNameChars', fieldName) }
+ }
+
+ return { valid: true, sanitized: trimmed }
+}
+
+/**
+ * 验证技术标识符(只允许字母、数字、连字符、下划线,必须以字母或数字开头)
+ * 适用于:用户名、实例技术ID、主机名等
+ */
+export function validateIdentifier(
+ input: string,
+ fieldName: string = validationField('identifier'),
+ minLength: number = 2,
+ maxLength: number = 64
+): ValidationResult {
+ if (!input || typeof input !== 'string') {
+ return { valid: false, message: validationMessage('required', fieldName) }
+ }
+
+ const trimmed = input.trim()
+
+ if (trimmed.length < minLength) {
+ return { valid: false, message: validationMessage('minLength', fieldName, { min: minLength }) }
+ }
+
+ if (trimmed.length > maxLength) {
+ return { valid: false, message: validationMessage('maxLength', fieldName, { max: maxLength }) }
+ }
+
+ if (!STRICT_NAME_REGEX.test(trimmed)) {
+ return { valid: false, message: validationMessage('identifierChars', fieldName) }
+ }
+
+ return { valid: true, sanitized: trimmed }
+}
+
+/**
+ * 验证通用文本输入(移除危险字符但允许更多内容)
+ * 适用于:描述、备注等长文本
+ */
+export function validateText(
+ text: string,
+ fieldName: string = validationField('content'),
+ maxLength: number = 1000
+): ValidationResult {
+ if (!text || typeof text !== 'string') {
+ return { valid: true, sanitized: '' }
+ }
+
+ if (text.length > maxLength) {
+ return { valid: false, message: validationMessage('maxLength', fieldName, { max: maxLength }) }
+ }
+
+ // 移除危险字符但保留其他内容
+ const sanitized = removeDangerousChars(text).trim()
+
+ return { valid: true, sanitized }
+}
+
+/**
+ * 验证URL格式
+ */
+export function validateUrl(url: string, fieldName: string = 'URL'): ValidationResult {
+ if (!url || typeof url !== 'string') {
+ return { valid: false, message: validationMessage('required', fieldName) }
+ }
+
+ const trimmed = url.trim()
+
+ // 基本URL格式验证
+ try {
+ new URL(trimmed)
+ } catch {
+ return { valid: false, message: validationMessage('invalidFormat', fieldName) }
+ }
+
+ // 只允许 http 和 https 协议
+ if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
+ return { valid: false, message: validationMessage('urlProtocol', fieldName) }
+ }
+
+ return { valid: true, sanitized: trimmed }
+}
+
+/**
+ * 验证主机地址(IPv4、IPv6 或域名),适用于 Incus API 连接地址
+ */
+export function validateHostAddress(input: string, fieldName: string = validationField('serverAddress')): ValidationResult {
+ if (!input || typeof input !== 'string') {
+ return { valid: false, message: validationMessage('required', fieldName) }
+ }
+ const trimmed = input.trim()
+ const normalized = normalizeHostAddress(input)
+ if (trimmed.length === 0 || normalized.length === 0) { return { valid: false, message: validationMessage('required', fieldName) } }
+ if (normalized.length > 253) { return { valid: false, message: validationMessage('maxLength', fieldName, { max: 253 }) } }
+ // IPv4
+ const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
+ if (ipv4Regex.test(normalized)) { return { valid: true, sanitized: normalized } }
+ if (isValidIpv6Format(normalized)) { return { valid: true, sanitized: normalized } }
+ // 域名
+ const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
+ if (domainRegex.test(normalized)) {
+ const labels = normalized.split('.')
+ for (const label of labels) {
+ if (label.length > 63) { return { valid: false, message: validationMessage('invalidFormat', fieldName) } }
+ }
+ return { valid: true, sanitized: normalized }
+ }
+ return { valid: false, message: validationMessage('hostAddressInvalid', fieldName) }
+}
+
+/**
+ * 验证IP地址格式(仅支持 IPv4 / IPv6,不支持域名)
+ */
+export function validateIpAddress(ip: string, fieldName: string = validationField('ipAddress')): ValidationResult {
+ if (!ip || typeof ip !== 'string') {
+ return { valid: false, message: validationMessage('required', fieldName) }
+ }
+
+ const normalized = normalizeHostAddress(ip)
+ const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
+
+ if (ipv4Regex.test(normalized) || isValidIpv6Format(normalized)) {
+ return { valid: true, sanitized: normalized }
+ }
+
+ return { valid: false, message: validationMessage('ipAddressInvalid', fieldName) }
+}
+
+/**
+ * 验证IPv4地址格式(仅支持IPv4,不支持域名)
+ * 适用于:NAT 网卡 IP
+ */
+export function validateIpv4(input: string, fieldName: string = validationField('ipAddress')): ValidationResult {
+ if (!input || typeof input !== 'string') {
+ return { valid: false, message: validationMessage('required', fieldName) }
+ }
+
+ const trimmed = input.trim()
+ const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
+ if (ipv4Regex.test(trimmed)) {
+ return { valid: true, sanitized: trimmed }
+ }
+
+ return { valid: false, message: validationMessage('ipv4Invalid', fieldName) }
+}
+
+/**
+ * 验证主机地址格式
+ * 适用于:连接地址(支持 IPv4、IPv6 或域名)
+ */
+export function validateIpOrDomain(input: string, fieldName: string = validationField('ipOrDomain')): ValidationResult {
+ return validateHostAddress(input, fieldName)
+}
+
+/**
+ * 实时输入过滤器 - 用于 v-model 绑定时实时过滤危险字符
+ */
+export function filterDangerousInput(event: Event): void {
+ const input = event.target as HTMLInputElement
+ if (input && input.value) {
+ const filtered = removeDangerousChars(input.value)
+ if (filtered !== input.value) {
+ input.value = filtered
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ }
+ }
+}
+
+/**
+ * 创建输入验证指令的处理函数
+ */
+export function createInputValidator(type: 'name' | 'identifier' | 'text' = 'name') {
+ return (el: HTMLInputElement) => {
+ el.addEventListener('input', () => {
+ const value = el.value
+ let result: ValidationResult
+
+ switch (type) {
+ case 'identifier':
+ result = validateIdentifier(value, '', 0, 1000)
+ break
+ case 'text':
+ result = validateText(value, '', 10000)
+ break
+ default:
+ result = validateName(value, '', 0, 1000)
+ }
+
+ if (!result.valid && result.sanitized !== undefined) {
+ el.value = result.sanitized
+ el.dispatchEvent(new Event('input', { bubbles: true }))
+ }
+ })
+ }
+}
+
+/**
+ * 验证重定向 URL 是否安全(防止开放重定向漏洞)
+ */
+export function isValidRedirectUrl(url: string | undefined | null): boolean {
+ if (!url || typeof url !== 'string') {
+ return false
+ }
+
+ const trimmed = url.trim()
+
+ if (trimmed.length === 0) {
+ return false
+ }
+
+ if (!trimmed.startsWith('/')) {
+ return false
+ }
+
+ if (trimmed.startsWith('//')) {
+ return false
+ }
+
+ const lowerUrl = trimmed.toLowerCase()
+ if (lowerUrl.includes('javascript:') || lowerUrl.includes('data:') || lowerUrl.includes('vbscript:')) {
+ return false
+ }
+
+ if (/[\r\n\t]/.test(trimmed)) {
+ return false
+ }
+
+ if (/%0[dD]|%0[aA]/.test(trimmed)) {
+ return false
+ }
+
+ return true
+}
+
+/**
+ * 获取安全的重定向 URL
+ */
+export function getSafeRedirectUrl(url: string | undefined | null, defaultUrl: string = '/'): string {
+ if (isValidRedirectUrl(url)) {
+ return url!.trim()
+ }
+ return defaultUrl
+}
+
+/**
+ * 验证 IPv6 地址格式是否有效
+ */
+export function isValidIpv6Format(address: string): boolean {
+ if (!address || typeof address !== 'string') {
+ return false
+ }
+ const normalized = normalizeHostAddress(address)
+ if (!normalized || !normalized.includes(':') || normalized.includes('/')) {
+ return false
+ }
+
+ try {
+ new URL(`http://[${normalized}]/`)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * 标准化主机地址输入,允许用户直接输入带方括号的 IPv6 地址
+ */
+export function normalizeHostAddress(input: string): string {
+ const trimmed = input.trim()
+
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ const inner = trimmed.slice(1, -1).trim()
+ if (inner.includes(':')) {
+ return inner
+ }
+ }
+
+ return trimmed
+}
+
+/**
+ * 判断主机地址是否为 IPv6
+ */
+export function isIpv6HostAddress(address: string): boolean {
+ return isValidIpv6Format(normalizeHostAddress(address))
+}
+
+/**
+ * 根据节点连接地址和端口拼接 Incus API URL
+ */
+export function buildHostApiUrl(address: string, port: number): string {
+ const normalized = normalizeHostAddress(address)
+ const host = isIpv6HostAddress(normalized) ? `[${normalized}]` : normalized
+ return `https://${host}:${port}`
+}
+
+/**
+ * 从节点 URL 中提取连接地址
+ */
+export function extractHostAddressFromUrl(url: string): string {
+ try {
+ return normalizeHostAddress(new URL(url).hostname)
+ } catch {
+ return ''
+ }
+}
diff --git a/client/src/utils/vipBadge.ts b/client/src/utils/vipBadge.ts
new file mode 100644
index 0000000..3e3e295
--- /dev/null
+++ b/client/src/utils/vipBadge.ts
@@ -0,0 +1,56 @@
+export interface VipBadgeStyle {
+ backgroundColor: string
+ textColor: string
+}
+
+const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
+
+const DEFAULT_VIP_BADGE_STYLES: VipBadgeStyle[] = [
+ { backgroundColor: '#FEF3C7', textColor: '#92400E' },
+ { backgroundColor: '#D1FAE5', textColor: '#047857' },
+ { backgroundColor: '#DBEAFE', textColor: '#1D4ED8' },
+ { backgroundColor: '#EDE9FE', textColor: '#6D28D9' },
+ { backgroundColor: '#FFEDD5', textColor: '#C2410C' },
+ { backgroundColor: '#FCE7F3', textColor: '#BE185D' },
+ { backgroundColor: '#FFE4E6', textColor: '#BE123C' },
+ { backgroundColor: '#CFFAFE', textColor: '#0E7490' },
+ { backgroundColor: '#FAE8FF', textColor: '#A21CAF' },
+ { backgroundColor: '#FEF9C3', textColor: '#A16207' }
+]
+
+function normalizeLevel(level: unknown): number {
+ const parsed = Number(level)
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1
+}
+
+function normalizeHexColor(value: unknown, fallback: string): string {
+ return typeof value === 'string' && HEX_COLOR_RE.test(value) ? value : fallback
+}
+
+export function getDefaultVipBadgeStyle(level: unknown): VipBadgeStyle {
+ const normalized = normalizeLevel(level)
+ const style = DEFAULT_VIP_BADGE_STYLES[(normalized - 1) % DEFAULT_VIP_BADGE_STYLES.length]
+ return { ...style }
+}
+
+export function normalizeVipBadgeStyle(style: unknown, level: unknown): VipBadgeStyle {
+ const fallback = getDefaultVipBadgeStyle(level)
+ if (!style || typeof style !== 'object' || Array.isArray(style)) {
+ return fallback
+ }
+
+ const record = style as Record
+ return {
+ backgroundColor: normalizeHexColor(record.backgroundColor, fallback.backgroundColor),
+ textColor: normalizeHexColor(record.textColor, fallback.textColor)
+ }
+}
+
+export function getVipBadgeInlineStyle(style: VipBadgeStyle | null | undefined): Record | undefined {
+ if (!style) return undefined
+ return {
+ backgroundColor: style.backgroundColor,
+ color: style.textColor,
+ borderColor: style.backgroundColor
+ }
+}
diff --git a/client/src/views/DashboardView.vue b/client/src/views/DashboardView.vue
new file mode 100644
index 0000000..45463f9
--- /dev/null
+++ b/client/src/views/DashboardView.vue
@@ -0,0 +1,799 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('dashboard.instanceStatusOverview') }}
+
+ {{ t('dashboard.viewAll') }}
+
+
+
+
+
{{ stats.total }}
+
{{ t('dashboard.totalInstances') }}
+
+
+
+
+ {{ t('dashboard.viewAll') }}
+
+
+
+
+
+
+
{{ item.value }}
+
{{ item.label }}
+
+
+
{{ instanceTypeStats.container }}
+
{{ t('dashboard.containerInstances') }}
+
+
+
{{ instanceTypeStats.vm }}
+
{{ t('dashboard.vmInstances') }}
+
+
+
+
+
+
+
+
+
+
{{ t('dashboard.accountOverview') }}
+
+ {{ formatCurrency(balanceOverview.balance) }}
+
+
{{ accountPanelHint }}
+
+
+ {{ formatUserVipLevel(userVipLevel) }}
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
{{ t('dashboard.vipProgressTitle') }}
+
{{ vipProgressTargetText }}
+
+
+ {{ vipProgressPercent }}%
+
+
+
+
+
{{ vipProgressHint }}
+
+
+
+
+ {{ getVipProgressMetricLabel(condition.metric) }}
+
+ {{ condition.progress }}%
+
+
+
+
+
{{ t('dashboard.vipProgressCurrent') }}
+
{{ formatVipProgressValue(condition, condition.current) }}
+
+
+
{{ t('dashboard.vipProgressTarget') }}
+
{{ formatVipProgressValue(condition, condition.target) }}
+
+
+
{{ t('dashboard.vipProgressRemaining') }}
+
+ {{ formatVipProgressRemaining(condition) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('dashboard.myInstances') }}
+
{{ instanceListSummary }}
+
+
+ {{ $t('dashboard.viewAll') }}
+
+
+
+
+
+
+
+
+
{{ $t('dashboard.noInstances') }}
+
{{ configStore.freeSiteMode ? freeSiteCopy.dashboardCreateFirst : $t('dashboard.createFirst') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+ {{ getStatusInfo(instance.status, t).label }}
+
+
+ {{ getInstanceTypeLabel(instance) }}
+
+
+
+
+
+ {{ formatImageName(instance.image, (instance as any).imageName) }}
+
+ •
+ CPU {{ instance.cpu }}%
+ •
+ {{ formatMemory(instance.memory) }}
+ •
+ {{ formatDisk(instance.disk) }}
+
+
+
+
+
+
+
+
+ {{ getHostName(instance) }}
+
+
+ {{ getDisplayIp(instance) || $t('dashboard.noPublicIp') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('dashboard.viewAllInstancesWithCount', { count: instances.length }) }} →
+
+
+
+
+
+
diff --git a/client/src/views/EntertainmentView.vue b/client/src/views/EntertainmentView.vue
new file mode 100644
index 0000000..fd48dd3
--- /dev/null
+++ b/client/src/views/EntertainmentView.vue
@@ -0,0 +1,2044 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.mainTabs.vipBenefits') }}
+
+
+
+
+
+ {{ $t('entertainment.mainTabs.lottery') }}
+
+
+
+
+
+ {{ $t('entertainment.mainTabs.checkin') }}
+
+
+
+
+ {{ $t('entertainment.mainTabs.badge') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('entertainment.currentPoints') }}
+
{{ points.points.toLocaleString() }}
+
+
+
+
+
+
+
+ {{ $t('entertainment.multiDraw') }}
+ ({{ selectedLottery.costPoints * 10 }}{{ $t('entertainment.pointsUnit') }})
+
+
+
+
+ {{ $t('entertainment.convertPoints') }}
+
+ (+{{ points.convertiblePoints }})
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.tabs.lottery') }}
+
+
+ {{ $t('entertainment.tabs.records') }}
+
+
+ {{ $t('entertainment.tabs.points') }}
+
+
+
+
+
+
+
+
+ {{ lottery.name }}
+ ({{ lottery.costPoints }}{{ $t('entertainment.pointsUnit') }})
+
+
+
+
+
+
+
{{ $t('entertainment.noActiveLotteries') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ .
+ .
+ .
+
+ {{ $t('entertainment.spin') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.spinCost', { points: selectedLottery.costPoints }) }}
+
+
+
+
+
+
{{ $t('entertainment.prizeList') }}
+
+
+
+
{{ prize.name }}
+
{{ getPrizeTypeName(prize.type) }}
+
+
+
{{ $t('entertainment.probability') }}: {{ prize.probability }}%
+
+ {{ $t('entertainment.remaining') }}: {{ prize.remainQuantity ?? 0 }}/{{ prize.totalQuantity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.prizeType') }}:
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('entertainment.noRecords') }}
+
+
+
+
+
+ | {{ $t('entertainment.lotteryName') }} |
+ {{ $t('entertainment.prize') }} |
+ {{ $t('entertainment.prizeType') }} |
+ {{ $t('entertainment.value') }} |
+ {{ $t('entertainment.time') }} |
+
+
+
+
+ | {{ rec.lotteryName || '-' }} |
+ {{ rec.prizeName || '-' }} |
+
+
+ {{ getPrizeTypeName(rec.prizeType) }}
+
+ |
+
+ +{{ rec.prizeValue }} {{ $t('entertainment.pointsUnit') }}
+ +¥{{ (rec.prizeValue / 100).toFixed(2) }}
+ {{ rec.prizeName || $t('entertainment.prizeTypes.badge') }}
+ {{ rec.instanceDesc || $t('entertainment.wonInstance') }}
+ +{{ rec.prizeValue }}%
+ +{{ rec.prizeValue }}MB
+ +{{ rec.prizeValue }}MB
+ +{{ rec.prizeValue }}GB
+ -
+ |
+ {{ formatDate(rec.createdAt) }} |
+
+
+
+
+
+
+
+
+ {{ $t('common.perPage') }}
+
+ {{ $t('common.totalCount', { count: recordsTotal }) }}
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ recordsPage }} / {{ recordsTotalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('entertainment.noPointsLogs') }}
+
+
+
+
+
+ | {{ $t('entertainment.pointsLogType') }} |
+ {{ $t('entertainment.pointsChange') }} |
+ {{ $t('entertainment.pointsAfter') }} |
+ {{ $t('entertainment.remark') }} |
+ {{ $t('entertainment.time') }} |
+
+
+
+
+ | {{ getPointsLogTypeName(log.type) }} |
+
+ {{ log.amount >= 0 ? '+' : '' }}{{ log.amount }}
+ |
+ {{ log.pointsAfter }} |
+ {{ log.remark || '-' }} |
+ {{ formatDate(log.createdAt) }} |
+
+
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ pointsLogsPage }} / {{ pointsLogsTotalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CPU
+
{{ resourcePool.cpu }}%
+
+
+
+
+
+
+
+
{{ $t('checkin.memory') }}
+
{{ resourcePool.memory }} MB
+
+
+
+
+
+
+
+
{{ $t('checkin.disk') }}
+
{{ resourcePool.disk }} MB
+
+
+
+
+
+
+
+
{{ $t('checkin.traffic') }}
+
{{ resourcePool.traffic }} GB
+
+
+
+
+
+
+
+
+ {{ $t('checkin.tabCheckin') }}
+
+
+ {{ $t('checkin.tabRedeem') }}
+
+
+ {{ $t('checkin.tabPool') }}
+
+
+ {{ $t('checkin.tabLogs') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +{{ checkinResult?.value }}{{ getResourceUnit(checkinResult?.type || 'c') }}
+
+
+
{{ getResourceTypeName(checkinResult?.type || 'c') }}
+
{{ $t('checkin.bonusPoints', { points: checkinResult?.bonusPoints }) }}
+
+
+
+
+
+
+ {{ $t('checkin.clickToCheckin') }}
+
+
+
+
+
+
+
+
{{ $t('checkin.alreadyCheckedIn') }}
+
+
+ {{ $t('checkin.noInstances') }}
+
+
+
+
+
+
+
+
{{ $t('checkin.redeemSystemCode') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('checkin.systemCodeHint') }}
+
+
+
+ {{ $t('checkin.redeem') }}
+
+
+
+
+
+
+
{{ $t('checkin.applyToInstance') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('checkin.kvmHint') }}
+
+
+
+ {{ $t('checkin.apply') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('common.loading') }}...
+
{{ $t('checkin.noLogs') }}
+
+
+
+
+ | {{ $t('checkin.action') }} |
+ {{ $t('checkin.resourceType') }} |
+ {{ $t('checkin.amount') }} |
+ {{ $t('checkin.instance') }} |
+ {{ $t('checkin.time') }} |
+
+
+
+
+ | {{ getActionName(log.action) }} |
+
+
+ {{ getResourceTypeName(log.resourceType) }}
+
+ |
+
+ {{ log.amount >= 0 ? '+' : '' }}{{ log.amount }}{{ getResourceUnit(log.resourceType) }}
+ |
+ {{ log.instance?.name || '-' }} |
+ {{ new Date(log.createdAt).toLocaleString() }} |
+
+
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ poolLogsPage }} / {{ poolLogsTotalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('entertainment.betterLuckNextTime') }}
+
{{ spinResult?.prizeName }}
+
+
+
+
{{ $t('entertainment.congratulations') }}
+
+ {{ spinResult?.prizeName }}
+
+
+
+ {{ $t('entertainment.wonPoints', { points: spinResult.prizeValue }) }}
+
+
+ {{ $t('entertainment.wonBalance', { amount: (spinResult.prizeValue / 100).toFixed(2) }) }}
+
+
+ {{ $t('entertainment.wonBadge', { badge: spinResult.prizeName }) }}
+
+
+ {{ $t('entertainment.wonInstance') }}
+
+
+ {{ $t('entertainment.wonCpu', { value: spinResult.prizeValue }) }}
+
+
+ {{ $t('entertainment.wonMemory', { value: spinResult.prizeValue }) }}
+
+
+ {{ $t('entertainment.wonDisk', { value: spinResult.prizeValue }) }}
+
+
+ {{ $t('entertainment.wonTraffic', { value: spinResult.prizeValue }) }}
+
+
+
+
+ {{ $t('common.confirm') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('entertainment.multiDrawResults') }}
+
+
+
+
+
+
+
+
+
#{{ idx + 1 }}
+
+
{{ result.prizeName }}
+
+ +{{ result.prizeValue }} {{ $t('entertainment.pointsUnit') }}
+ +¥{{ (result.prizeValue / 100).toFixed(2) }}
+ +1 {{ $t('entertainment.badgeUnit') }}
+ {{ $t('entertainment.wonInstance') }}
+ +{{ result.prizeValue }}%
+ +{{ result.prizeValue }}MB
+ +{{ result.prizeValue }}MB
+ +{{ result.prizeValue }}GB
+ -
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.totalDraws') }}:
+ {{ multiDrawResults.length }}
+
+
+ {{ $t('entertainment.totalPointsSpent') }}:
+ {{ multiDrawResults.reduce((sum, r) => sum + r.pointsSpent, 0) }}
+
+
+
+
+
+ {{ $t('entertainment.prizeTypes.balance') }}: {{ multiDrawSummary.balance.toFixed(2) }}
+
+
+ {{ $t('entertainment.prizeTypes.badge') }}: {{ multiDrawSummary.badge }} {{ $t('entertainment.badgeUnit') }}
+
+
+ {{ $t('entertainment.prizeTypes.instance') }}: {{ multiDrawSummary.instance }} {{ $t('entertainment.instanceUnit') }}
+
+
+ CPU: {{ multiDrawSummary.cpu }}%
+
+
+ {{ $t('entertainment.prizeTypes.memory') }}: {{ multiDrawSummary.memory }}MB
+
+
+ {{ $t('entertainment.prizeTypes.disk') }}: {{ multiDrawSummary.disk }}MB
+
+
+ {{ $t('entertainment.prizeTypes.traffic') }}: {{ multiDrawSummary.traffic }}GB
+
+
+ {{ $t('entertainment.prizeTypes.points') }}: {{ multiDrawSummary.points }}
+
+
+ {{ $t('entertainment.prizeTypes.nothing') }}: {{ multiDrawSummary.nothing }}
+
+
+
+
+
+
+ {{ $t('entertainment.multiDrawStopped') }}: {{ multiDrawError }}
+
+
+
+
+
+ {{ $t('common.confirm') }}
+
+
+
+ {{ $t('entertainment.multiDrawAgain') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/ExtensionsView.vue b/client/src/views/ExtensionsView.vue
new file mode 100644
index 0000000..940c1f0
--- /dev/null
+++ b/client/src/views/ExtensionsView.vue
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('extensions.initCommands.description') }}
+
+
+ {{ $t('extensions.initCommands.add') }}
+
+
+
+
+
+
+
+
{{ $t('common.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ cmd.name }}
+
+ {{ $t('extensions.initCommands.statusDisabled') }}
+
+
+
+ {{ cmd.commandPreview || $t('extensions.initCommands.noContent') }}
+
+
+
+
+ {{ getDistroName(distro) }}
+
+
+ · {{ $t('extensions.initCommands.lineCount', { count: cmd.commandLineCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('extensions.initCommands.empty') }}
+
+
+ {{ $t('extensions.initCommands.addFirst') }}
+
+
+
+
+
+
+ {{ $t('common.pageInfo', { current: currentPage, total: totalPages, count: commands.length }) }}
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/ForgotPasswordView.vue b/client/src/views/ForgotPasswordView.vue
new file mode 100644
index 0000000..4e0653f
--- /dev/null
+++ b/client/src/views/ForgotPasswordView.vue
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+
+
![]()
+
+ {{ $t('auth.forgotPassword.title') }}
+
+
+ {{ $t('auth.forgotPassword.subtitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.rememberPassword') }}
+
+ {{ $t('auth.login') }}
+
+
+
+
+
diff --git a/client/src/views/FriendsView.vue b/client/src/views/FriendsView.vue
new file mode 100644
index 0000000..de86ab4
--- /dev/null
+++ b/client/src/views/FriendsView.vue
@@ -0,0 +1,1297 @@
+
+
+
+
+
+
+
+
+
+ {{ t('friends.friendsList') }}
+ {{ friends.length }}
+
+
+ {{ t('friends.pendingRequests') }}
+ {{ pendingCount }}
+
+
+ {{ t('friends.historyRequests') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('friends.addFriend') }}
+
+
+
+
+
+
+
+
{{ t('friends.noFriends') }}
+
{{ t('friends.noFriendsHint') }}
+
+
+
+
+
{{ t('friends.noSearchResult') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ friend.username }}
+
+
+ {{ friend.email || t('friends.addedOn') + ' ' + formatDate(friend.createdAt) }}
+
+
+
+
+
+
+
+
+
+
{{ filteredFriends.length }} {{ t('common.items') }}
+
+
+
+
+
{{ friendsPage }}/{{ filteredFriendsTotalPages }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('friends.selectFriendHint') }}
+
{{ t('friends.selectFriendDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedFriend.username }}
+
+
{{ selectedFriend.email }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('friends.sharedPackages') }}
+ ({{ friendShares.length }})
+
+
+
+ {{ t('friends.addShare') }}
+
+
+
+
+
+
+
+
{{ t('friends.noSharedPackages') }}
+
+ {{ t('friends.addFirstShare') }}
+
+
+
+
+
+
+
+
+ {{ share.packageName }}
+
+
+
+ {{ t('friends.quotaMultiplier') }}: {{ share.quotaMultiplier }}x
+
+
+ {{ t('friends.maxInstances') }}: {{ share.maxInstances }}
+
+
+ {{ t('friends.currentUsage') }}: {{ share.usage.instanceCount }} {{ t('friends.instances') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('friends.availablePackages') }}
+ ({{ filteredAvailablePackages.length }})
+
+
+
+
+
+
+
+
{{ t('friends.noPackageSearchResult') }}
+
+
+
+
+
+
+ {{ pkg.name }}
+
+
+ {{ pkg.cpu_max }}%CPU · {{ formatMemory(pkg.memory_max) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('friends.noPackagesToShare') }}
+
{{ t('friends.createPackageFirst') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('friends.noPendingRequests') }}
+
+
+
+
+
+
+
+
+
+ {{ request.username }}
+
+
{{ request.email }}
+
+ {{ t('friends.requestedOn') }} {{ formatDate(request.createdAt) }}
+
+
+ {{ t('friends.remark') }}: {{ request.remark }}
+
+
+
+ {{ t('friends.accept') }}
+
+
+ {{ t('friends.reject') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.total') }} {{ pendingRequests.length }} {{ t('common.items') }}
+
+ {{ t('instance.prevPage') }}
+ {{ pendingPage }} / {{ pendingTotalPages }}
+ {{ t('instance.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.filter') }}:
+
+
+
+
+
+
+
+
+
{{ t('friends.noHistoryRecords') }}
+
{{ t('friends.noHistoryRecordsHint') }}
+
+
+
+
+
{{ t('friends.noHistorySearchResult') }}
+
+
+
+
+
+
+
+
+
+ {{ record.initiatedByMe ? t('friends.sentTo', { username: record.username }) : record.username }}
+
+
{{ record.email }}
+
+ {{ record.initiatedByMe ? t('friends.sentOn') : t('friends.requestedOn') }} {{ formatDate(record.createdAt) }}
+
+
+ {{ t('friends.remark') }}: {{ record.remark }}
+
+
+
+
+
+ {{ record.status === 'accepted' ? t('friends.statusAccepted') : t('friends.statusRejected') }}
+
+
+ {{ t('friends.processedOn') }} {{ formatDate(record.status === 'accepted' ? record.acceptedAt : record.rejectedAt) }}
+
+
+
+
+
+
+
+
+
{{ t('common.total') }} {{ filteredHistoryRecords.length }} {{ t('common.items') }}
+
+ {{ t('instance.prevPage') }}
+ {{ historyPage }} / {{ historyTotalPages }}
+ {{ t('instance.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedFriend?.username }}
+
+
{{ t('friends.shareToFriend') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ×
+
+
{{ t('friends.quotaMultiplierHint') }}
+
+
+
+
+
{{ t('friends.maxInstancesHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingShare?.packageName }}
+
+
+ {{ t('friends.sharedTo') }}: {{ selectedFriend?.username }}
+
+
+
+
+
+
+
+
+
+ ×
+
+
{{ t('friends.quotaMultiplierHint') }}
+
+
+
+
+
{{ t('friends.maxInstancesHint') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/HelpView.vue b/client/src/views/HelpView.vue
new file mode 100644
index 0000000..68f4d15
--- /dev/null
+++ b/client/src/views/HelpView.vue
@@ -0,0 +1,750 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('help.all') }}
+
+
+ {{ getCategoryLabel(cat.category) }}
+ ({{ cat.count }})
+
+
+
+
+
+
+
+
+ {{ $t('help.noArticles') }}
+
+
+
+
+
+
+
+
{{ article.title }}
+
+
+
+ {{ getCategoryLabel(article.category) }}
+
+ {{ formatDate(article.updated_at) }}
+
+
+
+
+
+
+
+
+
+
{{ $t('help.totalArticles', { count: total }) }}
+
+ {{ $t('instance.prevPage') }}
+ {{ page }} / {{ totalPages }}
+ {{ $t('instance.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('help.articleNotFound') }}
+
{{ $t('help.backToHelp') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/HostingWalletView.vue b/client/src/views/HostingWalletView.vue
new file mode 100644
index 0000000..6d4bf84
--- /dev/null
+++ b/client/src/views/HostingWalletView.vue
@@ -0,0 +1,1492 @@
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('hostingWallet.hostingMember') }}
+
+ VIP{{ stats.vipLevel }}
+
+
+
{{ authStore.user?.username || '--' }}
+
+
+ {{ item.label }} {{ item.value }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('hostingWallet.withdraw.button') }}
+
+
+ {{ t('hostingWallet.tabs.withdrawals') }}
+
+
+
+
+
+
+ {{ t('hostingWallet.balance.available') }}
+
+
+ {{ formatMoney(balance?.available || 0) }}
+
+
+
+ {{ t('hostingWallet.balance.frozenNote') }}
+
+ {{ withdrawalHint }}
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.value }}
+
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.tabs.logs') }}
+
{{ t('hostingWallet.balance.frozenNote') }}
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.value }}
+
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.tabs.withdrawals') }}
+
{{ withdrawalHint }}
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.value }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.blocks.title') }}
+
{{ t('hostingWallet.blocks.description') }}
+
+
+ {{ t('hostingWallet.blocks.total', { count: blocks.length }) }}
+
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.overview.howItWorks') }}
+
{{ t('hostingWallet.description') }}
+
+
+ {{ t('hostingWallet.balance.frozenNote') }}
+
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+
{{ step.title }}
+
{{ step.desc }}
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.notice.title') }}
+
+
+
+
+ {{ hostingNotice }}
+
+
+
+
+
+
+
+
{{ t('hostingWallet.overview.title') }}
+
+
+
{{ rule.title }}
+
{{ rule.desc }}
+
+
+
+
+
+
+
+
{{ t('hostingWallet.withdraw.button') }}
+
{{ withdrawalHint }}
+
+
+
+
+
+ {{ t('hostingWallet.withdraw.button') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.search') }}
+
+
+
+
+
+
+
+ {{ filter.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ logsSearch ? t('common.noSearchResults') : t('hostingWallet.logs.noRecords') }}
+
{{ t('hostingWallet.logs.emptyHint') }}
+
+
+
+
+
+
+
+
+ | {{ t('hostingWallet.logs.columns.type') }} |
+ {{ t('hostingWallet.logs.columns.amount') }} |
+ {{ t('hostingWallet.logs.columns.status') }} |
+ {{ t('hostingWallet.logs.columns.buyer') }} |
+ {{ t('hostingWallet.logs.columns.instance') }} |
+ {{ t('hostingWallet.logs.columns.time') }} |
+
+
+
+
+ |
+
+ {{ getActionTypeLabel(log.actionType) }}
+
+ |
+
+
+ {{ log.amount >= 0 ? '+' : '' }}{{ formatMoney(log.amount) }}
+
+ |
+
+
+
+ {{ t('hostingWallet.logs.status.frozen') }}
+
+
+
+ {{ t('hostingWallet.logs.status.unfrozen') }}
+
+ |
+
+
+
+ {{ log.instance.buyer.username || t('hostingWallet.logs.unknownUser') }}
+
+ -
+ |
+
+
+
+
+ {{ log.instance.id }}
+
+ {{ log.instance.name }}
+ ({{ t('common.deleted') }})
+
+
+
+ {{ log.instance.host.name }}
+
+
+
+ -
+ |
+ {{ formatShortDate(log.createdAt) }} |
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getActionTypeLabel(log.actionType) }}
+
+
+ {{ log.amount >= 0 ? '+' : '' }}{{ formatMoney(log.amount) }}
+
+
+
{{ formatShortDate(log.createdAt) }}
+
+
+
+
+
+
+
+
+ {{ getActionTypeLabel(log.actionType) }}
+
+
+ {{ log.amount >= 0 ? '+' : '' }}{{ formatMoney(log.amount) }}
+
+
+
+
+
+ {{ log.instance.buyer.username }}
+
+
+
+
+
+ {{ log.instance.id }}
+ {{ log.instance.name }}
+ ({{ t('common.deleted') }})
+ ·
+ {{ log.instance.host.name }}
+
+ {{ formatShortDate(log.createdAt) }}
+
+
+
+
+
+
+
+
+
{{ t('common.total') }} {{ logsTotal }} {{ t('common.items') }}
+
+
+ {{ t('hostingWallet.prevPage') }}
+
+ {{ logsPage }} / {{ logsTotalPages }}
+
+ {{ t('hostingWallet.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.tabs.withdrawals') }}
+
{{ t('common.total') }} {{ withdrawalsTotal }} {{ t('common.items') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('hostingWallet.withdrawals.emptyTitle') }}
+
{{ t('hostingWallet.withdrawals.emptyHint') }}
+
+
+
+
+ {{ t('hostingWallet.withdrawals.startEarning') }}
+
+
+
+
+
+
+
+
+
+ | {{ t('hostingWallet.withdrawals.columns.amount') }} |
+ {{ t('hostingWallet.withdrawals.columns.actualAmount') }} |
+ {{ t('hostingWallet.withdrawals.columns.target') }} |
+ {{ t('hostingWallet.withdrawals.columns.status') }} |
+ {{ t('hostingWallet.withdrawals.columns.time') }} |
+
+
+
+
+ | {{ formatMoney(w.amount) }} |
+ {{ formatMoney(w.actualAmount) }} |
+ {{ t('hostingWallet.withdrawals.target.balance') }} |
+
+
+ {{ getStatusLabel(w.status) }}
+
+ {{ w.rejectReason }}
+ |
+ {{ formatDate(w.createdAt) }} |
+
+
+
+
+
+
+
+
+
+ {{ formatMoney(w.amount) }}
+
+ {{ getStatusLabel(w.status) }}
+
+
+
+ {{ t('hostingWallet.withdrawals.columns.actualAmount') }}
+ {{ formatMoney(w.actualAmount) }}
+
+
+ {{ t('hostingWallet.withdrawals.columns.time') }}
+ {{ formatShortDate(w.createdAt) }}
+
+
{{ w.rejectReason }}
+
+
+
+
+
+
+
+
+ {{ t('hostingWallet.prevPage') }}
+
+ {{ withdrawalsPage }} / {{ withdrawalsTotalPages }}
+
+ {{ t('hostingWallet.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ blockSearching ? t('common.loading') : t('common.search') }}
+
+
+
+
+
+
+
+
+
{{ candidate.username }}
+
UID {{ candidate.id }} · {{ candidate.email || t('common.notSet') }}
+
+
+
+ {{ t('hostingWallet.blocks.unblock') }}
+
+
+ {{ t('hostingWallet.blocks.block') }}
+
+
+
+
+ {{ t('hostingWallet.blocks.noSearchResults') }}
+
+
+
+
+
+
{{ t('hostingWallet.blocks.blockedUsers') }}
+
{{ t('hostingWallet.blocks.effectHint') }}
+
+
+
+ {{ t('common.loading') }}
+
+
+
{{ t('hostingWallet.blocks.emptyTitle') }}
+
{{ t('hostingWallet.blocks.emptyHint') }}
+
+
+
+
+
+
+
{{ block.username }}
+
+ UID {{ block.blockedUserId }} · {{ block.email || t('common.notSet') }} · {{ formatShortDate(block.createdAt) }}
+
+
+
+
+ {{ t('hostingWallet.blocks.unblock') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¥
+
+
+
{{ t('hostingWallet.modal.availableNote', { amount: formatMoney(balance?.available || 0) }) }}
+
+
+
+
+
+
{{ t('hostingWallet.modal.targetBalance', { rate: ((config?.feeRateBalance || 0.05) * 100).toFixed(0) }) }}
+
+
+ {{ t('hostingWallet.modal.manualWithdrawNote') }}
+
+
+
+
+
+ {{ t('hostingWallet.modal.summary.amount') }}
+ {{ formatMoney(withdrawForm.amount) }}
+
+
+ {{ t('hostingWallet.modal.summary.fee') }}
+ -{{ formatMoney(withdrawForm.amount - estimatedAmount) }}
+
+
+ {{ t('hostingWallet.modal.summary.actual') }}
+ {{ formatMoney(estimatedAmount) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/InboxView.vue b/client/src/views/InboxView.vue
new file mode 100644
index 0000000..c702341
--- /dev/null
+++ b/client/src/views/InboxView.vue
@@ -0,0 +1,434 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('inbox.all') }}
+
+
+ {{ t('inbox.unread') }}
+
+ {{ inboxStore.unreadCount }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('inbox.allCategories') }}
+
+
+ {{ t(cat.config.label) }}
+
+
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
+ {{ filter === 'unread' ? t('inbox.noUnread') : t('inbox.noMessages') }}
+
+
+
+
+
+
+
+
{{ t('inbox.noCategoryMessages') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getCategoryLabel(message) }}
+
+
+ {{ message.title }}
+
+
+ {{ formatTime(message.createdAt) }}
+
+
+
+
+
+ {{ message.content }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.total') }} {{ total }} {{ t('common.items') }}
+
+
+
+ {{ t('common.previous') }}
+
+
+ {{ currentPage }} / {{ totalPages }}
+
+
+ {{ t('common.next') }}
+
+
+
+
+
+
+ {{ t('inbox.currentPageFiltered', { count: filteredMessages.length }) }}
+
+
+
+
+
diff --git a/client/src/views/InstanceCreateView.vue b/client/src/views/InstanceCreateView.vue
new file mode 100644
index 0000000..0d426a4
--- /dev/null
+++ b/client/src/views/InstanceCreateView.vue
@@ -0,0 +1,1373 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ hostOwnerInfo.username }}
+
UID: {{ hostOwnerInfo.id }}
+
+
+
+
+ VIP{{ hostOwnerInfo.vipLevel }}
+
+
+
+
+
+
+
+ {{ $t('instance.detail.info.hostOwnerHostCount') }}
+
+
+ {{ hostOwnerInfo.hostCount }}
+
+
+
+
+
+ {{ $t('instance.detail.info.hostOwnerInstanceCount') }}
+
+
+ {{ hostOwnerInfo.instanceCount }}
+
+
+
+
+
+ {{ $t('instance.detail.info.hostOwnerRegisteredDays') }}
+
+
+ {{ hostOwnerInfo.registeredDays }} {{ $t('common.days') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/InstanceDetailView.vue b/client/src/views/InstanceDetailView.vue
new file mode 100644
index 0000000..14e89c7
--- /dev/null
+++ b/client/src/views/InstanceDetailView.vue
@@ -0,0 +1,3606 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t(tab.labelKey) }}
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.errorBanner.title') }}
+
+
+ {{ $t('instance.errorBanner.description') }}
+
+
+
+
+
+
+ {{ $t('common.processing') }}
+
+ {{ $t('instance.errorBanner.destroyNow') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.hostAnnouncement') }}
+
+
+
+ {{ instanceHostName }}
+
+
+
+
+
+ {{ instanceHostName }}
+
+
+
+
+ {{ (instance as any).hostAnnouncement }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.subscription.premium') }}
+
+
+ {{ subscriptionRemainingDisplay.text }}
+
+
+ {{ subscriptionAutoRenewEnabled ? $t('instance.subscription.autoRenewOn') : $t('instance.subscription.autoRenewOff') }}
+
+
+
+
+
+ {{ (instance as any).planName }}
+
+
+ {{ (instance as any).packageName }}
+
+
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.renewPrice : $t('instance.subscription.renewPrice') }}
+
+
+
+ ¥{{ getRenewPrice(instance).toFixed(2) }}
+
+
+ {{ getBillingCycleShort((instance as any).billingCycle) }}
+
+
+
+
+
+ {{ getAffDiscountText(instance) }}
+
+
+ {{ $t('instance.subscription.applyAffShort') }}
+
+
+
+
+
+
+
+
+ {{ $t('instance.subscription.expiresAt') }}
+
+
+ {{ formatShortDate(instance.expires_at) }}
+
+
+
+
+
+ {{ $t('instance.subscription.expiresIn') }}
+
+
+ {{ subscriptionRemainingDisplay.text }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.billingCycle : $t('instance.subscription.billingCycle') }}
+
+
+ {{ getBillingCycleText((instance as any).billingCycle) }}
+
+
+
+
+
+ {{ $t('instance.subscription.autoRenew') }}
+
+
+ {{ subscriptionAutoRenewEnabled ? $t('instance.subscription.autoRenewEnabled') : $t('instance.subscription.autoRenewDisabled') }}
+
+
+
+
+
+
+
+
+ {{ $t('instance.subscription.renew') }}
+
+
+
+ {{ $t('instance.subscription.autoRenew') }}
+
+
+
+ {{ $t('billing.changePlan') }}
+
+
+
+ {{ $t('transfer.actions.transfer') }}
+
+
+
+
+ {{ $t('instance.destroy.button') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('checkin.targetInstance') }}:
+ {{ instance.name }}
+
+
+
+
{{ $t('checkin.systemCodeHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('instance.detail.actions.confirmClone', { name: instance?.name || '' }) }}
+
+
+ {{ $t('instance.detail.actions.cloneNotice') }}
+
+
+
+
+
{{ $t('instance.detail.actions.cloning') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('instance.detail.actions.confirmSuspend', { name: instance?.name || '' }) }}
+
+
+ {{ $t('instance.detail.actions.confirmSuspendNotice') }}
+
+
+
+
+
+
{{ suspendReason.length }}/500
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('terminal.title') }}
+
+
+ {{ terminalTabCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.subscription.autoRenew') }}
+
+
+
+
+
+
+
+ {{ $t('instance.subscription.currentStatus') }}
+
+
+ {{ (instance as any).autoRenew
+ ? $t('instance.subscription.autoRenewEnabled')
+ : $t('instance.subscription.autoRenewDisabled') }}
+
+
+
+
+ {{ $t('instance.subscription.autoRenewDesc', {
+ cycle: getBillingCycleText((instance as any).billingCycle),
+ price: getRenewPrice(instance).toFixed(2)
+ }) }}
+
+
+
+
+
{{ $t('instance.subscription.autoRenewHint') }}
+
+
+
+
+
+ {{ $t('common.cancel') }}
+
+
+
+
+ {{ $t('common.processing') }}
+
+ {{ $t('instance.subscription.disableAutoRenew') }}
+
+
+
+
+ {{ $t('common.processing') }}
+
+ {{ $t('instance.subscription.enableAutoRenew') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/InstancesView.vue b/client/src/views/InstancesView.vue
new file mode 100644
index 0000000..b6bc204
--- /dev/null
+++ b/client/src/views/InstancesView.vue
@@ -0,0 +1,2645 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.listLayout') }}
+
+
+
+ {{ $t('instance.cardLayout') }}
+
+
+
{{ $t('instance.totalCount', { count: total }) }}
+
+
{{ $t('common.perPage') }}
+
+
+
+
+
+
+
+
+
+
{{ $t('instance.totalCount', { count: total }) }}
+
+
{{ $t('common.perPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.batch.selectedCount', { count: selectedCount }) }}
+
+
{{ $t('instance.batch.currentPageOnly') }}
+
+ {{ $t('instance.batch.clear') }}
+
+
+
+
+
+
+ {{ $t('instance.batch.start') }}
+
+
+
+ {{ $t('instance.batch.stop') }}
+
+
+
+ {{ $t('instance.batch.restart') }}
+
+
+
+ {{ $t('instance.batch.sync') }}
+
+
+
+ {{ $t('instance.batch.autoRenewOn') }}
+
+
+
+ {{ $t('instance.batch.autoRenewOff') }}
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchRenewAction : $t('instance.batch.renew') }}
+
+
+ {{ $t('instance.batch.destroy') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ search ? $t('instance.noMatchingInstances') : $t('instance.noInstances') }}
+
+
{{ search ? $t('instance.tryOtherKeywords') : $t('instance.createFirstInstance') }}
+
{{ configStore.freeSiteMode ? freeSiteCopy.instanceCreateFirst : $t('instance.create') }}
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+ {{ $t('instance.name') }} |
+ {{ $t('instance.statusLabel') }} |
+ {{ $t('instance.ip') }} |
+ {{ $t('instance.modeLabel') }} |
+ {{ $t('instance.config') }} |
+ {{ $t('instance.quotaLabel') }} |
+ {{ $t('instance.user') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+ {{ formatImageName(instance.image, (instance as any).imageName) }}
+
+
+
+ |
+
+
+
+ {{ getStatusInfo(instance.status, t).label }}
+
+ |
+
+
+
+
+
+
+ {{ ipObj.type === 'ipv6' ? (ipObj.ip.length > 18 ? ipObj.ip.substring(0, 18) + '...' : ipObj.ip) : ipObj.ip }}
+
+
+ -
+
+
+ |
+
+
+
+ {{ (instance as any).instanceType === 'vm' ? $t('common.instanceType.vm') : $t('common.instanceType.container') }}
+
+
+ {{ $t('common.networkMode.' + ((instance as any).networkMode || instance.network_mode || 'nat')) }}
+
+
+ |
+
+
+
+ {{ instance.cpu }}% / {{ formatMemory(instance.memory) }} / {{ formatDisk(instance.disk) }}
+
+
+ {{ formatBytes(Number((instance as any).monthlyTrafficUsed || 0)) }}
+ /
+
+ {{ formatBytes(Number((instance as any).monthlyTrafficLimit)) }}
+
+
+ {{ $t('instance.mobileCard.unlimited') }}
+
+
+
+ |
+
+
+
+ {{ (instance as any).portLimit ?? '-' }} {{ $t('instance.mobileCard.ports') }}
+ /
+ {{ (instance as any).snapshotLimit ?? '-' }} {{ $t('instance.mobileCard.snapshots') }}
+ /
+ {{ (instance as any).siteLimit ?? '-' }} {{ $t('instance.mobileCard.sites') }}
+
+
+ {{ $t('instance.expireAt') }}:
+
+
+ {{ getInstanceExpiryInfo(instance).dateText }}
+ |
+
+
+ {{ getInstanceExpiryInfo(instance).remainingText }}
+
+
+
+
+ |
+
+ {{ (instance as any).username || '-' }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+ {{ getStatusInfo(instance.status, t).label }}
+
+
+
+ {{ formatImageName(instance.image, (instance as any).imageName) }}
+
+
+
+ {{ (instance as any).packageName }}
+
+
+
+
+
+
{{ $t('instance.mobileCard.ipAddress') }}
+
+
+
+ {{ ipObj.type === 'ipv6' ? (ipObj.ip.length > 18 ? ipObj.ip.substring(0, 18) + '...' : ipObj.ip) : ipObj.ip }}
+
+
+ -
+
+
+
+ {{ $t('instance.mobileCard.config') }}
+
+ {{ instance.cpu }}{{ $t('instance.mobileCard.cpuCore') }} / {{ formatMemory(instance.memory) }} / {{ formatDisk(instance.disk) }}
+
+
+
+ {{ $t('instance.mobileCard.quota') }}
+
+ {{ (instance as any).portLimit ?? '-' }} {{ $t('instance.mobileCard.ports') }} / {{ (instance as any).snapshotLimit ?? '-' }} {{ $t('instance.mobileCard.snapshots') }} / {{ (instance as any).siteLimit ?? '-' }} {{ $t('instance.mobileCard.sites') }}
+
+
+
+ {{ $t('instance.mobileCard.traffic') }}
+
+
+ {{ formatBytes(Number((instance as any).monthlyTrafficUsed || 0)) }} / {{ formatBytes(Number((instance as any).monthlyTrafficLimit)) }}
+
+
+ {{ formatBytes(Number((instance as any).monthlyTrafficUsed || 0)) }} / {{ $t('instance.mobileCard.unlimited') }}
+
+
+
+
+ {{ $t('instance.mobileCard.host') }}
+
+
+ {{ (instance as any).host?.name || (instance as any).host || '-' }}
+
+
+
+ {{ $t('instance.expireAt') }}
+
+
+ {{ getInstanceExpiryInfo(instance).dateText }}
+ |
+
+
+ {{ getInstanceExpiryInfo(instance).remainingText }}
+
+
+
+
+ {{ $t('instance.mobileCard.user') }}
+
+ {{ (instance as any).username || '-' }}
+
+
+
+
+
+
+
+
+
+ {{ actionLoading[instance.id] === 'start' ? '...' : $t('instance.actions.start') }}
+
+
+
+ {{ actionLoading[instance.id] === 'stop' ? '...' : $t('instance.actions.stop') }}
+
+
+
+ {{ actionLoading[instance.id] === 'restart' ? '...' : $t('instance.actions.restart') }}
+
+
+
+ {{ actionLoading[instance.id] === 'delete' ? '...' : $t('instance.actions.delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+ {{ formatImageName(instance.image, (instance as any).imageName) }}
+
+
+
+
+ {{ getStatusInfo(instance.status, t).label }}
+
+
+
+
+
+
+ {{ getInstanceHostName(instance) }}
+
+
+ {{ getInstancePackageName(instance) }}
+
+
+ {{ getInstanceTypeDisplayLabel(instance) }}
+
+
+ {{ $t('common.networkMode.' + getInstanceNetworkMode(instance)) }}
+
+
+ {{ (instance as any).username || '-' }}
+
+
+
+
+
+
+
+
+
{{ $t('instance.mobileCard.config') }}
+
+ {{ instance.cpu }}% / {{ formatMemory(instance.memory) }} / {{ formatDisk(instance.disk) }}
+
+
+
+
{{ $t('instance.mobileCard.quota') }}
+
+ {{ (instance as any).portLimit ?? '-' }} / {{ (instance as any).snapshotLimit ?? '-' }} / {{ (instance as any).siteLimit ?? '-' }}
+
+
+
+
{{ $t('instance.mobileCard.traffic') }}
+
+
+ {{ formatBytes(Number((instance as any).monthlyTrafficUsed || 0)) }} / {{ formatBytes(Number((instance as any).monthlyTrafficLimit)) }}
+
+
+ {{ formatBytes(Number((instance as any).monthlyTrafficUsed || 0)) }} / {{ $t('instance.mobileCard.unlimited') }}
+
+
+
+
+
{{ $t('instance.expireAt') }}
+
+ {{ getInstanceExpiryInfo(instance).dateText || '-' }}
+ ·
+
+ {{ getInstanceExpiryInfo(instance).remainingText }}
+
+
+
+
+
+
+
+
{{ $t('instance.mobileCard.ipAddress') }}
+
+
+
+ {{ ipObj.ip }}
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('instance.totalRecords', { count: total }) }} · {{ page }} / {{ totalPages }}
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ page }} / {{ totalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchRenewTitle : $t('instance.batch.renewTitle') }}
+
+
{{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchRenewDescription : $t('instance.batch.renewDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('instance.batch.selectedCount', { count: selectedCount }) }}
+
{{ selectedCount }}
+
+
+
{{ $t('instance.batch.eligibleCount') }}
+
{{ batchRenewEligibleItems.length }}
+
+
+
{{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchTotalAmount : $t('instance.batch.totalAmount') }}
+
{{ formatCurrency(batchRenewTotal) }}
+
+
+
{{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchBalanceAfter : $t('billing.balanceAfterRenew') }}
+
{{ formatCurrency(batchRenewBalanceAfter) }}
+
+
+
+
+
{{ $t('instance.batch.selectedMonths') }}
+
+
+ {{ getBatchRenewMonthsLabel(months) }}
+
+
+
+
+
+ {{ $t('instance.batch.renewEmpty') }}
+
+
+
+ {{ $t('billing.insufficientBalance') }}
+ {{ $t('billing.goRecharge') }}
+
+
+
+
+
+ {{ $t('instance.batch.eligibleList', { count: batchRenewEligibleItems.length }) }}
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchCurrentBalance : $t('billing.currentBalance') }}: {{ formatCurrency(batchRenewBalance.balance) }}
+
+
+
+
+
+
{{ item.name }}
+
+
+ {{ item.autoRenew ? $t('billing.autoRenewEnabled') : $t('billing.autoRenewDisabled') }}
+
+
+ {{ $t('instance.batch.hosted') }}
+
+
+
+
+
{{ formatCurrency(item.selectedOption?.discountedPrice) }}
+
{{ formatDate(item.selectedOption?.expiresAt) }}
+
+
+
+
+
{{ $t('instance.batch.renewEmpty') }}
+
+
+
+
+ {{ $t('instance.batch.skippedList', { count: batchRenewIneligibleItems.length }) }}
+
+
+
+
{{ item.name }}
+
+ {{ translateBatchReason(item.reason || (item.canRenew ? '该实例不支持当前续费时长' : undefined)) }}
+
+
+
+
+
+
+
+
+
{{ $t('instance.batch.currentPageOnly') }}
+
+
+ {{ $t('common.cancel') }}
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.instanceBatchRenewAction : $t('instance.batch.renew') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.batch.destroyTitle') }}
+
+
{{ $t('instance.batch.destroyDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('instance.destroy.warning') }}
+
+
+
+
+
+
+
{{ $t('instance.batch.selectedCount', { count: selectedCount }) }}
+
{{ selectedCount }}
+
+
+
{{ $t('instance.batch.eligibleCount') }}
+
{{ batchDestroyEligibleItems.length }}
+
+
+
{{ $t('instance.batch.refundTotal') }}
+
{{ formatCurrency(batchDestroyTotalRefund) }}
+
+
+
{{ $t('instance.batch.feeTotal') }}
+
{{ formatCurrency(batchDestroyTotalFee) }}
+
+
+
+
+ {{ $t('instance.batch.destroyEmpty') }}
+
+
+
+
+ {{ $t('instance.batch.eligibleList', { count: batchDestroyEligibleItems.length }) }}
+
+
+
+
+
+
{{ item.name }}
+
+ {{ item.instance.hostName }} · {{ item.instance.planName || $t('billing.freeInstance') }}
+
+
+
+ {{ item.isFreeInstance ? $t('billing.freeInstance') : $t('billing.paidInstance') }}
+
+
+ {{ $t('instance.destroy.firstTimeFree') }}
+
+
+ {{ $t('instance.batch.feeWaived') }}
+
+
+
+
+
{{ formatCurrency(item.refund.refundAmount) }}
+
{{ $t('instance.destroy.feeAmount') }} {{ formatCurrency(item.refund.feeAmount) }}
+
+
+
+
+
{{ $t('instance.batch.destroyEmpty') }}
+
+
+
+
+ {{ $t('instance.batch.skippedList', { count: batchDestroyIneligibleItems.length }) }}
+
+
+
+
{{ item.name }}
+
+ {{ item.cannotDestroyReason ? translateBatchReason(item.cannotDestroyReason) : $t('instance.destroy.cannotDestroy') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('instance.batch.currentPageOnly') }}
+
+
+ {{ $t('common.cancel') }}
+
+
+
+ {{ $t('instance.batch.destroy') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/InvitesView.vue b/client/src/views/InvitesView.vue
new file mode 100644
index 0000000..ef49eea
--- /dev/null
+++ b/client/src/views/InvitesView.vue
@@ -0,0 +1,331 @@
+
+
+
+
+
+
+
+
+
+
+
+
已生成
+
{{ summary?.stats.total || 0 }}
+
+
+
已使用
+
{{ summary?.stats.used || 0 }}
+
+
+
使用率
+
{{ summary?.stats.usageRate || 0 }}%
+
+
+
+
+
+
+
生成邀请码
+
选择一种管理员配置的成本方式,生成成功后可复制注册链接给新用户。
+
+
+
+
+
+
{{ option.label }}
+
{{ option.displayAmount }}
+
+
+ {{ getBalanceLabel(option.resource) }}
+
+
+
+
+
+
+ 管理员尚未开启用户生成邀请码的价格选项。
+
+
+
+
+
+ {{ generating ? '生成中' : '生成邀请码' }}
+
+
+
+
+
刚刚生成
+
+
+
{{ invite.code }}
+
+ 复制码
+ 复制链接
+
+
+
+
+
+
+
+
+
+
我的邀请码
+
可查看是否已被注册使用,以及注册用户的用户名和邮箱。
+
+
刷新
+
+
+
加载中...
+
+ 暂无邀请码
+
+
+
+
+
+ | 邀请码 |
+ 状态 |
+ 使用人 |
+ 生成成本 |
+ 时间 |
+ 操作 |
+
+
+
+
+
+ {{ invite.code }}
+ |
+
+ {{ getStatus(invite).label }}
+ |
+
+
+
+
+ {{ invite.usedByUser.username }}
+ {{ invite.usedByUser.email || '-' }}
+
+
+ -
+ |
+
+ {{ invite.costSnapshot?.displayAmount || '管理员生成' }}
+ |
+
+ 生成 {{ formatDate(invite.createdAt) }}
+ 使用 {{ formatDate(invite.usedAt) }}
+ 过期 {{ formatDate(invite.expiresAt) }}
+ |
+
+
+ 复制码
+ 复制链接
+
+ |
+
+
+
+
+
+
+
共 {{ total }} 条
+
+ 上一页
+ {{ page }} / {{ totalPages }}
+ 下一页
+
+
+
+
+
+
diff --git a/client/src/views/LoginView.vue b/client/src/views/LoginView.vue
new file mode 100644
index 0000000..304d4fe
--- /dev/null
+++ b/client/src/views/LoginView.vue
@@ -0,0 +1,415 @@
+
+
+
+
+
+
+
+
![]()
+
+ {{ brand.brandName }}
+
+
+ {{ $t('auth.loginTo') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.orUse') }}
+
+
+
+
+
+
+ {{ getProviderInfo(provider).name }}
+
+
+
+
+ {{ $t('auth.oauthBindHint') }}
+
+
+
+
+
+
+ {{ $t('auth.noAccount') }}
+
+ {{ $t('auth.register') }}
+
+
+
+ {{ $t('auth.registrationClosedShort') }}
+
+
+
+ {{ $t('auth.forgotPasswordLink') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/LogsView.vue b/client/src/views/LogsView.vue
new file mode 100644
index 0000000..811c4d5
--- /dev/null
+++ b/client/src/views/LogsView.vue
@@ -0,0 +1,356 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('logs.search') }}
+ {{ $t('logs.reset') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ $t('logs.time') }} |
+ {{ $t('logs.user') }} |
+ {{ $t('logs.module') }} |
+ {{ $t('logs.action') }} |
+ {{ $t('logs.content') }} |
+ {{ $t('logs.result') }} |
+
+
+
+
+ |
+ {{ $t('logs.noLogs') }}
+ |
+
+
+ |
+ {{ formatDate(log.created_at) }}
+ |
+
+ {{ log.username || $t('logs.system') }}
+ |
+
+ {{ formatModule(log.module) }}
+ |
+
+ {{ formatAction(log.action) }}
+ |
+
+
+
+
+ {{ log.content }}
+
+
+
+
+ {{ log.content.slice(0, 50) }}...
+
+
+
+
+
+ {{ log.content }}
+
+
+ {{ $t('logs.collapse') }}
+
+
+
+
+ |
+
+
+ {{ formatResult(log.result) }}
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ $t('logs.totalRecords', { total, page, totalPages }) }}
+
+
+
+
+
+
+
+
+ {{ page }}
+ /
+ {{ totalPages }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/MailDomainView.vue b/client/src/views/MailDomainView.vue
new file mode 100644
index 0000000..26e4d09
--- /dev/null
+++ b/client/src/views/MailDomainView.vue
@@ -0,0 +1,428 @@
+
+
+
+
+
+
+
+
+ {{ t('common.back') }}
+
+
+
+
+
{{ domain.domain }}
+
+ {{ t('mail.domainStatus.' + domain.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail.tabs.accounts') }}
+
+
+ {{ t('mail.tabs.dns') }}
+
+
+ {{ t('mail.tabs.settings') }}
+
+
+
+
+
+
+
+
+
+
{{ t('mail.completeDnsFirst') }}
+
{{ t('mail.goDnsConfig') }}
+
+
+
+
+
+
+
{{ t('mail.adminAccount') }}
+
{{ t('mail.adminAccountDesc') }}
+
+
+
+
+
{{ t('mail.emailAddress') }}
+
+
{{ domain.adminUsername }}
+
+
+
+
+
+
+
+
+
{{ t('mail.password') }}
+
+
{{ showPassword ? domain.adminPassword : '••••••••' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('mail.webmailUrl') }}
+
+
+
+
+
+
+ {{ t('mail.helpDoc') }}
+
+
+
+
+
+
+
+
+
{{ t('mail.noAdminAccount') }}
+
+
+
+
+
+
+
{{ t('mail.dnsDescription') }}
+
+
+ {{ t('mail.refreshStatus') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ TXT
+
+
+
+
{{ t('mail.hostRecord') }}
+
+
workspace-verification
+
+
+
+
+
+
+
+
{{ t('mail.recordValue') }}
+
+
{{ dnsConfig.txtRecord }}
+
+
+
+
+
+
+
+
{{ t('mail.dnsHint.txt') }}
+
+
+
+
+
+ {{ record.type }}
+
+
+
+
{{ t('mail.hostRecord') }}
+
+
{{ formatHostRecord(record.record) }}
+
+
+
+
+
+
+
+
{{ t('mail.recordValue') }}
+
+
{{ formatRecordValue(record) }}
+
+
+
+
+
+
+
+
{{ getDnsRecordDescription(record) }}
+
+
+
+
+
+
+
+
{{ t('mail.domainInfo') }}
+
+
+ {{ t('mail.domainName') }}
+ {{ domain.domain }}
+
+
+ {{ t('common.status') }}
+
+ {{ t('mail.domainStatus.' + domain.status) }}
+
+
+
+ {{ t('mail.createdAt') }}
+ {{ formatDate(domain.createdAt) }}
+
+
+ {{ t('mail.verifiedAt') }}
+ {{ formatDate(domain.verifiedAt) }}
+
+
+
+
+
+
+
+
+
{{ t('mail.dangerZone') }}
+
+
+
+
{{ t('mail.deleteDomainWarning') }}
+
+
+ {{ t('mail.deleteDomain') }}
+
+
+
+
+
+
+
diff --git a/client/src/views/MailView.vue b/client/src/views/MailView.vue
new file mode 100644
index 0000000..a37fc7f
--- /dev/null
+++ b/client/src/views/MailView.vue
@@ -0,0 +1,864 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail.tabs.my') }}
+
+
+ {{ t('mail.tabs.buy') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('mail.noSubscription') }}
+
{{ t('mail.buyNowHint') }}
+
+ {{ t('mail.buyNow') }}
+
+
+
+
+
+
+
+
+
{{ t('mail.subscriptionOverview') }}
+
+ {{ t('mail.status.' + subscription.status) }}
+
+
+
+
+
+
{{ t('mail.expiresAt') }}
+
{{ formatDate(subscription.expiresAt) }}
+
+
+
{{ t('mail.domainsUsed') }}
+
{{ subscription.usage.domainCount }}/{{ subscription.plan.domainLimit }}
+
+
+
{{ t('mail.totalSpace') }}
+
{{ subscription.plan.diskLimitGb }} GB
+
+
+
+
+
+ {{ t('mail.plan') }}:{{ subscription.plan.name }} ({{ subscription.source.name }}) ·
+ {{ subscription.plan.domainLimit }} {{ t('mail.domains') }} / {{ subscription.plan.diskLimitGb }}GB ·
+ {{ formatPrice(subscription.plan.price) }}{{ getBillingCycleSuffix(subscription.plan.billingCycle) }}
+
+
+ {{ t('mail.renew') }}
+
+
+
+
+
+
+
+
{{ t('mail.myDomains') }}
+
+ + {{ t('mail.addDomain') }}
+
+
+
+
+ {{ t('mail.noDomains') }}
+
+
+
+
+
+
+
+ {{ domain.domain }}
+
+ {{ t('mail.domainStatus.' + domain.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
{{ t('mail.selectRegion') }}
+
+
+
+
+
+
+
+
+
+
{{ source.name }}
+
{{ t('mail.nodeStatus.limited') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('mail.alreadyPurchased') }}
+
{{ t('mail.alreadyPurchasedDesc') }}
+
+ {{ t('mail.viewMySubscription') }}
+
+
+
+
+
+
+
+
+ 2
+
{{ t('mail.planDetails') }}
+
+
+
+
+
+
+
+
{{ plan.name }}
+
+
{{ plan.description }}
+
+
+
{{ formatPrice(plan.price) }}{{ getBillingCycleSuffix(plan.billingCycle) }}
+
+
+
+
+
+
+
+
{{ t('mail.feature.domainStorage', { count: plan.domainLimit, storage: plan.diskLimitGb }) }}
+
+
+
+
{{ t('mail.feature.unlimitedAliases') }}
+
+
+
+
{{ t('mail.feature.unlimitedMailboxes') }}
+
+
+
+
{{ t('mail.feature.emailLimit') }}
+
+
+
+
{{ t('mail.feature.emClientPro') }}
+
+
+
+
{{ t('mail.feature.catchAll') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 3
+
{{ t('mail.otherOptions') }}
+
+
+
+
+
+
+
+
+ {{ t('mail.verify') }}
+
+
+
+
+ {{ t('aff.promoCodeValid', { rate: (affCodeDiscount * 100).toFixed(0) + '%' }) }}
+
+
+ {{ affCodeError }}
+
+
+
+
+
+
+
+
+
{{ configStore.freeSiteMode ? freeSiteCopy.mailCheckoutTitle : t('mail.checkout.title') }}
+
+
+
+
+ {{ t('mail.checkout.region') }}
+ {{ selectedSource?.name }}
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailBillingCycle : t('mail.billingCycle') }}
+ {{ getBillingCycleLabel(selectedPlan.billingCycle) }}
+
+
+ {{ t('mail.checkout.serviceStatus') }}
+ {{ t('mail.checkout.instant') }}
+
+
+
+
+ {{ t('aff.originalPrice') }}
+ ¥{{ checkoutPriceInfo.originalPrice.toFixed(2) }}
+
+
+ {{ t('aff.discountAmount') }}
+ -¥{{ checkoutPriceInfo.discountAmount.toFixed(2) }}
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailCheckoutAmount : t('mail.checkout.amount') }}
+ {{ formatPrice(checkoutPriceInfo?.finalPrice ?? selectedPlan.price) }}
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailCheckoutConfirm : t('mail.checkout.confirm') }}
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailBalanceRequired : t('mail.checkout.balanceRequired') }}
+
+
+
+
+
+
+
+
+
+
+
{{ configStore.freeSiteMode ? freeSiteCopy.mailCheckoutTitle : t('mail.checkout.title') }}
+
+
+
+
+ {{ t('mail.checkout.region') }}
+ {{ selectedSource?.name }}
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailBillingCycle : t('mail.billingCycle') }}
+ {{ getBillingCycleLabel(selectedPlan.billingCycle) }}
+
+
+ {{ t('mail.checkout.serviceStatus') }}
+ {{ t('mail.checkout.instant') }}
+
+
+
+
+ {{ t('aff.originalPrice') }}
+ ¥{{ checkoutPriceInfo.originalPrice.toFixed(2) }}
+
+
+ {{ t('aff.discountAmount') }}
+ -¥{{ checkoutPriceInfo.discountAmount.toFixed(2) }}
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailCheckoutAmount : t('mail.checkout.amount') }}
+ {{ formatPrice(checkoutPriceInfo?.finalPrice ?? selectedPlan.price) }}
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailCheckoutConfirm : t('mail.checkout.confirm') }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.mailBalanceRequired : t('mail.checkout.balanceRequired') }}
+
+
+
+
+
+
+
+
+
{{ t('mail.help.title') }}
+
{{ t('mail.help.desc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('mail.domainHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 {{ t('mail.year') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail.yearlyPrice') }}
+ ¥{{ Number(subscription.plan.price).toFixed(2) }}/{{ t('mail.year') }}
+
+
+
+
+
+ {{ t('mail.monthlyPrice') }}
+ ¥{{ renewMonthlyPrice.toFixed(2) }}/{{ t('mail.month') }}
+
+
+ {{ t('mail.renewDuration') }}
+ {{ renewMonths }} {{ renewMonths > 1 ? t('mail.months') : t('mail.month') }}
+
+
+
+ {{ t('aff.promoDiscount') }} ({{ (renewDiscountRate * 100).toFixed(0) }}%)
+ -¥{{ renewDiscountAmount.toFixed(2) }}
+
+
+ {{ t('mail.totalPrice') }}
+ ¥{{ renewFinalPrice.toFixed(2) }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/MarketView.vue b/client/src/views/MarketView.vue
new file mode 100644
index 0000000..2450e97
--- /dev/null
+++ b/client/src/views/MarketView.vue
@@ -0,0 +1,1146 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.market.badge') }}
+
+
+
+ {{ t('publicSite.market.title') }}
+
+
+ {{ t('publicSite.market.description') }}
+
+
+
+
+
{{ infoBannerText }}
+
+
+
+
+
+
+ {{ card.label }}
+
+
+ {{ card.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.market.official') }}
+
+
+ {{ t('publicSite.market.market') }}
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.market.allRegions') }}
+
+
+
+
+
+ {{ getRegionLabel(region.code) }}
+
+
+
+
+
+
+
{{ loadError }}
+
+
+
+
+
+ {{ t('publicSite.market.noPackages') }}
+
+
+
+
+
+
+ {{ t('publicSite.market.noResults') }}
+
+
+ {{ t('common.reset') }}
+
+
+
+
+
+
+
+
+
+ {{ pkg.instance_type === 'vm' ? 'KVM' : 'LXC' }}
+
+
+ {{ pkg.soldOut ? t('publicSite.market.soldOut') : t('publicSite.market.inStock') }}
+
+
+
+
+ {{ pkg.name }}
+
+
+ {{ pkg.description || t('publicSite.portal.packageFallback') }}
+
+
+
+
+
+ {{ getPackagePriceLabel(pkg) }}
+
+
+ {{ formatTraffic(pkg.monthly_traffic_limit) }}
+
+
+
+
+
+
+
{{ t('publicSite.market.labels.plans') }}
+
{{ getPlanLabel(pkg) }}
+
+
+
{{ t('publicSite.market.labels.network') }}
+
{{ getNetworkLabel(pkg) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedPackage.sourceType === 'official' ? t('publicSite.market.official') : t('publicSite.market.market') }}
+
+
+ {{ selectedPackage.instance_type === 'vm' ? 'KVM' : 'LXC' }}
+
+
+
+
+ {{ selectedPackage.name }}
+
+
+ {{ selectedPackage.description || t('publicSite.portal.packageFallback') }}
+
+
+
+
+
+
+
+ {{ t('publicSite.market.labels.startingPrice') }}
+
+
+
{{ getPackagePriceLabel(selectedPackage) }}
+
+
+
+
+
+ {{ t('publicSite.market.labels.traffic') }}
+
+
+
{{ formatTraffic(selectedPackage.monthly_traffic_limit) }}
+
+
+
+
+
+ {{ t('publicSite.market.plansTitle') }}
+
+
+
+
+
+
+
+
+
+ {{ plan.name }}
+
+
+ {{ getMarketPlanCycleLabel(plan.billingCycle) }}
+
+ {{ t('publicSite.market.soldOut') }}
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.moneyJustForShow : `¥${formatPublicPrice(plan.price)}` }}
+
+
+ {{ getMarketMonthlyPriceLabel(plan.monthlyPrice) }}
+
+
+
+
+
+
+ CPU {{ plan.cpu }}%
+
+
+ {{ formatMemory(plan.memory) }}
+
+
+ {{ formatDisk(plan.disk) }}
+
+
+ {{ formatTraffic(plan.trafficLimit) }}
+
+
+
+
+
+
+
+
+ {{ t('publicSite.market.customConfigTitle') }}
+
+
+ {{ t('publicSite.market.customConfigDescription') }}
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.market.selectedPlanTitle') }}
+
+
+ {{ selectedPlan.name }}
+
+
+ {{ getMarketPlanCycleLabel(selectedPlan.billingCycle) }} · {{ getNetworkLabel(selectedPackage) }}
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.moneyJustForShow : `¥${formatPublicPrice(selectedPlan.price)}` }}
+
+
+ {{ getMarketMonthlyPriceLabel(selectedPlan.monthlyPrice) }}
+
+
+
+
+
+
+ CPU {{ selectedPlan.cpu }}%
+
+
+ {{ formatMemory(selectedPlan.memory) }}
+
+
+ {{ formatDisk(selectedPlan.disk) }}
+
+
+ {{ formatTraffic(selectedPlan.trafficLimit) }}
+
+
+ {{ t('publicSite.market.labels.hosts') }} {{ selectedPackage.host_ids.length }}
+
+
+ {{ t('publicSite.market.labels.nesting') }} {{ selectedPackage.nested ? t('common.yes') : t('common.no') }}
+
+
+
+
+
+
+ {{ getMarketCtaText(selectedPackage) }}
+
+
+
+ {{ t('publicSite.market.loginHint') }}
+
+
+
+
+
+ {{ t('publicSite.market.choosePackage') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/NotFoundView.vue b/client/src/views/NotFoundView.vue
new file mode 100644
index 0000000..4ebf732
--- /dev/null
+++ b/client/src/views/NotFoundView.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ 404
+
+
+ {{ $t('error.notFound') }}
+
+
{{ $t('error.backHome') }}
+
+
+
diff --git a/client/src/views/PortalView.vue b/client/src/views/PortalView.vue
new file mode 100644
index 0000000..d453686
--- /dev/null
+++ b/client/src/views/PortalView.vue
@@ -0,0 +1,575 @@
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.portal.badge') }}
+
+
+
+ {{ t('publicSite.portal.title') }}
+
+
+ {{ t('publicSite.portal.description') }}
+
+
+
+
+ {{ t('publicSite.actions.browseProducts') }}
+
+
+
+
+ {{ consoleActionCompactLabel }}
+ {{ consoleActionLabel }}
+
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.value }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.portal.previewLabel') }}
+
+
+ {{ t('publicSite.portal.previewTitle') }}
+
+
+
+
+ {{ t('publicSite.actions.viewCatalog') }}
+
+
+
+
+ {{ t('publicSite.portal.previewDescription') }}
+
+
+
+
+ root@incudal
+ incus console
+
+
+
{{ t('publicSite.portal.controlPoint1') }}
+
{{ t('publicSite.portal.controlPoint2') }}
+
{{ t('publicSite.portal.controlPoint3') }}
+
+
+
+
+
+
+ {{ card.title }}
+
+
+ {{ card.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.portal.catalogLabel') }}
+
+
+ {{ t('publicSite.portal.catalogTitle') }}
+
+
+ {{ t('publicSite.portal.catalogDescription') }}
+
+
+
+
+
+
+
+
+ {{ line.title }}
+
+
+ {{ line.description }}
+
+
+
+ {{ line.source === 'official' ? t('publicSite.market.official') : t('publicSite.market.market') }}
+
+
+
+
+
+
+
+ {{ line.source === 'official' ? t('publicSite.actions.browseOfficial') : t('publicSite.actions.browseMarket') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('publicSite.portal.browseLabel') }}
+
+
+ {{ t('publicSite.portal.browseTitle') }}
+
+
+ {{ t('publicSite.portal.browseDescription') }}
+
+
+
+
+ {{ t('publicSite.actions.browseProducts') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pkg.sourceType === 'official' ? t('publicSite.market.official') : t('publicSite.market.market') }}
+
+
+ {{ pkg.instance_type === 'vm' ? 'KVM' : 'LXC' }}
+
+
+
+ {{ pkg.name }}
+
+
+ {{ pkg.description || t('publicSite.portal.packageFallback') }}
+
+
+
+
+
+ {{ getPriceLabel(pkg) }}
+
+
+ {{ formatTraffic(pkg.monthly_traffic_limit) }}
+
+
+
+
+
+
+ {{ t('publicSite.portal.emptyPackages') }}
+
+
+
+
+
+
diff --git a/client/src/views/ProfileView.vue b/client/src/views/ProfileView.vue
new file mode 100644
index 0000000..b9bbdd1
--- /dev/null
+++ b/client/src/views/ProfileView.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/RegisterView.vue b/client/src/views/RegisterView.vue
new file mode 100644
index 0000000..3d7b490
--- /dev/null
+++ b/client/src/views/RegisterView.vue
@@ -0,0 +1,607 @@
+
+
+
+
+
+
+
+
![]()
+
+ {{ brand.brandName }}
+
+
+ {{ $t('auth.createAccount') }}
+
+
+
+
+
+
+
{{ $t('auth.registerSuccess') }}
+
+
+
+
{{ $t('common.loading') }}...
+
+
+
+
+
+ {{ $t('auth.registrationClosedTitle') }}
+
+
+ {{ $t('auth.registrationClosedMessage') }}
+
+
+ {{ $t('auth.backToLogin') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.confirmEmailMessage') }}
+
+
+ {{ emailUsername || form.email.split('@')[0] }}
+ @{{ selectedEmailDomain || form.email.split('@')[1] }}
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.hasAccount') }}
+
+ {{ $t('auth.login') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/TerminalView.vue b/client/src/views/TerminalView.vue
new file mode 100644
index 0000000..88f6141
--- /dev/null
+++ b/client/src/views/TerminalView.vue
@@ -0,0 +1,2167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText }}
+
+
+ {{ getConnectionModeLabel(activeTab.connectionMode) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminalPage.noConnections') }}
+
+
+ {{ t('terminalPage.newConnection') }}
+
+
+ {{ t('terminal.savedCommands.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.cloudInitChecking') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.cloudInitInProgress') }}
+
{{ t('terminal.cloudInitInProgressHint') }}
+
+
+
+
+
+ {{ t('terminal.cloudInitChecking') }}
+
+ {{ t('terminal.cloudInitRetry') }}
+
+
+ {{ t('terminal.cloudInitSkip') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.connectionFailed') }}
+
{{ activeTab.error }}
+
+
+ {{ t('terminal.reconnect') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('terminalPage.runningCount', { count: runningInstances.length }) }}
+
+
+
+ {{ t('terminalPage.selectInstance') }}
+
+
+ {{ t('terminalPage.selectionHint') }}
+
+
+
+
+
+
+
+ {{ t('terminalPage.selectedInstance') }}
+
+
+ {{ selectedInstance?.name || t('common.notSet') }}
+
+
+ {{ selectedInstance?.imageName || t('terminalPage.selectInstance') }}
+
+
+
+
+ {{ t('terminalPage.connect') }}
+
+
+ {{ t('terminalPage.directShell') }}
+
+
+ {{ t('terminalPage.directShellHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminalPage.noRunningInstances') }}
+
{{ t('terminalPage.noRunningInstancesHint') }}
+
+
+
+
{{ t('terminalPage.noMatchingInstances') }}
+
{{ t('terminalPage.noMatchingInstancesHint') }}
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+ #{{ instance.id }}
+
+
+
{{ instance.imageName }}
+
+
+
+
+
+
+ {{ t('terminalPage.host') }}: {{ instance.hostName }}
+
+
+ {{ t('terminalPage.package') }}: {{ instance.packageName }}
+
+
+ {{ t('terminalPage.statusRunning') }}
+
+
+
+
+
+
+
+
+
+ {{ (instancePage - 1) * instancePageSize + 1 }}-{{ Math.min(instancePage * instancePageSize, filteredRunningInstances.length) }} / {{ filteredRunningInstances.length }}
+
+
+
+ {{ t('common.prevPage') }}
+
+ {{ instancePage }} / {{ instanceTotalPages }}
+
+ {{ t('common.nextPage') }}
+
+
+
+
+
+
+
+
{{ t('terminalPage.selectedInstance') }}
+
+
+
+
+
+
+
{{ selectedInstance.name }}
+
{{ selectedInstance.imageName }}
+
+
+
+
+ {{ t('terminalPage.host') }}
+ {{ selectedInstance.hostName || '-' }}
+
+
+ {{ t('terminalPage.package') }}
+ {{ selectedInstance.packageName || '-' }}
+
+
+ {{ t('terminalPage.instanceId') }}
+ #{{ selectedInstance.id }}
+
+
+
+
+
+ {{ t('terminalPage.selectionHint') }}
+
+
+
+
+
+
{{ t('terminalPage.directShell') }}
+
{{ t('terminalPage.directShellHint') }}
+
+
+
+
+
+
+ {{ t('terminalPage.runningCount', { count: filteredRunningInstances.length }) }}
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ t('terminalPage.connect') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.helpTitle') }}
+
+
+
+
+
+
+
+
{{ t('terminal.helpShortcuts') }}
+
+
{{ t('terminal.helpShortcutSearch') }}Ctrl+Shift+F
+
{{ t('terminal.helpShortcutCopy') }}Ctrl+Shift+C
+
{{ t('terminal.helpShortcutPaste') }}Ctrl+Shift+V
+
{{ t('terminal.helpShortcutFontIncrease') }}Ctrl++
+
{{ t('terminal.helpShortcutFontDecrease') }}Ctrl+-
+
{{ t('terminal.helpShortcutFontReset') }}Ctrl+0
+
{{ t('terminal.helpShortcutExport') }}Ctrl+Shift+S
+
+
+
+
+
+
{{ t('terminal.helpMouseOps') }}
+
+ - • {{ t('terminal.helpMouseSelect') }}
+ - • {{ t('terminal.helpMouseCopy') }}
+ - • {{ t('terminal.helpMouseScroll') }}
+
+
+
+
+
+
{{ t('terminal.helpTouchOps') }}
+
+ - • {{ t('terminal.helpTouchPinchZoom') }}
+ - • {{ t('terminal.helpTouchSwipeScroll') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ linkTooltip.url }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settings') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingBell') }}
+
{{ t('terminal.settingBellDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingAutoCopy') }}
+
{{ t('terminal.settingAutoCopyDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingLinkPreview') }}
+
{{ t('terminal.settingLinkPreviewDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingTouch') }}
+
{{ t('terminal.settingTouchDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('terminal.settingTheme') }}
+
{{ t('terminal.settingThemeDesc') }}
+
+
+
+
+
+
+
{{ t('terminal.currentStatus') }}
+
+
+
+ {{ activeTab?.isWebGLEnabled ? 'WebGL' : 'Canvas' }}
+
+
+ {{ t('terminal.latency') }}: {{ networkLatency.get(activeTabId) }}ms
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/TicketsView.vue b/client/src/views/TicketsView.vue
new file mode 100644
index 0000000..55f8bbf
--- /dev/null
+++ b/client/src/views/TicketsView.vue
@@ -0,0 +1,1198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ viewMode === 'detail' ? t('tickets.ticketDetails') : viewMode === 'create' ? t('tickets.newTicket') : t('tickets.title') }}
+
+
+
+
+
+
+ {{ t('tickets.createTicket') }}
+
+
+
+
+
+
+
+
+
+ {{ t('tickets.myTickets') }}
+
+ {{ pendingCount.userTickets }}
+
+
+
+ {{ t('tickets.hostTickets') }}
+
+ {{ pendingCount.hostTickets }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('tickets.activeStatus') }}
+
+
+ {{ t(`tickets.status.${status}`) }}
+
+
+ {{ t('tickets.allStatus') }}
+
+
+
+
+
+
+
+ {{ t(`tickets.sourceFilter.${sourceType}`) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ activeTab === 'my' ? t('tickets.noTickets') : hostEmptyState.title }}
+
+
+ {{ activeTab === 'my' ? t('tickets.noTicketsHint') : hostEmptyState.hint }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tickets.needsReply') }}
+
+
+ {{ t(`tickets.status.${ticket.status}`) }}
+
+
+ {{ t(`tickets.priority.${ticket.priority}`) }}
+
+
+ #{{ ticket.id }}
+
+
+
+ {{ ticket.subject }}
+
+
+
+ {{ t('tickets.from') }}: {{ ticket.user.username }}
+
+
+ {{ ticket.host.name }}
+
+
+
+ {{ ticket.instance.name }}
+
+
+
+
+
+
+
{{ formatDateShort(ticket.createdAt) }}
+
+
+ {{ ticket.messageCount }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('tickets.perPage') }}
+
+ {{ t('tickets.totalCount', { count: total }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(`tickets.status.${selectedTicket.status}`) }}
+
+
+ {{ t(`tickets.priority.${selectedTicket.priority}`) }}
+
+
+ {{ t(`tickets.category.${selectedTicket.category}`) }}
+
+
+
+
+ {{ selectedTicket.subject }}
+
+ #{{ selectedTicket.id }}
+
+
+
+ {{ t('tickets.from') }}:
+
+ {{ selectedTicket.user.username }}
+
+
+ {{ t('tickets.host') }}: {{ selectedTicket.host.name }}
+
+
+ {{ t('tickets.instance') }}:
+
+ {{ selectedTicket.instance.name }}
+
+
+ {{ t('tickets.createdAt') }}: {{ formatDate(selectedTicket.createdAt) }}
+
+
+
+
+
+
+
+
+ {{ t('tickets.markInProgress') }}
+
+
+ {{ t('tickets.markResolved') }}
+
+
+ {{ t('tickets.close') }}
+
+
+
+
+ {{ t('tickets.close') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.loading') }}
+
+ {{ t('tickets.loadMoreMessages') }} ({{ messagesTotal - messages.length }} {{ t('tickets.remaining') }})
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ message.sender?.username }}
+
+
+ {{ t('tickets.ownerReply') }}
+
+
+ {{ formatDate(message.createdAt) }}
+
+
+
+
+
+
+
+
{{ message.content }}
+
+
+
+
+ {{ attachmentLoadingIds[attachment.id] ? t('common.loading') : t('tickets.images.loadFailed') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('tickets.ticketClosed') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ replying ? t('common.sending') : t('tickets.reply') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/TransfersView.vue b/client/src/views/TransfersView.vue
new file mode 100644
index 0000000..ebfa197
--- /dev/null
+++ b/client/src/views/TransfersView.vue
@@ -0,0 +1,703 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('transfer.sentTab') }}
+
+
+ {{ $t('transfer.receivedTab') }}
+
+ {{ pendingCount > 9 ? '9+' : pendingCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ $t('transfer.detail.instance') }} |
+
+ {{ activeTab === 'sent' ? $t('transfer.detail.toUser') : $t('transfer.detail.fromUser') }}
+ |
+ {{ $t('transfer.detail.snapshot') }} |
+ {{ $t('transfer.modal.remark') }} |
+ {{ $t('common.status') }} |
+ {{ $t('common.createdAt') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+
+ |
+ {{ transfer.instanceName }}
+
+ ID: {{ transfer.instanceId }}
+
+ |
+
+
+
+
+ {{ activeTab === 'sent' ? transfer.toUser?.username : transfer.fromUser?.username }}
+
+ |
+
+
+
+
+ {{ (transfer.snapshot.hostName || '-').toUpperCase() }}
+
+
+ -
+ |
+
+
+
+
+ {{ $t('transfer.hasRemark') }}
+
+ {{ $t('common.expand') }}
+
+
+
+
+ {{ transfer.remark }}
+
+
+ {{ $t('common.collapse') }}
+
+
+
+ -
+ |
+
+
+ {{ $t(`transfer.status.${transfer.status}`) }}
+
+ |
+
+ {{ formatDate(transfer.createdAt) }}
+ |
+
+
+
+
+
+
+
+ {{ pushLoading === transfer.id ? $t('common.processing') : $t('transfer.actions.push') }}
+
+
+ {{ $t('transfer.actions.cancel') }}
+
+
+
+ {{ $t('transfer.completedAt') }}: {{ formatDate(transfer.acceptedAt) }}
+
+
+ {{ $t('transfer.rejectedAt') }}: {{ formatDate(transfer.rejectedAt) }}
+
+ {{ transfer.rejectReason }}
+
+
+
+ {{ $t('transfer.cancelledAt') }}: {{ formatDate(transfer.cancelledAt) }}
+
+
+
+
+
+
+ {{ $t('transfer.actions.accept') }}
+
+
+ {{ $t('transfer.actions.reject') }}
+
+
+
+ {{ $t('transfer.completedAt') }}: {{ formatDate(transfer.acceptedAt) }}
+
+
+ {{ $t('transfer.rejectedAt') }}: {{ formatDate(transfer.rejectedAt) }}
+
+ {{ transfer.rejectReason }}
+
+
+
+ {{ $t('transfer.cancelledAt') }}: {{ formatDate(transfer.cancelledAt) }}
+
+
+
+ |
+
+
+
+
+
+
+
+ {{ total }} {{ $t('common.total') }}
+
+
+
+ {{ $t('instance.prevPage') }}
+
+
+ {{ $t('instance.nextPage') }}
+
+
+
+
+
+
+
+
+
+ {{ activeTab === 'sent' ? $t('transfer.noTransfers') : $t('transfer.noPendingTransfers') }}
+
+
+
+
+
+
+
{{ $t('transfer.rejectModal.title') }}
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+
+
+ {{ rejectLoading ? $t('common.loading') : $t('transfer.actions.reject') }}
+
+
+
+
+
+
+
+
+
+
+
{{ $t('transfer.configModal.title') }}
+
+
+
+
+
+
+
+
+
+
{{ $t('transfer.configModal.instanceName') }}
+
{{ selectedTransfer.snapshot?.originalName || selectedTransfer.instanceName }}
+
+
+
{{ $t('instance.detail.info.instanceId') }}
+
{{ selectedTransfer.instanceId }}
+
+
+
+
+
+
{{ $t('transfer.configModal.hostInfo') }}
+
+
+ {{ (selectedTransfer.snapshot?.hostName || '-').toUpperCase() }}
+
+ ({{ selectedTransfer.snapshot.hostLocation }})
+
+
+
+
+
+
+
+
CPU
+
{{ selectedTransfer.snapshot?.cpu ?? '-' }}%
+
+
+
{{ $t('instance.detail.info.memory') }}
+
{{ selectedTransfer.snapshot?.memory ? formatMemory(selectedTransfer.snapshot.memory) : '-' }}
+
+
+
{{ $t('instance.detail.info.disk') }}
+
{{ selectedTransfer.snapshot?.disk ? formatDisk(selectedTransfer.snapshot.disk) : '-' }}
+
+
+
{{ $t('transfer.configModal.networkMode') }}
+
{{ getNetworkModeText(selectedTransfer.snapshot?.networkMode) }}
+
+
+
+
+
+
+
IPv4
+
{{ selectedTransfer.snapshot?.ipv4 || '-' }}
+
+
+
IPv6
+
+ {{ selectedTransfer.snapshot?.ipv6 || '-' }}
+
+
+
+
+
+
+
{{ $t('transfer.configModal.package') }}
+
{{ selectedTransfer.snapshot.packageName }}
+
+
+
+
+
+ {{ $t('common.close') }}
+
+
+
+
+
+
diff --git a/client/src/views/WalletView.vue b/client/src/views/WalletView.vue
new file mode 100644
index 0000000..6ce36a6
--- /dev/null
+++ b/client/src/views/WalletView.vue
@@ -0,0 +1,2273 @@
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.walletCurrentBalance : $t('wallet.currentBalance') }}
+
+
+ {{ formatMoney(balance.balance) }}
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.walletDescription : $t('wallet.description') }}
+
+
+
+
+ {{ $t('wallet.recharge') }}
+
+
+
+
+
+
+
+ {{ $t('wallet.recharge') }}
+
+
+
+
+
+
+
+
+
+ {{ metric.label }}
+
+
+
+ {{ metric.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ configStore.freeSiteMode ? freeSiteCopy.walletLogsTab : $t('wallet.tabs.logs') }}
+
+
+ {{ logsTotal.toLocaleString() }}
+
+
+ {{ $t('common.total') }} {{ logsTotal }} {{ $t('common.items') }}
+
+
+
+
+
+ {{ showLotteryGiftOnly ? $t('wallet.showingLotteryGift') : $t('wallet.showLotteryGift') }}
+
+
+
+
+
+
+
+
+ {{ metric.label }}
+
+
+
+ {{ metric.value }}
+
+
+
+
+
+
+
+
+
{{ configStore.freeSiteMode ? freeSiteCopy.walletLogsTab : $t('wallet.tabs.logs') }}
+
+ {{ showLotteryGiftOnly ? $t('wallet.showingLotteryGift') : (configStore.freeSiteMode ? freeSiteCopy.walletLogsDescription : $t('wallet.description')) }}
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('wallet.noLogs') }}
+
+
+
+
{{ $t('wallet.instanceOrRemark') }}
+
{{ $t('wallet.amount') }}
+
{{ $t('wallet.balanceAfter') }}
+
{{ $t('wallet.time') }}
+
+
+
+
+
+
+
+
+ {{ getLogTypeName(log.type) }}
+
+
+ #{{ log.instanceId }}
+
+
+
+ {{ log.remark || '-' }}
+
+
+
+
+
+ {{ getLogTypeName(log.type) }}
+
+
+ #{{ log.instanceId }}
+
+
+ {{ log.remark || '-' }}
+
+
+
+
+
+
+ {{ $t('wallet.amount') }}
+
+
+ {{ log.amount >= 0 ? '+' : '-' }}{{ formatMoney(Math.abs(log.amount)) }}
+
+
+
+
+
+ {{ $t('wallet.balanceAfter') }}
+
+
+ {{ formatMoney(log.balanceAfter) }}
+
+
+
+
+
+ {{ $t('wallet.time') }}
+
+
+ {{ formatDate(log.createdAt) }}
+
+
+
+
+
+
+
+ {{ $t('common.total') }} {{ logsTotal }} {{ $t('common.items') }}
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ logsPage }} / {{ logsTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('wallet.tabs.records') }}
+
+
+ {{ recordsTotal.toLocaleString() }}
+
+
+ {{ $t('common.total') }} {{ recordsTotal }} {{ $t('common.items') }}
+
+
+ {{ $t('wallet.verifyingPayment') }}
+
+
+
+
+
+ {{ $t('wallet.recharge') }}
+
+
+
+
+
+
+
+
+ {{ metric.label }}
+
+
+
+ {{ metric.value }}
+
+
+
+
+
+
+
+
+
{{ $t('wallet.tabs.records') }}
+
{{ $t('wallet.description') }}
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('wallet.noRecords') }}
+
+
+
+
{{ $t('wallet.orderNo') }}
+
{{ $t('wallet.amount') }}
+
{{ $t('wallet.paymentChannel') }}
+
{{ $t('wallet.statusLabel') }}
+
{{ $t('wallet.time') }}
+
{{ $t('common.actions') }}
+
+
+
+
+
+
+
+ {{ rec.orderNo }}
+
+
+ {{ rec.provider?.name || '-' }}
+ · {{ getRechargeMethodDisplay(rec) }}
+
+
+ {{ $t('wallet.paymentUuid') }} {{ rec.paymentUuid }}
+ ·
+ {{ $t('wallet.paymentTxid') }} {{ rec.paymentTxid }}
+ ·
+ {{ $t('wallet.completedAt') }} {{ formatDate(rec.completedAt) }}
+
+
+
+
+
+ {{ rec.orderNo }}
+
+
+
+
+
+
+ {{ $t('wallet.amount') }}
+
+
+ {{ formatMoney(rec.amount) }}
+
+
+ {{ $t(getRechargeCreditLabelKey(rec.status)) }} {{ formatMoney(rec.actualAmount) }}
+
+
+
+
+
+ {{ $t('wallet.paymentChannel') }}
+
+
+ {{ rec.provider?.name || '-' }} · {{ getRechargeMethodDisplay(rec) }}
+
+
+ {{ $t('wallet.paymentUuid') }} {{ rec.paymentUuid }}
+
+
+ {{ $t('wallet.paymentTxid') }} {{ rec.paymentTxid }}
+
+
+
+
+
+ {{ $t('wallet.statusLabel') }}
+
+
+ {{ getStatusName(rec.status) }}
+
+
+ {{ getRechargeGatewayStatusText(rec) }}
+
+
+
+
+
+ {{ $t('wallet.time') }}
+
+
+ {{ formatDate(rec.createdAt) }}
+
+
+ {{ $t('wallet.completedAt') }} {{ formatDate(rec.completedAt) }}
+
+
+
+
+
+
+ {{ repayLoading === rec.orderNo ? $t('common.processing') : $t('wallet.pay') }}
+
+
+ {{ cancelLoading === rec.orderNo ? $t('common.processing') : $t('wallet.void') }}
+
+
+
+
+ {{ $t('wallet.orderExpired') }}
+
+
+
+ -
+
+
+
+
+
+
+
+ {{ $t('common.total') }} {{ recordsTotal }} {{ $t('common.items') }}
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ recordsPage }} / {{ recordsTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+
+
+
+
{{ $t('aff.notActivated') }}
+
{{ $t('aff.activateHint') }}
+
+
+ {{ $t('aff.goRecharge') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('aff.affBalance') }}
+
+
+ {{ formatMoney(affStatus.currentBalance) }}
+
+
+ {{ $t('aff.balanceHint') }}
+
+
+
+
+
+
+ {{ $t('aff.leaderboard.title') }}
+
+
+ {{ $t('aff.convert') }}
+
+
+
+
+
+
+
+
+
+ {{ metric.label }}
+
+
+
+ {{ metric.value }}
+
+
+
+
+
+
+
+
+
+
{{ $t('aff.myCodes') }}
+
+ {{ $t('common.total') }} {{ affStatus.totalCodes }} {{ $t('common.items') }}
+
+
+
+
+ {{ $t('aff.createCode') }}
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('aff.noCodes') }}
+
+
+
+
{{ $t('aff.code') }}
+
{{ $t('aff.plan') }}
+
{{ $t('aff.discount') }}/{{ $t('aff.commission') }}
+
{{ $t('aff.usedCount') }}
+
{{ $t('aff.earnings') }}
+
+
+
+
+
+
+
+
{{ code.code }}
+
+
+
+
+ {{ $t('aff.globalCodeBadge') }}
+
+
+
+
+
+ {{ code.isGlobal ? $t('aff.globalCode') : `${code.packageName} / ${code.planName}` }}
+
+
+
+
+ {{ $t('aff.discount') }}/{{ $t('aff.commission') }}
+
+
+ {{ (code.discountRate * 100).toFixed(0) }}% / {{ (code.commissionRate * 100).toFixed(0) }}%
+
+
+
+
+
+ {{ $t('aff.usedCount') }}
+
+
+ {{ code.usedCount.toLocaleString() }}
+
+
+
+
+
+ {{ $t('aff.earnings') }}
+
+
+ {{ formatMoney(code.totalEarnings) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('aff.earningsLog') }}
+
{{ $t('common.total') }} {{ affLogsTotal }} {{ $t('common.items') }}
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('aff.noLogs') }}
+
+
+
+
{{ $t('wallet.instanceOrRemark') }}
+
{{ $t('wallet.amount') }}
+
{{ $t('wallet.balanceAfter') }}
+
{{ $t('wallet.time') }}
+
+
+
+
+
+
+
+
+ {{ getAffLogTypeName(log.type) }}
+
+
+ #{{ log.instanceId }}
+
+
+ {{ log.affCode }}
+
+
+
+ {{ log.remark || '-' }}
+
+
+
+
+
+ {{ getAffLogTypeName(log.type) }}
+
+
+ #{{ log.instanceId }}
+
+
+ {{ log.affCode }}
+
+
+ {{ log.remark || '-' }}
+
+
+
+
+
+
+ {{ $t('wallet.amount') }}
+
+
+ {{ log.amount >= 0 ? '+' : '-' }}{{ formatMoney(Math.abs(log.amount)) }}
+
+
+
+
+
+ {{ $t('wallet.balanceAfter') }}
+
+
+ {{ formatMoney(log.balanceAfter) }}
+
+
+
+
+
+ {{ $t('wallet.time') }}
+
+
+ {{ formatDate(log.createdAt) }}
+
+
+
+
+
+
+
+ {{ $t('common.total') }} {{ affLogsTotal }} {{ $t('common.items') }}
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ affLogsPage }} / {{ affLogsTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
{{ $t('aff.withdrawals') }}
+
{{ $t('common.total') }} {{ affWithdrawalsTotal }} {{ $t('common.items') }}
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('aff.noWithdrawals') }}
+
+
+
+
{{ $t('wallet.amount') }}
+
{{ $t('aff.status') }}
+
{{ $t('aff.requestTime') }}
+
{{ $t('aff.rejectReason') }}
+
+
+
+
+
+
+ {{ $t('wallet.amount') }}
+
+
+ {{ formatMoney(w.amount) }}
+
+
+
+
+
+ {{ $t('aff.status') }}
+
+
+ {{ getWithdrawalStatusName(w.status) }}
+
+
+
+
+
+ {{ $t('aff.requestTime') }}
+
+
+ {{ formatDate(w.createdAt) }}
+
+
+
+
+ {{ w.rejectReason || '-' }}
+
+
+
+
+
+
+ {{ $t('common.total') }} {{ affWithdrawalsTotal }} {{ $t('common.items') }}
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ affWithdrawalsPage }} / {{ affWithdrawalsTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('wallet.noProviders') }}
+
+
+
+ {{ p.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getPaymentMethodName(method) }}
+
+
+
+
+
+ {{ $t('wallet.heleketSelectionHint') }}
+
+
+
+
+
+
+
+ ¥{{ amt }}
+
+
+
+ ¥
+
+
+
+
+ {{ $t('wallet.amountRange') }}:
+ {{ selectedProviderInfo.minAmount.toFixed(2) }} -
+ {{ selectedProviderInfo.maxAmount ? selectedProviderInfo.maxAmount.toFixed(2) : $t('common.unlimited') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('wallet.feeNote') }}:
+ {{ (selectedFeeConfig.feeRate * 100).toFixed(2) }}%
+ +
+ ¥{{ selectedFeeConfig.feeFixed.toFixed(2) }}
+ = ¥{{ selectedRechargeFee.toFixed(2) }}
+
+
+ {{ $t('wallet.payableAmount') }}:
+ ¥{{ selectedPayableAmount.toFixed(2) }}
+ / {{ $t('wallet.actualAmount') }} ¥{{ selectedCreditAmount.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('aff.selectPlanHint') }}
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('aff.noCodes') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('aff.orSelectPlan') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('aff.fixedRateHint') }}
+
+
+
5%
+
{{ $t('aff.discount') }} 5% / {{ $t('aff.commission') }} 5%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
¥{{ affStatus?.currentBalance.toFixed(2) || '0.00' }}
+
+
+
+
+
+ ¥
+
+
+
{{ $t('aff.convertModal.minAmount') }}
+
+
+
+
+
+
{{ $t('aff.convertModal.hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+
+
+
+
{{ $t('aff.leaderboard.empty') }}
+
+
+
+
+
+
+
+
+ {{ getRankEmoji(entry.rank) }}
+ {{ entry.rank }}
+
+
+
+
+ {{ entry.username }}
+
+
+ {{ $t('aff.leaderboard.you') }}
+
+
+
+
+
+ ¥{{ entry.totalEarnings.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/AdminInstanceCreateView.vue b/client/src/views/admin/AdminInstanceCreateView.vue
new file mode 100644
index 0000000..b035bba
--- /dev/null
+++ b/client/src/views/admin/AdminInstanceCreateView.vue
@@ -0,0 +1,833 @@
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}
+
+
+
+
+
diff --git a/client/src/views/admin/AdminMailView.vue b/client/src/views/admin/AdminMailView.vue
new file mode 100644
index 0000000..eba1ce3
--- /dev/null
+++ b/client/src/views/admin/AdminMailView.vue
@@ -0,0 +1,942 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.mail.tabs.sources') }}
+
+
+ {{ t('admin.mail.tabs.plans') }}
+
+
+ {{ t('admin.mail.tabs.subscriptions') }}
+
+
+ {{ t('admin.mail.tabs.domains') }}
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
+ + {{ t('admin.mail.createSource') }}
+
+
+
+
+ {{ t('admin.mail.noSources') }}
+
+
+
+
+
+
+ | {{ t('admin.mail.region') }} |
+ {{ t('common.name') }} |
+ {{ t('admin.mail.apiEndpoint') }} |
+ {{ t('admin.mail.plans') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+ |
+
+
+ {{ source.code.toUpperCase() }}
+
+ |
+ {{ source.name }} |
+ {{ source.apiUrl }} |
+ {{ source.planCount || 0 }} |
+
+
+ {{ t('common.edit') }}
+
+
+
+ {{ t('common.delete') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+ + {{ t('admin.mail.createPlan') }}
+
+
+
+
+ {{ t('admin.mail.noPlans') }}
+
+
+
+
+
+
+ | {{ t('common.name') }} |
+ {{ t('admin.mail.source') }} |
+ {{ t('admin.mail.domainLimit') }} |
+ {{ t('admin.mail.diskLimit') }} |
+ {{ t('admin.mail.price') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+ |
+ {{ plan.name }}
+ {{ plan.description }}
+ |
+ {{ plan.source?.name || '-' }} |
+ {{ plan.domainLimit }} |
+ {{ plan.diskLimitGb }} GB |
+
+ {{ formatPrice(plan.price) }}/{{ plan.billingCycle === 'monthly' ? t('mail.month') : t('mail.year') }}
+ |
+
+
+ {{ t('common.edit') }}
+
+
+
+ {{ t('common.delete') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.search') }}
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+
+ {{ subscriptionsSearch ? t('common.noSearchResults') : t('admin.mail.noSubscriptions') }}
+
+
+
+
+
+
+ | {{ t('admin.mail.user') }} |
+ {{ t('admin.mail.plan') }} |
+ {{ t('common.status') }} |
+ {{ t('admin.mail.expiresAt') }} |
+ {{ t('common.createdAt') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+
+
+
+
+ {{ sub.user?.username || '-' }}
+ ID: {{ sub.user?.id || '-' }} · {{ sub.user?.email || '-' }}
+
+
+ |
+ {{ sub.plan?.name || '-' }} |
+
+
+ {{ t('mail.status.' + sub.status) }}
+
+ |
+ {{ formatDate(sub.expiresAt) }} |
+ {{ formatDate(sub.createdAt) }} |
+
+
+ {{ t('admin.mail.unsub.button') }}
+
+ |
+
+
+
+
+
+
+
+
{{ t('common.totalRecords', { count: totalSubscriptions }) }}
+
+
+ {{ t('common.prevPage') }}
+
+ {{ subscriptionsPage }} / {{ subscriptionsTotalPages }}
+
+ {{ t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.search') }}
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+
+ {{ domainsSearch ? t('common.noSearchResults') : t('admin.mail.noDomains') }}
+
+
+
+
+
+
+ | {{ t('admin.mail.domain') }} |
+ {{ t('admin.mail.user') }} |
+ {{ t('common.status') }} |
+ {{ t('admin.mail.accounts') }} |
+ {{ t('common.createdAt') }} |
+
+
+
+
+ | {{ domain.domain }} |
+
+
+
+
+ {{ domain.subscription?.user?.username || '-' }}
+ ID: {{ domain.subscription?.user?.id || '-' }} · {{ domain.subscription?.user?.email || '-' }}
+
+
+ |
+
+
+ {{ t('mail.domainStatus.' + domain.status) }}
+
+ |
+ {{ domain._count?.accounts || 0 }} |
+ {{ formatDate(domain.createdAt) }} |
+
+
+
+
+
+
+
+
{{ t('common.totalRecords', { count: totalDomains }) }}
+
+
+ {{ t('common.prevPage') }}
+
+ {{ domainsPage }} / {{ domainsTotalPages }}
+
+ {{ t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.mail.user') }}
+ {{ unsubTarget.user?.username }} (ID: {{ unsubTarget.user?.id }})
+
+
+ {{ t('admin.mail.plan') }}
+ {{ unsubTarget.plan?.name }}
+
+
+ {{ t('admin.mail.price') }}
+ {{ formatPrice(unsubTarget.plan?.price) }}
+
+
+ {{ t('admin.mail.expiresAt') }}
+ {{ formatDate(unsubTarget.expiresAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/AffReviewView.vue b/client/src/views/admin/AffReviewView.vue
new file mode 100644
index 0000000..fc922a5
--- /dev/null
+++ b/client/src/views/admin/AffReviewView.vue
@@ -0,0 +1,292 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.refresh') }}
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('aff.noRequests') }}
+
+
+
+
+
+ | ID |
+ {{ $t('aff.user') }} |
+ {{ $t('aff.requestAmount') }} |
+ {{ $t('aff.userBalance') }} |
+ {{ $t('aff.status') }} |
+ {{ $t('aff.requestTime') }} |
+ {{ $t('aff.rejectReason') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+ | #{{ w.id }} |
+
+ {{ w.username }}
+ ID: {{ w.userId }}
+ |
+ {{ formatMoney(w.amount) }} |
+ {{ formatMoney(w.userAffBalance) }} |
+
+
+ {{ getStatusName(w.status) }}
+
+ |
+ {{ formatDate(w.createdAt) }} |
+ {{ w.rejectReason || '-' }} |
+
+
+
+
+ {{ approveLoading === w.id ? $t('common.processing') : $t('aff.approve') }}
+
+
+ {{ $t('aff.reject') }}
+
+
+
+
+ -
+
+ |
+
+
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ page }} / {{ totalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('aff.user') }}: {{ rejectTarget.username }}
+
{{ $t('aff.requestAmount') }}: {{ formatMoney(rejectTarget.amount) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/BillingView.vue b/client/src/views/admin/BillingView.vue
new file mode 100644
index 0000000..c369186
--- /dev/null
+++ b/client/src/views/admin/BillingView.vue
@@ -0,0 +1,2577 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.netRevenueLabel') }}
+
+ {{ formatMoney(overview.netRevenue) }}
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.todayRevenue') }}
+
{{ formatMoney(overview.todayRevenue) }}
+
{{ $t('admin.billing.todayRecharge') }} {{ formatMoney(overview.recharge.todayAmount) }}
+
+
+
{{ $t('admin.billing.thisMonthRevenue') }}
+
{{ formatMoney(overview.thisMonthRevenue) }}
+
+ {{ $t('admin.billing.thisMonthVsLastMonth') }} {{ formatSignedPercent(monthlyRevenueChange) }}
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.totalRevenue') }}
+
{{ formatMoney(overview.totalRevenue) }}
+
+
+
+
+
+ {{ $t('admin.billing.todayRevenue') }} {{ formatMoney(overview.todayRevenue) }}
+
+
+
+
+
+
{{ $t('admin.billing.totalRecharge') }}
+
{{ formatMoney(overview.recharge.totalAmount) }}
+
+
+
+
+
+ {{ formatCount(overview.recharge.totalCount) }} {{ $t('admin.billing.orders') }}
+
+
+
+
+
+
{{ $t('admin.billing.hostedRevenueShare') }}
+
{{ formatPercent(hostedRevenueShare) }}
+
+
+
+
+
+ {{ formatMoney(overview.revenueMix.hosted.totalAmount) }}
+
+
+
+
+
+
{{ $t('admin.billing.totalRefunds') }}
+
{{ formatMoney(overview.totalRefunds) }}
+
+
+
+
+
+ {{ formatPercent(refundRate) }}
+
+
+
+
+
+
+
{{ $t('admin.billing.rechargeLabel') }} / {{ $t('admin.billing.revenueLabel') }}
+
{{ $t('admin.billing.overviewPeriods.total') }} · {{ $t('admin.billing.overviewPeriods.thisMonth') }} · {{ $t('admin.billing.overviewPeriods.today') }}
+
+
+
+
+
+
+
+
+ {{ period.label }}
+
+
{{ $t('admin.billing.rechargeLabel') }} / {{ $t('admin.billing.revenueLabel') }}
+
+
+
+
+
+
+
+
+
- {{ $t('admin.billing.rechargeLabel') }}
+
-
+
{{ formatMoney(period.rechargeAmount) }}
+ {{ formatCount(period.rechargeCount) }} {{ $t('admin.billing.orders') }}
+
+
+
+
- {{ $t('admin.billing.revenueLabel') }}
+ - {{ formatMoney(period.revenueAmount) }}
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.revenueBreakdownTitle') }}
+
{{ $t('admin.billing.directRevenue') }} / {{ $t('admin.billing.hostedRevenue') }}
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.directRevenue') }}
+
{{ formatMoney(overview.revenueMix.direct.totalAmount) }}
+
{{ formatPercent(revenueMixRows[0]?.directRatio || 0) }}
+
+
+
{{ $t('admin.billing.hostedRevenue') }}
+
{{ formatMoney(overview.revenueMix.hosted.totalAmount) }}
+
{{ formatPercent(revenueMixRows[0]?.hostedRatio || 0) }}
+
+
+
+
+
+
+
+
+
+
{{ row.label }}
+
{{ $t('admin.billing.revenueLabel') }} {{ formatMoney(row.directAmount + row.hostedAmount) }}
+
+
+
+
+ {{ formatPercent(row.directRatio) }}
+
+
+
+ {{ formatPercent(row.hostedRatio) }}
+
+
+
+
+
+
+
+ {{ $t('admin.billing.directRevenue') }}
+ {{ formatMoney(row.directAmount) }}
+
+
+
+
+ {{ $t('admin.billing.hostedRevenue') }}
+ {{ formatMoney(row.hostedAmount) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.instanceHealthTitle') }}
+
{{ formatCount(overview.activePaidInstancesCount) }} / {{ formatCount(overview.paidInstancesCount) }}
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.paidInstances') }}
+
{{ formatCount(overview.paidInstancesCount) }}
+
+
+ {{ formatPercent(activeInstanceShare) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ card.label }}
+
{{ formatPercent(card.progress || 0) }}
+
+
+
{{ formatCount(card.value) }}
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.affOverviewTitle') }}
+
{{ formatCount(overview.aff.totalOrders) }} {{ $t('admin.billing.orders') }}
+
+
+
+
+
+
+
+
{{ card.label }}
+
+ {{ card.format === 'money' ? formatMoney(card.value) : formatCount(card.value) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.noData') }}
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.tabs.instances') }}
+
{{ $t('admin.billing.totalCount', { count: instancesTotal }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.search') }}
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.tabs.instances') }}
+
{{ $t('admin.billing.totalCount', { count: instancesTotal }) }}
+
+
+
+ {{ selectedInstancesCount > 0 ? $t('admin.billing.batchSelectedCount', { count: selectedInstancesCount }) : (showExpiring ? $t('admin.billing.showExpiring') : $t('admin.billing.allStatus')) }}
+
+
+ {{ $t('admin.billing.batchUpdatePrice') }}
+
+
+
+
+
+
+
+ |
+
+ |
+ {{ $t('admin.billing.hostingType') }} |
+ {{ $t('admin.billing.instanceType') }} |
+ ID |
+ {{ $t('admin.billing.instanceName') }} |
+ {{ $t('admin.billing.instanceStatus') }} |
+ {{ $t('admin.billing.user') }} |
+ {{ $t('admin.billing.package') }} |
+ {{ $t('admin.billing.plan') }} |
+ {{ $t('admin.billing.host') }} |
+ {{ $t('admin.billing.price') }} |
+ {{ $t('admin.billing.cycle') }} |
+ {{ $t('admin.billing.purchaseDate') }} |
+ {{ $t('admin.billing.expiresAt') }} |
+ {{ $t('admin.billing.remainingDays') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+ |
+
+ |
+
+
+
+ {{ inst.isHostedInstance ? $t('admin.billing.hosted') : $t('admin.billing.direct') }}
+
+ |
+
+
+
+
+
+ {{ inst.instanceTypeLabel }}
+
+ |
+
+
+
+ #{{ inst.id }}
+ {{ inst.incusId?.slice(0, 8) }}
+
+ |
+
+
+ {{ inst.name }}
+ |
+
+
+ {{ $t('instance.status.' + inst.status) }}
+ {{ $t('admin.billing.autoRenew') }}
+ |
+
+
+
+
+
+ {{ inst.user?.username || '-' }}
+
+ #{{ inst.user?.id }}
+ {{ inst.user.email }}
+
+
+
+ |
+
+ {{ inst.package?.name || '-' }} |
+
+ {{ inst.packagePlan?.name || '-' }} |
+
+ {{ inst.host?.name || '-' }} |
+
+
+
+ {{ formatMoney(inst.billingPrice) }}
+
+ -
+ |
+
+ {{ inst.billingCycle ? $t('admin.billing.cycleMonths', { months: inst.billingCycle }) : '-' }} |
+
+ {{ formatDate(inst.createdAt) }} |
+
+ {{ formatDate(inst.expiresAt) }} |
+
+
+
+ {{ inst.remainingDays <= 0 ? $t('admin.billing.expired') : inst.remainingDays + ' ' + $t('admin.billing.days') }}
+
+ -
+ |
+
+
+
+
+ {{ $t('admin.billing.unsuspend') }}
+
+
+ {{ $t('admin.billing.extend') }}
+
+
+ {{ $t('admin.billing.upgradePlan') }}
+
+
+ {{ $t('admin.billing.applyDiscount') }}
+
+
+ {{ $t('admin.billing.deleteRefund') }}
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.noInstances') }}
+
{{ $t('admin.billing.searchPlaceholder') }}
+
+
+
+
+
+
+ {{ $t('admin.billing.perPage') }}
+
+ {{ $t('admin.billing.totalCount', { count: instancesTotal }) }}
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ instancesPage }} / {{ instancesTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.tabs.records') }}
+
{{ $t('admin.billing.totalCount', { count: recordsTotal }) }}
+
+
+ {{ $t('admin.billing.recordType') }} / {{ $t('admin.billing.amount') }}
+
+
+
+
+
+
+ | {{ $t('admin.billing.recordType') }} |
+ {{ $t('admin.billing.amount') }} |
+ {{ $t('admin.billing.instance') }} |
+ {{ $t('admin.billing.user') }} |
+ {{ $t('admin.billing.remark') }} |
+ {{ $t('admin.billing.time') }} |
+
+
+
+
+ | {{ getRecordTypeLabel(rec.type) }} |
+
+ {{ formatMoney(rec.amount) }}
+ |
+ {{ rec.instance?.name || '-' }} |
+ {{ rec.user?.username || '-' }} |
+ {{ rec.remark || '-' }} |
+ {{ formatDate(rec.createdAt) }} |
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.noRecords') }}
+
{{ $t('admin.billing.tabs.records') }}
+
+
+
+
+
+
+ {{ $t('admin.billing.perPage') }}
+
+ {{ $t('admin.billing.totalCount', { count: recordsTotal }) }}
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ recordsPage }} / {{ recordsTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.tabs.rechargeRecords') }}
+
{{ $t('admin.billing.totalCount', { count: rechargeRecordsTotal }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.tabs.rechargeRecords') }}
+
{{ $t('admin.billing.totalCount', { count: rechargeRecordsTotal }) }}
+
+
+ {{ rechargeRecordsFilter ? $t(`admin.billing.rechargeStatus.${rechargeRecordsFilter}`) : $t('admin.billing.allStatus') }}
+
+
+
+
+
+
+
+ {{ rec.orderNo }}
+
+
{{ rec.user?.username || '-' }}
+
{{ formatDate(rec.createdAt) }}
+
+
{{ $t(`admin.billing.rechargeStatus.${rec.status}`) }}
+
+
+
+
+
{{ $t('admin.billing.amount') }}
+
{{ formatMoney(rec.amount) }}
+
+
+
{{ $t('admin.billing.creditAmount') }}
+
{{ rec.actualAmount !== null ? formatMoney(rec.actualAmount) : '-' }}
+
+ {{ $t(getRechargeCreditLabelKey(rec.status)) }}
+
+
+
+
+
+
{{ $t('admin.billing.payChannel') }}: {{ getPayChannelDisplay(rec) }}
+
{{ $t('admin.billing.paymentDetails') }}: {{ getRechargePaymentDetailsDisplay(rec) }}
+
{{ getRechargeGatewayStatusText(rec) }}
+
+ {{ $t('admin.billing.paymentUuid') }} {{ rec.paymentUuid }}
+
+
+ {{ $t('admin.billing.paymentTxid') }} {{ rec.paymentTxid }}
+
+
+ {{ $t('admin.billing.tradeNo') }} {{ rec.tradeNo }}
+
+
+
+
+
+ {{ syncingRecordId === rec.id ? $t('common.syncing') : $t('admin.billing.sync') }}
+
+
+
+
+
+
+
+
+
+ | {{ $t('admin.billing.rechargeOrderNo') }} |
+ {{ $t('admin.billing.user') }} |
+ {{ $t('admin.billing.amount') }} |
+ {{ $t('admin.billing.creditAmount') }} |
+ {{ $t('admin.billing.payChannel') }} |
+ {{ $t('admin.billing.paymentDetails') }} |
+ {{ $t('admin.billing.rechargeStatusLabel') }} |
+ {{ $t('admin.billing.tradeNo') }} |
+ {{ $t('admin.billing.time') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+ |
+ {{ rec.orderNo }}
+ |
+ {{ rec.user?.username || '-' }} |
+ {{ formatMoney(rec.amount) }} |
+
+ {{ rec.actualAmount !== null ? formatMoney(rec.actualAmount) : '-' }}
+
+ {{ $t(getRechargeCreditLabelKey(rec.status)) }}
+
+ |
+ {{ getPayChannelDisplay(rec) }} |
+
+ {{ getRechargePaymentDetailsDisplay(rec) }}
+ {{ getRechargeGatewayStatusText(rec) }}
+
+ {{ $t('admin.billing.paymentUuid') }} {{ rec.paymentUuid }}
+
+
+ {{ $t('admin.billing.paymentTxid') }} {{ rec.paymentTxid }}
+
+ |
+
+ {{ $t(`admin.billing.rechargeStatus.${rec.status}`) }}
+ |
+
+ {{ rec.tradeNo }}
+ -
+ |
+ {{ formatDate(rec.createdAt) }} |
+
+
+ {{ syncingRecordId === rec.id ? $t('common.syncing') : $t('admin.billing.sync') }}
+
+ -
+ |
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.noRechargeRecords') }}
+
{{ $t('admin.billing.tabs.rechargeRecords') }}
+
+
+
+
+
+
+ {{ $t('admin.billing.perPage') }}
+
+ {{ $t('admin.billing.totalCount', { count: rechargeRecordsTotal }) }}
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+
+ {{ rechargeRecordsPage }} / {{ rechargeRecordsTotalPages }}
+
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.targetInstance') }}: {{ actionTarget?.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.deleteRefundWarning') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.applyDiscountHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.billing.batchSelected') }}
+
{{ selectedInstancesCount }}
+
+
+
{{ $t('admin.billing.batchPreviewChanged') }}
+
{{ batchPricePreview?.summary.changedCount || 0 }}
+
+
+
{{ $t('admin.billing.batchPreviewFailed') }}
+
+ {{ batchPricePreview?.summary.failedCount || 0 }}
+
+
+
+
+
+
+
+ ¥
+
+
+
{{ $t('admin.billing.batchPriceHint') }}
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}
+
+
+
+
+
+
+
{{ $t('admin.billing.batchTotalCharge') }}
+
{{ formatMoney(batchPricePreview.summary.totalCharge) }}
+
+
+
{{ $t('admin.billing.batchTotalRefund') }}
+
{{ formatMoney(batchPricePreview.summary.totalRefund) }}
+
+
+
{{ $t('admin.billing.batchNetAmount') }}
+
{{ formatPriceDiff(batchPricePreview.summary.netAmount) }}
+
+
+
+ {{ $t('admin.billing.batchPreviewBlocked') }}
+
+
+
+
+
{{ $t('admin.billing.batchUserImpact') }}
+
+
+
{{ impact.username }}
+
{{ formatMoney(impact.balanceBefore) }}
+
{{ formatPriceDiff(impact.netDiff) }}
+
{{ formatMoney(impact.balanceAfter) }}
+
+
+
+
+
+
{{ $t('admin.billing.batchPreviewDetails') }}
+
+
+
+
+ | {{ $t('admin.billing.instance') }} |
+ {{ $t('admin.billing.user') }} |
+ {{ $t('admin.billing.currentPrice') }} |
+ {{ $t('admin.billing.newPrice') }} |
+ {{ $t('admin.billing.priceDifference') }} |
+ {{ $t('admin.billing.result') }} |
+
+
+
+
+ |
+ {{ item.name || '-' }}
+ #{{ item.id }}
+ |
+ {{ item.user?.username || '-' }} |
+ {{ item.oldPrice !== null ? formatMoney(item.oldPrice) : '-' }} |
+ {{ formatMoney(item.newPrice) }} |
+
+ {{ formatPriceDiff(item.priceDiff) }}
+ |
+
+ {{ item.error || getBatchPriceItemStatusLabel(item.status) }}
+ |
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.batchPreviewWaiting') }}
+
+
+
+
+ {{ $t('admin.billing.noSettleHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.instanceName') }}:
+ {{ priceTarget?.name }}
+
+
+ {{ $t('admin.billing.owner') }}:
+ {{ priceTarget?.user?.username }}
+
+
+ {{ $t('admin.billing.currentPrice') }}:
+ ¥{{ (priceTarget?.billingPrice || 0).toFixed(2) }}
+
+
+
+ {{ $t('admin.billing.affDiscount') }}:
+ -{{ (priceTarget.affDiscountRate * 100).toFixed(0) }}%
+
+
+
+ {{ $t('admin.billing.actualRenewPrice') }}:
+ ¥{{ actualRenewPrice.old.toFixed(2) }}
+
+
+ {{ $t('admin.billing.cycle') }}:
+ {{ formatBillingCycle(priceTarget?.billingCycle || 1) }}
+
+
+ {{ $t('admin.billing.remainingDays') }}:
+
+ {{ Math.ceil(pricePreview?.remainingDays || priceTarget?.remainingDays || 0) }} {{ $t('admin.billing.days') }}
+
+
+
+ {{ $t('admin.billing.userBalance') }}:
+ ¥{{ (priceTarget?.user?.balance || 0).toFixed(2) }}
+
+
+
+
+
+
+ 🎁 {{ $t('admin.billing.affAppliedHint', { discount: (priceTarget.affDiscountRate * 100).toFixed(0) }) }}
+
+
+
+
+
+
+
+ ¥
+
+
+
{{ $t('admin.billing.priceHint') }}
+
+
+ → {{ $t('admin.billing.newActualPrice') }}: ¥{{ actualRenewPrice.new.toFixed(2) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}
+
+
+
+
+
+ {{ priceDiff > 0 ? $t('admin.billing.needPay') : $t('admin.billing.willRefund') }}:
+
+
+ ¥{{ Math.abs(priceDiff).toFixed(2) }}
+
+
+
+ ⚠️ {{ $t('admin.billing.insufficientBalance') }}
+
+
+ {{ $t('admin.billing.priceDiffHint', { days: Math.ceil(pricePreview?.remainingDays || 0) }) }}
+
+
+
+
+
+
+ ⚠️ {{ $t('admin.billing.noSettleHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.targetInstance') }}: {{ upgradeTarget?.name }}
+
+
+
+
+
+
{{ $t('common.loading') }}
+
+
+
+
+
+
{{ $t('admin.billing.currentPlan') }}
+
+
{{ $t('admin.billing.planName') }}: {{ upgradeData.currentPlan.name }}
+
{{ $t('admin.billing.monthlyPrice') }}: ¥{{ upgradeData.currentPlan.monthlyPrice.toFixed(2) }}
+
CPU: {{ upgradeData.currentPlan.cpu }}%
+
{{ $t('admin.billing.memoryLabel') }}: {{ upgradeData.currentPlan.memory }}MB
+
{{ $t('admin.billing.diskLabel') }}: {{ upgradeData.currentPlan.disk }}MB
+
{{ $t('admin.billing.remainingDays') }}: {{ Math.ceil(upgradeData.remainingDays) }} {{ $t('admin.billing.days') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.billing.noAvailablePlans') }}
+
+
+
+
+
+ {{ $t('admin.billing.priceDifference') }}:
+ ¥{{ upgradePriceDiff.toFixed(2) }}
+
+
+ {{ $t('admin.billing.userBalance') }}:
+ ¥{{ upgradeData.userBalance.toFixed(2) }}
+
+
+ ⚠️ {{ $t('admin.billing.insufficientBalance') }}
+
+
+ {{ $t('admin.billing.priceDifferenceHint') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/BroadcastView.vue b/client/src/views/admin/BroadcastView.vue
new file mode 100644
index 0000000..fe8619d
--- /dev/null
+++ b/client/src/views/admin/BroadcastView.vue
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+
+ {{ t('admin.broadcast.title') }}
+
+
+ {{ t('admin.broadcast.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.broadcast.hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.broadcast.history') }}
+
({{ historyTotal }})
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.broadcast.noHistory') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t(`admin.broadcast.types.${item.type}`) }}
+
+
{{ item.title }}
+
+
{{ item.content }}
+
+
+ {{ t('admin.broadcast.recipients', { count: item.recipientCount }) }}
+ {{ d(new Date(item.createdAt), 'short') }}
+
+
+
+
+
+
+
+
+ {{ t('common.previous') }}
+
+
+ {{ historyPage }} / {{ historyTotalPages }}
+
+
+ {{ t('common.next') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(`admin.broadcast.types.${selectedAnnouncement.type}`) }}
+
+
{{ selectedAnnouncement.title }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.broadcast.sender') }}: {{ selectedAnnouncement.sender.username }}
+ {{ t('admin.broadcast.recipients', { count: selectedAnnouncement.recipientCount }) }}
+ {{ d(new Date(selectedAnnouncement.createdAt), 'long') }}
+
+
+
{{ selectedAnnouncement.content }}
+
+
+
+
+
+
+ {{ t('common.close') }}
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/EntertainmentView.vue b/client/src/views/admin/EntertainmentView.vue
new file mode 100644
index 0000000..0b71b89
--- /dev/null
+++ b/client/src/views/admin/EntertainmentView.vue
@@ -0,0 +1,1637 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.admin.tabs.lotteries') }}
+
+
+ {{ $t('entertainment.admin.tabs.records') }}
+
+
+ {{ $t('entertainment.admin.tabs.users') }}
+
+
+ {{ $t('entertainment.admin.tabs.badges') }}
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('entertainment.admin.noLotteries') }}
+
+
+
+
+
+ | {{ $t('entertainment.admin.lotteryName') }} |
+ {{ $t('entertainment.admin.costPoints') }} |
+ {{ $t('entertainment.admin.prizes') }} |
+ {{ $t('entertainment.admin.totalDraws') }} |
+ {{ $t('entertainment.admin.status') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+ |
+ {{ lottery.name }}
+
+ {{ lottery.description }}
+
+ |
+ {{ lottery.costPoints }} |
+ {{ lottery.prizesCount || lottery.prizes?.length || 0 }} |
+ {{ lottery.totalDraws }} |
+
+
+ {{ lottery.isActive ? $t('entertainment.admin.active') : $t('entertainment.admin.inactive') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.confirm') }}
+
+
+ {{ $t('common.cancel') }}
+
+
+
+ |
+
+
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ lotteriesPage }} / {{ lotteriesTotalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.search') }}
+
+
+
+
+ {{ $t('entertainment.admin.prizeType') }}:
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('entertainment.admin.noRecords') }}
+
+
+
+
+
+ | {{ $t('entertainment.admin.user') }} |
+ {{ $t('entertainment.admin.lotteryName') }} |
+ {{ $t('entertainment.admin.prize') }} |
+ {{ $t('entertainment.admin.prizeType') }} |
+ {{ $t('entertainment.admin.value') }} |
+ {{ $t('entertainment.time') }} |
+
+
+
+
+ | {{ rec.username || rec.userId }} |
+ {{ rec.lotteryName || '-' }} |
+ {{ rec.prizeName || '-' }} |
+
+ {{ getPrizeTypeName(rec.prizeType) }}
+ |
+
+ +{{ rec.prizeValue }}
+ +¥{{ (rec.prizeValue / 100).toFixed(2) }}
+ {{ rec.prizeName || $t('entertainment.prizeTypes.badge') }}
+ {{ rec.instanceDesc || $t('entertainment.wonInstance') }}
+ +{{ rec.prizeValue }}%
+ +{{ rec.prizeValue }}MB
+ +{{ rec.prizeValue }}MB
+ +{{ rec.prizeValue }}GB
+ -
+ |
+ {{ formatDate(rec.createdAt) }} |
+
+
+
+
+
+
+
+
+ {{ $t('common.perPage') }}:
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ recordsPage }} / {{ recordsTotalPages || 1 }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+ {{ $t('entertainment.admin.noUsers') }}
+
+
+
+
+
+ | {{ $t('entertainment.admin.user') }} |
+ {{ $t('entertainment.admin.currentPoints') }} |
+ {{ $t('entertainment.admin.totalEarned') }} |
+ {{ $t('entertainment.admin.totalSpent') }} |
+ {{ $t('entertainment.admin.lastConvertedAt') }} |
+
+
+
+
+ | {{ user.username || user.userId }} |
+ {{ user.points?.toLocaleString() }} |
+ +{{ user.totalEarned?.toLocaleString() }} |
+ -{{ user.totalSpent?.toLocaleString() }} |
+
+ {{ user.lastConvertedAt ? formatDate(user.lastConvertedAt) : '-' }}
+ |
+
+
+
+
+
+
+
+ {{ $t('common.prevPage') }}
+
+ {{ usersPage }} / {{ usersTotalPages }}
+
+ {{ $t('common.nextPage') }}
+
+
+
+
+
+
+
+
+ {{ $t('common.loading') }}...
+
+
+
+
+
+
+
{{ $t('entertainment.admin.badgeCatalog.series.title') }}
+
{{ $t('entertainment.admin.badgeCatalog.series.description') }}
+
+
{{ $t('entertainment.admin.badgeCatalog.series.add') }}
+
+
+
+ {{ $t('entertainment.admin.badgeCatalog.series.all') }}
+ {{ badges.length }}
+
+
+
+
+
+ {{ series.nameZh }}
+
+ {{ series.isActive ? $t('common.active') : $t('common.inactive') }}
+
+
+ {{ series.title }}
+
+ {{ $t('entertainment.admin.badgeCatalog.series.enabledCount', { active: series.activeBadgeCount || 0, total: series.badgeCount || 0 }) }}
+
+
+
+ {{ $t('common.edit') }}
+
+ {{ $t('common.confirm') }}
+ {{ $t('common.cancel') }}
+
+ {{ $t('common.delete') }}
+
+
+
+
+ {{ $t('entertainment.admin.badgeCatalog.series.empty') }}
+
+
+
+
+
+
+
+
{{ $t('entertainment.admin.badgeCatalog.badges.title') }}
+
+ {{ $t('entertainment.admin.badgeCatalog.badges.currentFilter', { name: selectedBadgeSeriesId === 'all' ? $t('entertainment.admin.badgeCatalog.series.all') : getSeriesTitle(selectedBadgeSeriesId) }) }}
+
+
+
+ {{ $t('entertainment.admin.badgeCatalog.addBadge') }}
+
+
+
+ {{ $t('entertainment.admin.badgeCatalog.badges.empty') }}
+
+
+
+
+
+ | {{ $t('entertainment.admin.badgeCatalog.badges.tableBadge') }} |
+ {{ $t('entertainment.admin.badgeCatalog.badges.tableSeries') }} |
+ {{ $t('entertainment.admin.badgeCatalog.badges.tableAssetUrl') }} |
+ {{ $t('entertainment.admin.badgeCatalog.badges.tableStatus') }} |
+ {{ $t('entertainment.admin.badgeCatalog.badges.tableUsage') }} |
+ {{ $t('common.actions') }} |
+
+
+
+
+
+
+ ![]()
+
+ {{ badge.name }}
+ {{ badge.fullLabel }}
+ {{ badge.id }}
+
+
+ |
+ {{ badge.seriesNameZh || badge.seriesTitle }} |
+ {{ badge.assetUrl }} |
+
+
+ {{ badge.isActive && badge.seriesIsActive ? $t('entertainment.admin.badgeCatalog.badges.drawable') : $t('entertainment.admin.badgeCatalog.badges.notDrawable') }}
+
+ |
+
+ {{ $t('entertainment.admin.badgeCatalog.badges.usage', { ownership: badge.ownershipCount || 0, avatar: badge.avatarUseCount || 0, instance: badge.instanceUseCount || 0 }) }}
+ |
+
+
+ {{ $t('common.edit') }}
+
+ {{ $t('common.confirm') }}
+ {{ $t('common.cancel') }}
+
+ {{ $t('common.delete') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingLottery ? $t('entertainment.admin.editLottery') : $t('entertainment.admin.createLottery') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+
+ {{ savingLottery ? $t('common.saving') : $t('common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.admin.managePrizes') }} - {{ editingPrizes?.name }}
+
+
+
+ {{ $t('entertainment.admin.addPrize') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.delete') }}
+
+
+
+
+
+ {{ $t('entertainment.admin.noPrizes') }}
+
+
+
+
+ {{ $t('common.cancel') }}
+
+ {{ savingPrizes ? $t('common.saving') : $t('common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('entertainment.admin.notification.title') }} - {{ editingNotification?.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('common.cancel') }}
+
+ {{ savingNotification ? $t('common.saving') : $t('common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingSeries ? $t('entertainment.admin.badgeCatalog.series.editTitle') : $t('entertainment.admin.badgeCatalog.series.createTitle') }}
+
+
+
+ {{ $t('common.cancel') }}
+
+ {{ savingSeries ? $t('common.saving') : $t('common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingBadge ? $t('entertainment.admin.badgeCatalog.badges.editTitle') : $t('entertainment.admin.badgeCatalog.badges.createTitle') }}
+
+
+
+
{{ $t('entertainment.admin.badgeCatalog.badges.preview') }}
+
+
![]()
+
+
{{ badgeForm.name || $t('entertainment.admin.badgeCatalog.badges.previewName') }}
+
{{ badgeForm.fullLabel || $t('entertainment.admin.badgeCatalog.badges.previewLabel') }}
+
{{ badgeForm.id || 'badge-id' }}
+
+
+
+
+ {{ $t('common.cancel') }}
+
+ {{ savingBadge ? $t('common.saving') : $t('common.save') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/HelpManageView.vue b/client/src/views/admin/HelpManageView.vue
new file mode 100644
index 0000000..bc05fbd
--- /dev/null
+++ b/client/src/views/admin/HelpManageView.vue
@@ -0,0 +1,1125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ cat.name }}
+
{{ cat.id }}
+
+
+
+ {{ t('common.edit') }}
+ {{ t('common.delete') }}
+
+
+
+
+
+
+ {{ t('admin.helpManage.noCategories') }}
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
{{ t('admin.helpManage.noArticles') }}
+
{{ t('admin.helpManage.createFirst') }}
+
+
+
+
+
+
+
+
+ | {{ t('admin.helpManage.articleTitleCol') }} |
+ {{ t('admin.helpManage.categoryCol') }} |
+ {{ t('admin.helpManage.statusCol') }} |
+ {{ t('admin.helpManage.updatedAtCol') }} |
+ {{ t('admin.helpManage.actionsCol') }} |
+
+
+
+
+ |
+ {{ article.title }}
+ /help/{{ article.slug }}
+ |
+
+
+
+ {{ getCategoryLabel(article.category) }}
+
+ |
+
+
+ {{ article.published ? t('admin.helpManage.published') : t('admin.helpManage.draft') }}
+
+ |
+ {{ formatDate(article.updated_at) }} |
+
+
+ {{ t('common.edit') }}
+
+ {{ article.published ? t('admin.helpManage.hide') : t('admin.helpManage.publish') }}
+
+ {{ t('common.delete') }}
+
+ |
+
+
+
+
+
+
+
+
+
{{ t('admin.helpManage.totalArticles', { count: total }) }}
+
+ {{ t('admin.helpManage.prevPage') }}
+ {{ page }} / {{ totalPages }}
+ {{ t('admin.helpManage.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editorMode === 'create' ? t('admin.helpManage.createArticle') : t('admin.helpManage.editArticle') }}
+
+
+
+ {{ previewMode ? t('common.edit') : t('admin.helpManage.preview') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.helpManage.urlSlugHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ showMarkdownHelp ? t('admin.helpManage.hideMarkdownHelp') : t('admin.helpManage.showMarkdownHelp') }}
+
+
+
+
+
+
+
# Title {{ t('admin.helpManage.markdownHeading1') }}
+
## Title {{ t('admin.helpManage.markdownHeading2') }}
+
**bold** {{ t('admin.helpManage.markdownBold') }}
+
*italic* {{ t('admin.helpManage.markdownItalic') }}
+
[link](url) {{ t('admin.helpManage.markdownLink') }}
+
 {{ t('admin.helpManage.markdownImage') }}
+
- item {{ t('admin.helpManage.markdownUnorderedList') }}
+
1. item {{ t('admin.helpManage.markdownOrderedList') }}
+
`code` {{ t('admin.helpManage.markdownInlineCode') }}
+
```code``` {{ t('admin.helpManage.markdownCodeBlock') }}
+
> quote {{ t('admin.helpManage.markdownQuote') }}
+
--- {{ t('admin.helpManage.markdownHr') }}
+
+
+
{{ t('admin.helpManage.customAlerts') }}
+
+
?{info}[text] {{ t('admin.helpManage.alertInfo') }}
+
?{success}[text] {{ t('admin.helpManage.alertSuccess') }}
+
?{warning}[text] {{ t('admin.helpManage.alertWarning') }}
+
?{danger}[text] {{ t('admin.helpManage.alertDanger') }}
+
?{note}[text] {{ t('admin.helpManage.alertNote') }}
+
+
+
+
+
+
+
+
{{ formError }}
+
+
+
+
+
+
{{ form.title || t('admin.helpManage.noTitle') }}
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+ {{ formLoading ? t('common.loading') : t('common.save') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ categoryMode === 'create' ? t('admin.helpManage.addCategory') : t('common.edit') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.helpManage.categoryIdHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ categoryError }}
+
+
+
+ {{ t('common.cancel') }}
+ {{ t('common.save') }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/HostingView.vue b/client/src/views/admin/HostingView.vue
new file mode 100644
index 0000000..cdcae4c
--- /dev/null
+++ b/client/src/views/admin/HostingView.vue
@@ -0,0 +1,635 @@
+
+
+
+
+
+
+
+
+ {{ t('admin.hosting.tabs.owners') }}
+
+
+ {{ t('admin.hosting.tabs.zones') }}
+
+
+ {{ t('admin.hosting.tabs.hostingVipLevels') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ card.label }}
+
{{ card.value }}
+
{{ card.caption }}
+
+
+
+
+
+
+
{{ t('admin.hosting.owners.title') }}
+
{{ t('admin.hosting.owners.description') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosting.owners.empty') }}
+
{{ t('admin.hosting.owners.emptyHint') }}
+
+
+
+
+
+
+ | {{ t('admin.hosting.owners.user') }} |
+
+
+ {{ t(column.labelKey) }}
+
+ {{ sortOrder === 'asc' ? '↑' : '↓' }}
+ ↕
+
+
+ |
+ {{ t('admin.hosting.owners.createdAt') }} |
+
+
+
+
+
+
+
+
+
+ {{ owner.username }}
+ #{{ owner.id }}
+
+ {{ owner.email || t('admin.hosting.owners.noEmail') }}
+
+
+ |
+
+
+ VIP {{ owner.vipLevel }}
+
+ |
+
+ {{ formatMoney(owner.hostingBalance.available) }}
+ |
+
+ {{ formatMoney(owner.hostingBalance.frozen) }}
+ |
+
+ {{ formatMoney(owner.hostingBalance.historicalTotal) }}
+ |
+ {{ numberFormatter.format(owner.hostCount) }} |
+ {{ numberFormatter.format(owner.listedPackageCount) }} |
+ {{ numberFormatter.format(owner.instanceCount) }} |
+ {{ formatDate(owner.createdAt) }} |
+
+
+
+
+
+
+
+ {{ t('common.total') }} {{ numberFormatter.format(total) }} {{ t('common.items') }}
+
+
+
+ {{ t('common.prevPage') }}
+
+ {{ page }} / {{ totalPages }}
+
+ {{ t('common.nextPage') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosting.zones.createTitle') }}
+
{{ t('admin.hosting.zones.createDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosting.zones.logoHint') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.hosting.zones.ownerHint') }}
+
+
+
+
+
+
+
![]()
+
{{ t('admin.hosting.zones.noLogo') }}
+
+
+
+ {{ zoneForm.name || t('admin.hosting.zones.previewName') }}
+
+
{{ t('admin.hosting.zones.previewHint') }}
+
+
+
+ {{ creatingZone ? t('common.processing') : t('admin.hosting.zones.create') }}
+
+
+
+
+
+
+
+
{{ t('admin.hosting.zones.title') }}
+
{{ t('admin.hosting.zones.description') }}
+
+
+
+
+
+
+
+
{{ t('admin.hosting.zones.empty') }}
+
{{ t('admin.hosting.zones.emptyHint') }}
+
+
+
+
+
+
+ | {{ t('admin.hosting.zones.zone') }} |
+ {{ t('admin.hosting.zones.owner') }} |
+ {{ t('admin.hosting.owners.hostCount') }} |
+ {{ t('admin.hosting.owners.packageCount') }} |
+ {{ t('admin.hosting.owners.createdAt') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+
+
+ ![]()
+
+ {{ zone.name }}
+ #{{ zone.id }}
+
+
+ |
+
+
+
+
+ {{ zone.owner.username }} #{{ zone.owner.id }}
+ {{ zone.owner.email || t('admin.hosting.owners.noEmail') }}
+
+
+ |
+ {{ numberFormatter.format(zone.hostCount) }} |
+ {{ numberFormatter.format(zone.listedPackageCount) }} |
+ {{ formatDate(zone.createdAt) }} |
+
+
+ {{ deletingZoneId === zone.id ? t('common.processing') : t('common.delete') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/ImagesView.vue b/client/src/views/admin/ImagesView.vue
new file mode 100644
index 0000000..9df1e5d
--- /dev/null
+++ b/client/src/views/admin/ImagesView.vue
@@ -0,0 +1,449 @@
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
+ {{ tab.labelKey ? t(tab.labelKey) : tab.label }}
+
+ {{ getArchitectureCount(tab.value) }}
+
+
+
+
+
+
+
+
+
+ | {{ t('admin.images.fields.icon') }} |
+ {{ t('admin.images.fields.name') }} |
+ {{ t('admin.images.fields.remoteAlias') }} |
+ {{ t('admin.images.fields.architecture') }} |
+ {{ t('admin.images.fields.instanceType') }} |
+ {{ t('admin.images.fields.sortOrder') }} |
+ {{ t('admin.images.fields.status') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+ |
+
+ |
+ {{ image.name }} |
+ {{ image.remoteAlias }} |
+
+
+ {{ image.architecture }}
+
+ |
+
+
+ {{ image.instanceType === 'container' ? t('admin.images.typeContainer') : image.instanceType === 'vm' ? t('admin.images.typeVm') : t('admin.images.typeBoth') }}
+
+ |
+ {{ image.sortOrder }} |
+
+
+ {{ image.hidden ? t('admin.images.statusHidden') : t('admin.images.statusVisible') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ {{ images.length === 0 ? t('admin.images.noImages') : t('admin.images.noImagesForArchitecture') }}
+
+
+
+
+
+
+
+
+
+
+
{{ modalTitle }}
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/OAuthConfigView.vue b/client/src/views/admin/OAuthConfigView.vue
new file mode 100644
index 0000000..4d1f87a
--- /dev/null
+++ b/client/src/views/admin/OAuthConfigView.vue
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ providerInfo[provider]?.name }}
+
+ {{ config.configured ? (config.enabled ? t('admin.oauth.enabled') : t('admin.oauth.disabled')) : '-' }}
+
+
+
+
+
+
+ {{ config.configured ? t('admin.oauth.edit') : t('common.create') }}
+
+
+ {{ t('common.delete') }}
+
+
+
+
+
+
+ Client ID
+ {{ config.clientId }}
+
+
+ Client Secret
+ {{ config.clientSecret }}
+
+
+
+
+
+ {{ t('admin.oauth.notConfigured') }}
+
+ {{ providerInfo[provider]?.name }} {{ t('admin.oauth.developerConsole') }}
+
+ {{ t('admin.oauth.createOAuthApp') }}
+
+
+
+
+
+
{{ t('admin.oauth.callbackUrl') }}
+
+ {{ callbackUrl }}{{ providerInfo[provider]?.callbackPath }}
+
+
+
+
+
+
+
+
{{ t('admin.oauth.usageGuide') }}
+
+
{{ t('admin.oauth.step1') }}
+
{{ t('admin.oauth.step2') }}
+
{{ t('admin.oauth.step3') }}
+
{{ t('admin.oauth.step4') }}
+
⚠️ {{ t('admin.oauth.warning') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.oauth.configure') }} {{ editProvider ? providerInfo[editProvider]?.name : '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/PaymentProvidersView.vue b/client/src/views/admin/PaymentProvidersView.vue
new file mode 100644
index 0000000..7ae1934
--- /dev/null
+++ b/client/src/views/admin/PaymentProvidersView.vue
@@ -0,0 +1,744 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ provider.name }}
+
+ {{ getStatusInfo(provider.status).label }}
+
+ {{ getTypeName(provider.type) }}
+
+
+
+
+ {{ $t('admin.paymentProviders.minAmount') }}:
+ ¥{{ provider.minAmount }}
+
+
+ {{ $t('admin.paymentProviders.maxAmount') }}:
+ {{ provider.maxAmount ? `¥${provider.maxAmount}` : $t('common.unlimited') }}
+
+
+ {{ $t('admin.paymentProviders.feeRate') }}:
+ {{ (provider.feeRate * 100).toFixed(2) }}%
+
+
+ {{ $t('admin.paymentProviders.feeFixed') }}:
+ ¥{{ provider.feeFixed }}
+
+
+
+
+ {{ $t('admin.paymentProviders.methods') }}: {{ getProviderMethodSummary(provider) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.empty') }}
+
+ {{ $t('admin.paymentProviders.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ (formData.config as any).version === 'v1'
+ ? $t('admin.paymentProviders.config.yipayVersionV1Hint')
+ : $t('admin.paymentProviders.config.yipayVersionV2Hint')
+ }}
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.config.yipayApiUrlHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.config.yipayKeyHint') }}
+
+
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.config.platformPublicKeyHint') }}
+
+
+
+
+
{{ $t('admin.paymentProviders.config.merchantPrivateKeyHint') }}
+
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.config.yipayMethodsHint') }}
+
{{ $t('admin.paymentProviders.config.yipayMethodFeeHint') }}
+
+
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.config.heleketApiUrlHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('admin.paymentProviders.config.heleketCurrencyHint') }}
+
+
+
+
+
{{ $t('admin.paymentProviders.config.heleketLifetimeHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.paymentProviders.config.yipayFeeFieldHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.paymentProviders.deleteWarning', { name: deleteTarget?.name }) }}
+
+
+
+
+
+
+
+
diff --git a/client/src/views/admin/StatisticsView.vue b/client/src/views/admin/StatisticsView.vue
new file mode 100644
index 0000000..bbc385f
--- /dev/null
+++ b/client/src/views/admin/StatisticsView.vue
@@ -0,0 +1,679 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(tab.labelKey) }}
+
+
+
+
+
+
+
+
+
{{ card.label }}
+
{{ formatValue(card.value, card.type) }}
+
{{ card.caption }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.statistics.sections.newUsers') }}
+
+ {{ userPeriod === 'daily' ? t('admin.statistics.ranges.last30Days') : t('admin.statistics.ranges.last12Months') }}
+
+
+
+
+ {{ t(period.labelKey) }}
+
+
+
+
+
+
+
+
{{ t('admin.statistics.tooltip', { label: point.label, value: formatValue(point.value, 'number') }) }}
+
+
+
{{ shouldShowTick(index, userSeries.length) ? formatTick(point.label) : '' }}
+
+
+
+
+
+
+
+
+
+
+
{{ card.label }}
+
{{ formatValue(card.value, card.type) }}
+
{{ card.caption }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.statistics.sections.createdInstances') }}
+
+ {{ instancePeriod === 'daily' ? t('admin.statistics.ranges.last30Days') : t('admin.statistics.ranges.last12Months') }}
+
+
+
+
+ {{ t(period.labelKey) }}
+
+
+
+
+
+
+
+
{{ t('admin.statistics.tooltip', { label: point.label, value: formatValue(point.value, 'number') }) }}
+
+
+
{{ shouldShowTick(index, instanceSeries.length) ? formatTick(point.label) : '' }}
+
+
+
+
+
+
{{ t('admin.statistics.sections.paidFreeInstances') }}
+
{{ t('admin.statistics.sections.paidFreeDescription') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.statistics.labels.paidInstances') }}
+
+
+
{{ formatValue(stats.instances.paid, 'number') }}
+
{{ paidPercent }}%
+
+
+
+
+
+
+ {{ t('admin.statistics.labels.freeInstances') }}
+
+
+
{{ formatValue(stats.instances.free, 'number') }}
+
{{ freePercent }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ card.label }}
+
{{ formatCompactValue(card.value, card.type) }}
+
{{ card.caption }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.statistics.sections.metricTrend', { metric: t(currentBillingMetric.labelKey) }) }}
+
+
+ {{ t('admin.statistics.sections.billingScope', {
+ range: billingPeriod === 'daily' ? t('admin.statistics.ranges.last30Days') : t('admin.statistics.ranges.last12Months')
+ }) }}
+
+
+
+
+
+
+ {{ t(metric.labelKey) }}
+
+
+
+
+
+ {{ t(period.labelKey) }}
+
+
+
+
+
+
+
+
+
{{ t('admin.statistics.tooltip', { label: point.label, value: formatCompactValue(point.value, 'money') }) }}
+
+
+
{{ shouldShowTick(index, billingSeries.length) ? formatTick(point.label) : '' }}
+
+
+
+
+
+
+
+
{{ t('admin.statistics.noData') }}
+
{{ t('admin.statistics.reload') }}
+
+
+
+
+
diff --git a/client/src/views/admin/SystemConfigView.vue b/client/src/views/admin/SystemConfigView.vue
new file mode 100644
index 0000000..1ba7895
--- /dev/null
+++ b/client/src/views/admin/SystemConfigView.vue
@@ -0,0 +1,1828 @@
+
+
+
+
+
+
+
+
+ {{ t(item.labelKey) }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.popupAnnouncement.title') }}
+
{{ t('admin.system.popupAnnouncement.description') }}
+
+
+ {{ savingPopupAnnouncement ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.popupAnnouncement.hint') }}
+
+ {{ form.popup_announcement.length }}/5000
+
+
+
+
+
+
+
+
+
{{ t('admin.system.popupAnnouncement.promoTitle') }}
+
{{ t('admin.system.popupAnnouncement.promoDescription') }}
+
+
+ {{ savingPopupPromo ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.popupAnnouncement.promoImageHint') }}
+
+ {{ form.popup_promo_image_url.length }}/1000
+
+
+
+
+
+
+
+
{{ t('admin.system.popupAnnouncement.promoPackageHint') }}
+
+
+
+
+
+
![]()
+
+ {{ t('admin.system.popupAnnouncement.promoPreviewEmpty') }}
+
+
+
+
{{ t('admin.system.popupAnnouncement.promoPreview') }}
+
+ {{ selectedPopupPromoPackage?.name || t('admin.system.popupAnnouncement.promoNoPackage') }}
+
+
+ {{ t('popupAnnouncement.buyNow', { name: selectedPopupPromoPackage?.name || t('admin.system.popupAnnouncement.promoPackageFallback') }) }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.registration') }}
+
+ {{ savingRegistration ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.registrationDesc') }}
+
+
+
+
+
{{ t('admin.system.registrationEnabledDesc') }}
+
+
+
+ {{ t('admin.system.registrationClosed') }}
+
+
+
+
+
+ {{ t('admin.system.registrationOpen') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.requireInviteCodeDesc') }}
+
+
+
+ {{ t('admin.system.openRegistration') }}
+
+
+
+
+
+ {{ t('admin.system.inviteOnly') }}
+
+
+
+
+
+
+
+
邀请码生成定价
+
+ {{ savingInvitePricing ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
配置普通用户生成一个邀请码需要消耗的资源。当前支持余额和积分,价格保存为可扩展的成本项列表。
+
+
+
+
+
+
+
+ {{ option.resource === 'balance' ? '用户每生成 1 个邀请码扣除账户余额' : '用户每生成 1 个邀请码扣除积分' }}
+
+
+
+
+
+
+
+
+
+
+ ¥
+
+
+
+
+
+
+
+
+
+
+ 天
+
+
0 表示用户生成的邀请码永不过期。
+
+
+
+
+
+
{{ t('admin.system.hostingFeature.title') }}
+
+ {{ savingHostingFeature ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.hostingFeature.description') }}
+
+
+
+
+
+
{{ t('admin.system.hostingFeature.enableDesc') }}
+
+
+
+ {{ t('admin.system.hostingFeature.hiddenForNewUsers') }}
+
+
+
+
+
+ {{ t('admin.system.hostingFeature.visibleToAll') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.hostingFeature.marketEntryDesc') }}
+
+
+
+ {{ t('admin.system.hostingFeature.marketEntryHidden') }}
+
+
+
+
+
+ {{ t('admin.system.hostingFeature.marketEntryVisible') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.hostingFeature.noticeHint') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.transfer.title') }}
+
+ {{ savingTransfer ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.transfer.description') }}
+
+
+
+
+ ¥
+
+
+ {{ t('admin.system.transfer.feeUnit') }}
+
+
+
{{ t('admin.system.transfer.feeDesc') }}
+
+
+
+
+
+
+
{{ t('admin.system.footerLinks.title') || '底部联系方式' }}
+
+ {{ savingFooterLinks ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.footerLinks.description') || '配置侧边栏底部的邮箱按钮' }}
+
+
+
+
+
+
{{ t('admin.system.footerLinks.emailDesc') || '留空则隐藏邮箱按钮;支持填写邮箱地址或完整 mailto: 链接。' }}
+
+
+
+
+
+
+
+
{{ t('admin.system.brand.title') || '品牌设置' }}
+
+ {{ savingBrand ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.brand.description') || '配置站点顶部、登录页、SEO 等位置使用的系统名称与 Logo。留空则使用默认值。' }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.brand.subtitleDesc') || '显示在公开站头部/底部、浏览器默认标题和 SEO 默认描述中。' }}
+
+
+
+
+
{{ t('admin.system.brand.logoDesc') || '支持 http(s) 图片地址或站点内绝对路径。' }}
+
+
+
+
+
+
+
+
{{ t('admin.system.freeSite.title') }}
+
+ {{ savingFreeSite ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.freeSite.description') }}
+
+
+
+
+
+
{{ t('admin.system.freeSite.enableDesc') }}
+
+
+
+ {{ t('admin.system.freeSite.disabled') }}
+
+
+
+
+
+ {{ t('admin.system.freeSite.enabled') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.system.freeSite.registerGiftDesc') }}
+
+
+
+ {{ t('admin.system.freeSite.giftDisabled') }}
+
+
+
+
+
+ {{ t('admin.system.freeSite.giftEnabled') }}
+
+
+
+
+
+
+
+
+ ¥
+
+
+
{{ t('admin.system.freeSite.giftBalanceDesc') }}
+
+
+
+
+
+
{{ t('admin.system.freeSite.giftPointsDesc') }}
+
+
+
+
+ {{ t('admin.system.freeSite.giftRequiresFreeSite') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.system.ticket.title') }}
+
+ {{ savingTicket ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.ticket.description') }}
+
+
+
+
+
{{ t('admin.system.ticket.enableDesc') }}
+
+
+
+ {{ t('admin.system.ticket.disabled') }}
+
+
+
+
+
+ {{ t('admin.system.ticket.enabled') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.system.ticketImages.title') }}
+
+ {{ savingTicketImages ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.ticketImages.description') }}
+
+
+
+
+
+
{{ t('admin.system.ticketImages.baseUrlDesc') }}
+
+
+
+
+
{{ t('admin.system.ticketImages.tokenDesc') }}
+
+
+
+
+
{{ t('admin.system.ticketImages.apiVersionDesc') }}
+
+
+
+
+
{{ t('admin.system.ticketImages.targetIdDesc') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.defaultQuota') }}
+
+ {{ savingQuota ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.defaultQuotaDesc') }}
+
+
+
+
+
+
+
+ {{ meta.unit }}
+
+
+
{{ meta.description }}
+
+
+
+
+
+
+
+
{{ t('admin.system.turnstile.title') }}
+
+ {{ savingTurnstile ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.turnstile.description') }}
+
+
+
+
+
+
{{ t('admin.system.turnstile.enableDesc') }}
+
+
+
+ {{ t('admin.system.turnstile.disabled') }}
+
+
+
+
+
+ {{ t('admin.system.turnstile.enabled') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.turnstile.siteKeyDesc') }}
+
+
+
+
+
{{ t('admin.system.turnstile.secretKeyDesc') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.avatar.title') }}
+
+ {{ savingAvatar ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.avatar.description') }}
+
+
+
+
+
{{ t('admin.system.avatar.apiBaseDesc') }}
+
+
+
+
+
+
+
+
+
+
{{ t('admin.system.smtp.title') }}
+
+ {{ savingSmtp ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.smtp.description') }}
+
+
+
+
+
+
{{ t('admin.system.smtp.enableDesc') }}
+
+
+
+ {{ t('admin.system.smtp.disabled') }}
+
+
+
+
+
+ {{ t('admin.system.smtp.enabled') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.system.smtp.secureHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ testingSmtp ? t('admin.system.smtp.testing') : t('admin.system.smtp.testConnection') }}
+
+
+
+
+
+
+
+ {{ t('admin.system.smtp.sendTestEmailDesc') }}
+
+
+
+
+ {{ sendingTestEmail ? t('admin.system.smtp.sending') : t('admin.system.smtp.send') }}
+
+
+
+
+
+
+
+
+ 💡 {{ t('admin.system.smtp.helpText') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.emailDomain.title') }}
+
+ {{ savingEmailDomain ? t('admin.system.saving') : t('admin.system.save') }}
+
+
+
{{ t('admin.system.emailDomain.description') }}
+
+
+
+
+
+
{{ t('admin.system.emailDomain.enableDesc') }}
+
+
+
+ {{ t('admin.system.emailDomain.disabled') }}
+
+
+
+
+
+ {{ t('admin.system.emailDomain.enabled') }}
+
+
+
+
+
+
+
+
+
{{ t('admin.system.emailDomain.allowedDomainsDesc') }}
+
+
+
+
+
+ 💡 {{ t('admin.system.emailDomain.helpText') }}
+
+
+
+
+
+
+
+
{{ t('admin.system.notes') }}
+
+
• {{ t('admin.system.note1') }}
+
• {{ t('admin.system.note2') }}
+
• {{ t('admin.system.note3') }}
+
• {{ t('admin.system.note4') }}
+
+
+
+
diff --git a/client/src/views/admin/TelegramConfigView.vue b/client/src/views/admin/TelegramConfigView.vue
new file mode 100644
index 0000000..3fcd933
--- /dev/null
+++ b/client/src/views/admin/TelegramConfigView.vue
@@ -0,0 +1,1261 @@
+
+
+
+
+
+
+
+
+ {{ t(item.labelKey) }}
+
+
+
+
+
+
+
+
+
站点 Telegram 入口
+
+ {{ savingFooterTelegramLink ? '保存中...' : '保存' }}
+
+
+
+ 配置侧边栏底部 Telegram 群按钮。留空后隐藏该按钮。
+
+
+
+
+
+ 这是网站展示入口,不影响机器人绑定、Webhook 或入群申请逻辑。
+
+
+
+
+
+
+
Telegram 专用机器人
+
+ {{ savingTelegramBot ? '保存中...' : '保存' }}
+
+
+
+ 配置网站配套 Bot,用于用户 Telegram 账号绑定和后续私有群准入。
+
+
+
+
+
+
+ 关闭后用户个人设置页会显示未启用,不允许生成绑定链接。
+
+
+
+
+ 关闭
+
+
+
+
+
+ 启用
+
+
+
+
+
+
+
+
+
不需要填写 @,用于生成 https://t.me/... 绑定链接。
+
+
+
+
+
由 BotFather 分配,保存后只会显示为占位符。
+
+
+
+
+
+
+ 自动生成 Secret
+
+
+
+ {{ telegramWebhookSecretError }}
+
+
+ 必填。自动生成会覆盖当前输入,保存配置后生效;也可用 openssl rand -hex 32 手动生成。
+
+
+
+
+
+
+ Webhook 地址:{{ telegramWebhookUrl }}
+
+
+ 按当前访问域名动态生成;点击“设置 Webhook”时会使用该地址。
+
+
+
+
+
+
+
Webhook 管理
+
+ 保存 Bot Token 和 Webhook Secret 后,可由系统自动设置回调并同步机器人指令菜单。
+
+
+
+
+ {{ settingTelegramWebhook ? '设置中' : '设置 Webhook' }}
+
+ checkTelegramWebhook()"
+ >
+ {{ checkingTelegramWebhook ? '检查中' : '检查 Webhook' }}
+
+
+ {{ deletingTelegramWebhook ? '删除中' : '删除 Webhook' }}
+
+
+
+
+
+
+ 当前地址:
+ {{ telegramWebhookInfo.url || '未设置' }}
+
+
待处理更新:{{ telegramWebhookInfo.pending_update_count ?? 0 }}
+
+ 最大连接数:{{ telegramWebhookInfo.max_connections }}
+
+
+ 允许更新:{{ telegramWebhookInfo.allowed_updates.join(', ') }}
+
+
+ 最近错误:{{ telegramWebhookInfo.last_error_message }}
+
+ ({{ formatTelegramWebhookDate(telegramWebhookInfo.last_error_date) }})
+
+
+
+
+
+
+
+
+
普通用户群入群申请
+
+ 用户绑定 Telegram 后,私聊机器人发送 /join。达标时机器人返回普通群一次性邀请链接。
+
+
+
+
+ 关闭
+
+
+
+
+
+ 启用
+
+
+
+
+
+
+
+
+
+ Bot 必须是该私有群管理员,并拥有创建邀请链接权限。
+
+
+
+
+
+
+ 门槛为 0 表示该项不参与判断。
+
+
+
+
+
+
+
+
+
+ 分钟
+
+
+
+ 每次申请生成一个 member_limit=1 的一次性邀请链接。
+
+
+
+
+
+
+
+
+
高级用户群入群申请
+
+ 用户绑定 Telegram 后,私聊机器人发送 /join_vip。达标时机器人返回高级群一次性邀请链接。
+
+
+
+
+ 关闭
+
+
+
+
+
+ 启用
+
+
+
+
+
+
+
+
+
+ 可以和普通群不同;Bot 必须是该群管理员,并拥有创建邀请链接权限。
+
+
+
+
+
+
+ 高级群默认建议使用更高门槛,门槛为 0 表示该项不参与判断。
+
+
+
+
+
+
+
+
+
+ 分钟
+
+
+
+ 每次申请生成一个 member_limit=1 的高级群一次性邀请链接。
+
+
+
+
+
+
+
+
+
+
绑定用户与入群资格
+
+ 查看已绑定 Telegram 的站内用户、累计充值/消费和当前入群资格,可人工解除异常绑定。
+
+
+
loadTelegramBindings()"
+ >
+ {{ bindingLoading ? '刷新中...' : '刷新列表' }}
+
+
+
+
+
+
+ 搜索
+
+
+ 清空
+
+
+
+
+
+
普通群门槛
+
{{ formatGroupRule(telegramBindingGroup) }}
+
+
+
高级群门槛
+
{{ formatGroupRule(telegramBindingVipGroup) }}
+
+
+
+
+ 加载中...
+
+
+ 暂无 Telegram 绑定用户。
+
+
+
+
+
+
+
+ {{ binding.user?.username || `用户 #${binding.userId}` }}
+
+
+ {{ binding.user.status === 'active' ? '正常' : '已封禁' }}
+
+ 用户 ID {{ binding.userId }}
+ Telegram {{ formatTelegramName(binding) }}
+ ID {{ binding.telegramUserId }}
+
+
+ {{ binding.user.email }}
+ 充值 ¥{{ formatMoney(binding.stats.totalRecharge) }}
+ 消费 ¥{{ formatMoney(binding.stats.totalConsume) }}
+ 绑定 {{ formatDate(binding.boundAt) }}
+
+
+
+
+ 普通:{{ eligibilityLabel(binding.eligibility.status) }}
+
+
+ 高级:{{ eligibilityLabel(binding.vipEligibility.status) }}
+
+
+ {{ unlinkingBindingId === binding.id ? '解绑中...' : '解除绑定' }}
+
+
+
+
+
+
+
+
+ 共 {{ telegramBindingsTotal }} 条,当前第 {{ telegramBindingsPage }} / {{ telegramBindingsTotalPages }} 页
+
+
+
+ 上一页
+
+
+ 下一页
+
+
+
+
+
+
+
+
全局 Telegram 通知渠道
+ + 添加渠道
+
+
创建全局 Telegram 通知渠道后,托管用户可在套餐设置中绑定,当有用户新购或销毁实例时自动发送通知。
+
+
加载中...
+
+ 暂无全局通知渠道,点击右上角「添加渠道」创建。
+
+
+
+
+
+ {{ ch.name }}
+
+ {{ ch.enabled ? '已启用' : '已停用' }}
+
+ 绑定 {{ ch.boundPackages }} 个套餐
+
+
{{ ch.configPreview }}
+
+
+
+ {{ testingChannelId === ch.id ? '发送中...' : '测试' }}
+
+ 编辑
+
+ {{ deletingChannelId === ch.id ? '删除中...' : '删除' }}
+
+
+
+
+
+
+
{{ editingChannel ? '编辑渠道' : '新建渠道' }}
+
{{ channelFormError }}
+
+
+
+
+
+
+
+
+
负数为群组/频道,正数为用户。
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+ {{ savingChannel ? '保存中...' : '保存' }}
+
+
+
+
+
+
+ 需要 Telegram Bot Token?去
+ @BotFather
+ 创建机器人,并将机器人添加为群组/频道管理员。
+
+
+
+
+
+
diff --git a/client/src/views/admin/UsersView.vue b/client/src/views/admin/UsersView.vue
new file mode 100644
index 0000000..5fab21e
--- /dev/null
+++ b/client/src/views/admin/UsersView.vue
@@ -0,0 +1,2806 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ userSearch ? t('admin.users.noMatchingUsers') : t('admin.users.noUsers') }}
+
+
+
+
+
+
+
+
+ | ID |
+ {{ t('admin.users.userInfo') }} |
+ {{ t('admin.users.role') }} |
+ {{ t('admin.users.status') }} |
+ {{ t('admin.users.balance') }} |
+ {{ t('admin.users.points') }} |
+ {{ t('admin.users.hostingBalance') }} |
+ {{ t('admin.users.allInstances') }} |
+ {{ t('admin.users.userActivity') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+
+ |
+ #{{ user.id }}
+ |
+
+
+
+
+
+ {{ user.username }}
+ {{ user.email || t('admin.users.noEmail') }}
+
+
+ |
+
+
+
+ {{ user.role === 'admin' ? t('admin.users.admin') : t('admin.users.user') }}
+
+ |
+
+
+
+ {{ user.status === 'active' ? t('admin.users.active') : t('admin.users.banned') }}
+
+ |
+
+
+
+
+ ¥{{ (user.balance || 0).toFixed(2) }}
+
+
+ {{ t('admin.users.consumed') }} ¥{{ (user.totalConsume || 0).toFixed(2) }}
+
+
+ |
+
+
+
+
+ {{ user.points || 0 }}
+
+
+ {{ t('admin.users.spent') }} {{ user.totalEarnedPoints - user.points }}
+
+
+ |
+
+
+
+
+ ¥{{ (user.hostingBalance || 0).toFixed(2) }}
+
+
+ {{ t('admin.users.frozen') }} ¥{{ (user.hostingBalanceFrozen || 0).toFixed(2) }}
+
+
+ |
+
+
+
+ {{ user.instanceCount || 0 }} {{ t('admin.users.instances') }}
+
+
+
+
+ -
+ |
+
+
+
+
+
+ {{ formatRegisteredAge(user.createdAt) }}
+
+ ·
+ {{ formatDate(user.lastLogin.createdAt) }}
+
+
+ {{ user.lastLogin.ip }}
+
+
+
+
+ {{ formatRegisteredAge(user.createdAt) }}
+
+ {{ t('admin.users.noLoginRecord') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.totalRecords', { count: userTotal }) }}
+
+
+
+
+
+
+
+
+ {{ userPage }}
+ /
+ {{ userTotalPages }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+ {{ inviteStatusFilter === 'all' ? t('admin.users.noInvites') : t('admin.users.noMatchingInvites') }}
+
+
+
+
+
+
+
+ | {{ t('admin.users.inviteCode') }} |
+ {{ t('admin.users.createdBy') }} |
+ {{ t('admin.users.inviteStatus') }} |
+ {{ t('admin.users.usedBy') }} |
+ {{ t('admin.users.createdAt') }} |
+ {{ t('admin.users.usedExpireAt') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+ | {{ invite.code }} |
+
+
+
+ {{ invite.createdByUsername }}
+
+ -
+ |
+
+ {{ getInviteStatus(invite).label }}
+ |
+
+
+
+ {{ invite.usedByUsername }}
+
+ -
+ |
+ {{ formatDate(invite.createdAt) }} |
+
+ {{ formatDate(invite.usedAt) }}
+ {{ formatDate(invite.expiresAt) }}
+ {{ t('admin.users.permanent') }}
+ |
+
+
+ {{ t('admin.users.deleteInvite') }}
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.totalRecords', { count: invitesTotal }) }}
+
+
+
+
+
+
+
+
+ {{ invitesPage }}
+ /
+ {{ invitesTotalPages }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ linkedAccountsLoading ? t('admin.users.detecting') : t('admin.users.startDetect') }}
+
+
+
+
+
+
+
{{ t('admin.users.detectingHint') }}
+
+
+
+
+
+ {{ t('admin.users.clickToDetect') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.users.detectTime') }}:
+ {{ formatDate(linkedAccountsData.detectedAt) }}
+
+
+ {{ t('admin.users.detectDuration') }}:
+ {{ linkedAccountsData.durationMs }}ms
+
+
+ {{ t('admin.users.detectRange') }}:
+ {{ linkedAccountsData.days }} {{ t('admin.users.daysUnit') }}
+
+
+
+
+ {{ linkedAccountsData.summary.ipGroups }}
+ {{ t('admin.users.ipGroupCount') }}
+
+
+ {{ linkedAccountsData.summary.emailGroups }}
+ {{ t('admin.users.emailGroupCount') }}
+
+
+ {{ linkedAccountsData.summary.usernameGroups }}
+ {{ t('admin.users.usernameGroupCount') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.users.ipLinkedGroups') }} ({{ linkedAccountsData.ipGroups.length }})
+
+
+
+
+
+ {{ group.ip }}
+ {{ group.userCount }} {{ t('admin.users.usersCount') }}
+ {{ group.totalLogins }} {{ t('admin.users.loginsCount') }}
+
+
+
+
+
+
+
+ #{{ user.id }}
+ {{ user.username }}
+ {{ user.email }}
+ {{ user.status === 'active' ? t('admin.users.active') : t('admin.users.banned') }}
+
+
+ {{ user.loginCount }} {{ t('admin.users.loginsCount') }} · {{ t('admin.users.lastLoginAt') }} {{ formatDate(user.lastLogin) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.emailSimilarGroups') }} ({{ linkedAccountsData.emailGroups.length }})
+
+
+
+
+
+ {{ group.pattern }}*
+ {{ group.userCount }} {{ t('admin.users.usersCount') }}
+
+
+
+
+
+
+
+ #{{ user.id }}
+ {{ user.username }}
+ {{ user.email }}
+ {{ user.status === 'active' ? t('admin.users.active') : t('admin.users.banned') }}
+
+
+ {{ t('admin.users.createdAt') }} {{ formatDate(user.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.usernameSimilarGroups') }} ({{ linkedAccountsData.usernameGroups.length }})
+
+
+
+
+
+ {{ group.pattern }}*
+ {{ group.userCount }} {{ t('admin.users.usersCount') }}
+
+
+
+
+
+
+
+ #{{ user.id }}
+ {{ user.username }}
+ {{ user.email }}
+ {{ user.status === 'active' ? t('admin.users.active') : t('admin.users.banned') }}
+
+
+ {{ t('admin.users.createdAt') }} {{ formatDate(user.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.noLinkedAccounts') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.users.expireHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ newInviteCode }}
+
+
+
+
+
+
+ {{ index + 1 }}. {{ code }}
+
+
+
+
+ {{ t('admin.users.validUntil') }}: {{ formatDate(newInviteExpiresAt) }}
+
+
{{ t('admin.users.permanentValid') }}
+
+
+ {{ copied ? t('admin.users.copied') : (newInviteCodes.length > 0 ? t('admin.users.copyAllCodes') : t('admin.users.copyCode')) }}
+
+ {{ t('admin.users.copyLink') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.resetPasswordConfirm', { name: resetPasswordUser?.username }) }}
+
+
{{ t('admin.users.resetPasswordHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.users.newPasswordFor', { name: resetPasswordUser?.username }) }}
+
+ {{ generatedPassword }}
+
+
{{ t('admin.users.copyPasswordHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.disable2FAConfirm', { name: disable2FAUser?.username }) }}
+
+
{{ t('admin.users.disable2FAWarning') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.unbindGitHubConfirm', { name: unbindGitHubUser?.username }) }}
+
+
{{ t('admin.users.unbindGitHubWarning') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.noLoginRecords') }}
+
+
+
+
+
+
+
+
{{ record.ip }}
+
+
+ {{ formatLocation(record) }}
+ · {{ record.isp }}
+
+
{{ record.isp }}
+
+
+ {{ formatDevice(record.userAgent) }} · {{ formatBrowser(record.userAgent) }}
+
+
+
+ {{ formatDate(record.createdAt) }}
+
+
+
+
+
+
+
{{ t('admin.users.totalRecords', { count: loginRecordsTotal }) }}
+
+
+ {{ t('admin.users.prevPage') }}
+
+ {{ loginRecordsPage }} / {{ loginRecordsTotalPages }}
+
+ {{ t('admin.users.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ sendMessageForm.title.length }}/200
+
+
+
+
+
{{ sendMessageForm.content.length }}/5000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.balanceOverview') }}
+
+
+ {{ t('admin.users.balanceLogs') }}
+
+
+ {{ t('admin.users.rechargeRecords') }}
+
+
+ {{ t('admin.users.adjustBalance') }}
+
+
+
+
+
+
+
+
+
+ ¥{{ balanceInfo.balance.toFixed(2) }}
+
+
{{ t('admin.users.currentBalance') }}
+
+
+
+ ¥{{ balanceInfo.totalRecharge.toFixed(2) }}
+
+
{{ t('admin.users.totalRecharge') }}
+
+
+
+ ¥{{ balanceInfo.totalConsume.toFixed(2) }}
+
+
{{ t('admin.users.totalConsume') }}
+
+
+
+
+
+
+
+
+
+
+ {{ balanceLogsShowLotteryGiftOnly ? t('wallet.showingLotteryGift') : t('wallet.showLotteryGift') }}
+
+
+
+
+ {{ t('admin.users.noBalanceLogs') }}
+
+
+
+
+
+
+
+ {{ getBalanceLogTypeLabel(log.type) }}
+
+
+ {{ log.amount > 0 ? '+' : '' }}¥{{ log.amount.toFixed(2) }}
+
+
+
{{ log.remark }}
+
+
{{ formatDate(log.createdAt) }}
+
+
+
+
+
{{ t('admin.users.totalRecords', { count: balanceLogsTotal }) }}
+
+
+ {{ t('admin.users.prevPage') }}
+
+ {{ balanceLogsPage }} / {{ balanceLogsTotalPages }}
+
+ {{ t('admin.users.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.noRechargeRecords') }}
+
+
+
+
+
+
+ ¥{{ record.amount.toFixed(2) }}
+
+ {{ t('wallet.status.' + record.status) }}
+
+
+
+ {{ record.orderNo }}
+ · {{ record.provider.name }}
+
+
+
{{ formatDate(record.createdAt) }}
+
+
+
+
+
{{ t('admin.users.totalRecords', { count: rechargeRecordsTotal }) }}
+
+
+ {{ t('admin.users.prevPage') }}
+
+ {{ rechargeRecordsPage }} / {{ rechargeRecordsTotalPages }}
+
+ {{ t('admin.users.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¥
+
+
+
+
+
+
+
+
{{ adjustBalanceForm.reason.length }}/500
+
+
+
+
+
+ {{ adjustBalanceLoading ? t('common.submitting') : t('common.confirm') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.adjustPoints') }} - {{ pointsUser?.username }}
+
+
+
+
+ {{ t('admin.users.currentPoints') }}
+ {{ pointsUser?.points || 0 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.cancel') }}
+
+
+ {{ adjustPointsLoading ? t('common.submitting') : t('common.confirm') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.hostingBalanceOverview') }}
+
+
+ {{ t('admin.users.hostingBalanceLogs') }}
+
+
+ {{ t('admin.users.adjustHostingBalance') }}
+
+
+
+
+
+
+
+
+
+ ¥{{ hostingBalanceInfo.available.toFixed(2) }}
+
+
{{ t('admin.users.availableBalance') }}
+
+
+
+ ¥{{ hostingBalanceInfo.frozen.toFixed(2) }}
+
+
{{ t('admin.users.frozenBalance') }}
+
+
+
+
+
+
+
+ {{ t('common.noData') }}
+
+
+
+
+
+
+ | {{ t('admin.users.logTime') }} |
+ {{ t('admin.users.logType') }} |
+ {{ t('admin.users.logAmount') }} |
+ {{ t('admin.users.logStatus') }} |
+ {{ t('admin.users.logDescription') }} |
+
+
+
+
+ |
+ {{ new Date(log.createdAt).toLocaleString() }}
+ |
+
+ {{ getHostingLogTypeLabel(log.type, log.actionType) }}
+ |
+
+ {{ log.type === 'income' || log.type === 'unfreeze' ? '+' : '-' }}¥{{ log.amount.toFixed(2) }}
+ |
+
+ {{ t('admin.users.frozen') }}
+ {{ t('admin.users.available') }}
+ |
+
+ {{ log.description || '-' }}
+ |
+
+
+
+
+
+
+
+ {{ t('common.prev') }}
+
+
+ {{ hostingBalanceLogsPage }} / {{ hostingBalanceLogsTotalPages }}
+
+
+ {{ t('common.next') }}
+
+
+
+
+
+
+
+
+
+
¥{{ hostingBalanceInfo.available.toFixed(2) }}
+
{{ t('admin.users.availableBalance') }}
+
+
+
¥{{ hostingBalanceInfo.frozen.toFixed(2) }}
+
{{ t('admin.users.frozenBalance') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ adjustHostingBalanceForm.reason.length }}/200
+
+
+
+
{{ t('common.cancel') }}
+
+
+ {{ adjustHostingBalanceLoading ? t('common.submitting') : t('common.confirm') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/resources/MyHostCreateView.vue b/client/src/views/resources/MyHostCreateView.vue
new file mode 100644
index 0000000..16af5c0
--- /dev/null
+++ b/client/src/views/resources/MyHostCreateView.vue
@@ -0,0 +1,596 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('resources.hosts.create') }}
+
{{ t('resources.hosts.createDesc') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('resources.hosts.ubuntuOnlyHint') }}
+
{{ t('resources.hosts.installHintTitle') }}
+
{{ t('resources.hosts.installHintIpv6') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ {{ t('admin.hosts.step1RunScript') }}
+
+
{{ t('admin.hosts.runOnHost') }}
+
+ {{ installCommand }}
+
+
+ {{ t('admin.hosts.copyCommand') }}
+
+
+
+
+
+
+ 2
+ {{ t('admin.hosts.step2Verify') }}
+
+
{{ t('admin.hosts.verifyHint') }}
+
+
+
+
+
+ {{ installStatus === 'verifying' ? t('admin.hosts.verifying') : t('admin.hosts.verifyAndConnect') }}
+
+
+
+
+
+
{{ t('admin.hosts.verifySuccess') }}
+
+
+
+
+
+ {{ verifyError }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/resources/MyHostDetailView.vue b/client/src/views/resources/MyHostDetailView.vue
new file mode 100644
index 0000000..95b369e
--- /dev/null
+++ b/client/src/views/resources/MyHostDetailView.vue
@@ -0,0 +1,658 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.hosts.extendHint', { count: paidInstanceCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.hosts.deleteWarning') }}
+
+
+ {{ t('admin.hosts.deleteConfirmHint', { name: host.name }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ {{ t('admin.hosts.step1RunScript') }}
+
+
{{ t('admin.hosts.runOnHost') }}
+
+ {{ reinstallCommand }}
+
+
+ {{ t('admin.hosts.copyCommand') }}
+
+
+
+
+
+
+ 2
+ {{ t('admin.hosts.step2Verify') }}
+
+
{{ t('admin.hosts.verifyHint') }}
+
+
+
+
+
+ {{ reinstallStatus === 'verifying' ? t('admin.hosts.verifying') : t('admin.hosts.verifyAndConnect') }}
+
+
+
+
+
+
{{ t('admin.hosts.verifySuccess') }}
+
+
+
+
+
+ {{ reinstallError }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/resources/MyHostsView.vue b/client/src/views/resources/MyHostsView.vue
new file mode 100644
index 0000000..9f7c56a
--- /dev/null
+++ b/client/src/views/resources/MyHostsView.vue
@@ -0,0 +1,677 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('resources.hosts.mine') }}
+
+
+ {{ t('resources.hosts.hosted') }}
+
+
+
+
+
+
+
+
+
+ {{ t('resources.hosts.calibrateAll') }}
+
+
+
+ {{ t('resources.hosts.create') }}
+
+
+
+
+
+
+
+
+
+
{{ t('resources.hosts.noHosts') }}
+
{{ t('resources.hosts.noHostsHint') }}
+
+
+
+
+
+
+
+ | {{ t('admin.hosts.name') }} |
+ {{ t('resources.hosts.owner') }} |
+ {{ t('admin.hosts.status') }} |
+ {{ t('admin.hosts.resources') }} |
+ {{ t('admin.hosts.instances') }} |
+ {{ t('admin.hosts.actions') }} |
+
+
+
+
+
+
+
+
+
+ {{ host.name?.toUpperCase() || host.name }}
+
+
+ {{ host.location || host.url }}
+
+
+
+ |
+
+
+
+
+
+ {{ host.owner.username }}
+ UID: {{ host.owner.id }}
+
+
+ -
+ |
+
+
+
+ {{ host.status }}
+
+ |
+
+
+ {{ t('admin.hosts.cpu') }}: {{ host.cpuAllowanceMax || 0 }}%
+
+
+ {{ t('admin.hosts.memory') }}: {{ formatMemory(host.memoryMax || 0) }}
+
+ |
+ {{ host.instanceCount }} |
+
+
+
+ {{ t('admin.hosts.test') }}
+ {{ t('admin.hosts.delete') }}
+
+
+
+ {{ takingOverHostId === host.id ? t('resources.hosts.takeoverOfficialLoading') : t('resources.hosts.takeoverOfficial') }}
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ t('admin.users.totalRecords', { count: total }) }}
+
+
+
+
+
+
+
+
+
+
+ …
+
+ {{ p }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('hosting.accessDenied.title') }}
+
+
+
{{ t('hosting.accessDenied.description') }}
+
+
+
+
+
+ !
+
+
+
{{ accessDeniedConditionText }}
+
+ {{ accessDeniedStatusText }}
+
+
+
+
+
+
{{ accessDeniedHintText }}
+
+
+ {{ t('common.confirm') }}
+
+
+
+
diff --git a/client/src/views/resources/MyPackagesView.vue b/client/src/views/resources/MyPackagesView.vue
new file mode 100644
index 0000000..90997ad
--- /dev/null
+++ b/client/src/views/resources/MyPackagesView.vue
@@ -0,0 +1,1862 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('resources.packages.mine') }}
+
+
+ {{ t('resources.packages.hosted') }}
+
+
+
+
+
+
+
+ {{ t('resources.packages.create') }}
+
+
+
+
+
+
+
+
+
+ {{ t('resources.packages.noSearchResults') }}
+ {{ t('resources.packages.clearSearch') }}
+
+
+ {{ t('resources.packages.noPackages') }}
+ {{ t('resources.packages.noPackagesHint') }}
+
+
+
+
+
+
+
+
+
+ | {{ t('admin.packages.name') }} |
+ {{ t('resources.packages.owner') }} |
+ {{ t('admin.packages.status') }} |
+ {{ t('resources.packages.networkModeColumn') }} |
+ {{ t('resources.packages.instanceTypeColumn') }} |
+ {{ t('resources.packages.trafficMultiplierColumn') }} |
+ {{ t('resources.packages.hostColumn') }} |
+ {{ t('resources.packages.instanceColumn') }} |
+ {{ t('common.actions') }} |
+
+
+
+
+
+ |
+
+ {{ pkg.name }}
+
+ {{ t('resources.packages.publicBadge') }}
+
+
+ {{ pkg.description }}
+ |
+
+
+
+
+
+ {{ pkg.owner.username }}
+ UID: {{ pkg.owner.id }}
+
+
+ -
+ |
+
+
+ {{ t('admin.packages.active') }}
+ {{ t('admin.packages.inactive') }}
+ |
+
+
+ {{ getPackageNetworkModeLabel(pkg) }}
+ |
+
+
+ {{ getPackageInstanceTypeLabel(pkg) }}
+ |
+
+
+ {{ getPackageTrafficMultiplierLabel(pkg) }}
+ |
+
+
+ {{ getHostNames(pkg) }}
+ |
+
+
+ {{ pkg.instance_count || 0 }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
{{ t('common.total') }} {{ filteredPackages.length }} {{ t('common.items') }}
+
+ {{ t('instance.prevPage') }}
+
+ …
+
+ {{ p }}
+
+
+ {{ t('instance.nextPage') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('resources.packages.noFriends') }}
+
{{ t('resources.packages.noFriendsHint') }}
+
+
+
+
{{ t('resources.packages.allFriendsShared') }}
+
{{ t('resources.packages.noAvailableFriends') }}
+
+
+
{{ t('resources.packages.selectToShare') }}
+
+
+
+
{{ friend.username }}
+
{{ friend.email }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('resources.packages.quotaSettings') }}
+
+
+
+
+
+
+
{{ t('resources.packages.quotaMultiplierHint') }}
+
+
+
+
+
{{ t('resources.packages.maxInstancesHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('resources.packages.noShares') }}
+
{{ t('resources.packages.noSharesHint') }}
+
+
+
+
+
+
+
+
+ {{ share.sharedToUsername }}
+
+
+
+ {{ formatDate(share.createdAt) }}
+
+
+
+
+
+
+
+
+
{{ t('resources.packages.quotaMultiplier') }}
+
+ {{ share.quotaMultiplier !== null ? share.quotaMultiplier + 'x' : t('resources.packages.noLimit') }}
+
+
+
+
{{ t('resources.packages.maxInstances') }}
+
+ {{ share.maxInstances !== null ? t('resources.packages.instanceUnit', { n: share.maxInstances }) : t('resources.packages.noLimit') }}
+
+
+
+
+
+
+
{{ t('resources.packages.usageStatus') }}
+
+
+
+
+ {{ t('resources.packages.instanceCount') }}
+ {{ share.usage.instanceCount }} / {{ share.maxInstances }}
+
+
+
+
+
+
+
+ CPU
+ {{ share.usage.totalCpu }}% / {{ Math.floor(packageQuota.cpuMax * share.quotaMultiplier) }}%
+
+
+
+
+
+
+
+ {{ t('admin.packages.memory') }}
+ {{ formatMemory(share.usage.totalMemory) }} / {{ formatMemory(Math.floor(packageQuota.memoryMax * share.quotaMultiplier)) }}
+
+
+
+
+
+
+ {{ t('resources.packages.currentUsage', { cpu: share.usage.totalCpu, memory: share.usage.totalMemory, instances: share.usage.instanceCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('resources.packages.quotaMultiplierHint') }}
+
+
+
+
+
{{ t('resources.packages.maxInstancesHint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('resources.packages.currentUsageInfo', { cpu: editingShare.usage.totalCpu, memory: editingShare.usage.totalMemory, instances: editingShare.usage.instanceCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('resources.plans.noPlans') }}
+
{{ t('resources.plans.noPlansHint') }}
+
+
+
+
+
+
+ {{ plan.name }}
+ {{ getPlanStatusLabel(plan) }}
+
+
{{ plan.description }}
+
+
+ {{ t('admin.packages.cpu') }}:
+ {{ plan.cpu }}%
+
+
+ {{ t('admin.packages.memory') }}:
+ {{ formatMemory(plan.memory) }}
+
+
+ {{ t('admin.packages.disk') }}:
+ {{ formatDisk(plan.disk) }}
+
+
+ {{ t('resources.plans.price') }}:
+ ¥{{ formatPrice(plan.price) }}/{{ getBillingCycleLabel(plan.billingCycle) }}
+
+
+
+
+ {{ t('resources.plans.portLimit') }}:
+ {{ plan.portLimit }}
+
+
+ {{ t('resources.plans.snapshotLimit') }}:
+ {{ plan.snapshotLimit }}
+
+
+ {{ t('resources.plans.swapSize') }}:
+ {{ plan.swapSize > 0 ? `${plan.swapSize} MB` : '-' }}
+
+
+ {{ t('resources.plans.siteLimit') }}:
+ {{ plan.siteLimit }}
+
+
+
+ SLA保证:
+ {{ plan.slaGuarantee }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.noIncudalHint') }}
+
+
+
+
{{ t('resources.plans.resourceConfig') }}
+
+
+
+
+
+
{{ t('resources.plans.billingConfig') }}
+
+
+
+
+
{{ t('resources.plans.priceRangeHint', { max: MAX_PACKAGE_PLAN_PRICE.toFixed(2) }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+ {{ selectedPlanStatusOption.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/views/resources/PackageFormView.vue b/client/src/views/resources/PackageFormView.vue
new file mode 100644
index 0000000..320bb5f
--- /dev/null
+++ b/client/src/views/resources/PackageFormView.vue
@@ -0,0 +1,1501 @@
+
+
+
+
+
+
+
+
+
+
+
{{ t('common.loading') }}
+
+
+
+
+
+
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 0000000..ba7b910
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,85 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ './index.html',
+ './src/**/*.{vue,js,ts,jsx,tsx}'
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ // Vercel/GitHub 风格 - 极简黑白灰
+ gray: {
+ 50: '#fafafa',
+ 100: '#f5f5f5',
+ 200: '#e5e5e5',
+ 300: '#d4d4d4',
+ 400: '#a3a3a3',
+ 500: '#737373',
+ 600: '#525252',
+ 700: '#404040',
+ 800: '#262626',
+ 900: '#171717',
+ 950: '#0a0a0a'
+ },
+ // 强调色 - 简洁蓝
+ accent: {
+ DEFAULT: '#0070f3',
+ hover: '#0060df',
+ light: '#3291ff'
+ },
+ // 状态色
+ success: '#10b981',
+ warning: '#f59e0b',
+ error: '#ef4444'
+ },
+ fontFamily: {
+ sans: [
+ 'Geist',
+ '-apple-system',
+ 'BlinkMacSystemFont',
+ 'Segoe UI',
+ 'Noto Sans SC',
+ 'sans-serif'
+ ],
+ mono: [
+ 'Geist Mono',
+ 'SF Mono',
+ 'Monaco',
+ 'Consolas',
+ 'monospace'
+ ]
+ },
+ fontSize: {
+ '2xs': ['0.6875rem', { lineHeight: '1rem' }],
+ },
+ borderRadius: {
+ DEFAULT: '6px',
+ 'lg': '8px',
+ 'xl': '12px'
+ },
+ boxShadow: {
+ 'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
+ 'DEFAULT': '0 1px 3px 0 rgb(0 0 0 / 0.1)',
+ 'border': '0 0 0 1px rgb(255 255 255 / 0.1)',
+ },
+ animation: {
+ 'fade-in': 'fadeIn 0.15s ease-out',
+ 'slide-up': 'slideUp 0.2s ease-out',
+ },
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' }
+ },
+ slideUp: {
+ '0%': { opacity: '0', transform: 'translateY(4px)' },
+ '100%': { opacity: '1', transform: 'translateY(0)' }
+ }
+ }
+ }
+ },
+ plugins: [
+ require('@tailwindcss/forms')({ strategy: 'class' })
+ ]
+}
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..4b0bc8a
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,44 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ /* Path mapping */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+
+ /* Vue specific */
+ "types": ["vite/client"]
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.d.ts",
+ "src/**/*.tsx",
+ "src/**/*.vue"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
+
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
new file mode 100644
index 0000000..9338fe5
--- /dev/null
+++ b/client/tsconfig.node.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts"]
+}
+
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..2fe148b
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,109 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import obfuscator from 'rollup-plugin-obfuscator'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig(({ mode }) => {
+ const isProd = mode === 'production'
+
+ return {
+ // 使用绝对路径根路径,在生产环境中静态资源需要从根路径加载
+ base: '/',
+
+ plugins: [
+ vue(),
+ // 代码混淆器(仅生产环境)
+ // 注意:如果遇到白屏问题,可能是混淆配置过于严格
+ // 临时禁用混淆以排查问题,确认问题后再启用
+ // isProd && obfuscator({
+ // include: ['src/**/*.{js,ts,vue}'],
+ // exclude: [/node_modules/, /\.html$/],
+ // options: {
+ // compact: true,
+ // controlFlowFlattening: true,
+ // controlFlowFlatteningThreshold: 0.3,
+ // identifierNamesGenerator: 'hexadecimal',
+ // stringArray: true,
+ // stringArrayEncoding: ['base64'],
+ // stringArrayThreshold: 0.5,
+ // debugProtection: false,
+ // debugProtectionInterval: 0,
+ // disableConsoleOutput: true,
+ // }
+ // }),
+ ].filter(Boolean),
+
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ }
+ },
+
+ server: {
+ port: 43173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8888',
+ changeOrigin: true,
+ ws: true,
+ timeout: 10000,
+ configure: (proxy, _options) => {
+ proxy.on('error', (err, _req, res) => {
+ // Handle proxy errors gracefully
+ if (res && !res.headersSent) {
+ res.writeHead(500, {
+ 'Content-Type': 'application/json'
+ })
+ res.end(JSON.stringify({ error: 'Proxy error: Backend server may not be ready yet' }))
+ }
+ })
+ proxy.on('proxyReq', (proxyReq, req, _res) => {
+ // Log proxy requests in development
+ if (process.env.NODE_ENV === 'development') {
+ console.log(`[Proxy] ${req.method} ${req.url} -> ${proxyReq.path}`)
+ }
+ })
+ }
+ }
+ }
+ },
+
+ build: {
+ outDir: 'dist',
+ sourcemap: false,
+ assetsDir: 'assets',
+ // 优化代码分割,提升加载性能
+ rollupOptions: {
+ output: {
+ // 使用纯哈希命名,不包含文件名,提高安全性
+ chunkFileNames: 'assets/[hash].js',
+ entryFileNames: 'assets/[hash].js',
+ assetFileNames: 'assets/[hash].[ext]',
+ // 手动分割代码块,优化缓存和加载
+ manualChunks: {
+ // 核心 Vue 库(基础库,缓存利用率高)
+ 'vue-core': ['vue', 'vue-router', 'pinia'],
+ // 国际化库
+ 'vue-i18n': ['vue-i18n'],
+ // 网络请求库
+ 'axios': ['axios'],
+ },
+ },
+ onwarn(warning, warn) {
+ // 忽略 sourcemap 相关警告
+ if (warning.message?.includes('sourcemap')) return
+ warn(warning)
+ }
+ },
+ minify: 'terser',
+ terserOptions: {
+ compress: {
+ // 临时保留 console.error 和 console.warn,方便调试白屏问题
+ drop_console: false, // 临时禁用,方便调试
+ pure_funcs: ['console.log', 'console.debug', 'console.info'], // 只移除这些
+ drop_debugger: true,
+ },
+ },
+ },
+ }
+})
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..340c812
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,76 @@
+services:
+ dev:
+ image: node:22-alpine
+ working_dir: /workspace
+ ports:
+ - "43173:43173"
+ - "8888:8888"
+ environment:
+ - NODE_ENV=development
+ - HOST=0.0.0.0
+ - PORT=8888
+ - FRONTEND_URL=${FRONTEND_URL:-https://idev.bitpd.com}
+ - DATABASE_URL=postgresql://incudal:incudal_dev_password@db:5432/incudal
+ - REDIS_URL=redis://redis:6379
+ - CHOKIDAR_USEPOLLING=true
+ - WATCHPACK_POLLING=true
+ - DB_POOL_MAX=${DB_POOL_MAX:-20}
+ - DB_POOL_MIN=${DB_POOL_MIN:-5}
+ - DB_CONNECTION_TIMEOUT=${DB_CONNECTION_TIMEOUT:-5000}
+ - DB_IDLE_TIMEOUT=${DB_IDLE_TIMEOUT:-30000}
+ - DB_STATEMENT_TIMEOUT=${DB_STATEMENT_TIMEOUT:-30000}
+ - DB_QUERY_TIMEOUT=${DB_QUERY_TIMEOUT:-30000}
+ - TRAFFIC_CONCURRENCY_PER_HOST=${TRAFFIC_CONCURRENCY_PER_HOST:-10}
+ - TRAFFIC_HOST_CONCURRENCY=${TRAFFIC_HOST_CONCURRENCY:-3}
+ - DB_WORKER_BACKOFF_MS=${DB_WORKER_BACKOFF_MS:-15000}
+ volumes:
+ - .:/workspace
+ - node_modules:/workspace/node_modules
+ - client_node_modules:/workspace/client/node_modules
+ - server_node_modules:/workspace/server/node_modules
+ - pnpm_store:/root/.local/share/pnpm/store
+ command: >
+ sh -lc "
+ corepack enable &&
+ corepack prepare pnpm@9.14.2 --activate &&
+ pnpm install &&
+ pnpm --filter server exec prisma migrate deploy &&
+ pnpm exec concurrently
+ -n server,client,migrate
+ -c blue,green,magenta
+ \"pnpm --filter server dev\"
+ \"pnpm --filter client dev --host 0.0.0.0 --port 43173\"
+ \"node scripts/watch-migrations.mjs\"
+ "
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_started
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_USER=incudal
+ - POSTGRES_PASSWORD=incudal_dev_password
+ - POSTGRES_DB=incudal
+ volumes:
+ - postgres_data_dev:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U incudal"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis_data_dev:/data
+
+volumes:
+ node_modules:
+ client_node_modules:
+ server_node_modules:
+ pnpm_store:
+ postgres_data_dev:
+ redis_data_dev:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2434797
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,74 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "127.0.0.1:${APP_PORT:-3000}:3000"
+ environment:
+ - NODE_ENV=production
+ - HOST=0.0.0.0
+ - PORT=3000
+ - DATABASE_URL=postgresql://${POSTGRES_USER:-incudal}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-incudal}
+ - REDIS_URL=redis://:${REDIS_PASSWORD:-}@redis:6379
+ - JWT_SECRET=${JWT_SECRET}
+ - COOKIE_SECRET=${COOKIE_SECRET:-}
+ - ENCRYPTION_KEY=${ENCRYPTION_KEY}
+ - FRONTEND_URL=${FRONTEND_URL:-}
+ - SITE_URL=${SITE_URL:-}
+ - LOG_LEVEL=${LOG_LEVEL:-info}
+ - DISABLE_REQUEST_LOG=${DISABLE_REQUEST_LOG:-true}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
+ - ALERT_WEBHOOK_URL=${ALERT_WEBHOOK_URL:-}
+ - DB_POOL_MAX=${DB_POOL_MAX:-20}
+ - DB_POOL_MIN=${DB_POOL_MIN:-5}
+ - DB_CONNECTION_TIMEOUT=${DB_CONNECTION_TIMEOUT:-5000}
+ - DB_IDLE_TIMEOUT=${DB_IDLE_TIMEOUT:-30000}
+ - DB_STATEMENT_TIMEOUT=${DB_STATEMENT_TIMEOUT:-30000}
+ - DB_QUERY_TIMEOUT=${DB_QUERY_TIMEOUT:-30000}
+ - TRAFFIC_CONCURRENCY_PER_HOST=${TRAFFIC_CONCURRENCY_PER_HOST:-10}
+ - TRAFFIC_HOST_CONCURRENCY=${TRAFFIC_HOST_CONCURRENCY:-3}
+ - DB_WORKER_BACKOFF_MS=${DB_WORKER_BACKOFF_MS:-15000}
+ volumes:
+ - ./server/certs:/app/server/certs:ro
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ restart: unless-stopped
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_USER=${POSTGRES_USER:-incudal}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_DB=${POSTGRES_DB:-incudal}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-incudal}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ command: redis-server --requirepass ${REDIS_PASSWORD:-}
+ volumes:
+ - redis_data:/data
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
+ redis_data:
+
+networks:
+ default:
+ driver: bridge
+ enable_ipv6: true
+ ipam:
+ config:
+ - subnet: "172.31.0.0/16"
+ - subnet: "fd42:dead:beef:10::/64"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..41897a6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,332 @@
+{
+ "name": "incudal",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "incudal",
+ "version": "1.0.0",
+ "devDependencies": {
+ "concurrently": "^9.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concurrently": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
+ "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "4.1.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.8.3",
+ "supports-color": "8.1.1",
+ "tree-kill": "1.2.2",
+ "yargs": "17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8de79cd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "incudal",
+ "version": "1.0.0",
+ "description": "Incudal 容器虚拟化管理平台",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "concurrently -n \"server,client\" -c \"blue,green\" \"pnpm --filter server dev\" \"pnpm --filter client dev\"",
+ "build": "pnpm --filter client build && pnpm --filter server build",
+ "start": "pnpm --filter server start",
+ "lint": "pnpm -r lint",
+ "migrate:data": "pnpm --filter server migrate:data",
+ "test:prisma": "pnpm --filter server test:prisma"
+ },
+ "devDependencies": {
+ "concurrently": "^9.1.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "packageManager": "pnpm@9.14.2"
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..9298d96
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,8322 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ concurrently:
+ specifier: ^9.1.0
+ version: 9.2.1
+
+ client:
+ dependencies:
+ '@vueuse/core':
+ specifier: ^12.0.0
+ version: 12.8.2(typescript@5.9.3)
+ '@xterm/addon-clipboard':
+ specifier: ^0.2.0
+ version: 0.2.0
+ '@xterm/addon-fit':
+ specifier: ^0.11.0
+ version: 0.11.0
+ '@xterm/addon-image':
+ specifier: ^0.9.0
+ version: 0.9.0
+ '@xterm/addon-search':
+ specifier: ^0.16.0
+ version: 0.16.0
+ '@xterm/addon-serialize':
+ specifier: ^0.14.0
+ version: 0.14.0
+ '@xterm/addon-unicode11':
+ specifier: ^0.9.0
+ version: 0.9.0
+ '@xterm/addon-web-links':
+ specifier: ^0.12.0
+ version: 0.12.0
+ '@xterm/addon-webgl':
+ specifier: ^0.19.0
+ version: 0.19.0
+ '@xterm/xterm':
+ specifier: ^6.0.0
+ version: 6.0.0
+ axios:
+ specifier: ^1.7.9
+ version: 1.13.2
+ flag-icons:
+ specifier: ^7.5.0
+ version: 7.5.0
+ marked:
+ specifier: ^17.0.1
+ version: 17.0.1
+ pinia:
+ specifier: ^2.2.8
+ version: 2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3))
+ simple-icons:
+ specifier: ^16.1.0
+ version: 16.1.0
+ vue:
+ specifier: ^3.5.13
+ version: 3.5.25(typescript@5.9.3)
+ vue-i18n:
+ specifier: ^11.2.2
+ version: 11.2.2(vue@3.5.25(typescript@5.9.3))
+ vue-router:
+ specifier: ^4.5.0
+ version: 4.6.3(vue@3.5.25(typescript@5.9.3))
+ vue-turnstile:
+ specifier: ^1.0.11
+ version: 1.0.11(vue@3.5.25(typescript@5.9.3))
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.16.0
+ version: 9.39.1
+ '@tailwindcss/forms':
+ specifier: ^0.5.9
+ version: 0.5.10(tailwindcss@3.4.18(tsx@4.21.0))
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.10.1
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.48.1
+ version: 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser':
+ specifier: ^8.48.1
+ version: 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@vitejs/plugin-vue':
+ specifier: ^5.2.1
+ version: 5.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@1.21.7)(terser@5.44.1)(tsx@4.21.0))(vue@3.5.25(typescript@5.9.3))
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.4.22(postcss@8.5.6)
+ eslint:
+ specifier: ^9.16.0
+ version: 9.39.1(jiti@1.21.7)
+ eslint-plugin-vue:
+ specifier: ^9.32.0
+ version: 9.33.0(eslint@9.39.1(jiti@1.21.7))
+ javascript-obfuscator:
+ specifier: ^5.0.1
+ version: 5.0.1
+ postcss:
+ specifier: ^8.4.49
+ version: 8.5.6
+ rollup-plugin-obfuscator:
+ specifier: ^1.1.0
+ version: 1.1.0(javascript-obfuscator@5.0.1)(rollup@4.53.3)
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.18(tsx@4.21.0)
+ terser:
+ specifier: ^5.44.1
+ version: 5.44.1
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.3
+ version: 6.4.1(@types/node@24.10.1)(jiti@1.21.7)(terser@5.44.1)(tsx@4.21.0)
+ vue-eslint-parser:
+ specifier: ^9.4.2
+ version: 9.4.3(eslint@9.39.1(jiti@1.21.7))
+ vue-tsc:
+ specifier: ^3.1.6
+ version: 3.1.6(typescript@5.9.3)
+
+ server:
+ dependencies:
+ '@fastify/cookie':
+ specifier: ^11.0.2
+ version: 11.0.2
+ '@fastify/cors':
+ specifier: ^10.0.1
+ version: 10.1.0
+ '@fastify/helmet':
+ specifier: ^13.0.2
+ version: 13.0.2
+ '@fastify/jwt':
+ specifier: ^9.0.1
+ version: 9.1.0
+ '@fastify/multipart':
+ specifier: ^9.0.3
+ version: 9.4.0
+ '@fastify/rate-limit':
+ specifier: ^10.3.0
+ version: 10.3.0
+ '@fastify/static':
+ specifier: ^8.0.3
+ version: 8.3.0
+ '@fastify/websocket':
+ specifier: ^11.0.1
+ version: 11.2.0
+ '@libsql/client':
+ specifier: ^0.14.0
+ version: 0.14.0
+ '@prisma/adapter-pg':
+ specifier: ^7.1.0
+ version: 7.1.0
+ '@prisma/client':
+ specifier: ^7.1.0
+ version: 7.1.0(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(typescript@5.9.3)
+ '@types/ws':
+ specifier: ^8.18.1
+ version: 8.18.1
+ basic-ftp:
+ specifier: ^5.0.5
+ version: 5.0.5
+ bcryptjs:
+ specifier: ^2.4.3
+ version: 2.4.3
+ dotenv:
+ specifier: ^17.2.3
+ version: 17.2.3
+ drizzle-orm:
+ specifier: ^0.38.2
+ version: 0.38.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.14.0)(@prisma/client@7.1.0(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(react@19.2.1)(sql.js@1.13.0)
+ fastify:
+ specifier: ^5.1.0
+ version: 5.6.2
+ fastify-cloudflare-turnstile:
+ specifier: ^2.0.2
+ version: 2.0.2
+ ip-address:
+ specifier: ^10.1.0
+ version: 10.1.0
+ jsonwebtoken:
+ specifier: ^9.0.3
+ version: 9.0.3
+ nanoid:
+ specifier: ^5.1.6
+ version: 5.1.6
+ node-cron:
+ specifier: ^4.2.1
+ version: 4.2.1
+ nodemailer:
+ specifier: ^7.0.11
+ version: 7.0.11
+ otplib:
+ specifier: ^12.0.1
+ version: 12.0.1
+ p-limit:
+ specifier: ^7.2.0
+ version: 7.2.0
+ pg:
+ specifier: ^8.16.3
+ version: 8.16.3
+ pino-pretty:
+ specifier: ^13.0.0
+ version: 13.1.3
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
+ ssh2-sftp-client:
+ specifier: ^11.0.0
+ version: 11.0.0
+ undici:
+ specifier: ^7.1.0
+ version: 7.16.0
+ webdav:
+ specifier: ^5.8.0
+ version: 5.8.0
+ ws:
+ specifier: ^8.18.3
+ version: 8.18.3
+ devDependencies:
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.10.1
+ '@types/node-cron':
+ specifier: ^3.0.11
+ version: 3.0.11
+ '@types/nodemailer':
+ specifier: ^7.0.4
+ version: 7.0.4
+ '@types/pg':
+ specifier: ^8.15.6
+ version: 8.15.6
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
+ '@types/ssh2-sftp-client':
+ specifier: ^9.0.4
+ version: 9.0.6
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.48.1
+ version: 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser':
+ specifier: ^8.48.1
+ version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ cross-env:
+ specifier: ^10.1.0
+ version: 10.1.0
+ drizzle-kit:
+ specifier: ^0.30.1
+ version: 0.30.6
+ prisma:
+ specifier: ^7.1.0
+ version: 7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@aws-crypto/sha256-browser@5.2.0':
+ resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
+
+ '@aws-crypto/sha256-js@5.2.0':
+ resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
+ engines: {node: '>=16.0.0'}
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
+
+ '@aws-crypto/util@5.2.0':
+ resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
+
+ '@aws-sdk/client-sesv2@3.947.0':
+ resolution: {integrity: sha512-XttaaNh2rPf0PrGShFIGh56QyNstKfQ9ozAfE+TGsYsMNok3yxbdZnFub8PrI1boYceJolhK2m6VBk3J5nDkAg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/client-sso@3.947.0':
+ resolution: {integrity: sha512-sDwcO8SP290WSErY1S8pz8hTafeghKmmWjNVks86jDK30wx62CfazOTeU70IpWgrUBEygyXk/zPogHsUMbW2Rg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/core@3.947.0':
+ resolution: {integrity: sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-env@3.947.0':
+ resolution: {integrity: sha512-VR2V6dRELmzwAsCpK4GqxUi6UW5WNhAXS9F9AzWi5jvijwJo3nH92YNJUP4quMpgFZxJHEWyXLWgPjh9u0zYOA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-http@3.947.0':
+ resolution: {integrity: sha512-inF09lh9SlHj63Vmr5d+LmwPXZc2IbK8lAruhOr3KLsZAIHEgHgGPXWDC2ukTEMzg0pkexQ6FOhXXad6klK4RA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-ini@3.947.0':
+ resolution: {integrity: sha512-A2ZUgJUJZERjSzvCi2NR/hBVbVkTXPD0SdKcR/aITb30XwF+n3T963b+pJl90qhOspoy7h0IVYNR7u5Nr9tJdQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-login@3.947.0':
+ resolution: {integrity: sha512-u7M3hazcB7aJiVwosNdJRbIJDzbwQ861NTtl6S0HmvWpixaVb7iyhJZWg8/plyUznboZGBm7JVEdxtxv3u0bTA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-node@3.947.0':
+ resolution: {integrity: sha512-S0Zqebr71KyrT6J4uYPhwV65g4V5uDPHnd7dt2W34FcyPu+hVC7Hx4MFmsPyVLeT5cMCkkZvmY3kAoEzgUPJJg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-process@3.947.0':
+ resolution: {integrity: sha512-WpanFbHe08SP1hAJNeDdBDVz9SGgMu/gc0XJ9u3uNpW99nKZjDpvPRAdW7WLA4K6essMjxWkguIGNOpij6Do2Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-sso@3.947.0':
+ resolution: {integrity: sha512-NktnVHTGaUMaozxycYrepvb3yfFquHTQ53lt6hBEVjYBzK3C4tVz0siUpr+5RMGLSiZ5bLBp2UjJPgwx4i4waQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-web-identity@3.947.0':
+ resolution: {integrity: sha512-gokm/e/YHiHLrZgLq4j8tNAn8RJDPbIcglFRKgy08q8DmAqHQ8MXAKW3eS0QjAuRXU9mcMmUo1NrX6FRNBCCPw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-host-header@3.936.0':
+ resolution: {integrity: sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-logger@3.936.0':
+ resolution: {integrity: sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-recursion-detection@3.936.0':
+ resolution: {integrity: sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-sdk-s3@3.947.0':
+ resolution: {integrity: sha512-DS2tm5YBKhPW2PthrRBDr6eufChbwXe0NjtTZcYDfUCXf0OR+W6cIqyKguwHMJ+IyYdey30AfVw9/Lb5KB8U8A==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-user-agent@3.947.0':
+ resolution: {integrity: sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/nested-clients@3.947.0':
+ resolution: {integrity: sha512-DjRJEYNnHUTu9kGPPQDTSXquwSEd6myKR4ssI4FaYLFhdT3ldWpj73yYt807H3tdmhS7vPmdVqchSJnjurUQAw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/region-config-resolver@3.936.0':
+ resolution: {integrity: sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/signature-v4-multi-region@3.947.0':
+ resolution: {integrity: sha512-UaYmzoxf9q3mabIA2hc4T6x5YSFUG2BpNjAZ207EA1bnQMiK+d6vZvb83t7dIWL/U1de1sGV19c1C81Jf14rrA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/token-providers@3.947.0':
+ resolution: {integrity: sha512-X/DyB8GuK44rsE89Tn5+s542B3PhGbXQSgV8lvqHDzvicwCt0tWny6790st6CPETrVVV2K3oJMfG5U3/jAmaZA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/types@3.936.0':
+ resolution: {integrity: sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-arn-parser@3.893.0':
+ resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-endpoints@3.936.0':
+ resolution: {integrity: sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-locate-window@3.893.0':
+ resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-user-agent-browser@3.936.0':
+ resolution: {integrity: sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==}
+
+ '@aws-sdk/util-user-agent-node@3.947.0':
+ resolution: {integrity: sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ aws-crt: '>=1.0.0'
+ peerDependenciesMeta:
+ aws-crt:
+ optional: true
+
+ '@aws-sdk/xml-builder@3.930.0':
+ resolution: {integrity: sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws/lambda-invoke-store@0.2.2':
+ resolution: {integrity: sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==}
+ engines: {node: '>=18.0.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.5':
+ resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.28.5':
+ resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ engines: {node: '>=6.9.0'}
+
+ '@buttercup/fetch@0.2.1':
+ resolution: {integrity: sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==}
+
+ '@chevrotain/cst-dts-gen@10.5.0':
+ resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==}
+
+ '@chevrotain/gast@10.5.0':
+ resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==}
+
+ '@chevrotain/types@10.5.0':
+ resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==}
+
+ '@chevrotain/utils@10.5.0':
+ resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==}
+
+ '@drizzle-team/brocli@0.10.2':
+ resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
+
+ '@electric-sql/pglite-socket@0.0.6':
+ resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==}
+ hasBin: true
+ peerDependencies:
+ '@electric-sql/pglite': 0.3.2
+
+ '@electric-sql/pglite-tools@0.2.7':
+ resolution: {integrity: sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==}
+ peerDependencies:
+ '@electric-sql/pglite': 0.3.2
+
+ '@electric-sql/pglite@0.3.2':
+ resolution: {integrity: sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==}
+
+ '@epic-web/invariant@1.0.0':
+ resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
+
+ '@esbuild-kit/core-utils@3.3.2':
+ resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild/aix-ppc64@0.19.12':
+ resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/aix-ppc64@0.27.1':
+ resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.18.20':
+ resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.19.12':
+ resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.27.1':
+ resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.18.20':
+ resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.19.12':
+ resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.1':
+ resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.18.20':
+ resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.19.12':
+ resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.1':
+ resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.18.20':
+ resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.19.12':
+ resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.27.1':
+ resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.18.20':
+ resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.19.12':
+ resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.1':
+ resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.19.12':
+ resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.27.1':
+ resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.18.20':
+ resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.19.12':
+ resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.1':
+ resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.18.20':
+ resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.19.12':
+ resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.27.1':
+ resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.18.20':
+ resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.19.12':
+ resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.1':
+ resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.18.20':
+ resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.19.12':
+ resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.1':
+ resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.18.20':
+ resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.19.12':
+ resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.1':
+ resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.18.20':
+ resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.19.12':
+ resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.1':
+ resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.18.20':
+ resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.19.12':
+ resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.1':
+ resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.18.20':
+ resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.19.12':
+ resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.1':
+ resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.18.20':
+ resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.19.12':
+ resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.1':
+ resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.18.20':
+ resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.19.12':
+ resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.1':
+ resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-arm64@0.27.1':
+ resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.18.20':
+ resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.19.12':
+ resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.1':
+ resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-arm64@0.27.1':
+ resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.18.20':
+ resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.19.12':
+ resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.1':
+ resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/openharmony-arm64@0.27.1':
+ resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.18.20':
+ resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.19.12':
+ resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.27.1':
+ resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.18.20':
+ resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.19.12':
+ resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.27.1':
+ resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.18.20':
+ resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.19.12':
+ resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.1':
+ resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.18.20':
+ resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.19.12':
+ resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.1':
+ resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.0':
+ resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.1':
+ resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.3':
+ resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.1':
+ resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@fastify/accept-negotiator@2.0.1':
+ resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
+
+ '@fastify/ajv-compiler@4.0.5':
+ resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
+
+ '@fastify/busboy@3.2.0':
+ resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
+
+ '@fastify/cookie@11.0.2':
+ resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
+
+ '@fastify/cors@10.1.0':
+ resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
+
+ '@fastify/deepmerge@3.2.1':
+ resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
+
+ '@fastify/error@4.2.0':
+ resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
+
+ '@fastify/fast-json-stringify-compiler@5.0.3':
+ resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
+
+ '@fastify/forwarded@3.0.1':
+ resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
+
+ '@fastify/helmet@13.0.2':
+ resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
+
+ '@fastify/jwt@9.1.0':
+ resolution: {integrity: sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==}
+
+ '@fastify/merge-json-schemas@0.2.1':
+ resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
+
+ '@fastify/multipart@9.4.0':
+ resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
+
+ '@fastify/proxy-addr@5.1.0':
+ resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
+
+ '@fastify/rate-limit@10.3.0':
+ resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
+
+ '@fastify/send@4.1.0':
+ resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
+
+ '@fastify/static@8.3.0':
+ resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
+
+ '@fastify/websocket@11.2.0':
+ resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==}
+
+ '@hono/node-server@1.19.6':
+ resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
+ '@humanfs/core@0.19.1':
+ resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.7':
+ resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@intlify/core-base@11.2.2':
+ resolution: {integrity: sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==}
+ engines: {node: '>= 16'}
+
+ '@intlify/message-compiler@11.2.2':
+ resolution: {integrity: sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==}
+ engines: {node: '>= 16'}
+
+ '@intlify/shared@11.2.2':
+ resolution: {integrity: sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==}
+ engines: {node: '>= 16'}
+
+ '@inversifyjs/common@1.3.3':
+ resolution: {integrity: sha512-ZH0wrgaJwIo3s9gMCDM2wZoxqrJ6gB97jWXncROfYdqZJv8f3EkqT57faZqN5OTeHWgtziQ6F6g3L8rCvGceCw==}
+
+ '@inversifyjs/core@1.3.4':
+ resolution: {integrity: sha512-gCCmA4BdbHEFwvVZ2elWgHuXZWk6AOu/1frxsS+2fWhjEk2c/IhtypLo5ytSUie1BCiT6i9qnEo4bruBomQsAA==}
+
+ '@inversifyjs/reflect-metadata-utils@0.2.3':
+ resolution: {integrity: sha512-d3D0o9TeSlvaGM2I24wcNw/Aj3rc4OYvHXOKDC09YEph5fMMiKd6fq1VTQd9tOkDNWvVbw+cnt45Wy9P/t5Lvw==}
+ peerDependencies:
+ reflect-metadata: 0.2.2
+
+ '@isaacs/balanced-match@4.0.1':
+ resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
+ engines: {node: 20 || >=22}
+
+ '@isaacs/brace-expansion@5.0.0':
+ resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
+ engines: {node: 20 || >=22}
+
+ '@isaacs/cliui@8.0.2':
+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+ engines: {node: '>=12'}
+
+ '@javascript-obfuscator/escodegen@2.3.1':
+ resolution: {integrity: sha512-Z0HEAVwwafOume+6LFXirAVZeuEMKWuPzpFbQhCEU9++BMz0IwEa9bmedJ+rMn/IlXRBID9j3gQ0XYAa6jM10g==}
+ engines: {node: '>=6.0'}
+
+ '@javascript-obfuscator/estraverse@5.4.0':
+ resolution: {integrity: sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w==}
+ engines: {node: '>=4.0'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/source-map@0.3.11':
+ resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@libsql/client@0.14.0':
+ resolution: {integrity: sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==}
+
+ '@libsql/core@0.14.0':
+ resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==}
+
+ '@libsql/darwin-arm64@0.4.7':
+ resolution: {integrity: sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@libsql/darwin-x64@0.4.7':
+ resolution: {integrity: sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@libsql/hrana-client@0.7.0':
+ resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==}
+
+ '@libsql/isomorphic-fetch@0.3.1':
+ resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==}
+ engines: {node: '>=18.0.0'}
+
+ '@libsql/isomorphic-ws@0.1.5':
+ resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
+
+ '@libsql/linux-arm64-gnu@0.4.7':
+ resolution: {integrity: sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@libsql/linux-arm64-musl@0.4.7':
+ resolution: {integrity: sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@libsql/linux-x64-gnu@0.4.7':
+ resolution: {integrity: sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@libsql/linux-x64-musl@0.4.7':
+ resolution: {integrity: sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@libsql/win32-x64-msvc@0.4.7':
+ resolution: {integrity: sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@lukeed/ms@2.0.2':
+ resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
+ engines: {node: '>=8'}
+
+ '@mrleebo/prisma-ast@0.12.1':
+ resolution: {integrity: sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==}
+ engines: {node: '>=16'}
+
+ '@neon-rs/load@0.0.4':
+ resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@otplib/core@12.0.1':
+ resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
+
+ '@otplib/plugin-crypto@12.0.1':
+ resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
+
+ '@otplib/plugin-thirty-two@12.0.1':
+ resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
+
+ '@otplib/preset-default@12.0.1':
+ resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
+
+ '@otplib/preset-v11@12.0.1':
+ resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
+
+ '@petamoriken/float16@3.9.3':
+ resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
+
+ '@pinojs/redact@0.4.0':
+ resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+
+ '@prisma/adapter-pg@7.1.0':
+ resolution: {integrity: sha512-DSAnUwkKfX4bUzhkrjGN4IBQzwg0nvFw2W17H0Oa532I5w9nLtTJ9mAEGDs1nUBEGRAsa0c7qsf8CSgfJ4DsBQ==}
+
+ '@prisma/client-runtime-utils@7.1.0':
+ resolution: {integrity: sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==}
+
+ '@prisma/client@7.1.0':
+ resolution: {integrity: sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==}
+ engines: {node: ^20.19 || ^22.12 || >=24.0}
+ peerDependencies:
+ prisma: '*'
+ typescript: '>=5.4.0'
+ peerDependenciesMeta:
+ prisma:
+ optional: true
+ typescript:
+ optional: true
+
+ '@prisma/config@7.1.0':
+ resolution: {integrity: sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==}
+
+ '@prisma/debug@6.8.2':
+ resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
+
+ '@prisma/debug@7.1.0':
+ resolution: {integrity: sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==}
+
+ '@prisma/dev@0.15.0':
+ resolution: {integrity: sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==}
+
+ '@prisma/driver-adapter-utils@7.1.0':
+ resolution: {integrity: sha512-AlVLzeXkw81+47MvQ9M8DvTiHkRfJ8xzklTbYjpskb0cTTDVHboTI/OVwT6Wcep/bNvfLKJYO0nylBiM5rxgww==}
+
+ '@prisma/engines-version@7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba':
+ resolution: {integrity: sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==}
+
+ '@prisma/engines@7.1.0':
+ resolution: {integrity: sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==}
+
+ '@prisma/fetch-engine@7.1.0':
+ resolution: {integrity: sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==}
+
+ '@prisma/get-platform@6.8.2':
+ resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
+
+ '@prisma/get-platform@7.1.0':
+ resolution: {integrity: sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==}
+
+ '@prisma/query-plan-executor@6.18.0':
+ resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==}
+
+ '@prisma/studio-core@0.8.2':
+ resolution: {integrity: sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+
+ '@rollup/pluginutils@5.3.0':
+ resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.53.3':
+ resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.53.3':
+ resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.53.3':
+ resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.53.3':
+ resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.53.3':
+ resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.53.3':
+ resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+ resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+ resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.3':
+ resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.53.3':
+ resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.3':
+ resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+ resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+ resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.3':
+ resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.3':
+ resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.53.3':
+ resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.53.3':
+ resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openharmony-arm64@4.53.3':
+ resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.3':
+ resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.3':
+ resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.53.3':
+ resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.53.3':
+ resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@smithy/abort-controller@4.2.5':
+ resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/config-resolver@4.4.3':
+ resolution: {integrity: sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/core@3.18.7':
+ resolution: {integrity: sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/credential-provider-imds@4.2.5':
+ resolution: {integrity: sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/fetch-http-handler@5.3.6':
+ resolution: {integrity: sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/hash-node@4.2.5':
+ resolution: {integrity: sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/invalid-dependency@4.2.5':
+ resolution: {integrity: sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/is-array-buffer@2.2.0':
+ resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/is-array-buffer@4.2.0':
+ resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-content-length@4.2.5':
+ resolution: {integrity: sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-endpoint@4.3.14':
+ resolution: {integrity: sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-retry@4.4.14':
+ resolution: {integrity: sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-serde@4.2.6':
+ resolution: {integrity: sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-stack@4.2.5':
+ resolution: {integrity: sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/node-config-provider@4.3.5':
+ resolution: {integrity: sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/node-http-handler@4.4.5':
+ resolution: {integrity: sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/property-provider@4.2.5':
+ resolution: {integrity: sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/protocol-http@5.3.5':
+ resolution: {integrity: sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/querystring-builder@4.2.5':
+ resolution: {integrity: sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/querystring-parser@4.2.5':
+ resolution: {integrity: sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/service-error-classification@4.2.5':
+ resolution: {integrity: sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/shared-ini-file-loader@4.4.0':
+ resolution: {integrity: sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/signature-v4@5.3.5':
+ resolution: {integrity: sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/smithy-client@4.9.10':
+ resolution: {integrity: sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/types@4.9.0':
+ resolution: {integrity: sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/url-parser@4.2.5':
+ resolution: {integrity: sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-base64@4.3.0':
+ resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-body-length-browser@4.2.0':
+ resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-body-length-node@4.2.1':
+ resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-buffer-from@2.2.0':
+ resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/util-buffer-from@4.2.0':
+ resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-config-provider@4.2.0':
+ resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-defaults-mode-browser@4.3.13':
+ resolution: {integrity: sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-defaults-mode-node@4.2.16':
+ resolution: {integrity: sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-endpoints@3.2.5':
+ resolution: {integrity: sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-hex-encoding@4.2.0':
+ resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-middleware@4.2.5':
+ resolution: {integrity: sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-retry@4.2.5':
+ resolution: {integrity: sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-stream@4.5.6':
+ resolution: {integrity: sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-uri-escape@4.2.0':
+ resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-utf8@2.3.0':
+ resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/util-utf8@4.2.0':
+ resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/uuid@1.1.0':
+ resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
+ engines: {node: '>=18.0.0'}
+
+ '@standard-schema/spec@1.0.0':
+ resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
+
+ '@tailwindcss/forms@0.5.10':
+ resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/minimatch@3.0.5':
+ resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
+
+ '@types/node-cron@3.0.11':
+ resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
+
+ '@types/node@18.19.130':
+ resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
+
+ '@types/node@24.10.1':
+ resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
+
+ '@types/nodemailer@7.0.4':
+ resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==}
+
+ '@types/pg@8.15.6':
+ resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
+
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
+ '@types/react@19.2.7':
+ resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
+
+ '@types/ssh2-sftp-client@9.0.6':
+ resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
+
+ '@types/ssh2@1.15.5':
+ resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
+
+ '@types/validator@13.15.10':
+ resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
+
+ '@types/web-bluetooth@0.0.21':
+ resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
+
+ '@types/ws@8.18.1':
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+
+ '@typescript-eslint/eslint-plugin@8.48.1':
+ resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.48.1
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/parser@8.48.1':
+ resolution: {integrity: sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/project-service@8.48.1':
+ resolution: {integrity: sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/scope-manager@8.48.1':
+ resolution: {integrity: sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.48.1':
+ resolution: {integrity: sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/type-utils@8.48.1':
+ resolution: {integrity: sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/types@8.48.1':
+ resolution: {integrity: sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.48.1':
+ resolution: {integrity: sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/utils@8.48.1':
+ resolution: {integrity: sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/visitor-keys@8.48.1':
+ resolution: {integrity: sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-vue@5.2.4':
+ resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+
+ '@volar/language-core@2.4.26':
+ resolution: {integrity: sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==}
+
+ '@volar/source-map@2.4.26':
+ resolution: {integrity: sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==}
+
+ '@volar/typescript@2.4.26':
+ resolution: {integrity: sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==}
+
+ '@vue/compiler-core@3.5.25':
+ resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==}
+
+ '@vue/compiler-dom@3.5.25':
+ resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==}
+
+ '@vue/compiler-sfc@3.5.25':
+ resolution: {integrity: sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==}
+
+ '@vue/compiler-ssr@3.5.25':
+ resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==}
+
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+ '@vue/language-core@3.1.6':
+ resolution: {integrity: sha512-F3BIvDVyyj+6Sgl9Ev9zsb/DJ48rrH2EiI5NnIEpJKo7Yk8v0n2QjfG7/RYyFhYSMOJcsf6aAt5hx4JaNbhKbg==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/reactivity@3.5.25':
+ resolution: {integrity: sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==}
+
+ '@vue/runtime-core@3.5.25':
+ resolution: {integrity: sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==}
+
+ '@vue/runtime-dom@3.5.25':
+ resolution: {integrity: sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==}
+
+ '@vue/server-renderer@3.5.25':
+ resolution: {integrity: sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==}
+ peerDependencies:
+ vue: 3.5.25
+
+ '@vue/shared@3.5.25':
+ resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==}
+
+ '@vueuse/core@12.8.2':
+ resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
+
+ '@vueuse/metadata@12.8.2':
+ resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
+
+ '@vueuse/shared@12.8.2':
+ resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
+
+ '@xterm/addon-clipboard@0.2.0':
+ resolution: {integrity: sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==}
+
+ '@xterm/addon-fit@0.11.0':
+ resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
+
+ '@xterm/addon-image@0.9.0':
+ resolution: {integrity: sha512-oYWA8/QAr5/Emwl1xL7WCoOqeG3IZfpzEz/OVq1j4Oi9934TQmHiyubClikRf0D/jL3JNiNuz/Lsqx0kXQ02BA==}
+
+ '@xterm/addon-search@0.16.0':
+ resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==}
+
+ '@xterm/addon-serialize@0.14.0':
+ resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==}
+
+ '@xterm/addon-unicode11@0.9.0':
+ resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
+
+ '@xterm/addon-web-links@0.12.0':
+ resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
+
+ '@xterm/addon-webgl@0.19.0':
+ resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
+
+ '@xterm/xterm@6.0.0':
+ resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
+
+ abstract-logging@2.0.1:
+ resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv@6.12.6:
+ resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+ ajv@8.17.1:
+ resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+
+ alien-signals@3.1.1:
+ resolution: {integrity: sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-regex@6.2.2:
+ resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+ engines: {node: '>=12'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ array-differ@3.0.0:
+ resolution: {integrity: sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==}
+ engines: {node: '>=8'}
+
+ array-union@2.1.0:
+ resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
+ engines: {node: '>=8'}
+
+ arrify@2.0.1:
+ resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
+ engines: {node: '>=8'}
+
+ asn1.js@5.4.1:
+ resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
+
+ asn1@0.2.6:
+ resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
+
+ assert@2.1.0:
+ resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ atomic-sleep@1.0.0:
+ resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
+ engines: {node: '>=8.0.0'}
+
+ atomically@2.1.0:
+ resolution: {integrity: sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==}
+
+ autoprefixer@10.4.22:
+ resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ avvio@9.1.0:
+ resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
+
+ aws-ssl-profiles@1.1.2:
+ resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
+ engines: {node: '>= 6.0.0'}
+
+ axios@1.13.2:
+ resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ base-64@1.0.0:
+ resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
+
+ baseline-browser-mapping@2.9.2:
+ resolution: {integrity: sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==}
+ hasBin: true
+
+ basic-ftp@5.0.5:
+ resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
+ engines: {node: '>=10.0.0'}
+
+ bcrypt-pbkdf@1.0.2:
+ resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
+
+ bcryptjs@2.4.3:
+ resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
+
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
+ bn.js@4.12.2:
+ resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
+
+ boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+
+ bowser@2.13.1:
+ resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==}
+
+ brace-expansion@1.1.12:
+ resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+ brace-expansion@2.0.2:
+ resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
+ buffer-from@1.1.2:
+ resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+ buildcheck@0.0.7:
+ resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
+ engines: {node: '>=10.0.0'}
+
+ byte-length@1.0.2:
+ resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==}
+
+ c12@3.1.0:
+ resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
+ peerDependencies:
+ magicast: ^0.3.5
+ peerDependenciesMeta:
+ magicast:
+ optional: true
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.8:
+ resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001759:
+ resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ chance@1.1.13:
+ resolution: {integrity: sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg==}
+
+ char-regex@1.0.2:
+ resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
+ engines: {node: '>=10'}
+
+ charenc@0.0.2:
+ resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
+
+ chevrotain@10.5.0:
+ resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==}
+
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ citty@0.1.6:
+ resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
+
+ class-validator@0.14.3:
+ resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==}
+
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
+ cliui@8.0.1:
+ resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+ engines: {node: '>=12'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ colorette@2.0.20:
+ resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
+
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
+ commander@12.1.0:
+ resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ engines: {node: '>=18'}
+
+ commander@2.20.3:
+ resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ concat-stream@2.0.0:
+ resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
+ engines: {'0': node >= 6.0}
+
+ concurrently@9.2.1:
+ resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ conf@15.0.2:
+ resolution: {integrity: sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==}
+ engines: {node: '>=20'}
+
+ confbox@0.2.2:
+ resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
+
+ consola@3.4.2:
+ resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
+ engines: {node: ^14.18.0 || >=16.10.0}
+
+ content-disposition@0.5.4:
+ resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+ engines: {node: '>= 0.6'}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cpu-features@0.0.10:
+ resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
+ engines: {node: '>=10.0.0'}
+
+ cross-env@10.1.0:
+ resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ crypt@0.0.2:
+ resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ data-uri-to-buffer@4.0.1:
+ resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+ engines: {node: '>= 12'}
+
+ dateformat@4.6.3:
+ resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
+
+ debounce-fn@6.0.0:
+ resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
+ engines: {node: '>=18'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ deepmerge-ts@7.1.5:
+ resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
+ engines: {node: '>=16.0.0'}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ defu@6.1.4:
+ resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ denque@2.1.0:
+ resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
+ engines: {node: '>=0.10'}
+
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ destr@2.0.5:
+ resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+
+ detect-libc@2.0.2:
+ resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
+ engines: {node: '>=8'}
+
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+ dot-prop@10.1.0:
+ resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==}
+ engines: {node: '>=20'}
+
+ dotenv@16.6.1:
+ resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
+ engines: {node: '>=12'}
+
+ dotenv@17.2.3:
+ resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
+ engines: {node: '>=12'}
+
+ drizzle-kit@0.30.6:
+ resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
+ hasBin: true
+
+ drizzle-orm@0.38.4:
+ resolution: {integrity: sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q==}
+ peerDependencies:
+ '@aws-sdk/client-rds-data': '>=3'
+ '@cloudflare/workers-types': '>=4'
+ '@electric-sql/pglite': '>=0.2.0'
+ '@libsql/client': '>=0.10.0'
+ '@libsql/client-wasm': '>=0.10.0'
+ '@neondatabase/serverless': '>=0.10.0'
+ '@op-engineering/op-sqlite': '>=2'
+ '@opentelemetry/api': ^1.4.1
+ '@planetscale/database': '>=1'
+ '@prisma/client': '*'
+ '@tidbcloud/serverless': '*'
+ '@types/better-sqlite3': '*'
+ '@types/pg': '*'
+ '@types/react': '>=18'
+ '@types/sql.js': '*'
+ '@vercel/postgres': '>=0.8.0'
+ '@xata.io/client': '*'
+ better-sqlite3: '>=7'
+ bun-types: '*'
+ expo-sqlite: '>=14.0.0'
+ knex: '*'
+ kysely: '*'
+ mysql2: '>=2'
+ pg: '>=8'
+ postgres: '>=3'
+ prisma: '*'
+ react: '>=18'
+ sql.js: '>=1'
+ sqlite3: '>=5'
+ peerDependenciesMeta:
+ '@aws-sdk/client-rds-data':
+ optional: true
+ '@cloudflare/workers-types':
+ optional: true
+ '@electric-sql/pglite':
+ optional: true
+ '@libsql/client':
+ optional: true
+ '@libsql/client-wasm':
+ optional: true
+ '@neondatabase/serverless':
+ optional: true
+ '@op-engineering/op-sqlite':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@tidbcloud/serverless':
+ optional: true
+ '@types/better-sqlite3':
+ optional: true
+ '@types/pg':
+ optional: true
+ '@types/react':
+ optional: true
+ '@types/sql.js':
+ optional: true
+ '@vercel/postgres':
+ optional: true
+ '@xata.io/client':
+ optional: true
+ better-sqlite3:
+ optional: true
+ bun-types:
+ optional: true
+ expo-sqlite:
+ optional: true
+ knex:
+ optional: true
+ kysely:
+ optional: true
+ mysql2:
+ optional: true
+ pg:
+ optional: true
+ postgres:
+ optional: true
+ prisma:
+ optional: true
+ react:
+ optional: true
+ sql.js:
+ optional: true
+ sqlite3:
+ optional: true
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ duplexify@4.1.3:
+ resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
+
+ eastasianwidth@0.2.0:
+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
+ effect@3.18.4:
+ resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
+
+ electron-to-chromium@1.5.264:
+ resolution: {integrity: sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==}
+
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+ empathic@2.0.0:
+ resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
+ engines: {node: '>=14'}
+
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
+ env-paths@3.0.0:
+ resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ err-code@2.0.3:
+ resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.1:
+ resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ esbuild-register@3.6.0:
+ resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
+ peerDependencies:
+ esbuild: '>=0.12 <1'
+
+ esbuild@0.18.20:
+ resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ esbuild@0.19.12:
+ resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ esbuild@0.27.1:
+ resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-plugin-vue@9.33.0:
+ resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==}
+ engines: {node: ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
+
+ eslint-scope@7.2.2:
+ resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.39.1:
+ resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ espree@9.6.1:
+ resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ esquery@1.6.0:
+ resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ exsolve@1.0.8:
+ resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
+
+ fast-check@3.23.2:
+ resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
+ engines: {node: '>=8.0.0'}
+
+ fast-copy@4.0.0:
+ resolution: {integrity: sha512-/oA0gx1xyXE9R2YlV4FXwZJXngFdm9Du0zN8FhY38jnLkhp1u35h6bCyKgRhlsA6C9I+1vfXE4KISdt7xc6M9w==}
+
+ fast-decode-uri-component@1.0.1:
+ resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-json-stringify@6.1.1:
+ resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==}
+
+ fast-jwt@5.0.6:
+ resolution: {integrity: sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==}
+ engines: {node: '>=20'}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fast-querystring@1.1.2:
+ resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
+
+ fast-safe-stringify@2.1.1:
+ resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
+
+ fast-uri@3.1.0:
+ resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+
+ fast-xml-parser@4.5.3:
+ resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==}
+ hasBin: true
+
+ fast-xml-parser@5.2.5:
+ resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
+ hasBin: true
+
+ fastfall@1.5.1:
+ resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==}
+ engines: {node: '>=0.10.0'}
+
+ fastify-cloudflare-turnstile@2.0.2:
+ resolution: {integrity: sha512-TshbsPhcYfqkn4uuzj3mr79a9Ow6v8nAuylVHnQZCZBoOTfnIv5X7DozfhCuuI9hfKxHDMTtFpKYxkj16UtLVQ==}
+ engines: {node: '>=20'}
+
+ fastify-plugin@5.1.0:
+ resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
+
+ fastify@5.6.2:
+ resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
+
+ fastparallel@2.4.1:
+ resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
+
+ fastq@1.19.1:
+ resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
+
+ fastseries@1.7.2:
+ resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fetch-blob@3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+ engines: {node: ^12.20 || >= 14.13}
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ find-my-way@9.3.0:
+ resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
+ engines: {node: '>=20'}
+
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flag-icons@7.5.0:
+ resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.3.3:
+ resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+
+ follow-redirects@1.15.11:
+ resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ foreground-child@3.3.1:
+ resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+ engines: {node: '>=14'}
+
+ form-data@4.0.5:
+ resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
+ engines: {node: '>= 6'}
+
+ formdata-polyfill@4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ gel@2.2.0:
+ resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
+ engines: {node: '>= 18.0.0'}
+ hasBin: true
+
+ generate-function@2.3.1:
+ resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-port-please@3.1.2:
+ resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-tsconfig@4.13.0:
+ resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
+
+ giget@2.0.0:
+ resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
+ hasBin: true
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ glob@11.1.0:
+ resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
+ engines: {node: 20 || >=22}
+ hasBin: true
+
+ globals@13.24.0:
+ resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
+ engines: {node: '>=8'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ grammex@3.1.12:
+ resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==}
+
+ graphemer@1.4.0:
+ resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.2:
+ resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+ engines: {node: '>= 0.4'}
+
+ helmet@8.1.0:
+ resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
+ engines: {node: '>=18.0.0'}
+
+ help-me@5.0.0:
+ resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
+
+ hono@4.10.6:
+ resolution: {integrity: sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==}
+ engines: {node: '>=16.9.0'}
+
+ hot-patcher@2.0.1:
+ resolution: {integrity: sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==}
+
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
+ http-status-codes@2.3.0:
+ resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
+
+ iconv-lite@0.7.0:
+ resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
+ engines: {node: '>=0.10.0'}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ inversify@6.1.4:
+ resolution: {integrity: sha512-PbxrZH/gTa1fpPEEGAjJQzK8tKMIp5gRg6EFNJlCtzUcycuNdmhv3uk5P8Itm/RIjgHJO16oQRLo9IHzQN51bA==}
+
+ ip-address@10.1.0:
+ resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@2.3.0:
+ resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
+ engines: {node: '>= 10'}
+
+ is-arguments@1.2.0:
+ resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
+ engines: {node: '>= 0.4'}
+
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-buffer@1.1.6:
+ resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-core-module@2.16.1:
+ resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-nan@1.3.2:
+ resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
+ engines: {node: '>= 0.4'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-property@1.0.2:
+ resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ isexe@3.1.1:
+ resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
+ engines: {node: '>=16'}
+
+ jackspeak@4.1.1:
+ resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
+ engines: {node: 20 || >=22}
+
+ javascript-obfuscator@5.0.1:
+ resolution: {integrity: sha512-x7EId6zLv5in+4MavFzOSY8KX7obmayNP1OEv47BiX6NDRm6vZvFJJAWW9N7ZkVS7BKoadO7nn5GjVG3bUZ9xQ==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ joycon@3.1.1:
+ resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
+ engines: {node: '>=10'}
+
+ js-base64@3.7.8:
+ resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
+
+ js-string-escape@1.0.1:
+ resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==}
+ engines: {node: '>= 0.8'}
+
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-ref-resolver@3.0.0:
+ resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ jsonwebtoken@9.0.3:
+ resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
+ engines: {node: '>=12', npm: '>=6'}
+
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ layerr@3.0.0:
+ resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==}
+
+ levn@0.3.0:
+ resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==}
+ engines: {node: '>= 0.8.0'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ libphonenumber-js@1.12.31:
+ resolution: {integrity: sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==}
+
+ libsql@0.4.7:
+ resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==}
+ cpu: [x64, arm64, wasm32]
+ os: [darwin, linux, win32]
+
+ light-my-request@6.6.0:
+ resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
+
+ lilconfig@2.1.0:
+ resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
+ engines: {node: '>=10'}
+
+ lilconfig@3.1.3:
+ resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
+ lodash@4.17.21:
+ resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
+ lru-cache@11.2.4:
+ resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
+ engines: {node: 20 || >=22}
+
+ lru-cache@7.18.3:
+ resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+ engines: {node: '>=12'}
+
+ lru.min@1.1.3:
+ resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==}
+ engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ marked@17.0.1:
+ resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ md5@2.3.0:
+ resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ mime@3.0.0:
+ resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+
+ mimic-function@5.0.1:
+ resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
+ engines: {node: '>=18'}
+
+ mini-svg-data-uri@1.4.4:
+ resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
+ hasBin: true
+
+ minimalistic-assert@1.0.1:
+ resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
+
+ minimatch@10.1.1:
+ resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
+ engines: {node: 20 || >=22}
+
+ minimatch@3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+ minimatch@9.0.5:
+ resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ minipass@7.1.2:
+ resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ mkdirp@3.0.1:
+ resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ mnemonist@0.40.0:
+ resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
+
+ mnemonist@0.40.3:
+ resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ multimatch@5.0.0:
+ resolution: {integrity: sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==}
+ engines: {node: '>=10'}
+
+ mysql2@3.15.3:
+ resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
+ engines: {node: '>= 8.0'}
+
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+ named-placeholders@1.1.3:
+ resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==}
+ engines: {node: '>=12.0.0'}
+
+ nan@2.24.0:
+ resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ nanoid@5.1.6:
+ resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ nested-property@4.0.0:
+ resolution: {integrity: sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==}
+
+ node-cron@4.2.1:
+ resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
+ engines: {node: '>=6.0.0'}
+
+ node-domexception@1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
+
+ node-fetch-native@1.6.7:
+ resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+
+ node-fetch@3.3.2:
+ resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
+ nodemailer@7.0.11:
+ resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==}
+ engines: {node: '>=6.0.0'}
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ normalize-range@0.1.2:
+ resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
+ engines: {node: '>=0.10.0'}
+
+ nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+
+ nypm@0.6.2:
+ resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
+ engines: {node: ^14.16.0 || >=16.10.0}
+ hasBin: true
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
+ object-is@1.1.6:
+ resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ obliterator@2.0.5:
+ resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
+
+ ohash@2.0.11:
+ resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+
+ on-exit-leak-free@2.1.2:
+ resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
+ engines: {node: '>=14.0.0'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ optionator@0.8.3:
+ resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
+ engines: {node: '>= 0.8.0'}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ otplib@12.0.1:
+ resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
+
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-limit@7.2.0:
+ resolution: {integrity: sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==}
+ engines: {node: '>=20'}
+
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
+ package-json-from-dist@1.0.1:
+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ path-posix@1.0.0:
+ resolution: {integrity: sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==}
+
+ path-scurry@2.0.1:
+ resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
+ engines: {node: 20 || >=22}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ perfect-debounce@1.0.0:
+ resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+
+ pg-cloudflare@1.2.7:
+ resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
+
+ pg-connection-string@2.9.1:
+ resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
+
+ pg-int8@1.0.1:
+ resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
+ engines: {node: '>=4.0.0'}
+
+ pg-pool@3.10.1:
+ resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
+ peerDependencies:
+ pg: '>=8.0'
+
+ pg-protocol@1.10.3:
+ resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
+
+ pg-types@2.2.0:
+ resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
+ engines: {node: '>=4'}
+
+ pg@8.16.3:
+ resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
+ engines: {node: '>= 16.0.0'}
+ peerDependencies:
+ pg-native: '>=3.0.1'
+ peerDependenciesMeta:
+ pg-native:
+ optional: true
+
+ pgpass@1.0.5:
+ resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+
+ pinia@2.3.1:
+ resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
+ peerDependencies:
+ typescript: '>=4.4.4'
+ vue: ^2.7.0 || ^3.5.11
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ pino-abstract-transport@2.0.0:
+ resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
+
+ pino-abstract-transport@3.0.0:
+ resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
+
+ pino-pretty@13.1.3:
+ resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
+ hasBin: true
+
+ pino-std-serializers@7.0.0:
+ resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
+
+ pino@10.1.0:
+ resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==}
+ hasBin: true
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ pkg-types@2.3.0:
+ resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.2:
+ resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postgres-array@2.0.0:
+ resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
+ engines: {node: '>=4'}
+
+ postgres-array@3.0.4:
+ resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
+ engines: {node: '>=12'}
+
+ postgres-bytea@1.0.0:
+ resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
+ engines: {node: '>=0.10.0'}
+
+ postgres-date@1.0.7:
+ resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
+ engines: {node: '>=0.10.0'}
+
+ postgres-interval@1.2.0:
+ resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
+ engines: {node: '>=0.10.0'}
+
+ postgres@3.4.7:
+ resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
+ engines: {node: '>=12'}
+
+ prelude-ls@1.1.2:
+ resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==}
+ engines: {node: '>= 0.8.0'}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prisma@7.1.0:
+ resolution: {integrity: sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==}
+ engines: {node: ^20.19 || ^22.12 || >=24.0}
+ hasBin: true
+ peerDependencies:
+ better-sqlite3: '>=9.0.0'
+ typescript: '>=5.4.0'
+ peerDependenciesMeta:
+ better-sqlite3:
+ optional: true
+ typescript:
+ optional: true
+
+ process-warning@4.0.1:
+ resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
+
+ process-warning@5.0.0:
+ resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
+
+ process@0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+ engines: {node: '>= 0.6.0'}
+
+ promise-limit@2.7.0:
+ resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
+
+ promise-retry@2.0.1:
+ resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
+ engines: {node: '>=10'}
+
+ proper-lockfile@4.1.2:
+ resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
+
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+ pump@3.0.3:
+ resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ pure-rand@6.1.0:
+ resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
+ querystringify@2.2.0:
+ resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ quick-format-unescaped@4.0.4:
+ resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+
+ rc9@2.1.2:
+ resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
+
+ react-dom@19.2.1:
+ resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
+ peerDependencies:
+ react: ^19.2.1
+
+ react@19.2.1:
+ resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
+ engines: {node: '>=0.10.0'}
+
+ read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
+ real-require@0.2.0:
+ resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
+ engines: {node: '>= 12.13.0'}
+
+ reflect-metadata@0.2.2:
+ resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
+
+ regexp-to-ast@0.5.0:
+ resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==}
+
+ remeda@2.21.3:
+ resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==}
+
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
+ requires-port@1.0.0:
+ resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ resolve@1.22.11:
+ resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ ret@0.5.0:
+ resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
+ engines: {node: '>=10'}
+
+ retry@0.12.0:
+ resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
+ engines: {node: '>= 4'}
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rfdc@1.4.1:
+ resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+
+ rollup-plugin-obfuscator@1.1.0:
+ resolution: {integrity: sha512-cMfQIKyGePlfHGGO+rSDhSATMBx7WWxXW/X66c53HylUE/owanTbG6nhttUQOkbdCiQH8tClskNEH/IPRoqZwA==}
+ peerDependencies:
+ javascript-obfuscator: '*'
+ rollup: ^2.56.3||^3.0.0||^4.0.0
+
+ rollup@4.53.3:
+ resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex2@5.0.0:
+ resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
+
+ safe-stable-stringify@2.5.0:
+ resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
+ engines: {node: '>=10'}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ secure-json-parse@4.1.0:
+ resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
+
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ seq-queue@0.0.5:
+ resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
+
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ shell-quote@1.8.3:
+ resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
+ engines: {node: '>= 0.4'}
+
+ signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
+ simple-icons@16.1.0:
+ resolution: {integrity: sha512-fL95b22MWTn3y+zQ/QC9oKqJ6mXXNwm35oy5XMVPdvJZGfGNWDMMb33kfqm50OuRKNcdjbk7HQ73NdWcvaBXIQ==}
+ engines: {node: '>=0.12.18'}
+
+ sonic-boom@4.2.0:
+ resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ source-map-support@0.5.21:
+ resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
+ sql.js@1.13.0:
+ resolution: {integrity: sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==}
+
+ sqlstring@2.3.3:
+ resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
+ engines: {node: '>= 0.6'}
+
+ ssh2-sftp-client@11.0.0:
+ resolution: {integrity: sha512-lOjgNYtioYquhtgyHwPryFNhllkuENjvCKkUXo18w/Q4UpEffCnEUBfiOTlwFdKIhG1rhrOGnA6DeKPSF2CP6w==}
+ engines: {node: '>=18.20.4'}
+
+ ssh2@1.17.0:
+ resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
+ engines: {node: '>=10.16.0'}
+
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
+ std-env@3.9.0:
+ resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
+
+ steed@1.1.3:
+ resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
+
+ stream-shift@1.0.3:
+ resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
+
+ string-template@1.0.0:
+ resolution: {integrity: sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==}
+
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ string-width@5.1.2:
+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+ engines: {node: '>=12'}
+
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ stringz@2.1.0:
+ resolution: {integrity: sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A==}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
+ strip-ansi@7.1.2:
+ resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
+ engines: {node: '>=12'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ strip-json-comments@5.0.3:
+ resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
+ engines: {node: '>=14.16'}
+
+ strnum@1.1.2:
+ resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
+
+ strnum@2.1.1:
+ resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==}
+
+ stubborn-fs@2.0.0:
+ resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==}
+
+ stubborn-utils@1.0.2:
+ resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==}
+
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-color@8.1.1:
+ resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+ engines: {node: '>=10'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
+ tailwindcss@3.4.18:
+ resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ terser@5.44.1:
+ resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+ thirty-two@1.0.2:
+ resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
+ engines: {node: '>=0.2.6'}
+
+ thread-stream@3.1.0:
+ resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
+
+ tinyexec@1.0.2:
+ resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ toad-cache@3.7.0:
+ resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
+ engines: {node: '>=12'}
+
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
+ ts-api-utils@2.1.0:
+ resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tsx@4.21.0:
+ resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ tweetnacl@0.14.5:
+ resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
+
+ type-check@0.3.2:
+ resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
+ engines: {node: '>= 0.8.0'}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ type-fest@0.20.2:
+ resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
+ engines: {node: '>=10'}
+
+ type-fest@4.41.0:
+ resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
+ engines: {node: '>=16'}
+
+ type-fest@5.3.1:
+ resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==}
+ engines: {node: '>=20'}
+
+ typedarray@0.0.6:
+ resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ uint8array-extras@1.5.0:
+ resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
+ engines: {node: '>=18'}
+
+ undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ undici@6.22.0:
+ resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==}
+ engines: {node: '>=18.17'}
+
+ undici@7.16.0:
+ resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
+ engines: {node: '>=20.18.1'}
+
+ update-browserslist-db@1.2.2:
+ resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ url-join@5.0.0:
+ resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ url-parse@1.5.10:
+ resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ util@0.12.5:
+ resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
+
+ valibot@1.2.0:
+ resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
+ peerDependencies:
+ typescript: '>=5'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ validator@13.15.23:
+ resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
+ engines: {node: '>= 0.10'}
+
+ vite@6.4.1:
+ resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vscode-uri@3.1.0:
+ resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+ vue-demi@0.14.10:
+ resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
+ engines: {node: '>=12'}
+ hasBin: true
+ peerDependencies:
+ '@vue/composition-api': ^1.0.0-rc.1
+ vue: ^3.0.0-0 || ^2.6.0
+ peerDependenciesMeta:
+ '@vue/composition-api':
+ optional: true
+
+ vue-eslint-parser@9.4.3:
+ resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
+ engines: {node: ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '>=6.0.0'
+
+ vue-i18n@11.2.2:
+ resolution: {integrity: sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ vue: ^3.0.0
+
+ vue-router@4.6.3:
+ resolution: {integrity: sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==}
+ peerDependencies:
+ vue: ^3.5.0
+
+ vue-tsc@3.1.6:
+ resolution: {integrity: sha512-h5mMNGIDI+WMZxTeuYcpfSeDtBIiHXAg3qsrt65H4vcFTYmuM1THNHMzlnDvD8kX0fwLuf6auxWP340bH/zcpw==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
+ vue-turnstile@1.0.11:
+ resolution: {integrity: sha512-iaTBoZ5oUqtNRto6bmbn6FQvW0h/sK7mPUJc1Qn4em+cELXN59U2FQTcpWfKssV3OY6lEZzmCpcn/zrb7htK3A==}
+ peerDependencies:
+ vue: ^3.2.45
+
+ vue@3.5.25:
+ resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ web-streams-polyfill@3.3.3:
+ resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
+ engines: {node: '>= 8'}
+
+ webdav@5.8.0:
+ resolution: {integrity: sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==}
+ engines: {node: '>=14'}
+
+ when-exit@2.1.5:
+ resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==}
+
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
+ which-typed-array@1.1.19:
+ resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ which@4.0.0:
+ resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
+ engines: {node: ^16.13.0 || >=18.0.0}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
+ wrap-ansi@8.1.0:
+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+ engines: {node: '>=12'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ ws@8.18.3:
+ resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ xml-name-validator@4.0.0:
+ resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
+ engines: {node: '>=12'}
+
+ xtend@4.0.2:
+ resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+ engines: {node: '>=0.4'}
+
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
+ y18n@5.0.8:
+ resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+ engines: {node: '>=10'}
+
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
+ yargs-parser@21.1.1:
+ resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+ engines: {node: '>=12'}
+
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
+ yargs@17.7.2:
+ resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ engines: {node: '>=12'}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ yocto-queue@1.2.2:
+ resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
+ engines: {node: '>=12.20'}
+
+ zeptomatch@2.0.2:
+ resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@aws-crypto/sha256-browser@5.2.0':
+ dependencies:
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-crypto/supports-web-crypto': 5.2.0
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-locate-window': 3.893.0
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-crypto/sha256-js@5.2.0':
+ dependencies:
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.936.0
+ tslib: 2.8.1
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-crypto/util@5.2.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-sdk/client-sesv2@3.947.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/credential-provider-node': 3.947.0
+ '@aws-sdk/middleware-host-header': 3.936.0
+ '@aws-sdk/middleware-logger': 3.936.0
+ '@aws-sdk/middleware-recursion-detection': 3.936.0
+ '@aws-sdk/middleware-user-agent': 3.947.0
+ '@aws-sdk/region-config-resolver': 3.936.0
+ '@aws-sdk/signature-v4-multi-region': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@aws-sdk/util-user-agent-browser': 3.936.0
+ '@aws-sdk/util-user-agent-node': 3.947.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/core': 3.18.7
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/hash-node': 4.2.5
+ '@smithy/invalid-dependency': 4.2.5
+ '@smithy/middleware-content-length': 4.2.5
+ '@smithy/middleware-endpoint': 4.3.14
+ '@smithy/middleware-retry': 4.4.14
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-body-length-node': 4.2.1
+ '@smithy/util-defaults-mode-browser': 4.3.13
+ '@smithy/util-defaults-mode-node': 4.2.16
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/client-sso@3.947.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/middleware-host-header': 3.936.0
+ '@aws-sdk/middleware-logger': 3.936.0
+ '@aws-sdk/middleware-recursion-detection': 3.936.0
+ '@aws-sdk/middleware-user-agent': 3.947.0
+ '@aws-sdk/region-config-resolver': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@aws-sdk/util-user-agent-browser': 3.936.0
+ '@aws-sdk/util-user-agent-node': 3.947.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/core': 3.18.7
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/hash-node': 4.2.5
+ '@smithy/invalid-dependency': 4.2.5
+ '@smithy/middleware-content-length': 4.2.5
+ '@smithy/middleware-endpoint': 4.3.14
+ '@smithy/middleware-retry': 4.4.14
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-body-length-node': 4.2.1
+ '@smithy/util-defaults-mode-browser': 4.3.13
+ '@smithy/util-defaults-mode-node': 4.2.16
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/core@3.947.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/xml-builder': 3.930.0
+ '@smithy/core': 3.18.7
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/signature-v4': 5.3.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-env@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-http@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/util-stream': 4.5.6
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-ini@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/credential-provider-env': 3.947.0
+ '@aws-sdk/credential-provider-http': 3.947.0
+ '@aws-sdk/credential-provider-login': 3.947.0
+ '@aws-sdk/credential-provider-process': 3.947.0
+ '@aws-sdk/credential-provider-sso': 3.947.0
+ '@aws-sdk/credential-provider-web-identity': 3.947.0
+ '@aws-sdk/nested-clients': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/credential-provider-imds': 4.2.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-login@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/nested-clients': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-node@3.947.0':
+ dependencies:
+ '@aws-sdk/credential-provider-env': 3.947.0
+ '@aws-sdk/credential-provider-http': 3.947.0
+ '@aws-sdk/credential-provider-ini': 3.947.0
+ '@aws-sdk/credential-provider-process': 3.947.0
+ '@aws-sdk/credential-provider-sso': 3.947.0
+ '@aws-sdk/credential-provider-web-identity': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/credential-provider-imds': 4.2.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-process@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-sso@3.947.0':
+ dependencies:
+ '@aws-sdk/client-sso': 3.947.0
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/token-providers': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-web-identity@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/nested-clients': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/middleware-host-header@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-logger@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-recursion-detection@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@aws/lambda-invoke-store': 0.2.2
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-sdk-s3@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-arn-parser': 3.893.0
+ '@smithy/core': 3.18.7
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/signature-v4': 5.3.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/util-config-provider': 4.2.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-stream': 4.5.6
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-user-agent@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@smithy/core': 3.18.7
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/nested-clients@3.947.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/middleware-host-header': 3.936.0
+ '@aws-sdk/middleware-logger': 3.936.0
+ '@aws-sdk/middleware-recursion-detection': 3.936.0
+ '@aws-sdk/middleware-user-agent': 3.947.0
+ '@aws-sdk/region-config-resolver': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@aws-sdk/util-user-agent-browser': 3.936.0
+ '@aws-sdk/util-user-agent-node': 3.947.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/core': 3.18.7
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/hash-node': 4.2.5
+ '@smithy/invalid-dependency': 4.2.5
+ '@smithy/middleware-content-length': 4.2.5
+ '@smithy/middleware-endpoint': 4.3.14
+ '@smithy/middleware-retry': 4.4.14
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-body-length-node': 4.2.1
+ '@smithy/util-defaults-mode-browser': 4.3.13
+ '@smithy/util-defaults-mode-node': 4.2.16
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/region-config-resolver@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/signature-v4-multi-region@3.947.0':
+ dependencies:
+ '@aws-sdk/middleware-sdk-s3': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/signature-v4': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.947.0':
+ dependencies:
+ '@aws-sdk/core': 3.947.0
+ '@aws-sdk/nested-clients': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/types@3.936.0':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/util-arn-parser@3.893.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-sdk/util-endpoints@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-endpoints': 3.2.5
+ tslib: 2.8.1
+
+ '@aws-sdk/util-locate-window@3.893.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-sdk/util-user-agent-browser@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/types': 4.9.0
+ bowser: 2.13.1
+ tslib: 2.8.1
+
+ '@aws-sdk/util-user-agent-node@3.947.0':
+ dependencies:
+ '@aws-sdk/middleware-user-agent': 3.947.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/xml-builder@3.930.0':
+ dependencies:
+ '@smithy/types': 4.9.0
+ fast-xml-parser: 5.2.5
+ tslib: 2.8.1
+
+ '@aws/lambda-invoke-store@0.2.2': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/parser@7.28.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@babel/types@7.28.5':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@buttercup/fetch@0.2.1':
+ optionalDependencies:
+ node-fetch: 3.3.2
+
+ '@chevrotain/cst-dts-gen@10.5.0':
+ dependencies:
+ '@chevrotain/gast': 10.5.0
+ '@chevrotain/types': 10.5.0
+ lodash: 4.17.21
+
+ '@chevrotain/gast@10.5.0':
+ dependencies:
+ '@chevrotain/types': 10.5.0
+ lodash: 4.17.21
+
+ '@chevrotain/types@10.5.0': {}
+
+ '@chevrotain/utils@10.5.0': {}
+
+ '@drizzle-team/brocli@0.10.2': {}
+
+ '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)':
+ dependencies:
+ '@electric-sql/pglite': 0.3.2
+
+ '@electric-sql/pglite-tools@0.2.7(@electric-sql/pglite@0.3.2)':
+ dependencies:
+ '@electric-sql/pglite': 0.3.2
+
+ '@electric-sql/pglite@0.3.2': {}
+
+ '@epic-web/invariant@1.0.0': {}
+
+ '@esbuild-kit/core-utils@3.3.2':
+ dependencies:
+ esbuild: 0.18.20
+ source-map-support: 0.5.21
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ dependencies:
+ '@esbuild-kit/core-utils': 3.3.2
+ get-tsconfig: 4.13.0
+
+ '@esbuild/aix-ppc64@0.19.12':
+ optional: true
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/aix-ppc64@0.27.1':
+ optional: true
+
+ '@esbuild/android-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm64@0.19.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/android-arm@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm@0.19.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.27.1':
+ optional: true
+
+ '@esbuild/android-x64@0.18.20':
+ optional: true
+
+ '@esbuild/android-x64@0.19.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.27.1':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.19.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/darwin-x64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-x64@0.19.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.1':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.19.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.19.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.1':
+ optional: true
+
+ '@esbuild/linux-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm64@0.19.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/linux-arm@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm@0.19.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.1':
+ optional: true
+
+ '@esbuild/linux-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ia32@0.19.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.1':
+ optional: true
+
+ '@esbuild/linux-loong64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-loong64@0.19.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.1':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.18.20':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.19.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.1':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.19.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.1':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.19.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.1':
+ optional: true
+
+ '@esbuild/linux-s390x@0.18.20':
+ optional: true
+
+ '@esbuild/linux-s390x@0.19.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.1':
+ optional: true
+
+ '@esbuild/linux-x64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-x64@0.19.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.1':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.19.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.1':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.19.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.1':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/sunos-x64@0.18.20':
+ optional: true
+
+ '@esbuild/sunos-x64@0.19.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.1':
+ optional: true
+
+ '@esbuild/win32-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-arm64@0.19.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.1':
+ optional: true
+
+ '@esbuild/win32-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/win32-ia32@0.19.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.1':
+ optional: true
+
+ '@esbuild/win32-x64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-x64@0.19.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.1':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))':
+ dependencies:
+ eslint: 9.39.1(jiti@1.21.7)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
+ dependencies:
+ eslint: 9.39.1(jiti@2.6.1)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.1':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.3':
+ dependencies:
+ ajv: 6.12.6
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.1': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@fastify/accept-negotiator@2.0.1': {}
+
+ '@fastify/ajv-compiler@4.0.5':
+ dependencies:
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ fast-uri: 3.1.0
+
+ '@fastify/busboy@3.2.0': {}
+
+ '@fastify/cookie@11.0.2':
+ dependencies:
+ cookie: 1.1.1
+ fastify-plugin: 5.1.0
+
+ '@fastify/cors@10.1.0':
+ dependencies:
+ fastify-plugin: 5.1.0
+ mnemonist: 0.40.0
+
+ '@fastify/deepmerge@3.2.1': {}
+
+ '@fastify/error@4.2.0': {}
+
+ '@fastify/fast-json-stringify-compiler@5.0.3':
+ dependencies:
+ fast-json-stringify: 6.1.1
+
+ '@fastify/forwarded@3.0.1': {}
+
+ '@fastify/helmet@13.0.2':
+ dependencies:
+ fastify-plugin: 5.1.0
+ helmet: 8.1.0
+
+ '@fastify/jwt@9.1.0':
+ dependencies:
+ '@fastify/error': 4.2.0
+ '@lukeed/ms': 2.0.2
+ fast-jwt: 5.0.6
+ fastify-plugin: 5.1.0
+ steed: 1.1.3
+
+ '@fastify/merge-json-schemas@0.2.1':
+ dependencies:
+ dequal: 2.0.3
+
+ '@fastify/multipart@9.4.0':
+ dependencies:
+ '@fastify/busboy': 3.2.0
+ '@fastify/deepmerge': 3.2.1
+ '@fastify/error': 4.2.0
+ fastify-plugin: 5.1.0
+ secure-json-parse: 4.1.0
+
+ '@fastify/proxy-addr@5.1.0':
+ dependencies:
+ '@fastify/forwarded': 3.0.1
+ ipaddr.js: 2.3.0
+
+ '@fastify/rate-limit@10.3.0':
+ dependencies:
+ '@lukeed/ms': 2.0.2
+ fastify-plugin: 5.1.0
+ toad-cache: 3.7.0
+
+ '@fastify/send@4.1.0':
+ dependencies:
+ '@lukeed/ms': 2.0.2
+ escape-html: 1.0.3
+ fast-decode-uri-component: 1.0.1
+ http-errors: 2.0.1
+ mime: 3.0.0
+
+ '@fastify/static@8.3.0':
+ dependencies:
+ '@fastify/accept-negotiator': 2.0.1
+ '@fastify/send': 4.1.0
+ content-disposition: 0.5.4
+ fastify-plugin: 5.1.0
+ fastq: 1.19.1
+ glob: 11.1.0
+
+ '@fastify/websocket@11.2.0':
+ dependencies:
+ duplexify: 4.1.3
+ fastify-plugin: 5.1.0
+ ws: 8.18.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@hono/node-server@1.19.6(hono@4.10.6)':
+ dependencies:
+ hono: 4.10.6
+
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.7':
+ dependencies:
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@intlify/core-base@11.2.2':
+ dependencies:
+ '@intlify/message-compiler': 11.2.2
+ '@intlify/shared': 11.2.2
+
+ '@intlify/message-compiler@11.2.2':
+ dependencies:
+ '@intlify/shared': 11.2.2
+ source-map-js: 1.2.1
+
+ '@intlify/shared@11.2.2': {}
+
+ '@inversifyjs/common@1.3.3': {}
+
+ '@inversifyjs/core@1.3.4(reflect-metadata@0.2.2)':
+ dependencies:
+ '@inversifyjs/common': 1.3.3
+ '@inversifyjs/reflect-metadata-utils': 0.2.3(reflect-metadata@0.2.2)
+ transitivePeerDependencies:
+ - reflect-metadata
+
+ '@inversifyjs/reflect-metadata-utils@0.2.3(reflect-metadata@0.2.2)':
+ dependencies:
+ reflect-metadata: 0.2.2
+
+ '@isaacs/balanced-match@4.0.1': {}
+
+ '@isaacs/brace-expansion@5.0.0':
+ dependencies:
+ '@isaacs/balanced-match': 4.0.1
+
+ '@isaacs/cliui@8.0.2':
+ dependencies:
+ string-width: 5.1.2
+ string-width-cjs: string-width@4.2.3
+ strip-ansi: 7.1.2
+ strip-ansi-cjs: strip-ansi@6.0.1
+ wrap-ansi: 8.1.0
+ wrap-ansi-cjs: wrap-ansi@7.0.0
+
+ '@javascript-obfuscator/escodegen@2.3.1':
+ dependencies:
+ '@javascript-obfuscator/estraverse': 5.4.0
+ esprima: 4.0.1
+ esutils: 2.0.3
+ optionator: 0.8.3
+ optionalDependencies:
+ source-map: 0.6.1
+
+ '@javascript-obfuscator/estraverse@5.4.0': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/source-map@0.3.11':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@libsql/client@0.14.0':
+ dependencies:
+ '@libsql/core': 0.14.0
+ '@libsql/hrana-client': 0.7.0
+ js-base64: 3.7.8
+ libsql: 0.4.7
+ promise-limit: 2.7.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/core@0.14.0':
+ dependencies:
+ js-base64: 3.7.8
+
+ '@libsql/darwin-arm64@0.4.7':
+ optional: true
+
+ '@libsql/darwin-x64@0.4.7':
+ optional: true
+
+ '@libsql/hrana-client@0.7.0':
+ dependencies:
+ '@libsql/isomorphic-fetch': 0.3.1
+ '@libsql/isomorphic-ws': 0.1.5
+ js-base64: 3.7.8
+ node-fetch: 3.3.2
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/isomorphic-fetch@0.3.1': {}
+
+ '@libsql/isomorphic-ws@0.1.5':
+ dependencies:
+ '@types/ws': 8.18.1
+ ws: 8.18.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@libsql/linux-arm64-gnu@0.4.7':
+ optional: true
+
+ '@libsql/linux-arm64-musl@0.4.7':
+ optional: true
+
+ '@libsql/linux-x64-gnu@0.4.7':
+ optional: true
+
+ '@libsql/linux-x64-musl@0.4.7':
+ optional: true
+
+ '@libsql/win32-x64-msvc@0.4.7':
+ optional: true
+
+ '@lukeed/ms@2.0.2': {}
+
+ '@mrleebo/prisma-ast@0.12.1':
+ dependencies:
+ chevrotain: 10.5.0
+ lilconfig: 2.1.0
+
+ '@neon-rs/load@0.0.4': {}
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.19.1
+
+ '@otplib/core@12.0.1': {}
+
+ '@otplib/plugin-crypto@12.0.1':
+ dependencies:
+ '@otplib/core': 12.0.1
+
+ '@otplib/plugin-thirty-two@12.0.1':
+ dependencies:
+ '@otplib/core': 12.0.1
+ thirty-two: 1.0.2
+
+ '@otplib/preset-default@12.0.1':
+ dependencies:
+ '@otplib/core': 12.0.1
+ '@otplib/plugin-crypto': 12.0.1
+ '@otplib/plugin-thirty-two': 12.0.1
+
+ '@otplib/preset-v11@12.0.1':
+ dependencies:
+ '@otplib/core': 12.0.1
+ '@otplib/plugin-crypto': 12.0.1
+ '@otplib/plugin-thirty-two': 12.0.1
+
+ '@petamoriken/float16@3.9.3': {}
+
+ '@pinojs/redact@0.4.0': {}
+
+ '@prisma/adapter-pg@7.1.0':
+ dependencies:
+ '@prisma/driver-adapter-utils': 7.1.0
+ pg: 8.16.3
+ postgres-array: 3.0.4
+ transitivePeerDependencies:
+ - pg-native
+
+ '@prisma/client-runtime-utils@7.1.0': {}
+
+ '@prisma/client@7.1.0(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(typescript@5.9.3)':
+ dependencies:
+ '@prisma/client-runtime-utils': 7.1.0
+ optionalDependencies:
+ prisma: 7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)
+ typescript: 5.9.3
+
+ '@prisma/config@7.1.0':
+ dependencies:
+ c12: 3.1.0
+ deepmerge-ts: 7.1.5
+ effect: 3.18.4
+ empathic: 2.0.0
+ transitivePeerDependencies:
+ - magicast
+
+ '@prisma/debug@6.8.2': {}
+
+ '@prisma/debug@7.1.0': {}
+
+ '@prisma/dev@0.15.0(typescript@5.9.3)':
+ dependencies:
+ '@electric-sql/pglite': 0.3.2
+ '@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2)
+ '@electric-sql/pglite-tools': 0.2.7(@electric-sql/pglite@0.3.2)
+ '@hono/node-server': 1.19.6(hono@4.10.6)
+ '@mrleebo/prisma-ast': 0.12.1
+ '@prisma/get-platform': 6.8.2
+ '@prisma/query-plan-executor': 6.18.0
+ foreground-child: 3.3.1
+ get-port-please: 3.1.2
+ hono: 4.10.6
+ http-status-codes: 2.3.0
+ pathe: 2.0.3
+ proper-lockfile: 4.1.2
+ remeda: 2.21.3
+ std-env: 3.9.0
+ valibot: 1.2.0(typescript@5.9.3)
+ zeptomatch: 2.0.2
+ transitivePeerDependencies:
+ - typescript
+
+ '@prisma/driver-adapter-utils@7.1.0':
+ dependencies:
+ '@prisma/debug': 7.1.0
+
+ '@prisma/engines-version@7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba': {}
+
+ '@prisma/engines@7.1.0':
+ dependencies:
+ '@prisma/debug': 7.1.0
+ '@prisma/engines-version': 7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
+ '@prisma/fetch-engine': 7.1.0
+ '@prisma/get-platform': 7.1.0
+
+ '@prisma/fetch-engine@7.1.0':
+ dependencies:
+ '@prisma/debug': 7.1.0
+ '@prisma/engines-version': 7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
+ '@prisma/get-platform': 7.1.0
+
+ '@prisma/get-platform@6.8.2':
+ dependencies:
+ '@prisma/debug': 6.8.2
+
+ '@prisma/get-platform@7.1.0':
+ dependencies:
+ '@prisma/debug': 7.1.0
+
+ '@prisma/query-plan-executor@6.18.0': {}
+
+ '@prisma/studio-core@0.8.2(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ dependencies:
+ '@types/react': 19.2.7
+ react: 19.2.1
+ react-dom: 19.2.1(react@19.2.1)
+
+ '@rollup/pluginutils@5.3.0(rollup@4.53.3)':
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-walker: 2.0.2
+ picomatch: 4.0.3
+ optionalDependencies:
+ rollup: 4.53.3
+
+ '@rollup/rollup-android-arm-eabi@4.53.3':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.53.3':
+ optional: true
+
+ '@smithy/abort-controller@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/config-resolver@4.4.3':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-config-provider': 4.2.0
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ tslib: 2.8.1
+
+ '@smithy/core@3.18.7':
+ dependencies:
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-stream': 4.5.6
+ '@smithy/util-utf8': 4.2.0
+ '@smithy/uuid': 1.1.0
+ tslib: 2.8.1
+
+ '@smithy/credential-provider-imds@4.2.5':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ tslib: 2.8.1
+
+ '@smithy/fetch-http-handler@5.3.6':
+ dependencies:
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/querystring-builder': 4.2.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ tslib: 2.8.1
+
+ '@smithy/hash-node@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ '@smithy/util-buffer-from': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/invalid-dependency@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/is-array-buffer@2.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/is-array-buffer@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/middleware-content-length@4.2.5':
+ dependencies:
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/middleware-endpoint@4.3.14':
+ dependencies:
+ '@smithy/core': 3.18.7
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-middleware': 4.2.5
+ tslib: 2.8.1
+
+ '@smithy/middleware-retry@4.4.14':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/service-error-classification': 4.2.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/uuid': 1.1.0
+ tslib: 2.8.1
+
+ '@smithy/middleware-serde@4.2.6':
+ dependencies:
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/middleware-stack@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/node-config-provider@4.3.5':
+ dependencies:
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/node-http-handler@4.4.5':
+ dependencies:
+ '@smithy/abort-controller': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/querystring-builder': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/property-provider@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/protocol-http@5.3.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/querystring-builder@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ '@smithy/util-uri-escape': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/querystring-parser@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/service-error-classification@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+
+ '@smithy/shared-ini-file-loader@4.4.0':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/signature-v4@5.3.5':
+ dependencies:
+ '@smithy/is-array-buffer': 4.2.0
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-hex-encoding': 4.2.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-uri-escape': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/smithy-client@4.9.10':
+ dependencies:
+ '@smithy/core': 3.18.7
+ '@smithy/middleware-endpoint': 4.3.14
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-stream': 4.5.6
+ tslib: 2.8.1
+
+ '@smithy/types@4.9.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/url-parser@4.2.5':
+ dependencies:
+ '@smithy/querystring-parser': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-base64@4.3.0':
+ dependencies:
+ '@smithy/util-buffer-from': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-body-length-browser@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-body-length-node@4.2.1':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-buffer-from@2.2.0':
+ dependencies:
+ '@smithy/is-array-buffer': 2.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-buffer-from@4.2.0':
+ dependencies:
+ '@smithy/is-array-buffer': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-config-provider@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-defaults-mode-browser@4.3.13':
+ dependencies:
+ '@smithy/property-provider': 4.2.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-defaults-mode-node@4.2.16':
+ dependencies:
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/credential-provider-imds': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/smithy-client': 4.9.10
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-endpoints@3.2.5':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-hex-encoding@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-middleware@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-retry@4.2.5':
+ dependencies:
+ '@smithy/service-error-classification': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-stream@4.5.6':
+ dependencies:
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-buffer-from': 4.2.0
+ '@smithy/util-hex-encoding': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-uri-escape@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-utf8@2.3.0':
+ dependencies:
+ '@smithy/util-buffer-from': 2.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-utf8@4.2.0':
+ dependencies:
+ '@smithy/util-buffer-from': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/uuid@1.1.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@standard-schema/spec@1.0.0': {}
+
+ '@tailwindcss/forms@0.5.10(tailwindcss@3.4.18(tsx@4.21.0))':
+ dependencies:
+ mini-svg-data-uri: 1.4.4
+ tailwindcss: 3.4.18(tsx@4.21.0)
+
+ '@types/estree@1.0.8': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/minimatch@3.0.5': {}
+
+ '@types/node-cron@3.0.11': {}
+
+ '@types/node@18.19.130':
+ dependencies:
+ undici-types: 5.26.5
+
+ '@types/node@24.10.1':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/nodemailer@7.0.4':
+ dependencies:
+ '@aws-sdk/client-sesv2': 3.947.0
+ '@types/node': 24.10.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@types/pg@8.15.6':
+ dependencies:
+ '@types/node': 24.10.1
+ pg-protocol: 1.10.3
+ pg-types: 2.2.0
+
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 24.10.1
+
+ '@types/react@19.2.7':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/ssh2-sftp-client@9.0.6':
+ dependencies:
+ '@types/ssh2': 1.15.5
+
+ '@types/ssh2@1.15.5':
+ dependencies:
+ '@types/node': 18.19.130
+
+ '@types/validator@13.15.10': {}
+
+ '@types/web-bluetooth@0.0.21': {}
+
+ '@types/ws@8.18.1':
+ dependencies:
+ '@types/node': 24.10.1
+
+ '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.48.1
+ '@typescript-eslint/type-utils': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.48.1
+ eslint: 9.39.1(jiti@1.21.7)
+ graphemer: 1.4.0
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.48.1
+ '@typescript-eslint/type-utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.48.1
+ eslint: 9.39.1(jiti@2.6.1)
+ graphemer: 1.4.0
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.48.1
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.48.1
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.48.1
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.48.1
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.48.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.48.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.48.1':
+ dependencies:
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/visitor-keys': 8.48.1
+
+ '@typescript-eslint/tsconfig-utils@8.48.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@1.21.7)
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/type-utils@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@2.6.1)
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.48.1': {}
+
+ '@typescript-eslint/typescript-estree@8.48.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/visitor-keys': 8.48.1
+ debug: 4.4.3
+ minimatch: 9.0.5
+ semver: 7.7.3
+ tinyglobby: 0.2.15
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.48.1
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
+ eslint: 9.39.1(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.48.1
+ '@typescript-eslint/types': 8.48.1
+ '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
+ eslint: 9.39.1(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.48.1':
+ dependencies:
+ '@typescript-eslint/types': 8.48.1
+ eslint-visitor-keys: 4.2.1
+
+ '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@1.21.7)(terser@5.44.1)(tsx@4.21.0))(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ vite: 6.4.1(@types/node@24.10.1)(jiti@1.21.7)(terser@5.44.1)(tsx@4.21.0)
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@volar/language-core@2.4.26':
+ dependencies:
+ '@volar/source-map': 2.4.26
+
+ '@volar/source-map@2.4.26': {}
+
+ '@volar/typescript@2.4.26':
+ dependencies:
+ '@volar/language-core': 2.4.26
+ path-browserify: 1.0.1
+ vscode-uri: 3.1.0
+
+ '@vue/compiler-core@3.5.25':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/shared': 3.5.25
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.25':
+ dependencies:
+ '@vue/compiler-core': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/compiler-sfc@3.5.25':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/compiler-core': 3.5.25
+ '@vue/compiler-dom': 3.5.25
+ '@vue/compiler-ssr': 3.5.25
+ '@vue/shared': 3.5.25
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.6
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.25':
+ dependencies:
+ '@vue/compiler-dom': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/devtools-api@6.6.4': {}
+
+ '@vue/language-core@3.1.6(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.26
+ '@vue/compiler-dom': 3.5.25
+ '@vue/shared': 3.5.25
+ alien-signals: 3.1.1
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ picomatch: 4.0.3
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@vue/reactivity@3.5.25':
+ dependencies:
+ '@vue/shared': 3.5.25
+
+ '@vue/runtime-core@3.5.25':
+ dependencies:
+ '@vue/reactivity': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/runtime-dom@3.5.25':
+ dependencies:
+ '@vue/reactivity': 3.5.25
+ '@vue/runtime-core': 3.5.25
+ '@vue/shared': 3.5.25
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.25(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.25
+ '@vue/shared': 3.5.25
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@vue/shared@3.5.25': {}
+
+ '@vueuse/core@12.8.2(typescript@5.9.3)':
+ dependencies:
+ '@types/web-bluetooth': 0.0.21
+ '@vueuse/metadata': 12.8.2
+ '@vueuse/shared': 12.8.2(typescript@5.9.3)
+ vue: 3.5.25(typescript@5.9.3)
+ transitivePeerDependencies:
+ - typescript
+
+ '@vueuse/metadata@12.8.2': {}
+
+ '@vueuse/shared@12.8.2(typescript@5.9.3)':
+ dependencies:
+ vue: 3.5.25(typescript@5.9.3)
+ transitivePeerDependencies:
+ - typescript
+
+ '@xterm/addon-clipboard@0.2.0':
+ dependencies:
+ js-base64: 3.7.8
+
+ '@xterm/addon-fit@0.11.0': {}
+
+ '@xterm/addon-image@0.9.0': {}
+
+ '@xterm/addon-search@0.16.0': {}
+
+ '@xterm/addon-serialize@0.14.0': {}
+
+ '@xterm/addon-unicode11@0.9.0': {}
+
+ '@xterm/addon-web-links@0.12.0': {}
+
+ '@xterm/addon-webgl@0.19.0': {}
+
+ '@xterm/xterm@6.0.0': {}
+
+ abstract-logging@2.0.1: {}
+
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
+ acorn@8.15.0: {}
+
+ ajv-formats@3.0.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+
+ ajv@6.12.6:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ajv@8.17.1:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ alien-signals@3.1.1: {}
+
+ ansi-regex@5.0.1: {}
+
+ ansi-regex@6.2.2: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ ansi-styles@6.2.3: {}
+
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+
+ arg@5.0.2: {}
+
+ argparse@2.0.1: {}
+
+ array-differ@3.0.0: {}
+
+ array-union@2.1.0: {}
+
+ arrify@2.0.1: {}
+
+ asn1.js@5.4.1:
+ dependencies:
+ bn.js: 4.12.2
+ inherits: 2.0.4
+ minimalistic-assert: 1.0.1
+ safer-buffer: 2.1.2
+
+ asn1@0.2.6:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ assert@2.1.0:
+ dependencies:
+ call-bind: 1.0.8
+ is-nan: 1.3.2
+ object-is: 1.1.6
+ object.assign: 4.1.7
+ util: 0.12.5
+
+ asynckit@0.4.0: {}
+
+ atomic-sleep@1.0.0: {}
+
+ atomically@2.1.0:
+ dependencies:
+ stubborn-fs: 2.0.0
+ when-exit: 2.1.5
+
+ autoprefixer@10.4.22(postcss@8.5.6):
+ dependencies:
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001759
+ fraction.js: 5.3.4
+ normalize-range: 0.1.2
+ picocolors: 1.1.1
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ avvio@9.1.0:
+ dependencies:
+ '@fastify/error': 4.2.0
+ fastq: 1.19.1
+
+ aws-ssl-profiles@1.1.2: {}
+
+ axios@1.13.2:
+ dependencies:
+ follow-redirects: 1.15.11
+ form-data: 4.0.5
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
+ balanced-match@1.0.2: {}
+
+ base-64@1.0.0: {}
+
+ baseline-browser-mapping@2.9.2: {}
+
+ basic-ftp@5.0.5: {}
+
+ bcrypt-pbkdf@1.0.2:
+ dependencies:
+ tweetnacl: 0.14.5
+
+ bcryptjs@2.4.3: {}
+
+ binary-extensions@2.3.0: {}
+
+ bn.js@4.12.2: {}
+
+ boolbase@1.0.0: {}
+
+ bowser@2.13.1: {}
+
+ brace-expansion@1.1.12:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.9.2
+ caniuse-lite: 1.0.30001759
+ electron-to-chromium: 1.5.264
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.2(browserslist@4.28.1)
+
+ buffer-equal-constant-time@1.0.1: {}
+
+ buffer-from@1.1.2: {}
+
+ buildcheck@0.0.7:
+ optional: true
+
+ byte-length@1.0.2: {}
+
+ c12@3.1.0:
+ dependencies:
+ chokidar: 4.0.3
+ confbox: 0.2.2
+ defu: 6.1.4
+ dotenv: 16.6.1
+ exsolve: 1.0.8
+ giget: 2.0.0
+ jiti: 2.6.1
+ ohash: 2.0.11
+ pathe: 2.0.3
+ perfect-debounce: 1.0.0
+ pkg-types: 2.3.0
+ rc9: 2.1.2
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.8:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ camelcase-css@2.0.1: {}
+
+ camelcase@5.3.1: {}
+
+ caniuse-lite@1.0.30001759: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ chance@1.1.13: {}
+
+ char-regex@1.0.2: {}
+
+ charenc@0.0.2: {}
+
+ chevrotain@10.5.0:
+ dependencies:
+ '@chevrotain/cst-dts-gen': 10.5.0
+ '@chevrotain/gast': 10.5.0
+ '@chevrotain/types': 10.5.0
+ '@chevrotain/utils': 10.5.0
+ lodash: 4.17.21
+ regexp-to-ast: 0.5.0
+
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+
+ citty@0.1.6:
+ dependencies:
+ consola: 3.4.2
+
+ class-validator@0.14.3:
+ dependencies:
+ '@types/validator': 13.15.10
+ libphonenumber-js: 1.12.31
+ validator: 13.15.23
+
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
+ cliui@8.0.1:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ colorette@2.0.20: {}
+
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
+ commander@12.1.0: {}
+
+ commander@2.20.3: {}
+
+ commander@4.1.1: {}
+
+ concat-map@0.0.1: {}
+
+ concat-stream@2.0.0:
+ dependencies:
+ buffer-from: 1.1.2
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ typedarray: 0.0.6
+
+ concurrently@9.2.1:
+ dependencies:
+ chalk: 4.1.2
+ rxjs: 7.8.2
+ shell-quote: 1.8.3
+ supports-color: 8.1.1
+ tree-kill: 1.2.2
+ yargs: 17.7.2
+
+ conf@15.0.2:
+ dependencies:
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ atomically: 2.1.0
+ debounce-fn: 6.0.0
+ dot-prop: 10.1.0
+ env-paths: 3.0.0
+ json-schema-typed: 8.0.2
+ semver: 7.7.3
+ uint8array-extras: 1.5.0
+
+ confbox@0.2.2: {}
+
+ consola@3.4.2: {}
+
+ content-disposition@0.5.4:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ cookie@1.1.1: {}
+
+ cpu-features@0.0.10:
+ dependencies:
+ buildcheck: 0.0.7
+ nan: 2.24.0
+ optional: true
+
+ cross-env@10.1.0:
+ dependencies:
+ '@epic-web/invariant': 1.0.0
+ cross-spawn: 7.0.6
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ crypt@0.0.2: {}
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ data-uri-to-buffer@4.0.1: {}
+
+ dateformat@4.6.3: {}
+
+ debounce-fn@6.0.0:
+ dependencies:
+ mimic-function: 5.0.1
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decamelize@1.2.0: {}
+
+ deep-is@0.1.4: {}
+
+ deepmerge-ts@7.1.5: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ defu@6.1.4: {}
+
+ delayed-stream@1.0.0: {}
+
+ denque@2.1.0: {}
+
+ depd@2.0.0: {}
+
+ dequal@2.0.3: {}
+
+ destr@2.0.5: {}
+
+ detect-libc@2.0.2: {}
+
+ didyoumean@1.2.2: {}
+
+ dijkstrajs@1.0.3: {}
+
+ dlv@1.1.3: {}
+
+ dot-prop@10.1.0:
+ dependencies:
+ type-fest: 5.3.1
+
+ dotenv@16.6.1: {}
+
+ dotenv@17.2.3: {}
+
+ drizzle-kit@0.30.6:
+ dependencies:
+ '@drizzle-team/brocli': 0.10.2
+ '@esbuild-kit/esm-loader': 2.6.5
+ esbuild: 0.19.12
+ esbuild-register: 3.6.0(esbuild@0.19.12)
+ gel: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ drizzle-orm@0.38.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.14.0)(@prisma/client@7.1.0(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(react@19.2.1)(sql.js@1.13.0):
+ optionalDependencies:
+ '@electric-sql/pglite': 0.3.2
+ '@libsql/client': 0.14.0
+ '@prisma/client': 7.1.0(prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3))(typescript@5.9.3)
+ '@types/pg': 8.15.6
+ '@types/react': 19.2.7
+ mysql2: 3.15.3
+ pg: 8.16.3
+ postgres: 3.4.7
+ prisma: 7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)
+ react: 19.2.1
+ sql.js: 1.13.0
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ duplexify@4.1.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ stream-shift: 1.0.3
+
+ eastasianwidth@0.2.0: {}
+
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ effect@3.18.4:
+ dependencies:
+ '@standard-schema/spec': 1.0.0
+ fast-check: 3.23.2
+
+ electron-to-chromium@1.5.264: {}
+
+ emoji-regex@8.0.0: {}
+
+ emoji-regex@9.2.2: {}
+
+ empathic@2.0.0: {}
+
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
+ entities@4.5.0: {}
+
+ entities@6.0.1: {}
+
+ env-paths@3.0.0: {}
+
+ err-code@2.0.3: {}
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+ esbuild-register@3.6.0(esbuild@0.19.12):
+ dependencies:
+ debug: 4.4.3
+ esbuild: 0.19.12
+ transitivePeerDependencies:
+ - supports-color
+
+ esbuild@0.18.20:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.18.20
+ '@esbuild/android-arm64': 0.18.20
+ '@esbuild/android-x64': 0.18.20
+ '@esbuild/darwin-arm64': 0.18.20
+ '@esbuild/darwin-x64': 0.18.20
+ '@esbuild/freebsd-arm64': 0.18.20
+ '@esbuild/freebsd-x64': 0.18.20
+ '@esbuild/linux-arm': 0.18.20
+ '@esbuild/linux-arm64': 0.18.20
+ '@esbuild/linux-ia32': 0.18.20
+ '@esbuild/linux-loong64': 0.18.20
+ '@esbuild/linux-mips64el': 0.18.20
+ '@esbuild/linux-ppc64': 0.18.20
+ '@esbuild/linux-riscv64': 0.18.20
+ '@esbuild/linux-s390x': 0.18.20
+ '@esbuild/linux-x64': 0.18.20
+ '@esbuild/netbsd-x64': 0.18.20
+ '@esbuild/openbsd-x64': 0.18.20
+ '@esbuild/sunos-x64': 0.18.20
+ '@esbuild/win32-arm64': 0.18.20
+ '@esbuild/win32-ia32': 0.18.20
+ '@esbuild/win32-x64': 0.18.20
+
+ esbuild@0.19.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.19.12
+ '@esbuild/android-arm': 0.19.12
+ '@esbuild/android-arm64': 0.19.12
+ '@esbuild/android-x64': 0.19.12
+ '@esbuild/darwin-arm64': 0.19.12
+ '@esbuild/darwin-x64': 0.19.12
+ '@esbuild/freebsd-arm64': 0.19.12
+ '@esbuild/freebsd-x64': 0.19.12
+ '@esbuild/linux-arm': 0.19.12
+ '@esbuild/linux-arm64': 0.19.12
+ '@esbuild/linux-ia32': 0.19.12
+ '@esbuild/linux-loong64': 0.19.12
+ '@esbuild/linux-mips64el': 0.19.12
+ '@esbuild/linux-ppc64': 0.19.12
+ '@esbuild/linux-riscv64': 0.19.12
+ '@esbuild/linux-s390x': 0.19.12
+ '@esbuild/linux-x64': 0.19.12
+ '@esbuild/netbsd-x64': 0.19.12
+ '@esbuild/openbsd-x64': 0.19.12
+ '@esbuild/sunos-x64': 0.19.12
+ '@esbuild/win32-arm64': 0.19.12
+ '@esbuild/win32-ia32': 0.19.12
+ '@esbuild/win32-x64': 0.19.12
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ esbuild@0.27.1:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.1
+ '@esbuild/android-arm': 0.27.1
+ '@esbuild/android-arm64': 0.27.1
+ '@esbuild/android-x64': 0.27.1
+ '@esbuild/darwin-arm64': 0.27.1
+ '@esbuild/darwin-x64': 0.27.1
+ '@esbuild/freebsd-arm64': 0.27.1
+ '@esbuild/freebsd-x64': 0.27.1
+ '@esbuild/linux-arm': 0.27.1
+ '@esbuild/linux-arm64': 0.27.1
+ '@esbuild/linux-ia32': 0.27.1
+ '@esbuild/linux-loong64': 0.27.1
+ '@esbuild/linux-mips64el': 0.27.1
+ '@esbuild/linux-ppc64': 0.27.1
+ '@esbuild/linux-riscv64': 0.27.1
+ '@esbuild/linux-s390x': 0.27.1
+ '@esbuild/linux-x64': 0.27.1
+ '@esbuild/netbsd-arm64': 0.27.1
+ '@esbuild/netbsd-x64': 0.27.1
+ '@esbuild/openbsd-arm64': 0.27.1
+ '@esbuild/openbsd-x64': 0.27.1
+ '@esbuild/openharmony-arm64': 0.27.1
+ '@esbuild/sunos-x64': 0.27.1
+ '@esbuild/win32-arm64': 0.27.1
+ '@esbuild/win32-ia32': 0.27.1
+ '@esbuild/win32-x64': 0.27.1
+
+ escalade@3.2.0: {}
+
+ escape-html@1.0.3: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-vue@9.33.0(eslint@9.39.1(jiti@1.21.7)):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ eslint: 9.39.1(jiti@1.21.7)
+ globals: 13.24.0
+ natural-compare: 1.4.0
+ nth-check: 2.1.1
+ postcss-selector-parser: 6.1.2
+ semver: 7.7.3
+ vue-eslint-parser: 9.4.3(eslint@9.39.1(jiti@1.21.7))
+ xml-name-validator: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-scope@7.2.2:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.39.1(jiti@1.21.7):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.1
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.3
+ '@eslint/js': 9.39.1
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.6.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 1.21.7
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint@9.39.1(jiti@2.6.1):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.1
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.3
+ '@eslint/js': 9.39.1
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.6.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 4.2.1
+
+ espree@9.6.1:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 3.4.3
+
+ esprima@4.0.1: {}
+
+ esquery@1.6.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ estree-walker@2.0.2: {}
+
+ esutils@2.0.3: {}
+
+ exsolve@1.0.8: {}
+
+ fast-check@3.23.2:
+ dependencies:
+ pure-rand: 6.1.0
+
+ fast-copy@4.0.0: {}
+
+ fast-decode-uri-component@1.0.1: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-json-stringify@6.1.1:
+ dependencies:
+ '@fastify/merge-json-schemas': 0.2.1
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ fast-uri: 3.1.0
+ json-schema-ref-resolver: 3.0.0
+ rfdc: 1.4.1
+
+ fast-jwt@5.0.6:
+ dependencies:
+ '@lukeed/ms': 2.0.2
+ asn1.js: 5.4.1
+ ecdsa-sig-formatter: 1.0.11
+ mnemonist: 0.40.3
+
+ fast-levenshtein@2.0.6: {}
+
+ fast-querystring@1.1.2:
+ dependencies:
+ fast-decode-uri-component: 1.0.1
+
+ fast-safe-stringify@2.1.1: {}
+
+ fast-uri@3.1.0: {}
+
+ fast-xml-parser@4.5.3:
+ dependencies:
+ strnum: 1.1.2
+
+ fast-xml-parser@5.2.5:
+ dependencies:
+ strnum: 2.1.1
+
+ fastfall@1.5.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fastify-cloudflare-turnstile@2.0.2:
+ dependencies:
+ fastify-plugin: 5.1.0
+ undici: 6.22.0
+
+ fastify-plugin@5.1.0: {}
+
+ fastify@5.6.2:
+ dependencies:
+ '@fastify/ajv-compiler': 4.0.5
+ '@fastify/error': 4.2.0
+ '@fastify/fast-json-stringify-compiler': 5.0.3
+ '@fastify/proxy-addr': 5.1.0
+ abstract-logging: 2.0.1
+ avvio: 9.1.0
+ fast-json-stringify: 6.1.1
+ find-my-way: 9.3.0
+ light-my-request: 6.6.0
+ pino: 10.1.0
+ process-warning: 5.0.0
+ rfdc: 1.4.1
+ secure-json-parse: 4.1.0
+ semver: 7.7.3
+ toad-cache: 3.7.0
+
+ fastparallel@2.4.1:
+ dependencies:
+ reusify: 1.1.0
+ xtend: 4.0.2
+
+ fastq@1.19.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fastseries@1.7.2:
+ dependencies:
+ reusify: 1.1.0
+ xtend: 4.0.2
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ fetch-blob@3.2.0:
+ dependencies:
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.3.3
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ find-my-way@9.3.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-querystring: 1.1.2
+ safe-regex2: 5.0.0
+
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flag-icons@7.5.0: {}
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.3.3
+ keyv: 4.5.4
+
+ flatted@3.3.3: {}
+
+ follow-redirects@1.15.11: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ foreground-child@3.3.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 4.1.0
+
+ form-data@4.0.5:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.2
+ mime-types: 2.1.35
+
+ formdata-polyfill@4.0.10:
+ dependencies:
+ fetch-blob: 3.2.0
+
+ fraction.js@5.3.4: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ gel@2.2.0:
+ dependencies:
+ '@petamoriken/float16': 3.9.3
+ debug: 4.4.3
+ env-paths: 3.0.0
+ semver: 7.7.3
+ shell-quote: 1.8.3
+ which: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ generate-function@2.3.1:
+ dependencies:
+ is-property: 1.0.2
+
+ generator-function@2.0.1: {}
+
+ get-caller-file@2.0.5: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.1
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.2
+ math-intrinsics: 1.1.0
+
+ get-port-please@3.1.2: {}
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
+ get-tsconfig@4.13.0:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ giget@2.0.0:
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.2
+ defu: 6.1.4
+ node-fetch-native: 1.6.7
+ nypm: 0.6.2
+ pathe: 2.0.3
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob@11.1.0:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 4.1.1
+ minimatch: 10.1.1
+ minipass: 7.1.2
+ package-json-from-dist: 1.0.1
+ path-scurry: 2.0.1
+
+ globals@13.24.0:
+ dependencies:
+ type-fest: 0.20.2
+
+ globals@14.0.0: {}
+
+ gopd@1.2.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ grammex@3.1.12: {}
+
+ graphemer@1.4.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.2:
+ dependencies:
+ function-bind: 1.1.2
+
+ helmet@8.1.0: {}
+
+ help-me@5.0.0: {}
+
+ hono@4.10.6: {}
+
+ hot-patcher@2.0.1: {}
+
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
+ http-status-codes@2.3.0: {}
+
+ iconv-lite@0.7.0:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ inherits@2.0.4: {}
+
+ inversify@6.1.4(reflect-metadata@0.2.2):
+ dependencies:
+ '@inversifyjs/common': 1.3.3
+ '@inversifyjs/core': 1.3.4(reflect-metadata@0.2.2)
+ transitivePeerDependencies:
+ - reflect-metadata
+
+ ip-address@10.1.0: {}
+
+ ipaddr.js@2.3.0: {}
+
+ is-arguments@1.2.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-buffer@1.1.6: {}
+
+ is-callable@1.2.7: {}
+
+ is-core-module@2.16.1:
+ dependencies:
+ hasown: 2.0.2
+
+ is-extglob@2.1.1: {}
+
+ is-fullwidth-code-point@3.0.0: {}
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-nan@1.3.2:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+
+ is-number@7.0.0: {}
+
+ is-property@1.0.2: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.19
+
+ isexe@2.0.0: {}
+
+ isexe@3.1.1: {}
+
+ jackspeak@4.1.1:
+ dependencies:
+ '@isaacs/cliui': 8.0.2
+
+ javascript-obfuscator@5.0.1:
+ dependencies:
+ '@javascript-obfuscator/escodegen': 2.3.1
+ '@javascript-obfuscator/estraverse': 5.4.0
+ acorn: 8.15.0
+ assert: 2.1.0
+ chalk: 4.1.2
+ chance: 1.1.13
+ class-validator: 0.14.3
+ commander: 12.1.0
+ conf: 15.0.2
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ fast-deep-equal: 3.1.3
+ inversify: 6.1.4(reflect-metadata@0.2.2)
+ js-string-escape: 1.0.1
+ md5: 2.3.0
+ mkdirp: 3.0.1
+ multimatch: 5.0.0
+ process: 0.11.10
+ reflect-metadata: 0.2.2
+ source-map-support: 0.5.21
+ string-template: 1.0.0
+ stringz: 2.1.0
+ tslib: 2.8.1
+
+ jiti@1.21.7: {}
+
+ jiti@2.6.1: {}
+
+ joycon@3.1.1: {}
+
+ js-base64@3.7.8: {}
+
+ js-string-escape@1.0.1: {}
+
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ json-buffer@3.0.1: {}
+
+ json-schema-ref-resolver@3.0.0:
+ dependencies:
+ dequal: 2.0.3
+
+ json-schema-traverse@0.4.1: {}
+
+ json-schema-traverse@1.0.0: {}
+
+ json-schema-typed@8.0.2: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ jsonwebtoken@9.0.3:
+ dependencies:
+ jws: 4.0.1
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.7.3
+
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ layerr@3.0.0: {}
+
+ levn@0.3.0:
+ dependencies:
+ prelude-ls: 1.1.2
+ type-check: 0.3.2
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ libphonenumber-js@1.12.31: {}
+
+ libsql@0.4.7:
+ dependencies:
+ '@neon-rs/load': 0.0.4
+ detect-libc: 2.0.2
+ optionalDependencies:
+ '@libsql/darwin-arm64': 0.4.7
+ '@libsql/darwin-x64': 0.4.7
+ '@libsql/linux-arm64-gnu': 0.4.7
+ '@libsql/linux-arm64-musl': 0.4.7
+ '@libsql/linux-x64-gnu': 0.4.7
+ '@libsql/linux-x64-musl': 0.4.7
+ '@libsql/win32-x64-msvc': 0.4.7
+
+ light-my-request@6.6.0:
+ dependencies:
+ cookie: 1.1.1
+ process-warning: 4.0.1
+ set-cookie-parser: 2.7.2
+
+ lilconfig@2.1.0: {}
+
+ lilconfig@3.1.3: {}
+
+ lines-and-columns@1.2.4: {}
+
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
+ lodash.merge@4.6.2: {}
+
+ lodash.once@4.1.1: {}
+
+ lodash@4.17.21: {}
+
+ long@5.3.2: {}
+
+ lru-cache@11.2.4: {}
+
+ lru-cache@7.18.3: {}
+
+ lru.min@1.1.3: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ marked@17.0.1: {}
+
+ math-intrinsics@1.1.0: {}
+
+ md5@2.3.0:
+ dependencies:
+ charenc: 0.0.2
+ crypt: 0.0.2
+ is-buffer: 1.1.6
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ mime@3.0.0: {}
+
+ mimic-function@5.0.1: {}
+
+ mini-svg-data-uri@1.4.4: {}
+
+ minimalistic-assert@1.0.1: {}
+
+ minimatch@10.1.1:
+ dependencies:
+ '@isaacs/brace-expansion': 5.0.0
+
+ minimatch@3.1.2:
+ dependencies:
+ brace-expansion: 1.1.12
+
+ minimatch@9.0.5:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ minimist@1.2.8: {}
+
+ minipass@7.1.2: {}
+
+ mkdirp@3.0.1: {}
+
+ mnemonist@0.40.0:
+ dependencies:
+ obliterator: 2.0.5
+
+ mnemonist@0.40.3:
+ dependencies:
+ obliterator: 2.0.5
+
+ ms@2.1.3: {}
+
+ muggle-string@0.4.1: {}
+
+ multimatch@5.0.0:
+ dependencies:
+ '@types/minimatch': 3.0.5
+ array-differ: 3.0.0
+ array-union: 2.1.0
+ arrify: 2.0.1
+ minimatch: 3.1.2
+
+ mysql2@3.15.3:
+ dependencies:
+ aws-ssl-profiles: 1.1.2
+ denque: 2.1.0
+ generate-function: 2.3.1
+ iconv-lite: 0.7.0
+ long: 5.3.2
+ lru.min: 1.1.3
+ named-placeholders: 1.1.3
+ seq-queue: 0.0.5
+ sqlstring: 2.3.3
+
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ named-placeholders@1.1.3:
+ dependencies:
+ lru-cache: 7.18.3
+
+ nan@2.24.0:
+ optional: true
+
+ nanoid@3.3.11: {}
+
+ nanoid@5.1.6: {}
+
+ natural-compare@1.4.0: {}
+
+ nested-property@4.0.0: {}
+
+ node-cron@4.2.1: {}
+
+ node-domexception@1.0.0: {}
+
+ node-fetch-native@1.6.7: {}
+
+ node-fetch@3.3.2:
+ dependencies:
+ data-uri-to-buffer: 4.0.1
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+
+ node-releases@2.0.27: {}
+
+ nodemailer@7.0.11: {}
+
+ normalize-path@3.0.0: {}
+
+ normalize-range@0.1.2: {}
+
+ nth-check@2.1.1:
+ dependencies:
+ boolbase: 1.0.0
+
+ nypm@0.6.2:
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.2
+ pathe: 2.0.3
+ pkg-types: 2.3.0
+ tinyexec: 1.0.2
+
+ object-assign@4.1.1: {}
+
+ object-hash@3.0.0: {}
+
+ object-is@1.1.6:
+ dependencies:
+ call-bind: 1.0.8
+ define-properties: 1.2.1
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.1
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ obliterator@2.0.5: {}
+
+ ohash@2.0.11: {}
+
+ on-exit-leak-free@2.1.2: {}
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ optionator@0.8.3:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.3.0
+ prelude-ls: 1.1.2
+ type-check: 0.3.2
+ word-wrap: 1.2.5
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ otplib@12.0.1:
+ dependencies:
+ '@otplib/core': 12.0.1
+ '@otplib/preset-default': 12.0.1
+ '@otplib/preset-v11': 12.0.1
+
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-limit@7.2.0:
+ dependencies:
+ yocto-queue: 1.2.2
+
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ p-try@2.2.0: {}
+
+ package-json-from-dist@1.0.1: {}
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-browserify@1.0.1: {}
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ path-parse@1.0.7: {}
+
+ path-posix@1.0.0: {}
+
+ path-scurry@2.0.1:
+ dependencies:
+ lru-cache: 11.2.4
+ minipass: 7.1.2
+
+ pathe@2.0.3: {}
+
+ perfect-debounce@1.0.0: {}
+
+ pg-cloudflare@1.2.7:
+ optional: true
+
+ pg-connection-string@2.9.1: {}
+
+ pg-int8@1.0.1: {}
+
+ pg-pool@3.10.1(pg@8.16.3):
+ dependencies:
+ pg: 8.16.3
+
+ pg-protocol@1.10.3: {}
+
+ pg-types@2.2.0:
+ dependencies:
+ pg-int8: 1.0.1
+ postgres-array: 2.0.0
+ postgres-bytea: 1.0.0
+ postgres-date: 1.0.7
+ postgres-interval: 1.2.0
+
+ pg@8.16.3:
+ dependencies:
+ pg-connection-string: 2.9.1
+ pg-pool: 3.10.1(pg@8.16.3)
+ pg-protocol: 1.10.3
+ pg-types: 2.2.0
+ pgpass: 1.0.5
+ optionalDependencies:
+ pg-cloudflare: 1.2.7
+
+ pgpass@1.0.5:
+ dependencies:
+ split2: 4.2.0
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.1: {}
+
+ picomatch@4.0.3: {}
+
+ pify@2.3.0: {}
+
+ pinia@2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.25(typescript@5.9.3)
+ vue-demi: 0.14.10(vue@3.5.25(typescript@5.9.3))
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+
+ pino-abstract-transport@2.0.0:
+ dependencies:
+ split2: 4.2.0
+
+ pino-abstract-transport@3.0.0:
+ dependencies:
+ split2: 4.2.0
+
+ pino-pretty@13.1.3:
+ dependencies:
+ colorette: 2.0.20
+ dateformat: 4.6.3
+ fast-copy: 4.0.0
+ fast-safe-stringify: 2.1.1
+ help-me: 5.0.0
+ joycon: 3.1.1
+ minimist: 1.2.8
+ on-exit-leak-free: 2.1.2
+ pino-abstract-transport: 3.0.0
+ pump: 3.0.3
+ secure-json-parse: 4.1.0
+ sonic-boom: 4.2.0
+ strip-json-comments: 5.0.3
+
+ pino-std-serializers@7.0.0: {}
+
+ pino@10.1.0:
+ dependencies:
+ '@pinojs/redact': 0.4.0
+ atomic-sleep: 1.0.0
+ on-exit-leak-free: 2.1.2
+ pino-abstract-transport: 2.0.0
+ pino-std-serializers: 7.0.0
+ process-warning: 5.0.0
+ quick-format-unescaped: 4.0.4
+ real-require: 0.2.0
+ safe-stable-stringify: 2.5.0
+ sonic-boom: 4.2.0
+ thread-stream: 3.1.0
+
+ pirates@4.0.7: {}
+
+ pkg-types@2.3.0:
+ dependencies:
+ confbox: 0.2.2
+ exsolve: 1.0.8
+ pathe: 2.0.3
+
+ pngjs@5.0.0: {}
+
+ possible-typed-array-names@1.1.0: {}
+
+ postcss-import@15.1.0(postcss@8.5.6):
+ dependencies:
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.11
+
+ postcss-js@4.1.0(postcss@8.5.6):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.6
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.6
+ tsx: 4.21.0
+
+ postcss-nested@6.2.0(postcss@8.5.6):
+ dependencies:
+ postcss: 8.5.6
+ postcss-selector-parser: 6.1.2
+
+ postcss-selector-parser@6.1.2:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postgres-array@2.0.0: {}
+
+ postgres-array@3.0.4: {}
+
+ postgres-bytea@1.0.0: {}
+
+ postgres-date@1.0.7: {}
+
+ postgres-interval@1.2.0:
+ dependencies:
+ xtend: 4.0.2
+
+ postgres@3.4.7: {}
+
+ prelude-ls@1.1.2: {}
+
+ prelude-ls@1.2.1: {}
+
+ prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3):
+ dependencies:
+ '@prisma/config': 7.1.0
+ '@prisma/dev': 0.15.0(typescript@5.9.3)
+ '@prisma/engines': 7.1.0
+ '@prisma/studio-core': 0.8.2(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ mysql2: 3.15.3
+ postgres: 3.4.7
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/react'
+ - magicast
+ - react
+ - react-dom
+
+ process-warning@4.0.1: {}
+
+ process-warning@5.0.0: {}
+
+ process@0.11.10: {}
+
+ promise-limit@2.7.0: {}
+
+ promise-retry@2.0.1:
+ dependencies:
+ err-code: 2.0.3
+ retry: 0.12.0
+
+ proper-lockfile@4.1.2:
+ dependencies:
+ graceful-fs: 4.2.11
+ retry: 0.12.0
+ signal-exit: 3.0.7
+
+ proxy-from-env@1.1.0: {}
+
+ pump@3.0.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
+ punycode@2.3.1: {}
+
+ pure-rand@6.1.0: {}
+
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
+ querystringify@2.2.0: {}
+
+ queue-microtask@1.2.3: {}
+
+ quick-format-unescaped@4.0.4: {}
+
+ rc9@2.1.2:
+ dependencies:
+ defu: 6.1.4
+ destr: 2.0.5
+
+ react-dom@19.2.1(react@19.2.1):
+ dependencies:
+ react: 19.2.1
+ scheduler: 0.27.0
+
+ react@19.2.1: {}
+
+ read-cache@1.0.0:
+ dependencies:
+ pify: 2.3.0
+
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.1
+
+ readdirp@4.1.2: {}
+
+ real-require@0.2.0: {}
+
+ reflect-metadata@0.2.2: {}
+
+ regexp-to-ast@0.5.0: {}
+
+ remeda@2.21.3:
+ dependencies:
+ type-fest: 4.41.0
+
+ require-directory@2.1.1: {}
+
+ require-from-string@2.0.2: {}
+
+ require-main-filename@2.0.0: {}
+
+ requires-port@1.0.0: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ resolve@1.22.11:
+ dependencies:
+ is-core-module: 2.16.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ ret@0.5.0: {}
+
+ retry@0.12.0: {}
+
+ reusify@1.1.0: {}
+
+ rfdc@1.4.1: {}
+
+ rollup-plugin-obfuscator@1.1.0(javascript-obfuscator@5.0.1)(rollup@4.53.3):
+ dependencies:
+ '@rollup/pluginutils': 5.3.0(rollup@4.53.3)
+ javascript-obfuscator: 5.0.1
+ rollup: 4.53.3
+
+ rollup@4.53.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.53.3
+ '@rollup/rollup-android-arm64': 4.53.3
+ '@rollup/rollup-darwin-arm64': 4.53.3
+ '@rollup/rollup-darwin-x64': 4.53.3
+ '@rollup/rollup-freebsd-arm64': 4.53.3
+ '@rollup/rollup-freebsd-x64': 4.53.3
+ '@rollup/rollup-linux-arm-gnueabihf': 4.53.3
+ '@rollup/rollup-linux-arm-musleabihf': 4.53.3
+ '@rollup/rollup-linux-arm64-gnu': 4.53.3
+ '@rollup/rollup-linux-arm64-musl': 4.53.3
+ '@rollup/rollup-linux-loong64-gnu': 4.53.3
+ '@rollup/rollup-linux-ppc64-gnu': 4.53.3
+ '@rollup/rollup-linux-riscv64-gnu': 4.53.3
+ '@rollup/rollup-linux-riscv64-musl': 4.53.3
+ '@rollup/rollup-linux-s390x-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-musl': 4.53.3
+ '@rollup/rollup-openharmony-arm64': 4.53.3
+ '@rollup/rollup-win32-arm64-msvc': 4.53.3
+ '@rollup/rollup-win32-ia32-msvc': 4.53.3
+ '@rollup/rollup-win32-x64-gnu': 4.53.3
+ '@rollup/rollup-win32-x64-msvc': 4.53.3
+ fsevents: 2.3.3
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
+ safe-buffer@5.2.1: {}
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ safe-regex2@5.0.0:
+ dependencies:
+ ret: 0.5.0
+
+ safe-stable-stringify@2.5.0: {}
+
+ safer-buffer@2.1.2: {}
+
+ scheduler@0.27.0: {}
+
+ secure-json-parse@4.1.0: {}
+
+ semver@7.7.3: {}
+
+ seq-queue@0.0.5: {}
+
+ set-blocking@2.0.0: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ setprototypeof@1.2.0: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ shell-quote@1.8.3: {}
+
+ signal-exit@3.0.7: {}
+
+ signal-exit@4.1.0: {}
+
+ simple-icons@16.1.0: {}
+
+ sonic-boom@4.2.0:
+ dependencies:
+ atomic-sleep: 1.0.0
+
+ source-map-js@1.2.1: {}
+
+ source-map-support@0.5.21:
+ dependencies:
+ buffer-from: 1.1.2
+ source-map: 0.6.1
+
+ source-map@0.6.1: {}
+
+ split2@4.2.0: {}
+
+ sql.js@1.13.0:
+ optional: true
+
+ sqlstring@2.3.3: {}
+
+ ssh2-sftp-client@11.0.0:
+ dependencies:
+ concat-stream: 2.0.0
+ promise-retry: 2.0.1
+ ssh2: 1.17.0
+
+ ssh2@1.17.0:
+ dependencies:
+ asn1: 0.2.6
+ bcrypt-pbkdf: 1.0.2
+ optionalDependencies:
+ cpu-features: 0.0.10
+ nan: 2.24.0
+
+ statuses@2.0.2: {}
+
+ std-env@3.9.0: {}
+
+ steed@1.1.3:
+ dependencies:
+ fastfall: 1.5.1
+ fastparallel: 2.4.1
+ fastq: 1.19.1
+ fastseries: 1.7.2
+ reusify: 1.1.0
+
+ stream-shift@1.0.3: {}
+
+ string-template@1.0.0: {}
+
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ string-width@5.1.2:
+ dependencies:
+ eastasianwidth: 0.2.0
+ emoji-regex: 9.2.2
+ strip-ansi: 7.1.2
+
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ stringz@2.1.0:
+ dependencies:
+ char-regex: 1.0.2
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
+ strip-ansi@7.1.2:
+ dependencies:
+ ansi-regex: 6.2.2
+
+ strip-json-comments@3.1.1: {}
+
+ strip-json-comments@5.0.3: {}
+
+ strnum@1.1.2: {}
+
+ strnum@2.1.1: {}
+
+ stubborn-fs@2.0.0:
+ dependencies:
+ stubborn-utils: 1.0.2
+
+ stubborn-utils@1.0.2: {}
+
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.15
+ ts-interface-checker: 0.1.13
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-color@8.1.1:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tagged-tag@1.0.0: {}
+
+ tailwindcss@3.4.18(tsx@4.21.0):
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.3
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.6
+ postcss-import: 15.1.0(postcss@8.5.6)
+ postcss-js: 4.1.0(postcss@8.5.6)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)
+ postcss-nested: 6.2.0(postcss@8.5.6)
+ postcss-selector-parser: 6.1.2
+ resolve: 1.22.11
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
+ terser@5.44.1:
+ dependencies:
+ '@jridgewell/source-map': 0.3.11
+ acorn: 8.15.0
+ commander: 2.20.3
+ source-map-support: 0.5.21
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
+ thirty-two@1.0.2: {}
+
+ thread-stream@3.1.0:
+ dependencies:
+ real-require: 0.2.0
+
+ tinyexec@1.0.2: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ toad-cache@3.7.0: {}
+
+ toidentifier@1.0.1: {}
+
+ tree-kill@1.2.2: {}
+
+ ts-api-utils@2.1.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ ts-interface-checker@0.1.13: {}
+
+ tslib@2.8.1: {}
+
+ tsx@4.21.0:
+ dependencies:
+ esbuild: 0.27.1
+ get-tsconfig: 4.13.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ tweetnacl@0.14.5: {}
+
+ type-check@0.3.2:
+ dependencies:
+ prelude-ls: 1.1.2
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ type-fest@0.20.2: {}
+
+ type-fest@4.41.0: {}
+
+ type-fest@5.3.1:
+ dependencies:
+ tagged-tag: 1.0.0
+
+ typedarray@0.0.6: {}
+
+ typescript@5.9.3: {}
+
+ uint8array-extras@1.5.0: {}
+
+ undici-types@5.26.5: {}
+
+ undici-types@7.16.0: {}
+
+ undici@6.22.0: {}
+
+ undici@7.16.0: {}
+
+ update-browserslist-db@1.2.2(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ url-join@5.0.0: {}
+
+ url-parse@1.5.10:
+ dependencies:
+ querystringify: 2.2.0
+ requires-port: 1.0.0
+
+ util-deprecate@1.0.2: {}
+
+ util@0.12.5:
+ dependencies:
+ inherits: 2.0.4
+ is-arguments: 1.2.0
+ is-generator-function: 1.1.2
+ is-typed-array: 1.1.15
+ which-typed-array: 1.1.19
+
+ valibot@1.2.0(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
+ validator@13.15.23: {}
+
+ vite@6.4.1(@types/node@24.10.1)(jiti@1.21.7)(terser@5.44.1)(tsx@4.21.0):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.53.3
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 24.10.1
+ fsevents: 2.3.3
+ jiti: 1.21.7
+ terser: 5.44.1
+ tsx: 4.21.0
+
+ vscode-uri@3.1.0: {}
+
+ vue-demi@0.14.10(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue-eslint-parser@9.4.3(eslint@9.39.1(jiti@1.21.7)):
+ dependencies:
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@1.21.7)
+ eslint-scope: 7.2.2
+ eslint-visitor-keys: 3.4.3
+ espree: 9.6.1
+ esquery: 1.6.0
+ lodash: 4.17.21
+ semver: 7.7.3
+ transitivePeerDependencies:
+ - supports-color
+
+ vue-i18n@11.2.2(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ '@intlify/core-base': 11.2.2
+ '@intlify/shared': 11.2.2
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue-tsc@3.1.6(typescript@5.9.3):
+ dependencies:
+ '@volar/typescript': 2.4.26
+ '@vue/language-core': 3.1.6(typescript@5.9.3)
+ typescript: 5.9.3
+
+ vue-turnstile@1.0.11(vue@3.5.25(typescript@5.9.3)):
+ dependencies:
+ vue: 3.5.25(typescript@5.9.3)
+
+ vue@3.5.25(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.25
+ '@vue/compiler-sfc': 3.5.25
+ '@vue/runtime-dom': 3.5.25
+ '@vue/server-renderer': 3.5.25(vue@3.5.25(typescript@5.9.3))
+ '@vue/shared': 3.5.25
+ optionalDependencies:
+ typescript: 5.9.3
+
+ web-streams-polyfill@3.3.3: {}
+
+ webdav@5.8.0:
+ dependencies:
+ '@buttercup/fetch': 0.2.1
+ base-64: 1.0.0
+ byte-length: 1.0.2
+ entities: 6.0.1
+ fast-xml-parser: 4.5.3
+ hot-patcher: 2.0.1
+ layerr: 3.0.0
+ md5: 2.3.0
+ minimatch: 9.0.5
+ nested-property: 4.0.0
+ node-fetch: 3.3.2
+ path-posix: 1.0.0
+ url-join: 5.0.0
+ url-parse: 1.5.10
+
+ when-exit@2.1.5: {}
+
+ which-module@2.0.1: {}
+
+ which-typed-array@1.1.19:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ which@4.0.0:
+ dependencies:
+ isexe: 3.1.1
+
+ word-wrap@1.2.5: {}
+
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ wrap-ansi@8.1.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 5.1.2
+ strip-ansi: 7.1.2
+
+ wrappy@1.0.2: {}
+
+ ws@8.18.3: {}
+
+ xml-name-validator@4.0.0: {}
+
+ xtend@4.0.2: {}
+
+ y18n@4.0.3: {}
+
+ y18n@5.0.8: {}
+
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
+ yargs-parser@21.1.1: {}
+
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
+ yargs@17.7.2:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
+ yocto-queue@0.1.0: {}
+
+ yocto-queue@1.2.2: {}
+
+ zeptomatch@2.0.2:
+ dependencies:
+ grammex: 3.1.12
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..274c4b4
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+packages:
+ - 'client'
+ - 'server'
+
diff --git a/scripts/generate-badges-from-html.mjs b/scripts/generate-badges-from-html.mjs
new file mode 100644
index 0000000..ab745a3
--- /dev/null
+++ b/scripts/generate-badges-from-html.mjs
@@ -0,0 +1,315 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import vm from 'node:vm'
+
+import ts from '../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.js'
+
+const rootDir = process.cwd()
+const outputDir = path.join(rootDir, 'client', 'public', 'badges')
+const manifestPath = path.join(rootDir, 'server', 'src', 'config', 'badges.generated.json')
+const manifestTsPath = path.join(rootDir, 'server', 'src', 'config', 'badges.generated.ts')
+const sourceFiles = [
+ {
+ fileName: 'aaaa.html',
+ sourceId: 'core',
+ sourceLabel: '核心科幻'
+ },
+ {
+ fileName: 'bbbb.html',
+ sourceId: 'astro-zodiac',
+ sourceLabel: '星相生肖'
+ }
+]
+
+function writeManifestTs(manifest) {
+ fs.writeFileSync(
+ manifestTsPath,
+ `export const badgeCatalogData = ${JSON.stringify(manifest, null, 2)} as const\n`
+ )
+}
+
+const passthroughAttributes = new Set(['viewBox', 'xmlns', 'preserveAspectRatio'])
+
+function toKebabCase(value) {
+ return value.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
+}
+
+function toAttributeName(name) {
+ if (name === 'className') return 'class'
+ if (passthroughAttributes.has(name)) return name
+ return toKebabCase(name)
+}
+
+function escapeAttribute(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>')
+}
+
+function serializeStyle(style) {
+ return Object.entries(style)
+ .map(([key, value]) => `${toKebabCase(key)}:${value}`)
+ .join(';')
+}
+
+function h(tag, props, ...children) {
+ const normalizedProps = props || {}
+ if (typeof tag === 'function') {
+ return tag({ ...normalizedProps, children })
+ }
+
+ const attributes = Object.entries(normalizedProps)
+ .filter(([, value]) => value !== null && value !== undefined && value !== false)
+ .map(([key, value]) => {
+ if (value === true) {
+ return toAttributeName(key)
+ }
+
+ const attrValue = key === 'style' && typeof value === 'object'
+ ? serializeStyle(value)
+ : value
+
+ return `${toAttributeName(key)}="${escapeAttribute(attrValue)}"`
+ })
+ .join(' ')
+
+ const content = children
+ .flat(Infinity)
+ .filter(value => value !== null && value !== undefined && value !== false)
+ .join('')
+
+ return `<${tag}${attributes ? ` ${attributes}` : ''}>${content}${tag}>`
+}
+
+function Fragment(_props, ...children) {
+ return children.flat(Infinity).join('')
+}
+
+const gradients = [
+ { id: 'supreme', dark: ['#2a2d3e', '#0a0a0f'], light: ['#ffffff', '#cbd5e1'] },
+ { id: 'void', dark: ['#1a0033', '#05000a', '#000000'], light: ['#312e81', '#0f172a', '#020617'] },
+ { id: 'ice', dark: ['#002244', '#000a1a'], light: ['#e0f2fe', '#7dd3fc'] },
+ { id: 'gold', dark: ['#4a3b1c', '#1a140a'], light: ['#fef3c7', '#fbbf24'] },
+ { id: 'moon', dark: ['#334155', '#0f172a'], light: ['#f8fafc', '#94a3b8'] },
+ { id: 'omega', dark: ['#4a0000', '#1a0000'], light: ['#fee2e2', '#f87171'] },
+ { id: 'matrix', dark: ['#003311', '#001100'], light: ['#dcfce7', '#4ade80'] },
+ { id: 'platinum', dark: ['#475569', '#1e293b'], light: ['#ffffff', '#94a3b8'] },
+ { id: 'nebula', dark: ['#581c87', '#831843', '#171717'], light: ['#f3e8ff', '#fbcfe8', '#cbd5e1'] },
+ { id: 'aegis', dark: ['#0f172a', '#020617'], light: ['#f1f5f9', '#94a3b8'] },
+ { id: 'reactor', dark: ['#14532d', '#052e16'], light: ['#dcfce7', '#4ade80'] },
+ { id: 'tactical', dark: ['#450a0a', '#2a0000'], light: ['#fee2e2', '#f87171'] },
+ { id: 'arcane', dark: ['#3b0764', '#17002e'], light: ['#f3e8ff', '#c084fc'] }
+]
+
+function buildDefs() {
+ const filters = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+ const gradientMarkup = gradients.map(gradient => {
+ const buildGradient = (colors, theme) => {
+ const stops = colors.map((color, index) => {
+ const offset = colors.length === 1
+ ? '0%'
+ : `${(index / (colors.length - 1)) * 100}%`
+ return ``
+ }).join('')
+ return `${stops}`
+ }
+
+ return [
+ buildGradient(gradient.dark, 'd'),
+ buildGradient(gradient.light, 'l')
+ ].join('')
+ }).join('')
+
+ return `${filters}${gradientMarkup}`
+}
+
+const defsMarkup = buildDefs()
+
+function injectDefs(svg, extraStyles = '') {
+ const withXmlns = svg.replace('