diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c4bd04b..22c8204 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,20 +1,20 @@ -#### 💻 变更类型 | Change Type - - - -- [ ] ✨ feat -- [ ] 🐛 fix -- [ ] ♻️ refactor -- [ ] 💄 style -- [ ] 👷 build -- [ ] ⚡️ perf -- [ ] 📝 docs -- [ ] 🔨 chore - -#### 🔀 变更说明 | Description of Change - - - -#### 📝 补充信息 | Additional Information - - +#### 💻 变更类型 | Change Type + + + +- [ ] ✨ feat +- [ ] 🐛 fix +- [ ] ♻️ refactor +- [ ] 💄 style +- [ ] 👷 build +- [ ] ⚡️ perf +- [ ] 📝 docs +- [ ] 🔨 chore + +#### 🔀 变更说明 | Description of Change + + + +#### 📝 补充信息 | Additional Information + + \ No newline at end of file diff --git a/.github/workflows/release-dockerhub.yml b/.github/workflows/release-dockerhub.yml new file mode 100644 index 0000000..de86912 --- /dev/null +++ b/.github/workflows/release-dockerhub.yml @@ -0,0 +1,127 @@ +name: Release to Docker Hub + +on: + workflow_dispatch: + inputs: + version_type: + description: '版本类型' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: get_version + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + TAG_VERSION=${LATEST_TAG#v} + + if [ -f VERSION ]; then + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + else + FILE_VERSION="0.0.0" + fi + + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + else + VERSION="$TAG_VERSION" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: next_version + env: + VERSION_TYPE: ${{ github.event.inputs.version_type }} + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') + + IFS='.' read -r -a version_parts <<< "$BASE_VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + case "$VERSION_TYPE" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + *) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + - name: Commit VERSION and create tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add VERSION + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" + fi + + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + # Docker 构建并推送到 Docker Hub + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_version }} + ${{ secrets.DOCKERHUB_USERNAME }}/ds2api:latest + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8b7cbc1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +name: Release to Aliyun CR + +on: + workflow_dispatch: + inputs: + version_type: + description: '版本类型' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: get_version + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + TAG_VERSION=${LATEST_TAG#v} + + if [ -f VERSION ]; then + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + else + FILE_VERSION="0.0.0" + fi + + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + else + VERSION="$TAG_VERSION" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: next_version + env: + VERSION_TYPE: ${{ github.event.inputs.version_type }} + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') + + IFS='.' read -r -a version_parts <<< "$BASE_VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + case "$VERSION_TYPE" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + *) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + - name: Commit VERSION and create tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add VERSION + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" + fi + + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + # Docker 构建并推送到阿里云 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Aliyun Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.ALIYUN_REGISTRY }} + username: ${{ secrets.ALIYUN_REGISTRY_USER }} + password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 422c203..d096b58 100644 --- a/.gitignore +++ b/.gitignore @@ -2,37 +2,6 @@ config.json .env -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments -venv/ -ENV/ -env/ -.venv - # IDE .vscode/ .idea/ @@ -44,7 +13,6 @@ env/ # Logs *.log logs/ -uvicorn.log artifacts/ # Vercel @@ -56,8 +24,6 @@ webui/node_modules/ webui/dist/ .npm .pnpm-store/ -# 保留 webui/package-lock.json 用于 CI 缓存 -# package-lock.json # 如果有根目录的可以忽略 yarn.lock pnpm-lock.yaml @@ -86,7 +52,9 @@ coverage*.out cover/ # Misc -*.pyc -*.pyo .git/ Thumbs.db + +# Claude Code +.claude/ +CLAUDE.local.md diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7ecf123 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/docker-compose.yml b/docker-compose.yml index 2ac83e1..3e6b605 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,14 @@ -services: - ds2api: - build: . - image: ds2api:latest - container_name: ds2api - ports: - - "${PORT:-5001}:${PORT:-5001}" - env_file: - - .env - environment: - - HOST=0.0.0.0 - restart: unless-stopped - healthcheck: - test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s +services: + ds2api: + image: crpi-cnazxqmg4avmg4fq.cn-beijing.personal.cr.aliyuncs.com/ronghuaxueleng/ds2api:latest + container_name: ds2api + restart: always + ports: + - "6011:5001" + volumes: + - ./config.json:/app/config.json # 配置文件 + - ./.env:/app/.env # 环境变量 + environment: + - TZ=Asia/Shanghai + - LOG_LEVEL=INFO + - DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api} diff --git a/internal/admin/deps.go b/internal/admin/deps.go index e92c37b..7debcf0 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -16,6 +16,7 @@ type ConfigStore interface { Accounts() []config.Account FindAccount(identifier string) (config.Account, bool) UpdateAccountToken(identifier, token string) error + UpdateAccountTestStatus(identifier, status string) error Update(mutator func(*config.Config) error) error ExportJSONAndBase64() (string, string, error) IsEnvBacked() bool diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index daaa434..a0d64df 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -56,6 +56,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { "has_password": acc.Password != "", "has_token": token != "", "token_preview": preview, + "test_status": acc.TestStatus, }) } writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages}) diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 2bd7706..7a7430d 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -88,7 +88,15 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { start := time.Now() - result := map[string]any{"account": acc.Identifier(), "success": false, "response_time": 0, "message": "", "model": model} + identifier := acc.Identifier() + result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model} + defer func() { + status := "failed" + if ok, _ := result["success"].(bool); ok { + status = "ok" + } + _ = h.Store.UpdateAccountTestStatus(identifier, status) + }() token := strings.TrimSpace(acc.Token) if token == "" { newToken, err := h.DS.Login(ctx, acc) diff --git a/internal/config/config.go b/internal/config/config.go index 4b281a2..b8d59d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,10 +18,11 @@ type Config struct { } type Account struct { - Email string `json:"email,omitempty"` - Mobile string `json:"mobile,omitempty"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` + TestStatus string `json:"test_status,omitempty"` } type CompatConfig struct { diff --git a/internal/config/store.go b/internal/config/store.go index 2e6fcaf..7a09cdc 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -97,6 +97,18 @@ func (s *Store) FindAccount(identifier string) (Account, bool) { return Account{}, false } +func (s *Store) UpdateAccountTestStatus(identifier, status string) error { + identifier = strings.TrimSpace(identifier) + s.mu.Lock() + defer s.mu.Unlock() + idx, ok := s.findAccountIndexLocked(identifier) + if !ok { + return errors.New("account not found") + } + s.cfg.Accounts[idx].TestStatus = status + return s.saveLocked() +} + func (s *Store) UpdateAccountToken(identifier, token string) error { identifier = strings.TrimSpace(identifier) s.mu.Lock() diff --git a/start.mjs b/start.mjs new file mode 100644 index 0000000..59c5101 --- /dev/null +++ b/start.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env node +/** + * DS2API 启动脚本 - 交互式菜单 + * + * 使用方法: + * node start.mjs # 显示交互式菜单 + * node start.mjs dev # 开发模式(后端 + 前端热重载) + * node start.mjs prod # 生产模式(编译后运行) + * node start.mjs build # 编译后端二进制 + * node start.mjs webui # 构建前端静态文件 + * node start.mjs install # 安装前端依赖 + * node start.mjs stop # 停止所有服务 + * node start.mjs status # 查看服务状态 + */ + +import { spawn, execSync } from 'child_process'; +import { createInterface } from 'readline'; +import { existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// 判断是否为 Windows +const isWindows = process.platform === 'win32'; + +// 编译产物路径 +const BINARY = join(__dirname, isWindows ? 'ds2api.exe' : 'ds2api'); + +// 配置(从环境变量读取,与 Go 主程序保持一致) +const CONFIG = { + port: process.env.PORT || '5001', + frontendPort: 5173, + logLevel: process.env.LOG_LEVEL || 'INFO', + adminKey: process.env.DS2API_ADMIN_KEY || 'admin', + webuiDir: join(__dirname, 'webui'), + staticAdminDir: process.env.DS2API_STATIC_ADMIN_DIR || join(__dirname, 'static', 'admin'), +}; + +// 国内镜像配置 +const MIRRORS = { + goproxy: process.env.GOPROXY || 'https://goproxy.cn,direct', + npm: process.env.NPM_REGISTRY || 'https://registry.npmmirror.com', +}; + +// 存储子进程 +const processes = []; + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', +}; + +const log = { + info: (msg) => console.log(`${colors.cyan}[INFO]${colors.reset} ${msg}`), + success: (msg) => console.log(`${colors.green}[OK]${colors.reset} ${msg}`), + warn: (msg) => console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`), + error: (msg) => console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`), + title: (msg) => console.log(`\n${colors.bright}${colors.magenta}${msg}${colors.reset}`), +}; + +// 清理并退出 +function cleanup() { + console.log('\n'); + log.info('正在关闭所有服务...'); + processes.forEach(proc => { + if (proc && !proc.killed) { + proc.kill('SIGTERM'); + } + }); + log.success('已退出'); + process.exit(0); +} + +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + +// 检查命令是否存在 +function commandExists(cmd) { + try { + execSync(`${isWindows ? 'where' : 'which'} ${cmd}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +// 检查 Go 是否安装 +function checkGo() { + return commandExists('go'); +} + +// 获取 Go 版本 +function getGoVersion() { + try { + return execSync('go version', { encoding: 'utf-8' }).trim(); + } catch { + return null; + } +} + +// 检查前端依赖是否已安装 +function checkFrontendDeps() { + if (!existsSync(CONFIG.webuiDir)) return null; + return existsSync(join(CONFIG.webuiDir, 'node_modules')); +} + +// 检查前端是否已构建 +function checkWebuiBuilt() { + return existsSync(join(CONFIG.staticAdminDir, 'index.html')); +} + +// 检查后端二进制是否存在 +function binaryExists() { + return existsSync(BINARY); +} + +// 查找占用端口的进程 PID +function findPidByPort(port) { + try { + if (isWindows) { + const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'ignore'], + }); + const pids = new Set(); + for (const line of output.trim().split('\n')) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && pid !== '0') pids.add(pid); + } + return [...pids]; + } else { + const output = execSync(`lsof -ti :${port}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return output.trim().split('\n').filter(Boolean); + } + } catch { + return []; + } +} + +// 获取运行中的服务状态 +function getRunningStatus() { + const backendPids = findPidByPort(CONFIG.port); + const frontendPids = findPidByPort(CONFIG.frontendPort); + return { + backend: backendPids, + frontend: frontendPids, + isRunning: backendPids.length > 0 || frontendPids.length > 0, + }; +} + +// 停止服务 +async function stopServices() { + const running = getRunningStatus(); + + if (!running.isRunning) { + log.warn('没有检测到正在运行的服务'); + return; + } + + log.title('========== 停止服务 =========='); + + const killProcess = async (pid) => { + try { + if (isWindows) { + try { + execSync(`taskkill /PID ${pid}`, { stdio: 'ignore', shell: true }); + } catch { + execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', shell: true }); + } + } else { + execSync(`kill -15 ${pid}`, { stdio: 'ignore' }); + await new Promise(r => setTimeout(r, 500)); + try { + execSync(`kill -0 ${pid}`, { stdio: 'ignore' }); + execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); + } catch { /* 进程已退出 */ } + } + } catch { /* 进程可能已退出 */ } + }; + + if (running.backend.length > 0) { + log.info(`停止后端服务 (端口 ${CONFIG.port}, PID: ${running.backend.join(', ')})...`); + for (const pid of running.backend) await killProcess(pid); + log.success('后端服务已停止'); + } + + if (running.frontend.length > 0) { + log.info(`停止前端服务 (端口 ${CONFIG.frontendPort}, PID: ${running.frontend.join(', ')})...`); + for (const pid of running.frontend) await killProcess(pid); + log.success('前端服务已停止'); + } +} + +// 安装前端依赖 +async function installFrontendDeps() { + if (!existsSync(CONFIG.webuiDir)) { + log.warn('webui 目录不存在,跳过前端依赖安装'); + return; + } + log.info(`安装前端依赖 (npm ci, registry: ${MIRRORS.npm})...`); + return new Promise((resolve, reject) => { + const proc = spawn('npm', ['ci', '--registry', MIRRORS.npm], { + cwd: CONFIG.webuiDir, + stdio: 'inherit', + shell: true, + }); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败'))); + }); +} + +// 确保前端依赖已安装 +async function ensureFrontendDeps() { + if (checkFrontendDeps() === false) { + log.warn('检测到前端依赖未安装,正在安装...'); + await installFrontendDeps(); + } +} + +// 编译后端二进制 +async function buildBackend() { + if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)'); + log.info(`编译后端二进制 (GOPROXY: ${MIRRORS.goproxy})...`); + return new Promise((resolve, reject) => { + const proc = spawn('go', ['build', '-o', BINARY, './cmd/ds2api'], { + cwd: __dirname, + stdio: 'inherit', + shell: true, + env: { ...process.env, GOPROXY: MIRRORS.goproxy }, + }); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('后端编译失败'))); + }); +} + +// 构建前端静态文件 +async function buildWebui() { + if (!existsSync(CONFIG.webuiDir)) { + log.warn('webui 目录不存在'); + return; + } + await ensureFrontendDeps(); + log.info('构建前端静态文件...'); + return new Promise((resolve, reject) => { + const proc = spawn( + 'npm', ['run', 'build', '--', '--outDir', CONFIG.staticAdminDir, '--emptyOutDir'], + { cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true } + ); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败'))); + }); +} + +// 启动后端(开发模式:go run,无需预编译) +async function startBackendDev() { + if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)'); + log.info(`启动后端(go run)... http://localhost:${CONFIG.port}`); + const proc = spawn('go', ['run', './cmd/ds2api'], { + cwd: __dirname, + stdio: 'inherit', + shell: true, + env: { + ...process.env, + PORT: CONFIG.port, + LOG_LEVEL: CONFIG.logLevel, + DS2API_ADMIN_KEY: CONFIG.adminKey, + GOPROXY: MIRRORS.goproxy, + }, + }); + processes.push(proc); + return proc; +} + +// 启动后端(生产模式:运行编译好的二进制) +async function startBackendProd() { + if (!binaryExists()) { + log.warn('未找到编译产物,正在编译...'); + await buildBackend(); + } + log.info(`启动后端(二进制)... http://localhost:${CONFIG.port}`); + const proc = spawn(BINARY, [], { + cwd: __dirname, + stdio: 'inherit', + shell: false, + env: { + ...process.env, + PORT: CONFIG.port, + LOG_LEVEL: CONFIG.logLevel, + DS2API_ADMIN_KEY: CONFIG.adminKey, + }, + }); + processes.push(proc); + return proc; +} + +// 启动前端开发服务器 +async function startFrontend() { + if (!existsSync(CONFIG.webuiDir)) { + log.warn('webui 目录不存在,跳过前端启动'); + return null; + } + await ensureFrontendDeps(); + log.info(`启动前端开发服务器... http://localhost:${CONFIG.frontendPort}`); + const proc = spawn('npm', ['run', 'dev'], { + cwd: CONFIG.webuiDir, + stdio: 'inherit', + shell: true, + }); + processes.push(proc); + return proc; +} + +// 显示状态信息 +function showStatus() { + console.log('\n' + '─'.repeat(50)); + log.success(`后端 API: http://localhost:${CONFIG.port}`); + log.success(`管理界面: http://localhost:${CONFIG.port}/admin`); + if (existsSync(CONFIG.webuiDir)) { + log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`); + } + console.log('─'.repeat(50)); + log.info('按 Ctrl+C 停止所有服务\n'); +} + +// 等待进程退出 +function waitForProcesses() { + return new Promise(resolve => { + const check = setInterval(() => { + if (processes.filter(p => !p.killed).length === 0) { + clearInterval(check); + resolve(); + } + }, 1000); + }); +} + +// 交互式菜单 +async function showMenu() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve)); + + console.clear(); + log.title('╔══════════════════════════════════════════╗'); + log.title('║ DS2API 启动脚本 (Go) ║'); + log.title('╚══════════════════════════════════════════╝'); + + // 环境状态 + const goVersion = getGoVersion(); + const frontendDeps = checkFrontendDeps(); + const webuiBuilt = checkWebuiBuilt(); + const hasBinary = binaryExists(); + const running = getRunningStatus(); + + const ok = (v) => v ? `${colors.green}✓${colors.reset}` : `${colors.yellow}✗${colors.reset}`; + + console.log(`\n${colors.bright}环境状态:${colors.reset}`); + console.log(` Go: ${goVersion ? `${colors.green}${goVersion}${colors.reset}` : `${colors.red}未安装${colors.reset}`}`); + console.log(` 前端依赖: ${frontendDeps === null ? `${colors.dim}N/A${colors.reset}` : frontendDeps ? `${colors.green}已安装${colors.reset}` : `${colors.yellow}未安装${colors.reset}`}`); + console.log(` 前端构建: ${ok(webuiBuilt)} ${webuiBuilt ? `(${CONFIG.staticAdminDir})` : '未构建'}`); + console.log(` 后端二进制: ${ok(hasBinary)} ${hasBinary ? BINARY : '未编译'}`); + + console.log(`\n${colors.bright}服务状态:${colors.reset}`); + console.log(` 后端 (:${CONFIG.port}): ${running.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`); + console.log(` 前端 (:${CONFIG.frontendPort}): ${running.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`); + + console.log(`\n${colors.bright}环境变量:${colors.reset}`); + console.log(` PORT: ${colors.cyan}${CONFIG.port}${colors.reset}`); + console.log(` LOG_LEVEL: ${colors.cyan}${CONFIG.logLevel}${colors.reset}`); + console.log(` DS2API_ADMIN_KEY: ${colors.cyan}${CONFIG.adminKey}${colors.reset}`); + console.log(` GOPROXY: ${colors.cyan}${MIRRORS.goproxy}${colors.reset}`); + console.log(` NPM_REGISTRY: ${colors.cyan}${MIRRORS.npm}${colors.reset}`); + console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=密钥 PORT=5001 node start.mjs${colors.reset}`); + + console.log(` +${colors.bright}请选择操作:${colors.reset} + + ${colors.cyan}1.${colors.reset} 开发模式 (go run + 前端热重载) + ${colors.cyan}2.${colors.reset} 仅后端 (go run,无需编译) + ${colors.cyan}3.${colors.reset} 仅前端 (npm dev) + ${colors.cyan}4.${colors.reset} 生产模式 (编译后运行,前端已嵌入) + ${colors.cyan}5.${colors.reset} 编译后端 (go build) + ${colors.cyan}6.${colors.reset} 构建前端 (npm build → static/admin) + ${colors.cyan}7.${colors.reset} 安装前端依赖 (npm ci) + ${colors.red}8.${colors.reset} 停止所有服务 + ${colors.cyan}0.${colors.reset} 退出 +`); + + const choice = await question(`${colors.yellow}请输入选项 [1]: ${colors.reset}`); + rl.close(); + + switch (choice.trim() || '1') { + case '1': + log.title('========== 开发模式 =========='); + await startBackendDev(); + await new Promise(r => setTimeout(r, 1500)); + await startFrontend(); + showStatus(); + await waitForProcesses(); + break; + + case '2': + log.title('========== 仅后端 (go run) =========='); + await startBackendDev(); + showStatus(); + await waitForProcesses(); + break; + + case '3': + log.title('========== 仅前端 =========='); + await startFrontend(); + showStatus(); + await waitForProcesses(); + break; + + case '4': + log.title('========== 生产模式 =========='); + await startBackendProd(); + showStatus(); + await waitForProcesses(); + break; + + case '5': + log.title('========== 编译后端 =========='); + await buildBackend(); + log.success(`编译完成:${BINARY}`); + break; + + case '6': + log.title('========== 构建前端 =========='); + await buildWebui(); + log.success('前端构建完成!'); + break; + + case '7': + log.title('========== 安装前端依赖 =========='); + await installFrontendDeps(); + log.success('前端依赖安装完成!'); + break; + + case '8': + await stopServices(); + break; + + case '0': + log.info('再见!'); + process.exit(0); + break; + + default: + log.warn('无效选项'); + await showMenu(); + } +} + +// 命令行参数处理 +async function main() { + const cmd = process.argv[2]; + + if (!checkGo() && !['install', 'webui', 'stop', 'status', 'help', '-h', '--help'].includes(cmd)) { + log.error('未找到 Go,请先安装 Go: https://go.dev/dl/'); + if (!cmd) { + // 无 Go 时仍允许进入菜单(可以只操作前端) + } else { + process.exit(1); + } + } + + switch (cmd) { + case 'dev': + log.title('========== 开发模式 =========='); + await startBackendDev(); + await new Promise(r => setTimeout(r, 1500)); + await startFrontend(); + showStatus(); + await waitForProcesses(); + break; + + case 'prod': + log.title('========== 生产模式 =========='); + await startBackendProd(); + showStatus(); + await waitForProcesses(); + break; + + case 'build': + await buildBackend(); + log.success(`编译完成:${BINARY}`); + break; + + case 'webui': + await buildWebui(); + log.success('前端构建完成!'); + break; + + case 'install': + await installFrontendDeps(); + log.success('前端依赖安装完成!'); + break; + + case 'stop': + await stopServices(); + break; + + case 'status': { + const status = getRunningStatus(); + const goVer = getGoVersion(); + console.log(`\n${colors.bright}环境:${colors.reset}`); + console.log(` Go: ${goVer || `${colors.red}未安装${colors.reset}`}`); + console.log(`\n${colors.bright}服务状态:${colors.reset}`); + console.log(` 后端 (:${CONFIG.port}): ${status.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`); + console.log(` 前端 (:${CONFIG.frontendPort}): ${status.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}\n`); + break; + } + + case 'help': + case '-h': + case '--help': + console.log(` +${colors.bright}DS2API 启动脚本 (Go)${colors.reset} + +${colors.cyan}使用方法:${colors.reset} + node start.mjs 显示交互式菜单 + node start.mjs dev 开发模式 (go run + 前端热重载) + node start.mjs prod 生产模式 (编译产物,前端已嵌入) + node start.mjs build 编译后端二进制 (go build) + node start.mjs webui 构建前端静态文件 + node start.mjs install 安装前端依赖 (npm ci) + node start.mjs stop 停止所有服务 + node start.mjs status 查看服务状态 + +${colors.cyan}常用环境变量:${colors.reset} + PORT 后端端口 (默认: 5001) + LOG_LEVEL 日志级别: DEBUG|INFO|WARN|ERROR (默认: INFO) + DS2API_ADMIN_KEY 管理员密钥 (默认: admin) + DS2API_CONFIG_PATH 配置文件路径 (默认: config.json) + GOPROXY Go 模块代理 (默认: https://goproxy.cn,direct) + NPM_REGISTRY npm 镜像源 (默认: https://registry.npmmirror.com) + +${colors.cyan}示例:${colors.reset} + DS2API_ADMIN_KEY=mykey PORT=8080 node start.mjs dev + GOPROXY=off NPM_REGISTRY=https://registry.npmjs.org node start.mjs dev +`); + break; + + default: + await showMenu(); + } +} + +main().catch(e => { + log.error(e.message); + process.exit(1); +}); diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index f5f9324..558739d 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -17,10 +17,12 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, setKeysExpanded, accounts, page, + pageSize, totalPages, totalAccounts, loadingAccounts, fetchAccounts, + changePageSize, resolveAccountIdentifier, } = useAccountsData({ apiFetch }) @@ -79,6 +81,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, batchProgress={batchProgress} totalAccounts={totalAccounts} page={page} + pageSize={pageSize} totalPages={totalPages} resolveAccountIdentifier={resolveAccountIdentifier} onTestAll={testAllAccounts} @@ -87,6 +90,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, onDeleteAccount={deleteAccount} onPrevPage={() => fetchAccounts(page - 1)} onNextPage={() => fetchAccounts(page + 1)} + onPageSizeChange={changePageSize} /> { + navigator.clipboard.writeText(id).then(() => { + setCopiedId(id) + setTimeout(() => setCopiedId(null), 1500) + }) + } return (
@@ -83,12 +94,23 @@ export default function AccountsTable({
-
{id || '-'}
+
copyId(id)} + > + {id || '-'} + {copiedId === id + ? + : + } +
- {acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} + {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} {acc.token_preview && ( {acc.token_preview} @@ -122,8 +144,19 @@ export default function AccountsTable({ {totalPages > 1 && (
-
- {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} +
+
+ {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} +
+