From 22a2a97a76e8794610bcafca393d617036438217 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 2 Feb 2026 20:23:33 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Docker=20?= =?UTF-8?q?=E5=92=8C=20GitHub=20Actions=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 docker/Dockerfile 多阶段构建(前端+后端) - 添加 docker-compose.yml 支持阿里云镜像部署 - 添加 .github/workflows/release.yml 自动发布到阿里云 - 添加 .dockerignore 优化构建 - 添加 VERSION 版本管理文件 - 添加 start.mjs 本地开发启动脚本 --- .dockerignore | 50 +++ .github/workflows/release.yml | 128 +++++++ VERSION | 1 + docker-compose.yml | 14 + docker/Dockerfile | 70 ++++ start.mjs | 613 ++++++++++++++++++++++++++++++++++ 6 files changed, 876 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/release.yml create mode 100644 VERSION create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 start.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e82f928 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.venv +venv +ENV + +# IDE +.idea +.vscode +*.swp +*.swo + +# Node +webui/node_modules +webui/.vite + +# Build artifacts (前端构建产物在 Docker 中重新生成) +static/admin + +# 配置和敏感文件 +.env +.env.* +config.json + +# 日志和临时文件 +*.log +logs/ +tmp/ +temp/ + +# 测试 +tests/ +*.test.py + +# 文档和截图 +*.md +截图/ +docs/ + +# Claude Code +.claude/ +CLAUDE*.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8186c1c --- /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/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..54cec15 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +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/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..66d71e0 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,70 @@ +# ========== 阶段1: 构建前端 ========== +FROM node:20-slim AS frontend-builder + +WORKDIR /app/webui + +# 复制前端依赖文件 +COPY webui/package.json webui/package-lock.json ./ + +# 安装依赖 +RUN npm ci + +# 复制前端源码 +COPY webui/ ./ + +# 构建前端 +RUN npm run build + +# ========== 阶段2: 构建后端 ========== +FROM python:3.11-slim + +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV TZ=Asia/Shanghai + +# 安装系统依赖 +# curl_cffi 需要 libcurl 和编译工具 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + libffi-dev \ + libcurl4-openssl-dev \ + libssl-dev \ + curl \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +# 复制并安装 Python 依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 复制后端代码 +COPY app.py . +COPY core/ ./core/ +COPY routes/ ./routes/ + +# 创建 templates 目录(预留扩展用) +RUN mkdir -p ./templates + +# 复制 WASM 文件和 Tokenizer 相关文件 +COPY sha3_wasm_bg.7b9ca65ddd.wasm ./ +COPY tokenizer.json tokenizer_config.json ./ + +# 从前端构建阶段复制构建产物到 static/admin +COPY --from=frontend-builder /app/webui/dist ./static/admin + +# 创建配置文件目录(运行时挂载) +RUN mkdir -p /app/data + +# 暴露端口 +EXPOSE 5001 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5001/ || exit 1 + +# 启动命令 +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"] diff --git a/start.mjs b/start.mjs new file mode 100644 index 0000000..d4711af --- /dev/null +++ b/start.mjs @@ -0,0 +1,613 @@ +#!/usr/bin/env node +/** + * DS2API 启动脚本 - 交互式菜单 + * + * 使用方法: + * node start.mjs # 显示交互式菜单 + * node start.mjs dev # 开发模式(后端+前端) + * node start.mjs prod # 生产模式 + */ + +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 CONFIG = { + backendPort: process.env.PORT || 5001, + frontendPort: 5173, + host: process.env.HOST || '0.0.0.0', + logLevel: process.env.LOG_LEVEL || 'info', + adminKey: process.env.DS2API_ADMIN_KEY || 'ds2api', + webuiDir: join(__dirname, 'webui'), + venvDir: join(__dirname, '.venv'), +}; + +// venv 中的可执行文件路径 +const VENV = { + python: isWindows + ? join(CONFIG.venvDir, 'Scripts', 'python.exe') + : join(CONFIG.venvDir, 'bin', 'python'), + pip: isWindows + ? join(CONFIG.venvDir, 'Scripts', 'pip.exe') + : join(CONFIG.venvDir, 'bin', 'pip'), +}; + +// 存储子进程 +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; + } +} + +// 获取系统 Python 命令 +function getSystemPython() { + const candidates = isWindows + ? ['python', 'python3', 'py'] + : ['python3', 'python']; + + for (const cmd of candidates) { + if (commandExists(cmd)) { + return cmd; + } + } + return null; +} + +// 系统 Python 命令 +const SYSTEM_PYTHON = getSystemPython(); + +// 检查 venv 是否存在 +function venvExists() { + return existsSync(VENV.python); +} + +// 检查 Python 依赖是否已安装 +function checkPythonDeps() { + if (!venvExists()) return false; + try { + execSync(`"${VENV.python}" -c "import fastapi, uvicorn"`, { + stdio: 'ignore', + shell: true, + }); + return true; + } catch { + return false; + } +} + +// 检查前端依赖是否已安装 +function checkFrontendDeps() { + if (!existsSync(CONFIG.webuiDir)) return null; + return existsSync(join(CONFIG.webuiDir, 'node_modules')); +} + +// 获取依赖状态 +function getDepsStatus() { + return { + venv: venvExists(), + python: checkPythonDeps(), + frontend: checkFrontendDeps(), + }; +} + +// 查找占用端口的进程 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.backendPort); + 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.backendPort}, 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('前端服务已停止'); + } +} + +// 创建 venv +async function createVenv() { + if (venvExists()) { + log.info('虚拟环境已存在'); + return true; + } + + if (!SYSTEM_PYTHON) { + throw new Error('未找到 Python,请先安装 Python'); + } + + log.info('创建 Python 虚拟环境...'); + return new Promise((resolve, reject) => { + const proc = spawn(SYSTEM_PYTHON, ['-m', 'venv', CONFIG.venvDir], { + cwd: __dirname, + stdio: 'inherit', + shell: true, + }); + proc.on('close', code => { + if (code === 0) { + log.success('虚拟环境创建成功'); + resolve(true); + } else { + reject(new Error('虚拟环境创建失败')); + } + }); + }); +} + +// 确保 venv 存在 +async function ensureVenv() { + if (!venvExists()) { + await createVenv(); + } +} + +// 确保 Python 依赖已安装 +async function ensurePythonDeps() { + await ensureVenv(); + if (!checkPythonDeps()) { + log.warn('检测到 Python 依赖未安装,正在安装...'); + await installPythonDeps(); + } +} + +// 确保前端依赖已安装 +async function ensureFrontendDeps() { + if (checkFrontendDeps() === false) { + log.warn('检测到前端依赖未安装,正在安装...'); + await installFrontendDeps(); + } +} + +// 安装 Python 依赖 +async function installPythonDeps() { + await ensureVenv(); + log.info('安装 Python 依赖...'); + return new Promise((resolve, reject) => { + const proc = spawn(VENV.pip, ['install', '-r', 'requirements.txt'], { + cwd: __dirname, + stdio: 'inherit', + shell: true, + }); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('Python 依赖安装失败'))); + }); +} + +// 安装前端依赖 +async function installFrontendDeps() { + if (!existsSync(CONFIG.webuiDir)) { + log.warn('webui 目录不存在,跳过前端依赖安装'); + return; + } + log.info('安装前端依赖...'); + return new Promise((resolve, reject) => { + const proc = spawn('npm', ['install'], { + cwd: CONFIG.webuiDir, + stdio: 'inherit', + shell: true, + }); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败'))); + }); +} + +// 安装所有依赖 +async function installAll() { + log.title('========== 安装依赖 =========='); + try { + await installPythonDeps(); + log.success('Python 依赖安装完成'); + await installFrontendDeps(); + log.success('前端依赖安装完成'); + log.success('所有依赖安装完成!'); + } catch (e) { + log.error(e.message); + } +} + +// 启动后端 +async function startBackend(devMode = true) { + await ensurePythonDeps(); + + log.info(`启动后端服务... http://localhost:${CONFIG.backendPort}`); + + const args = [ + '-m', 'uvicorn', + 'app:app', + '--host', CONFIG.host, + '--port', String(CONFIG.backendPort), + '--log-level', CONFIG.logLevel, + ]; + + if (devMode) { + args.push('--reload', '--reload-dir', __dirname); + } + + const proc = spawn(VENV.python, args, { + cwd: __dirname, + stdio: 'inherit', + shell: true, + env: { + ...process.env, + 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; +} + +// 构建前端 +async function buildFrontend() { + if (!existsSync(CONFIG.webuiDir)) { + log.warn('webui 目录不存在'); + return; + } + + log.info('构建前端...'); + return new Promise((resolve, reject) => { + const proc = spawn('npm', ['run', 'build'], { + cwd: CONFIG.webuiDir, + stdio: 'inherit', + shell: true, + }); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败'))); + }); +} + +// 显示状态信息 +function showStatus() { + console.log('\n' + '─'.repeat(50)); + log.success(`后端 API: http://localhost:${CONFIG.backendPort}`); + if (existsSync(CONFIG.webuiDir)) { + log.success(`管理界面: http://localhost:${CONFIG.frontendPort}`); + } + console.log('─'.repeat(50)); + log.info('按 Ctrl+C 停止所有服务\n'); +} + +// 等待进程 +function waitForProcesses() { + return new Promise(resolve => { + const checkInterval = setInterval(() => { + const alive = processes.filter(p => !p.killed); + if (alive.length === 0) { + clearInterval(checkInterval); + 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 启动脚本 ║'); + log.title('╚══════════════════════════════════════════╝'); + + // 获取依赖状态 + const deps = getDepsStatus(); + const running = getRunningStatus(); + + const statusText = (ok) => ok ? `${colors.green}已安装${colors.reset}` : `${colors.yellow}未安装${colors.reset}`; + + console.log(`\n${colors.bright}环境状态:${colors.reset}`); + console.log(` Python: ${SYSTEM_PYTHON || `${colors.red}未找到${colors.reset}`}`); + console.log(` 虚拟环境: ${deps.venv ? `${colors.green}已创建${colors.reset}` : `${colors.yellow}未创建${colors.reset}`} (${CONFIG.venvDir})`); + console.log(` 后端依赖: ${statusText(deps.python)}`); + if (deps.frontend !== null) { + console.log(` 前端依赖: ${statusText(deps.frontend)}`); + } + + console.log(`\n${colors.bright}服务状态:${colors.reset}`); + console.log(` 后端 (${CONFIG.backendPort}): ${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} ${colors.cyan}${CONFIG.adminKey}${colors.reset}`); + console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=你的密钥 node start.mjs${colors.reset}`); + + console.log(` +${colors.bright}请选择操作:${colors.reset} + + ${colors.cyan}1.${colors.reset} 开发模式 (后端 + 前端热重载) + ${colors.cyan}2.${colors.reset} 仅启动后端 (开发模式) + ${colors.cyan}3.${colors.reset} 仅启动前端 + ${colors.cyan}4.${colors.reset} 生产模式 (仅后端,无热重载) + ${colors.cyan}5.${colors.reset} 构建前端 + ${colors.cyan}6.${colors.reset} 安装依赖 (创建venv + 安装包) + ${colors.red}7.${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 startBackend(true); + await new Promise(r => setTimeout(r, 1500)); + await startFrontend(); + showStatus(); + await waitForProcesses(); + break; + + case '2': + log.title('========== 仅后端 (开发模式) =========='); + await startBackend(true); + showStatus(); + await waitForProcesses(); + break; + + case '3': + log.title('========== 仅前端 =========='); + await startFrontend(); + showStatus(); + await waitForProcesses(); + break; + + case '4': + log.title('========== 生产模式 =========='); + await startBackend(false); + showStatus(); + await waitForProcesses(); + break; + + case '5': + await buildFrontend(); + log.success('前端构建完成!'); + break; + + case '6': + await installAll(); + break; + + case '7': + await stopServices(); + break; + + case '0': + log.info('再见!'); + process.exit(0); + break; + + default: + log.warn('无效选项'); + await showMenu(); + } +} + +// 命令行参数处理 +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + + // 检查必要工具 + if (!SYSTEM_PYTHON) { + log.error('未找到 Python,请先安装 Python (尝试了 python, python3, py)'); + process.exit(1); + } + + switch (cmd) { + case 'dev': + log.title('========== 开发模式 =========='); + await startBackend(true); + await new Promise(r => setTimeout(r, 1500)); + await startFrontend(); + showStatus(); + await waitForProcesses(); + break; + + case 'prod': + log.title('========== 生产模式 =========='); + await startBackend(false); + showStatus(); + await waitForProcesses(); + break; + + case 'build': + await buildFrontend(); + log.success('前端构建完成!'); + break; + + case 'install': + await installAll(); + break; + + case 'stop': + await stopServices(); + break; + + case 'status': + const status = getRunningStatus(); + console.log(`\n${colors.bright}服务状态:${colors.reset}`); + console.log(` 后端 (${CONFIG.backendPort}): ${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 启动脚本${colors.reset} + +${colors.cyan}使用方法:${colors.reset} + node start.mjs 显示交互式菜单 + node start.mjs dev 开发模式 (后端 + 前端) + node start.mjs prod 生产模式 (无热重载) + node start.mjs build 构建前端 + node start.mjs install 安装所有依赖 (自动创建venv) + node start.mjs stop 停止所有服务 + node start.mjs status 查看服务状态 + +${colors.cyan}环境变量:${colors.reset} + PORT 后端端口 (默认: 5001) + HOST 监听地址 (默认: 0.0.0.0) + LOG_LEVEL 日志级别 (默认: info) + +${colors.cyan}虚拟环境:${colors.reset} + 默认路径: .venv/ + 首次运行 install 时自动创建 +`); + break; + + default: + await showMenu(); + } +} + +main().catch(e => { + log.error(e.message); + process.exit(1); +}); From bd4c2bacbcc34bea2cc7d6a34b98d1eac0705492 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 2 Feb 2026 20:31:42 +0800 Subject: [PATCH 02/13] =?UTF-8?q?merge:=20=E5=90=88=E5=B9=B6=20main=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=88=B0=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 100 ++--- .github/PULL_REQUEST_TEMPLATE.md | 44 +-- .github/workflows/release.yml | 256 ++++++------ .gitignore | 163 ++++---- DEPLOY.md | 659 +++++++++++++++---------------- VERSION | 2 +- docker-compose.yml | 28 +- docker/Dockerfile | 140 +++---- routes/home.py | 604 ++++++++++++++-------------- static/admin/index.html | 84 ++-- 10 files changed, 1026 insertions(+), 1054 deletions(-) diff --git a/.dockerignore b/.dockerignore index e82f928..3fc3934 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,50 +1,50 @@ -# Git -.git -.gitignore - -# Python -__pycache__ -*.py[cod] -*$py.class -*.so -.Python -.venv -venv -ENV - -# IDE -.idea -.vscode -*.swp -*.swo - -# Node -webui/node_modules -webui/.vite - -# Build artifacts (前端构建产物在 Docker 中重新生成) -static/admin - -# 配置和敏感文件 -.env -.env.* -config.json - -# 日志和临时文件 -*.log -logs/ -tmp/ -temp/ - -# 测试 -tests/ -*.test.py - -# 文档和截图 -*.md -截图/ -docs/ - -# Claude Code -.claude/ -CLAUDE*.md +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.venv +venv +ENV + +# IDE +.idea +.vscode +*.swp +*.swo + +# Node +webui/node_modules +webui/.vite + +# Build artifacts (前端构建产物在 Docker 中重新生成) +static/admin + +# 配置和敏感文件 +.env +.env.* +config.json + +# 日志和临时文件 +*.log +logs/ +tmp/ +temp/ + +# 测试 +tests/ +*.test.py + +# 文档和截图 +*.md +截图/ +docs/ + +# Claude Code +.claude/ +CLAUDE*.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cdc141f..22c8204 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,24 +1,20 @@ -#### 💻 变更类型 | Change Type - - - -- [ ] ✨ feat -- [ ] 🐛 fix -- [ ] ♻️ refactor -- [ ] 💄 style -- [ ] 👷 build -- [ ] ⚡️ perf -- [ ] 📝 docs -- [ ] 🔨 chore - -#### 🔀 变更说明 | Description of Change - - - -#### 📝 补充信息 | Additional Information - - - ---- - -> 💡 **提示**:如果修改了 `webui/` 目录下的文件,PR 合并后 CI 会自动构建并提交产物,无需手动构建。 \ No newline at end of file +#### 💻 变更类型 | 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.yml b/.github/workflows/release.yml index 8186c1c..8b7cbc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,128 +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 +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 1c77a62..7c56b63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,82 +1,81 @@ -*.bak -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/ -*.swp -*.swo -*~ -.DS_Store - -# Logs -*.log -logs/ -uvicorn.log - -# Vercel -.vercel - -# Node.js / Frontend -node_modules/ -webui/node_modules/ -webui/dist/ -.npm -.pnpm-store/ -# 保留 webui/package-lock.json 用于 CI 缓存 -# package-lock.json # 如果有根目录的可以忽略 -yarn.lock -pnpm-lock.yaml - -# Build artifacts -*.tsbuildinfo -.cache/ -.parcel-cache/ - -# Environment -.env.local -.env.*.local - -# Testing -.coverage -htmlcov/ -.pytest_cache/ -.tox/ - -# Misc -*.pyc -*.pyo -.git/ -Thumbs.db +*.bak +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/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ +uvicorn.log + +# Vercel +.vercel + +# Node.js / Frontend +node_modules/ +webui/node_modules/ +webui/dist/ +.npm +.pnpm-store/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build artifacts +*.tsbuildinfo +.cache/ +.parcel-cache/ + +# Environment +.env.local +.env.*.local + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Misc +*.pyc +*.pyo +.git/ +Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md index ff13005..09a6462 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,340 +1,319 @@ -# DS2API 部署指南 - -本文档详细介绍 DS2API 的各种部署方式。 - ---- - -## 目录 - -- [Vercel 部署(推荐)](#vercel-部署推荐) -- [本地开发](#本地开发) -- [生产环境部署](#生产环境部署) -- [常见问题](#常见问题) - ---- - -## Vercel 部署(推荐) - -### 一键部署 - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api) - -### 部署步骤 - -1. **点击部署按钮** - - 登录你的 GitHub 账号 - - 授权 Vercel 访问 - -2. **设置环境变量** - - `DS2API_ADMIN_KEY`: 管理面板密码(**必填**) - -3. **等待部署完成** - - Vercel 会自动构建并部署项目 - - 部署完成后获得访问 URL - -4. **配置账号** - - 访问 `https://your-project.vercel.app/admin` - - 输入管理密码登录 - - 添加 DeepSeek 账号 - - 设置自定义 API Key - -5. **同步配置** - - 点击「同步到 Vercel」按钮 - - 首次需要输入 Vercel Token 和 Project ID - - 同步成功后配置会持久化 - -### 获取 Vercel 凭证 - -**Vercel Token**: -1. 访问 https://vercel.com/account/tokens -2. 点击 "Create Token" -3. 设置名称和有效期 -4. 复制生成的 Token - -**Project ID**: -1. 进入 Vercel 项目页面 -2. 点击 Settings -> General -3. 复制 "Project ID" - ---- - -## 本地开发 - -### 环境要求 - -- Python 3.9+ -- Node.js 18+ (WebUI 开发) -- pip - -### 快速开始 - -```bash -# 1. 克隆项目 -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# 2. 安装 Python 依赖 -pip install -r requirements.txt - -# 3. 配置账号 -cp config.example.json config.json -# 编辑 config.json,填入 DeepSeek 账号信息 - -# 4. 启动服务 -python dev.py -``` - -### 配置文件示例 - -```json -{ - "keys": ["my-api-key-1", "my-api-key-2"], - "accounts": [ - { - "email": "your-email@example.com", - "password": "your-password", - "token": "" - }, - { - "mobile": "12345678901", - "password": "your-password", - "token": "" - } - ] -} -``` - -**说明**: -- `keys`: 自定义 API Key,用于调用本服务的接口 -- `accounts`: DeepSeek 网页版账号 - - 支持 `email` 或 `mobile` 登录 - - `token` 留空,系统会自动获取 - -### WebUI 开发 - -```bash -# 进入 WebUI 目录 -cd webui - -# 安装依赖 -npm install - -# 启动开发服务器 -npm run dev -``` - -WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API 请求到后端 `http://localhost:5001`。 - -### WebUI 构建 - -WebUI 构建产物位于 `static/admin/` 目录。 - -**自动构建(推荐)**: -- 当 `webui/` 目录下的文件变更并推送到 `main` 分支时,GitHub Actions 会自动构建并提交产物 -- PR 合并时会自动触发构建 - -**手动构建**: -```bash -# 方式1:使用脚本 -./scripts/build-webui.sh - -# 方式2:直接执行 -cd webui -npm install -npm run build -``` - -> **贡献者注意**:修改 WebUI 后无需手动构建,CI 会自动处理。 - ---- - -## 生产环境部署 - -### 使用 systemd (Linux) - -1. **创建服务文件** - -```bash -sudo nano /etc/systemd/system/ds2api.service -``` - -```ini -[Unit] -Description=DS2API Service -After=network.target - -[Service] -Type=simple -User=www-data -WorkingDirectory=/opt/ds2api -ExecStart=/usr/bin/python3 app.py -Restart=always -RestartSec=10 -Environment=PORT=5001 -Environment=DS2API_ADMIN_KEY=your-admin-key - -[Install] -WantedBy=multi-user.target -``` - -2. **启动服务** - -```bash -sudo systemctl daemon-reload -sudo systemctl enable ds2api -sudo systemctl start ds2api -``` - -3. **查看状态** - -```bash -sudo systemctl status ds2api -sudo journalctl -u ds2api -f -``` - -### Nginx 反向代理 - -```nginx -server { - listen 80; - server_name api.yourdomain.com; - - # SSL 配置(推荐) - # listen 443 ssl http2; - # ssl_certificate /path/to/cert.pem; - # ssl_certificate_key /path/to/key.pem; - - location / { - proxy_pass http://127.0.0.1:5001; - proxy_http_version 1.1; - - # 关闭缓冲,支持 SSE - proxy_buffering off; - proxy_cache off; - - # 连接设置 - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # SSE 超时设置 - proxy_read_timeout 300s; - proxy_send_timeout 300s; - - # 分块传输 - chunked_transfer_encoding on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 120; - } -} -``` - -### Docker 部署(可选) - -```dockerfile -# Dockerfile -FROM python:3.11-slim - -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 5001 -CMD ["python", "app.py"] -``` - -```bash -# 构建镜像 -docker build -t ds2api . - -# 运行容器 -docker run -d \ - --name ds2api \ - -p 5001:5001 \ - -e DS2API_ADMIN_KEY=your-admin-key \ - -e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \ - ds2api -``` - -### Docker Compose - -```yaml -# docker-compose.yml -version: '3.8' - -services: - ds2api: - build: . - ports: - - "5001:5001" - environment: - - DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY} - - DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON} - restart: unless-stopped -``` - ---- - -## 常见问题 - -### Q: 账号验证失败怎么办? - -**A**: 检查以下几点: -1. 确认 DeepSeek 账号密码正确 -2. 检查账号是否被封禁或需要验证 -3. 尝试在浏览器中手动登录一次 -4. 查看日志获取详细错误信息 - -### Q: 流式响应断开怎么办? - -**A**: -1. 检查 Nginx/反向代理配置,确保关闭了 `proxy_buffering` -2. 增加 `proxy_read_timeout` 超时时间 -3. 检查网络连接稳定性 - -### Q: Vercel 部署后配置丢失? - -**A**: -1. 确保点击了「同步到 Vercel」按钮 -2. 检查 Vercel Token 是否正确且未过期 -3. 确认 Project ID 正确 - -### Q: 如何更新到新版本? - -**本地部署**: -```bash -git pull origin main -pip install -r requirements.txt -# 重启服务 -``` - -**Vercel 部署**: -- 项目会自动从 GitHub 同步更新 -- 或在 Vercel 控制台手动触发重新部署 - -### Q: 如何查看日志? - -**本地开发**: -```bash -# 设置日志级别 -export LOG_LEVEL=DEBUG -python dev.py -``` - -**Vercel**: -- 访问 Vercel 控制台 -> 项目 -> Deployments -> Logs - -### Q: Token 计数不准确? - -**A**: DS2API 使用估算方式计算 token 数量(字符数 / 4),与 OpenAI 官方的 tokenizer 可能有差异,仅供参考。 - ---- - -## 获取帮助 - -- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues -- **文档**: https://github.com/CJackHwang/ds2api +# DS2API 部署指南 + +本文档详细介绍 DS2API 的各种部署方式。 + +--- + +## 目录 + +- [Vercel 部署(推荐)](#vercel-部署推荐) +- [本地开发](#本地开发) +- [生产环境部署](#生产环境部署) +- [常见问题](#常见问题) + +--- + +## Vercel 部署(推荐) + +### 一键部署 + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api) + +### 部署步骤 + +1. **点击部署按钮** + - 登录你的 GitHub 账号 + - 授权 Vercel 访问 + +2. **设置环境变量** + - `DS2API_ADMIN_KEY`: 管理面板密码(**必填**) + +3. **等待部署完成** + - Vercel 会自动构建并部署项目 + - 部署完成后获得访问 URL + +4. **配置账号** + - 访问 `https://your-project.vercel.app/admin` + - 输入管理密码登录 + - 添加 DeepSeek 账号 + - 设置自定义 API Key + +5. **同步配置** + - 点击「同步到 Vercel」按钮 + - 首次需要输入 Vercel Token 和 Project ID + - 同步成功后配置会持久化 + +### 获取 Vercel 凭证 + +**Vercel Token**: +1. 访问 https://vercel.com/account/tokens +2. 点击 "Create Token" +3. 设置名称和有效期 +4. 复制生成的 Token + +**Project ID**: +1. 进入 Vercel 项目页面 +2. 点击 Settings -> General +3. 复制 "Project ID" + +--- + +## 本地开发 + +### 环境要求 + +- Python 3.9+ +- Node.js 18+ (WebUI 开发) +- pip + +### 快速开始 + +```bash +# 1. 克隆项目 +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api + +# 2. 安装 Python 依赖 +pip install -r requirements.txt + +# 3. 配置账号 +cp config.example.json config.json +# 编辑 config.json,填入 DeepSeek 账号信息 + +# 4. 启动服务 +python dev.py +``` + +### 配置文件示例 + +```json +{ + "keys": ["my-api-key-1", "my-api-key-2"], + "accounts": [ + { + "email": "your-email@example.com", + "password": "your-password", + "token": "" + }, + { + "mobile": "12345678901", + "password": "your-password", + "token": "" + } + ] +} +``` + +**说明**: +- `keys`: 自定义 API Key,用于调用本服务的接口 +- `accounts`: DeepSeek 网页版账号 + - 支持 `email` 或 `mobile` 登录 + - `token` 留空,系统会自动获取 + +### WebUI 开发 + +```bash +# 进入 WebUI 目录 +cd webui + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + +WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API 请求到后端 `http://localhost:5001`。 + +--- + +## 生产环境部署 + +### 使用 systemd (Linux) + +1. **创建服务文件** + +```bash +sudo nano /etc/systemd/system/ds2api.service +``` + +```ini +[Unit] +Description=DS2API Service +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/ds2api +ExecStart=/usr/bin/python3 app.py +Restart=always +RestartSec=10 +Environment=PORT=5001 +Environment=DS2API_ADMIN_KEY=your-admin-key + +[Install] +WantedBy=multi-user.target +``` + +2. **启动服务** + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ds2api +sudo systemctl start ds2api +``` + +3. **查看状态** + +```bash +sudo systemctl status ds2api +sudo journalctl -u ds2api -f +``` + +### Nginx 反向代理 + +```nginx +server { + listen 80; + server_name api.yourdomain.com; + + # SSL 配置(推荐) + # listen 443 ssl http2; + # ssl_certificate /path/to/cert.pem; + # ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + + # 关闭缓冲,支持 SSE + proxy_buffering off; + proxy_cache off; + + # 连接设置 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE 超时设置 + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + # 分块传输 + chunked_transfer_encoding on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 120; + } +} +``` + +### Docker 部署(可选) + +```dockerfile +# Dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5001 +CMD ["python", "app.py"] +``` + +```bash +# 构建镜像 +docker build -t ds2api . + +# 运行容器 +docker run -d \ + --name ds2api \ + -p 5001:5001 \ + -e DS2API_ADMIN_KEY=your-admin-key \ + -e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \ + ds2api +``` + +### Docker Compose + +```yaml +# docker-compose.yml +version: '3.8' + +services: + ds2api: + build: . + ports: + - "5001:5001" + environment: + - DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY} + - DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON} + restart: unless-stopped +``` + +--- + +## 常见问题 + +### Q: 账号验证失败怎么办? + +**A**: 检查以下几点: +1. 确认 DeepSeek 账号密码正确 +2. 检查账号是否被封禁或需要验证 +3. 尝试在浏览器中手动登录一次 +4. 查看日志获取详细错误信息 + +### Q: 流式响应断开怎么办? + +**A**: +1. 检查 Nginx/反向代理配置,确保关闭了 `proxy_buffering` +2. 增加 `proxy_read_timeout` 超时时间 +3. 检查网络连接稳定性 + +### Q: Vercel 部署后配置丢失? + +**A**: +1. 确保点击了「同步到 Vercel」按钮 +2. 检查 Vercel Token 是否正确且未过期 +3. 确认 Project ID 正确 + +### Q: 如何更新到新版本? + +**本地部署**: +```bash +git pull origin main +pip install -r requirements.txt +# 重启服务 +``` + +**Vercel 部署**: +- 项目会自动从 GitHub 同步更新 +- 或在 Vercel 控制台手动触发重新部署 + +### Q: 如何查看日志? + +**本地开发**: +```bash +# 设置日志级别 +export LOG_LEVEL=DEBUG +python dev.py +``` + +**Vercel**: +- 访问 Vercel 控制台 -> 项目 -> Deployments -> Logs + +### Q: Token 计数不准确? + +**A**: DS2API 使用估算方式计算 token 数量(字符数 / 4),与 OpenAI 官方的 tokenizer 可能有差异,仅供参考。 + +--- + +## 获取帮助 + +- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues +- **文档**: https://github.com/CJackHwang/ds2api diff --git a/VERSION b/VERSION index 6e8bf73..7ecf123 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.0 diff --git a/docker-compose.yml b/docker-compose.yml index 54cec15..3e6b605 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,14 @@ -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} +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/docker/Dockerfile b/docker/Dockerfile index 66d71e0..ccac75f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,70 +1,70 @@ -# ========== 阶段1: 构建前端 ========== -FROM node:20-slim AS frontend-builder - -WORKDIR /app/webui - -# 复制前端依赖文件 -COPY webui/package.json webui/package-lock.json ./ - -# 安装依赖 -RUN npm ci - -# 复制前端源码 -COPY webui/ ./ - -# 构建前端 -RUN npm run build - -# ========== 阶段2: 构建后端 ========== -FROM python:3.11-slim - -WORKDIR /app - -# 设置环境变量 -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -ENV TZ=Asia/Shanghai - -# 安装系统依赖 -# curl_cffi 需要 libcurl 和编译工具 -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - g++ \ - libffi-dev \ - libcurl4-openssl-dev \ - libssl-dev \ - curl \ - && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ - && rm -rf /var/lib/apt/lists/* - -# 复制并安装 Python 依赖 -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple - -# 复制后端代码 -COPY app.py . -COPY core/ ./core/ -COPY routes/ ./routes/ - -# 创建 templates 目录(预留扩展用) -RUN mkdir -p ./templates - -# 复制 WASM 文件和 Tokenizer 相关文件 -COPY sha3_wasm_bg.7b9ca65ddd.wasm ./ -COPY tokenizer.json tokenizer_config.json ./ - -# 从前端构建阶段复制构建产物到 static/admin -COPY --from=frontend-builder /app/webui/dist ./static/admin - -# 创建配置文件目录(运行时挂载) -RUN mkdir -p /app/data - -# 暴露端口 -EXPOSE 5001 - -# 健康检查 -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5001/ || exit 1 - -# 启动命令 -CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"] +# ========== 阶段1: 构建前端 ========== +FROM node:20-slim AS frontend-builder + +WORKDIR /app/webui + +# 复制前端依赖文件 +COPY webui/package.json webui/package-lock.json ./ + +# 安装依赖 +RUN npm ci + +# 复制前端源码 +COPY webui/ ./ + +# 构建前端 +RUN npm run build + +# ========== 阶段2: 构建后端 ========== +FROM python:3.11-slim + +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV TZ=Asia/Shanghai + +# 安装系统依赖 +# curl_cffi 需要 libcurl 和编译工具 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + libffi-dev \ + libcurl4-openssl-dev \ + libssl-dev \ + curl \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +# 复制并安装 Python 依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 复制后端代码 +COPY app.py . +COPY core/ ./core/ +COPY routes/ ./routes/ + +# 创建 templates 目录(预留扩展用) +RUN mkdir -p ./templates + +# 复制 WASM 文件和 Tokenizer 相关文件 +COPY sha3_wasm_bg.7b9ca65ddd.wasm ./ +COPY tokenizer.json tokenizer_config.json ./ + +# 从前端构建阶段复制构建产物到 static/admin +COPY --from=frontend-builder /app/webui/dist ./static/admin + +# 创建配置文件目录(运行时挂载) +RUN mkdir -p /app/data + +# 暴露端口 +EXPOSE 5001 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5001/ || exit 1 + +# 启动命令 +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"] diff --git a/routes/home.py b/routes/home.py index dad0fb8..4105302 100644 --- a/routes/home.py +++ b/routes/home.py @@ -1,303 +1,301 @@ -# -*- coding: utf-8 -*- -"""首页和 WebUI 路由""" -import os -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse, FileResponse - -from core.config import STATIC_ADMIN_DIR - -router = APIRouter() - -# 首页 HTML(内嵌避免依赖模板目录) -WELCOME_HTML = """ - - - - - DS2API - DeepSeek to OpenAI API - - - - - - - - -
-
-
- -
-
- -

DeepSeek to OpenAI & Claude Compatible API Interface

-
- - - -
-
- 🚀 -

全面兼容

-

完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。

-
-
- ⚖️ -

负载均衡

-

内置智能轮询机制,支持多账号并发,稳定高效。

-
-
- 🧠 -

深度思考

-

完整支持 推理过程输出,让思考可见。

-
-
- 🔍 -

联网搜索

-

集成 DeepSeek 原生搜索能力,获取最新实时资讯。

-
-
- -
-

© 2026 DS2API Project. Designed for flexibility & performance.

-
-
- -""" - - -@router.get("/") -def index(request: Request): - return HTMLResponse(content=WELCOME_HTML) - - -@router.get("/admin") -@router.get("/admin/{path:path}") -async def webui(request: Request, path: str = ""): - """提供 WebUI 静态文件""" - # 检查 static/admin 目录是否存在 - if not os.path.isdir(STATIC_ADMIN_DIR): - return HTMLResponse( - content="

WebUI not built

Run cd webui && npm run build first.

", - status_code=404 - ) - - # 如果请求的是具体文件(如 js, css) - if path and "." in path: - file_path = os.path.join(STATIC_ADMIN_DIR, path) - if os.path.isfile(file_path): - return FileResponse(file_path) - return HTMLResponse(content="Not Found", status_code=404) - - # 否则返回 index.html(SPA 路由) - index_path = os.path.join(STATIC_ADMIN_DIR, "index.html") - if os.path.isfile(index_path): - return FileResponse(index_path) - - return HTMLResponse(content="index.html not found", status_code=404) - - +# -*- coding: utf-8 -*- +"""首页和 WebUI 路由""" +import os +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, FileResponse + +from core.config import STATIC_ADMIN_DIR + +router = APIRouter() + +# 首页 HTML(内嵌避免依赖模板目录) +WELCOME_HTML = """ + + + + + DS2API - DeepSeek to OpenAI API + + + + + + + + +
+
+
+ +
+
+ +

DeepSeek to OpenAI & Claude Compatible API Interface

+
+ + + +
+
+ 🚀 +

全面兼容

+

完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。

+
+
+ ⚖️ +

负载均衡

+

内置智能轮询机制,支持多账号并发,稳定高效。

+
+
+ 🧠 +

深度思考

+

完整支持 推理过程输出,让思考可见。

+
+
+ 🔍 +

联网搜索

+

集成 DeepSeek 原生搜索能力,获取最新实时资讯。

+
+
+ +
+

© 2026 DS2API Project. Designed for flexibility & performance.

+
+
+ +""" + + +@router.get("/") +def index(request: Request): + return HTMLResponse(content=WELCOME_HTML) + + +@router.get("/admin") +@router.get("/admin/{path:path}") +async def webui(request: Request, path: str = ""): + """提供 WebUI 静态文件""" + # 检查 static/admin 目录是否存在 + if not os.path.isdir(STATIC_ADMIN_DIR): + return HTMLResponse( + content="

WebUI not built

Run cd webui && npm run build first.

", + status_code=404 + ) + + # 如果请求的是具体文件(如 js, css) + if path and "." in path: + file_path = os.path.join(STATIC_ADMIN_DIR, path) + if os.path.isfile(file_path): + return FileResponse(file_path) + return HTMLResponse(content="Not Found", status_code=404) + + # 否则返回 index.html(SPA 路由) + index_path = os.path.join(STATIC_ADMIN_DIR, "index.html") + if os.path.isfile(index_path): + return FileResponse(index_path) + + return HTMLResponse(content="index.html not found", status_code=404) diff --git a/static/admin/index.html b/static/admin/index.html index 76e4e9d..a08181e 100644 --- a/static/admin/index.html +++ b/static/admin/index.html @@ -1,43 +1,43 @@ - - - - - - - - - DS2API - DeepSeek API 管理面板 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - + + + + + + + + + DS2API - DeepSeek API 管理面板 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + \ No newline at end of file From 6b8f7f8821b7403387b33632dd1b990287a3a402 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Feb 2026 10:55:34 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=E5=90=AF=E5=8A=A8=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=98=BE=E7=A4=BA=E6=89=80=E6=9C=89=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- start.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/start.mjs b/start.mjs index d4711af..c706154 100644 --- a/start.mjs +++ b/start.mjs @@ -454,7 +454,11 @@ async function showMenu() { console.log(` 后端 (${CONFIG.backendPort}): ${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} ${colors.cyan}${CONFIG.adminKey}${colors.reset}`); + console.log(`\n${colors.bright}环境变量:${colors.reset}`); + console.log(` DS2API_ADMIN_KEY: ${colors.cyan}${CONFIG.adminKey}${colors.reset}`); + console.log(` PORT: ${colors.cyan}${CONFIG.backendPort}${colors.reset}`); + console.log(` HOST: ${colors.cyan}${CONFIG.host}${colors.reset}`); + console.log(` LOG_LEVEL: ${colors.cyan}${CONFIG.logLevel}${colors.reset}`); console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=你的密钥 node start.mjs${colors.reset}`); console.log(` From 3f3198c9598d9ca005ac147522f333d53f84bdfc Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Feb 2026 13:40:14 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 账号列表支持分页(每页10条,倒序显示) - API 密钥列表支持展开/关闭 --- routes/admin/config.py | 43 ++++++ webui/src/components/AccountManager.jsx | 178 +++++++++++++++++------- webui/src/locales/en.json | 3 +- webui/src/locales/zh.json | 3 +- 4 files changed, 173 insertions(+), 54 deletions(-) diff --git a/routes/admin/config.py b/routes/admin/config.py index d0739a5..5a8471a 100644 --- a/routes/admin/config.py +++ b/routes/admin/config.py @@ -122,6 +122,49 @@ async def delete_key(key: str, _: bool = Depends(verify_admin)): # ---------------------------------------------------------------------- # 账号管理 # ---------------------------------------------------------------------- +@router.get("/accounts") +async def list_accounts( + page: int = 1, + page_size: int = 10, + _: bool = Depends(verify_admin) +): + """获取账号列表(分页,倒序,密码脱敏)""" + accounts = CONFIG.get("accounts", []) + total = len(accounts) + + # 倒序排列 + accounts = list(reversed(accounts)) + + # 计算分页 + page = max(1, page) + page_size = max(1, min(100, page_size)) # 限制每页最多 100 条 + total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + + start = (page - 1) * page_size + end = start + page_size + page_accounts = accounts[start:end] + + # 脱敏处理 + safe_accounts = [] + for acc in page_accounts: + safe_acc = { + "email": acc.get("email", ""), + "mobile": acc.get("mobile", ""), + "has_password": bool(acc.get("password")), + "has_token": bool(acc.get("token")), + "token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "", + } + safe_accounts.append(safe_acc) + + return JSONResponse(content={ + "items": safe_accounts, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + }) + + @router.post("/accounts") async def add_account(request: Request, _: bool = Depends(verify_admin)): """添加账号""" diff --git a/webui/src/components/AccountManager.jsx b/webui/src/components/AccountManager.jsx index 25aa8c1..773b84e 100644 --- a/webui/src/components/AccountManager.jsx +++ b/webui/src/components/AccountManager.jsx @@ -8,7 +8,10 @@ import { Server, ShieldCheck, Copy, - Check + Check, + ChevronLeft, + ChevronRight, + ChevronDown } from 'lucide-react' import clsx from 'clsx' import { useI18n } from '../i18n' @@ -25,9 +28,36 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const [testingAll, setTestingAll] = useState(false) const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] }) const [queueStatus, setQueueStatus] = useState(null) + const [keysExpanded, setKeysExpanded] = useState(false) + + // 分页状态 + const [accounts, setAccounts] = useState([]) + const [page, setPage] = useState(1) + const [pageSize] = useState(10) + const [totalPages, setTotalPages] = useState(1) + const [totalAccounts, setTotalAccounts] = useState(0) + const [loadingAccounts, setLoadingAccounts] = useState(false) const apiFetch = authFetch || fetch + const fetchAccounts = async (targetPage = page) => { + setLoadingAccounts(true) + try { + const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`) + if (res.ok) { + const data = await res.json() + setAccounts(data.items || []) + setTotalPages(data.total_pages || 1) + setTotalAccounts(data.total || 0) + setPage(data.page || 1) + } + } catch (e) { + console.error('Failed to fetch accounts:', e) + } finally { + setLoadingAccounts(false) + } + } + const fetchQueueStatus = async () => { try { const res = await apiFetch('/admin/queue/status') @@ -41,6 +71,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch } useEffect(() => { + fetchAccounts() fetchQueueStatus() const interval = setInterval(fetchQueueStatus, 5000) return () => clearInterval(interval) @@ -102,6 +133,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch onMessage('success', t('accountManager.addAccountSuccess')) setNewAccount({ email: '', mobile: '', password: '' }) setShowAddAccount(false) + fetchAccounts(1) // 添加后回到第一页 onRefresh() } else { const data = await res.json() @@ -120,6 +152,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (res.ok) { onMessage('success', t('messages.deleted')) + fetchAccounts() // 刷新当前页 onRefresh() } else { onMessage('error', t('messages.deleteFailed')) @@ -142,6 +175,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch ? t('apiTester.testSuccess', { account: identifier, time: data.response_time }) : `${identifier}: ${data.message}` onMessage(data.success ? 'success' : 'error', statusMessage) + fetchAccounts() // 刷新当前页 onRefresh() } catch (e) { onMessage('error', t('accountManager.testFailed', { error: e.message })) @@ -152,17 +186,17 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const testAllAccounts = async () => { if (!confirm(t('accountManager.testAllConfirm'))) return - const accounts = config.accounts || [] - if (accounts.length === 0) return + const allAccounts = config.accounts || [] + if (allAccounts.length === 0) return setTestingAll(true) - setBatchProgress({ current: 0, total: accounts.length, results: [] }) + setBatchProgress({ current: 0, total: allAccounts.length, results: [] }) let successCount = 0 const results = [] - for (let i = 0; i < accounts.length; i++) { - const acc = accounts[i] + for (let i = 0; i < allAccounts.length; i++) { + const acc = allAccounts[i] const id = acc.email || acc.mobile try { @@ -178,10 +212,11 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch results.push({ id, success: false, message: e.message }) } - setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] }) + setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] }) } - onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: accounts.length })) + onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: allAccounts.length })) + fetchAccounts() // 刷新当前页 onRefresh() setTestingAll(false) } @@ -228,13 +263,22 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch {/* API Keys Section */}
-
-
-

{t('accountManager.apiKeysTitle')}

-

{t('accountManager.apiKeysDesc')}

+
setKeysExpanded(!keysExpanded)} + > +
+ +
+

{t('accountManager.apiKeysTitle')}

+

{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})

+
-
- {config.keys?.length > 0 ? ( - config.keys.map((key, i) => ( -
-
-
- {key.slice(0, 16)}**** + {keysExpanded && ( +
+ {config.keys?.length > 0 ? ( + config.keys.map((key, i) => ( +
+
+
+ {key.slice(0, 16)}**** +
+ {copiedKey === key && ( + {t('accountManager.copied')} + )} +
+
+ +
- {copiedKey === key && ( - {t('accountManager.copied')} - )}
-
- - -
-
- )) - ) : ( -
{t('accountManager.noApiKeys')}
- )} -
+ )) + ) : ( +
{t('accountManager.noApiKeys')}
+ )} +
+ )}
{/* Accounts Section */} @@ -292,7 +338,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
+ {page} / {totalPages} + +
+
+ )}
{/* Modals */} diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 38d1795..2490dea 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -122,7 +122,8 @@ "passwordLabel": "Password", "passwordPlaceholder": "Account password", "addAccountLoading": "Adding...", - "addAccountAction": "Add account" + "addAccountAction": "Add account", + "pageInfo": "Page {current}/{total}, {count} accounts total" }, "apiTester": { "defaultMessage": "Hello, please introduce yourself in one sentence.", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index a8c0f71..7e2c5a2 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -122,7 +122,8 @@ "passwordLabel": "密码", "passwordPlaceholder": "账号密码", "addAccountLoading": "添加中...", - "addAccountAction": "添加账号" + "addAccountAction": "添加账号", + "pageInfo": "第 {current}/{total} 页,共 {count} 个账号" }, "apiTester": { "defaultMessage": "你好,请用一句话介绍你自己。", From 36099a4ada3d78de0050bb98b4f704d3c3087645 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 18 Feb 2026 20:50:07 +0800 Subject: [PATCH 05/13] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20Python=20?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E6=96=87=E4=BB=B6=EF=BC=88=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=B7=B2=E8=BF=81=E7=A7=BB=E8=87=B3=20Go=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/admin/config.py | 226 ----------------------------------------- 1 file changed, 226 deletions(-) delete mode 100644 routes/admin/config.py diff --git a/routes/admin/config.py b/routes/admin/config.py deleted file mode 100644 index 5a8471a..0000000 --- a/routes/admin/config.py +++ /dev/null @@ -1,226 +0,0 @@ -# -*- coding: utf-8 -*- -"""Admin 配置管理模块 - 配置、API Keys、账号管理""" -import os - -from fastapi import APIRouter, HTTPException, Request, Depends -from fastapi.responses import JSONResponse - -from core.config import CONFIG, save_config, logger -from core.auth import init_account_queue, get_queue_status, get_account_identifier -from core.deepseek import login_deepseek_via_account - -from .auth import verify_admin - -router = APIRouter() - -# Vercel 预配置 -VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "") -VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "") -VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "") - - -# ---------------------------------------------------------------------- -# Vercel 预配置信息 -# ---------------------------------------------------------------------- -@router.get("/vercel/config") -async def get_vercel_config(_: bool = Depends(verify_admin)): - """获取预配置的 Vercel 信息(脱敏)""" - return JSONResponse(content={ - "has_token": bool(VERCEL_TOKEN), - "project_id": VERCEL_PROJECT_ID, - "team_id": VERCEL_TEAM_ID or None, - }) - - -# ---------------------------------------------------------------------- -# 配置管理 -# ---------------------------------------------------------------------- -@router.get("/config") -async def get_config(_: bool = Depends(verify_admin)): - """获取当前配置(密码脱敏)""" - safe_config = { - "keys": CONFIG.get("keys", []), - "accounts": [], - "claude_mapping": CONFIG.get("claude_mapping", {}), - } - - for acc in CONFIG.get("accounts", []): - safe_acc = { - "email": acc.get("email", ""), - "mobile": acc.get("mobile", ""), - "has_password": bool(acc.get("password")), - "has_token": bool(acc.get("token")), - "token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "", - } - safe_config["accounts"].append(safe_acc) - - return JSONResponse(content=safe_config) - - -@router.post("/config") -async def update_config(request: Request, _: bool = Depends(verify_admin)): - """更新完整配置""" - data = await request.json() - - if "keys" in data: - CONFIG["keys"] = data["keys"] - - if "accounts" in data: - # 保留原有密码和 token - existing = {get_account_identifier(a): a for a in CONFIG.get("accounts", [])} - for acc in data["accounts"]: - acc_id = get_account_identifier(acc) - if acc_id in existing: - if not acc.get("password"): - acc["password"] = existing[acc_id].get("password", "") - if not acc.get("token"): - acc["token"] = existing[acc_id].get("token", "") - CONFIG["accounts"] = data["accounts"] - init_account_queue() - - if "claude_mapping" in data: - CONFIG["claude_mapping"] = data["claude_mapping"] - - save_config(CONFIG) - return JSONResponse(content={"success": True, "message": "配置已更新"}) - - -# ---------------------------------------------------------------------- -# API Keys 管理 -# ---------------------------------------------------------------------- -@router.post("/keys") -async def add_key(request: Request, _: bool = Depends(verify_admin)): - """添加 API Key""" - data = await request.json() - key = data.get("key", "").strip() - - if not key: - raise HTTPException(status_code=400, detail="Key 不能为空") - - if key in CONFIG.get("keys", []): - raise HTTPException(status_code=400, detail="Key 已存在") - - if "keys" not in CONFIG: - CONFIG["keys"] = [] - CONFIG["keys"].append(key) - save_config(CONFIG) - - return JSONResponse(content={"success": True, "total_keys": len(CONFIG["keys"])}) - - -@router.delete("/keys/{key}") -async def delete_key(key: str, _: bool = Depends(verify_admin)): - """删除 API Key""" - if key not in CONFIG.get("keys", []): - raise HTTPException(status_code=404, detail="Key 不存在") - - CONFIG["keys"].remove(key) - save_config(CONFIG) - return JSONResponse(content={"success": True, "total_keys": len(CONFIG["keys"])}) - - -# ---------------------------------------------------------------------- -# 账号管理 -# ---------------------------------------------------------------------- -@router.get("/accounts") -async def list_accounts( - page: int = 1, - page_size: int = 10, - _: bool = Depends(verify_admin) -): - """获取账号列表(分页,倒序,密码脱敏)""" - accounts = CONFIG.get("accounts", []) - total = len(accounts) - - # 倒序排列 - accounts = list(reversed(accounts)) - - # 计算分页 - page = max(1, page) - page_size = max(1, min(100, page_size)) # 限制每页最多 100 条 - total_pages = (total + page_size - 1) // page_size if total > 0 else 1 - - start = (page - 1) * page_size - end = start + page_size - page_accounts = accounts[start:end] - - # 脱敏处理 - safe_accounts = [] - for acc in page_accounts: - safe_acc = { - "email": acc.get("email", ""), - "mobile": acc.get("mobile", ""), - "has_password": bool(acc.get("password")), - "has_token": bool(acc.get("token")), - "token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "", - } - safe_accounts.append(safe_acc) - - return JSONResponse(content={ - "items": safe_accounts, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - }) - - -@router.post("/accounts") -async def add_account(request: Request, _: bool = Depends(verify_admin)): - """添加账号""" - data = await request.json() - email = data.get("email", "").strip() - mobile = data.get("mobile", "").strip() - password = data.get("password", "").strip() - token = data.get("token", "").strip() - - if not email and not mobile: - raise HTTPException(status_code=400, detail="需要 email 或 mobile") - - # 检查是否已存在 - for acc in CONFIG.get("accounts", []): - if email and acc.get("email") == email: - raise HTTPException(status_code=400, detail="邮箱已存在") - if mobile and acc.get("mobile") == mobile: - raise HTTPException(status_code=400, detail="手机号已存在") - - new_account = {} - if email: - new_account["email"] = email - if mobile: - new_account["mobile"] = mobile - if password: - new_account["password"] = password - if token: - new_account["token"] = token - - if "accounts" not in CONFIG: - CONFIG["accounts"] = [] - CONFIG["accounts"].append(new_account) - init_account_queue() - save_config(CONFIG) - - return JSONResponse(content={"success": True, "total_accounts": len(CONFIG["accounts"])}) - - -@router.delete("/accounts/{identifier}") -async def delete_account(identifier: str, _: bool = Depends(verify_admin)): - """删除账号(通过 email 或 mobile)""" - accounts = CONFIG.get("accounts", []) - for i, acc in enumerate(accounts): - if acc.get("email") == identifier or acc.get("mobile") == identifier: - accounts.pop(i) - init_account_queue() - save_config(CONFIG) - return JSONResponse(content={"success": True, "total_accounts": len(accounts)}) - - raise HTTPException(status_code=404, detail="账号不存在") - - -# ---------------------------------------------------------------------- -# 账号队列状态 -# ---------------------------------------------------------------------- -@router.get("/queue/status") -async def get_account_queue_status(_: bool = Depends(verify_admin)): - """获取账号轮询队列状态""" - return JSONResponse(content=get_queue_status()) From 2f853d73648ab6065faad86346e1d1368a3ffaa0 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 18 Feb 2026 20:53:10 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=E9=87=8D=E5=86=99=20start.mjs=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=20Go=20=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- start.mjs | 423 +++++++++++++++++++++++------------------------------- 1 file changed, 179 insertions(+), 244 deletions(-) diff --git a/start.mjs b/start.mjs index c706154..896723f 100644 --- a/start.mjs +++ b/start.mjs @@ -4,8 +4,13 @@ * * 使用方法: * node start.mjs # 显示交互式菜单 - * node start.mjs dev # 开发模式(后端+前端) - * node start.mjs prod # 生产模式 + * 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'; @@ -20,25 +25,17 @@ const __dirname = dirname(__filename); // 判断是否为 Windows const isWindows = process.platform === 'win32'; -// 配置 -const CONFIG = { - backendPort: process.env.PORT || 5001, - frontendPort: 5173, - host: process.env.HOST || '0.0.0.0', - logLevel: process.env.LOG_LEVEL || 'info', - adminKey: process.env.DS2API_ADMIN_KEY || 'ds2api', - webuiDir: join(__dirname, 'webui'), - venvDir: join(__dirname, '.venv'), -}; +// 编译产物路径 +const BINARY = join(__dirname, isWindows ? 'ds2api.exe' : 'ds2api'); -// venv 中的可执行文件路径 -const VENV = { - python: isWindows - ? join(CONFIG.venvDir, 'Scripts', 'python.exe') - : join(CONFIG.venvDir, 'bin', 'python'), - pip: isWindows - ? join(CONFIG.venvDir, 'Scripts', 'pip.exe') - : join(CONFIG.venvDir, 'bin', 'pip'), +// 配置(从环境变量读取,与 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'), }; // 存储子进程 @@ -78,7 +75,6 @@ function cleanup() { process.exit(0); } -// 注册退出处理 process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); @@ -92,39 +88,17 @@ function commandExists(cmd) { } } -// 获取系统 Python 命令 -function getSystemPython() { - const candidates = isWindows - ? ['python', 'python3', 'py'] - : ['python3', 'python']; - - for (const cmd of candidates) { - if (commandExists(cmd)) { - return cmd; - } - } - return null; +// 检查 Go 是否安装 +function checkGo() { + return commandExists('go'); } -// 系统 Python 命令 -const SYSTEM_PYTHON = getSystemPython(); - -// 检查 venv 是否存在 -function venvExists() { - return existsSync(VENV.python); -} - -// 检查 Python 依赖是否已安装 -function checkPythonDeps() { - if (!venvExists()) return false; +// 获取 Go 版本 +function getGoVersion() { try { - execSync(`"${VENV.python}" -c "import fastapi, uvicorn"`, { - stdio: 'ignore', - shell: true, - }); - return true; + return execSync('go version', { encoding: 'utf-8' }).trim(); } catch { - return false; + return null; } } @@ -134,13 +108,14 @@ function checkFrontendDeps() { return existsSync(join(CONFIG.webuiDir, 'node_modules')); } -// 获取依赖状态 -function getDepsStatus() { - return { - venv: venvExists(), - python: checkPythonDeps(), - frontend: checkFrontendDeps(), - }; +// 检查前端是否已构建 +function checkWebuiBuilt() { + return existsSync(join(CONFIG.staticAdminDir, 'index.html')); +} + +// 检查后端二进制是否存在 +function binaryExists() { + return existsSync(BINARY); } // 查找占用端口的进程 PID @@ -173,7 +148,7 @@ function findPidByPort(port) { // 获取运行中的服务状态 function getRunningStatus() { - const backendPids = findPidByPort(CONFIG.backendPort); + const backendPids = findPidByPort(CONFIG.port); const frontendPids = findPidByPort(CONFIG.frontendPort); return { backend: backendPids, @@ -213,67 +188,35 @@ async function stopServices() { }; if (running.backend.length > 0) { - log.info(`停止后端服务 (端口 ${CONFIG.backendPort}, PID: ${running.backend.join(', ')})...`); - for (const pid of running.backend) { - await killProcess(pid); - } + 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); - } + for (const pid of running.frontend) await killProcess(pid); log.success('前端服务已停止'); } } -// 创建 venv -async function createVenv() { - if (venvExists()) { - log.info('虚拟环境已存在'); - return true; +// 安装前端依赖 +async function installFrontendDeps() { + if (!existsSync(CONFIG.webuiDir)) { + log.warn('webui 目录不存在,跳过前端依赖安装'); + return; } - - if (!SYSTEM_PYTHON) { - throw new Error('未找到 Python,请先安装 Python'); - } - - log.info('创建 Python 虚拟环境...'); + log.info('安装前端依赖 (npm ci)...'); return new Promise((resolve, reject) => { - const proc = spawn(SYSTEM_PYTHON, ['-m', 'venv', CONFIG.venvDir], { - cwd: __dirname, + const proc = spawn('npm', ['ci'], { + cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true, }); - proc.on('close', code => { - if (code === 0) { - log.success('虚拟环境创建成功'); - resolve(true); - } else { - reject(new Error('虚拟环境创建失败')); - } - }); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败'))); }); } -// 确保 venv 存在 -async function ensureVenv() { - if (!venvExists()) { - await createVenv(); - } -} - -// 确保 Python 依赖已安装 -async function ensurePythonDeps() { - await ensureVenv(); - if (!checkPythonDeps()) { - log.warn('检测到 Python 依赖未安装,正在安装...'); - await installPythonDeps(); - } -} - // 确保前端依赖已安装 async function ensureFrontendDeps() { if (checkFrontendDeps() === false) { @@ -282,140 +225,113 @@ async function ensureFrontendDeps() { } } -// 安装 Python 依赖 -async function installPythonDeps() { - await ensureVenv(); - log.info('安装 Python 依赖...'); +// 编译后端二进制 +async function buildBackend() { + if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)'); + log.info('编译后端二进制...'); return new Promise((resolve, reject) => { - const proc = spawn(VENV.pip, ['install', '-r', 'requirements.txt'], { + const proc = spawn('go', ['build', '-o', BINARY, './cmd/ds2api'], { cwd: __dirname, stdio: 'inherit', shell: true, }); - proc.on('close', code => code === 0 ? resolve() : reject(new Error('Python 依赖安装失败'))); + proc.on('close', code => code === 0 ? resolve() : reject(new Error('后端编译失败'))); }); } -// 安装前端依赖 -async function installFrontendDeps() { +// 构建前端静态文件 +async function buildWebui() { if (!existsSync(CONFIG.webuiDir)) { - log.warn('webui 目录不存在,跳过前端依赖安装'); + log.warn('webui 目录不存在'); return; } - log.info('安装前端依赖...'); + await ensureFrontendDeps(); + log.info('构建前端静态文件...'); return new Promise((resolve, reject) => { - const proc = spawn('npm', ['install'], { - cwd: CONFIG.webuiDir, - stdio: 'inherit', - shell: true, - }); - proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败'))); + 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('前端构建失败'))); }); } -// 安装所有依赖 -async function installAll() { - log.title('========== 安装依赖 =========='); - try { - await installPythonDeps(); - log.success('Python 依赖安装完成'); - await installFrontendDeps(); - log.success('前端依赖安装完成'); - log.success('所有依赖安装完成!'); - } catch (e) { - log.error(e.message); - } -} - -// 启动后端 -async function startBackend(devMode = true) { - await ensurePythonDeps(); - - log.info(`启动后端服务... http://localhost:${CONFIG.backendPort}`); - - const args = [ - '-m', 'uvicorn', - 'app:app', - '--host', CONFIG.host, - '--port', String(CONFIG.backendPort), - '--log-level', CONFIG.logLevel, - ]; - - if (devMode) { - args.push('--reload', '--reload-dir', __dirname); - } - - const proc = spawn(VENV.python, args, { +// 启动后端(开发模式: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, }, }); - 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}`); - + log.info(`启动前端开发服务器... http://localhost:${CONFIG.frontendPort}`); const proc = spawn('npm', ['run', 'dev'], { cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true, }); - processes.push(proc); return proc; } -// 构建前端 -async function buildFrontend() { - if (!existsSync(CONFIG.webuiDir)) { - log.warn('webui 目录不存在'); - return; - } - - log.info('构建前端...'); - return new Promise((resolve, reject) => { - const proc = spawn('npm', ['run', 'build'], { - cwd: CONFIG.webuiDir, - stdio: 'inherit', - shell: true, - }); - proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败'))); - }); -} - // 显示状态信息 function showStatus() { console.log('\n' + '─'.repeat(50)); - log.success(`后端 API: http://localhost:${CONFIG.backendPort}`); + log.success(`后端 API: http://localhost:${CONFIG.port}`); + log.success(`管理界面: http://localhost:${CONFIG.port}/admin`); if (existsSync(CONFIG.webuiDir)) { - log.success(`管理界面: http://localhost:${CONFIG.frontendPort}`); + log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`); } console.log('─'.repeat(50)); log.info('按 Ctrl+C 停止所有服务\n'); } -// 等待进程 +// 等待进程退出 function waitForProcesses() { return new Promise(resolve => { - const checkInterval = setInterval(() => { - const alive = processes.filter(p => !p.killed); - if (alive.length === 0) { - clearInterval(checkInterval); + const check = setInterval(() => { + if (processes.filter(p => !p.killed).length === 0) { + clearInterval(check); resolve(); } }, 1000); @@ -424,53 +340,50 @@ function waitForProcesses() { // 交互式菜单 async function showMenu() { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - + 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 启动脚本 ║'); + log.title('║ DS2API 启动脚本 (Go) ║'); log.title('╚══════════════════════════════════════════╝'); - // 获取依赖状态 - const deps = getDepsStatus(); + // 环境状态 + const goVersion = getGoVersion(); + const frontendDeps = checkFrontendDeps(); + const webuiBuilt = checkWebuiBuilt(); + const hasBinary = binaryExists(); const running = getRunningStatus(); - const statusText = (ok) => ok ? `${colors.green}已安装${colors.reset}` : `${colors.yellow}未安装${colors.reset}`; + const ok = (v) => v ? `${colors.green}✓${colors.reset}` : `${colors.yellow}✗${colors.reset}`; console.log(`\n${colors.bright}环境状态:${colors.reset}`); - console.log(` Python: ${SYSTEM_PYTHON || `${colors.red}未找到${colors.reset}`}`); - console.log(` 虚拟环境: ${deps.venv ? `${colors.green}已创建${colors.reset}` : `${colors.yellow}未创建${colors.reset}`} (${CONFIG.venvDir})`); - console.log(` 后端依赖: ${statusText(deps.python)}`); - if (deps.frontend !== null) { - console.log(` 前端依赖: ${statusText(deps.frontend)}`); - } + 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.backendPort}): ${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(` 后端 (:${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(` DS2API_ADMIN_KEY: ${colors.cyan}${CONFIG.adminKey}${colors.reset}`); - console.log(` PORT: ${colors.cyan}${CONFIG.backendPort}${colors.reset}`); - console.log(` HOST: ${colors.cyan}${CONFIG.host}${colors.reset}`); - console.log(` LOG_LEVEL: ${colors.cyan}${CONFIG.logLevel}${colors.reset}`); - console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=你的密钥 node start.mjs${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(`${colors.dim} 自定义: DS2API_ADMIN_KEY=密钥 PORT=5001 node start.mjs${colors.reset}`); console.log(` ${colors.bright}请选择操作:${colors.reset} - ${colors.cyan}1.${colors.reset} 开发模式 (后端 + 前端热重载) - ${colors.cyan}2.${colors.reset} 仅启动后端 (开发模式) - ${colors.cyan}3.${colors.reset} 仅启动前端 - ${colors.cyan}4.${colors.reset} 生产模式 (仅后端,无热重载) - ${colors.cyan}5.${colors.reset} 构建前端 - ${colors.cyan}6.${colors.reset} 安装依赖 (创建venv + 安装包) - ${colors.red}7.${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} 退出 `); @@ -480,7 +393,7 @@ ${colors.bright}请选择操作:${colors.reset} switch (choice.trim() || '1') { case '1': log.title('========== 开发模式 =========='); - await startBackend(true); + await startBackendDev(); await new Promise(r => setTimeout(r, 1500)); await startFrontend(); showStatus(); @@ -488,8 +401,8 @@ ${colors.bright}请选择操作:${colors.reset} break; case '2': - log.title('========== 仅后端 (开发模式) =========='); - await startBackend(true); + log.title('========== 仅后端 (go run) =========='); + await startBackendDev(); showStatus(); await waitForProcesses(); break; @@ -503,21 +416,30 @@ ${colors.bright}请选择操作:${colors.reset} case '4': log.title('========== 生产模式 =========='); - await startBackend(false); + await startBackendProd(); showStatus(); await waitForProcesses(); break; case '5': - await buildFrontend(); - log.success('前端构建完成!'); + log.title('========== 编译后端 =========='); + await buildBackend(); + log.success(`编译完成:${BINARY}`); break; case '6': - await installAll(); + log.title('========== 构建前端 =========='); + await buildWebui(); + log.success('前端构建完成!'); break; case '7': + log.title('========== 安装前端依赖 =========='); + await installFrontendDeps(); + log.success('前端依赖安装完成!'); + break; + + case '8': await stopServices(); break; @@ -534,19 +456,21 @@ ${colors.bright}请选择操作:${colors.reset} // 命令行参数处理 async function main() { - const args = process.argv.slice(2); - const cmd = args[0]; + const cmd = process.argv[2]; - // 检查必要工具 - if (!SYSTEM_PYTHON) { - log.error('未找到 Python,请先安装 Python (尝试了 python, python3, py)'); - process.exit(1); + 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 startBackend(true); + await startBackendDev(); await new Promise(r => setTimeout(r, 1500)); await startFrontend(); showStatus(); @@ -555,54 +479,65 @@ async function main() { case 'prod': log.title('========== 生产模式 =========='); - await startBackend(false); + await startBackendProd(); showStatus(); await waitForProcesses(); break; case 'build': - await buildFrontend(); + await buildBackend(); + log.success(`编译完成:${BINARY}`); + break; + + case 'webui': + await buildWebui(); log.success('前端构建完成!'); break; case 'install': - await installAll(); + await installFrontendDeps(); + log.success('前端依赖安装完成!'); break; case 'stop': await stopServices(); break; - case 'status': + 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.backendPort}): ${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`); + 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 启动脚本${colors.reset} +${colors.bright}DS2API 启动脚本 (Go)${colors.reset} ${colors.cyan}使用方法:${colors.reset} node start.mjs 显示交互式菜单 - node start.mjs dev 开发模式 (后端 + 前端) - node start.mjs prod 生产模式 (无热重载) - node start.mjs build 构建前端 - node start.mjs install 安装所有依赖 (自动创建venv) + 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) - HOST 监听地址 (默认: 0.0.0.0) - LOG_LEVEL 日志级别 (默认: info) +${colors.cyan}常用环境变量:${colors.reset} + PORT 后端端口 (默认: 5001) + LOG_LEVEL 日志级别: DEBUG|INFO|WARN|ERROR (默认: INFO) + DS2API_ADMIN_KEY 管理员密钥 (默认: admin) + DS2API_CONFIG_PATH 配置文件路径 (默认: config.json) -${colors.cyan}虚拟环境:${colors.reset} - 默认路径: .venv/ - 首次运行 install 时自动创建 +${colors.cyan}示例:${colors.reset} + DS2API_ADMIN_KEY=mykey PORT=8080 node start.mjs dev `); break; From e143d13ff642cffac33c195327ab202725c73e4c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 18 Feb 2026 20:57:23 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=E7=BC=96=E8=AF=91=E5=92=8C?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E4=BE=9D=E8=B5=96=E4=BD=BF=E7=94=A8=E5=9B=BD?= =?UTF-8?q?=E5=86=85=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- start.mjs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/start.mjs b/start.mjs index 896723f..59c5101 100644 --- a/start.mjs +++ b/start.mjs @@ -38,6 +38,12 @@ const CONFIG = { 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 = []; @@ -206,9 +212,9 @@ async function installFrontendDeps() { log.warn('webui 目录不存在,跳过前端依赖安装'); return; } - log.info('安装前端依赖 (npm ci)...'); + log.info(`安装前端依赖 (npm ci, registry: ${MIRRORS.npm})...`); return new Promise((resolve, reject) => { - const proc = spawn('npm', ['ci'], { + const proc = spawn('npm', ['ci', '--registry', MIRRORS.npm], { cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true, @@ -228,12 +234,13 @@ async function ensureFrontendDeps() { // 编译后端二进制 async function buildBackend() { if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)'); - log.info('编译后端二进制...'); + 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('后端编译失败'))); }); @@ -269,6 +276,7 @@ async function startBackendDev() { PORT: CONFIG.port, LOG_LEVEL: CONFIG.logLevel, DS2API_ADMIN_KEY: CONFIG.adminKey, + GOPROXY: MIRRORS.goproxy, }, }); processes.push(proc); @@ -371,6 +379,8 @@ async function showMenu() { 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(` @@ -535,9 +545,12 @@ ${colors.cyan}常用环境变量:${colors.reset} 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; From 962700f525273c1aaf6f3c9e597a2b177910fadf Mon Sep 17 00:00:00 2001 From: root Date: Wed, 18 Feb 2026 21:06:02 +0800 Subject: [PATCH 08/13] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E6=96=87=E4=BB=B6=EF=BC=8C=E6=B8=85=E7=90=86=20.gitig?= =?UTF-8?q?nore=20Python=20=E6=AE=8B=E7=95=99=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 42 ------------------------- docker/Dockerfile | 70 ----------------------------------------- static/admin/index.html | 43 ------------------------- 3 files changed, 155 deletions(-) delete mode 100644 docker/Dockerfile delete mode 100644 static/admin/index.html diff --git a/.gitignore b/.gitignore index 5f776e2..c1146eb 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 @@ -76,14 +42,6 @@ ds2api-tests .env.local .env.*.local -# Testing -.coverage -htmlcov/ -.pytest_cache/ -.tox/ - # Misc -*.pyc -*.pyo .git/ Thumbs.db diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index ccac75f..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -# ========== 阶段1: 构建前端 ========== -FROM node:20-slim AS frontend-builder - -WORKDIR /app/webui - -# 复制前端依赖文件 -COPY webui/package.json webui/package-lock.json ./ - -# 安装依赖 -RUN npm ci - -# 复制前端源码 -COPY webui/ ./ - -# 构建前端 -RUN npm run build - -# ========== 阶段2: 构建后端 ========== -FROM python:3.11-slim - -WORKDIR /app - -# 设置环境变量 -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -ENV TZ=Asia/Shanghai - -# 安装系统依赖 -# curl_cffi 需要 libcurl 和编译工具 -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - g++ \ - libffi-dev \ - libcurl4-openssl-dev \ - libssl-dev \ - curl \ - && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \ - && rm -rf /var/lib/apt/lists/* - -# 复制并安装 Python 依赖 -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple - -# 复制后端代码 -COPY app.py . -COPY core/ ./core/ -COPY routes/ ./routes/ - -# 创建 templates 目录(预留扩展用) -RUN mkdir -p ./templates - -# 复制 WASM 文件和 Tokenizer 相关文件 -COPY sha3_wasm_bg.7b9ca65ddd.wasm ./ -COPY tokenizer.json tokenizer_config.json ./ - -# 从前端构建阶段复制构建产物到 static/admin -COPY --from=frontend-builder /app/webui/dist ./static/admin - -# 创建配置文件目录(运行时挂载) -RUN mkdir -p /app/data - -# 暴露端口 -EXPOSE 5001 - -# 健康检查 -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5001/ || exit 1 - -# 启动命令 -CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"] diff --git a/static/admin/index.html b/static/admin/index.html deleted file mode 100644 index a08181e..0000000 --- a/static/admin/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - DS2API - DeepSeek API 管理面板 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - \ No newline at end of file From 70c59eb71d4e8245f12708420b3c439d4dc33a1a Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 20:19:00 +0800 Subject: [PATCH 09/13] =?UTF-8?q?chore:=20=E5=B0=86=20.claude/=20=E5=92=8C?= =?UTF-8?q?=20CLAUDE.local.md=20=E4=BB=8E=20git=20=E8=B7=9F=E8=B8=AA?= =?UTF-8?q?=E4=B8=AD=E6=8E=92=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/throttled-task.md | 30 -- .claude/plans/compiled-discovering-sunset.md | 407 ------------------- .claude/remove-root-check.sh | 172 -------- .claude/settings.local.json | 24 -- .claude/show-status.mjs | 91 ----- .gitignore | 4 + CLAUDE.local.md | 261 ------------ 7 files changed, 4 insertions(+), 985 deletions(-) delete mode 100644 .claude/commands/throttled-task.md delete mode 100644 .claude/plans/compiled-discovering-sunset.md delete mode 100644 .claude/remove-root-check.sh delete mode 100644 .claude/settings.local.json delete mode 100644 .claude/show-status.mjs delete mode 100644 CLAUDE.local.md diff --git a/.claude/commands/throttled-task.md b/.claude/commands/throttled-task.md deleted file mode 100644 index 72ba1a5..0000000 --- a/.claude/commands/throttled-task.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -description: 执行带有请求节流的任务,可以控制工具调用的频率 ---- - -# 节流任务执行 - -你现在进入了节流模式。在这个模式下,你需要: - -## 节流规则 - -1. **工具调用间隔**:每次调用工具后,等待至少 2 秒再调用下一个工具 -2. **并行限制**:同时最多只能并行调用 2 个工具(原本可能更多) -3. **批处理优化**:优先将相关的操作合并到一个工具调用中 -4. **进度提示**:每次等待时向用户说明正在节流等待 - -## 执行方式 - -- 对于读取操作(Read, Glob, Grep),可以适当放宽并行限制 -- 对于写入操作(Write, Edit),严格遵守间隔要求 -- 对于 API 调用(Bash 中的 API 请求),必须串行执行 - -## 用户任务 - -请按照上述节流规则执行以下任务: - -{{PROMPT}} - ---- - -**注意**:完成任务后,你将自动退出节流模式,恢复正常的工具调用频率。 diff --git a/.claude/plans/compiled-discovering-sunset.md b/.claude/plans/compiled-discovering-sunset.md deleted file mode 100644 index 6769b63..0000000 --- a/.claude/plans/compiled-discovering-sunset.md +++ /dev/null @@ -1,407 +0,0 @@ -# 心跳配置功能重构计划 - -## 概述 - -将心跳配置从"选择curl命令"模式重构为"基于URL的可配置心跳"模式。 - -**核心变化**: -- ✅ 支持多个URL同时发送心跳 -- ✅ 每个URL独立配置间隔时间 -- ✅ curl命令作为导入模板,可解析为URL配置 -- ✅ 完全替换旧的基于curl选择的方式 - -## 架构设计 - -### 数据模型 - -**新表:heartbeat_url_configs** -```typescript -{ - id: number; - name: string; // URL配置名称 - url: string; // 目标URL - method: string; // HTTP方法(GET/POST/PUT/DELETE) - headers: Record; // 请求头(JSONB) - body: string | null; // 请求体 - intervalSeconds: number; // 独立的心跳间隔(10-3600秒) - isEnabled: boolean; // 是否启用此配置 - lastSuccessAt: Date | null; // 统计:上次成功时间 - lastErrorAt: Date | null; // 统计:上次失败时间 - lastErrorMessage: string | null; // 统计:上次错误信息 - successCount: number; // 统计:成功次数 - failureCount: number; // 统计:失败次数 - providerId: number | null; // 关联的供应商ID(可选) - model: string | null; // 模型名称(展示用) - endpoint: string | null; // 端点路径(展示用) - createdAt: Date; - updatedAt: Date; -} -``` - -**修改表:heartbeat_settings** -```typescript -{ - id: number; - enabled: boolean; // 全局开关(保留) - // 删除:intervalSeconds, savedCurls, selectedCurlIndex - createdAt: Date; - updatedAt: Date; -} -``` - -### 心跳执行逻辑 - -**ProviderHeartbeat类重构**: -```typescript -class ProviderHeartbeat { - // 多定时器管理:Map - private static timers: Map = new Map(); - - // 启动:为每个启用的URL配置创建独立定时器 - static async start() { - const configs = await findEnabledHeartbeatUrlConfigs(); - for (const config of configs) { - this.startConfigTimer(config); - } - } - - // 停止:清除所有定时器 - static stop() { - for (const timer of this.timers.values()) { - clearInterval(timer); - } - this.timers.clear(); - } - - // 单个配置的定时器 - private static startConfigTimer(config: HeartbeatUrlConfig) { - const interval = setInterval(() => { - this.sendHeartbeat(config); - }, config.intervalSeconds * 1000); - this.timers.set(config.id, interval); - } - - // 发送心跳并记录成功/失败 - private static async sendHeartbeat(config: HeartbeatUrlConfig) { - const response = await fetch(config.url, { - method: config.method, - headers: config.headers, - body: config.body, - signal: AbortSignal.timeout(10000), - }); - - if (response.ok) { - await recordHeartbeatSuccess(config.id); - } else { - await recordHeartbeatFailure(config.id, errorMessage); - } - } -} -``` - -### 前端UI设计 - -**页面布局**: -``` -/settings/heartbeat/page.tsx -├── GlobalSettingsCard(全局开关) -├── CurlHistorySection(curl历史记录 + 导入按钮) -└── UrlConfigsSection(URL配置列表 + 新建/编辑/删除) -``` - -**组件拆分**: -- `global-settings-card.tsx` - 全局开关卡片 -- `curl-history-section.tsx` - curl历史记录区域 -- `curl-history-card.tsx` - 单个curl历史卡片 -- `url-configs-section.tsx` - URL配置列表区域 -- `url-config-card.tsx` - 单个URL配置卡片 -- `url-config-dialog.tsx` - 新建/编辑对话框 -- `_lib/hooks.ts` - 自定义hooks(useHeartbeatPageData) - -**curl导入流程**: -1. 用户点击curl历史卡片上的"导入"按钮 -2. 使用`parseCurlCommand()`解析curl命令 -3. 自动打开新建对话框,表单预填充解析后的数据 -4. 用户修改后保存,创建URL配置 - -## 实施步骤 - -### 阶段1:数据库和Repository层 - -1. **修改schema.ts** - - 添加`heartbeatUrlConfigs`表定义 - - 修改`heartbeatSettings`表定义(删除3个字段) - -2. **生成和审查迁移** - ```bash - bun run db:generate - # 检查生成的 drizzle/0061_*.sql - # 确保数据迁移逻辑正确(将选中的curl转为第一个URL配置) - ``` - -3. **创建repository/heartbeat-url-configs.ts** - - 接口:`HeartbeatUrlConfig`、`CreateHeartbeatUrlConfigInput`、`UpdateHeartbeatUrlConfigInput` - - 函数: - - `findAllHeartbeatUrlConfigs()` - 获取所有配置 - - `findEnabledHeartbeatUrlConfigs()` - 获取启用的配置 - - `findHeartbeatUrlConfigById(id)` - 根据ID获取 - - `createHeartbeatUrlConfig(input)` - 创建配置 - - `updateHeartbeatUrlConfig(id, input)` - 更新配置 - - `deleteHeartbeatUrlConfig(id)` - 删除配置 - - `recordHeartbeatSuccess(id)` - 记录成功 - - `recordHeartbeatFailure(id, errorMessage)` - 记录失败 - -4. **修改repository/heartbeat-settings.ts** - - 简化为只管理全局开关 - - 删除`savedCurls`和`selectedCurlIndex`相关逻辑 - - 保持`getHeartbeatSettings()`和`updateHeartbeatSettings()`接口 - -5. **运行迁移** - ```bash - bun run db:migrate - ``` - -### 阶段2:Action层 - -6. **创建actions/heartbeat-url-configs.ts** - - `fetchHeartbeatUrlConfigs()` - 获取所有配置 - - `createHeartbeatUrlConfigAction(input)` - 创建配置 - - `updateHeartbeatUrlConfigAction(id, input)` - 更新配置 - - `deleteHeartbeatUrlConfigAction(id)` - 删除配置 - - 验证规则: - - 名称不能为空 - - URL不能为空 - - 间隔时间范围:10-3600秒 - - 权限检查:仅admin可操作 - - 副作用:修改配置后重启心跳任务 - -7. **修改actions/heartbeat-settings.ts** - - 简化为只管理全局开关 - - 保持`fetchHeartbeatSettings()`和`saveHeartbeatSettings()` - - 开关变化时重启心跳任务 - -### 阶段3:心跳执行逻辑 - -8. **重构lib/provider-heartbeat.ts** - - 添加`timers: Map` - - 修改`start()`:为每个启用的配置创建定时器 - - 修改`stop()`:清除所有定时器 - - 新增`startConfigTimer(config)`:创建单个配置的定时器 - - 新增`stopConfigTimer(configId)`:停止单个配置的定时器 - - 修改`sendHeartbeat(config)`:发送请求并记录结果 - - 删除curl解析逻辑(不再需要) - -9. **修改app/v1/_lib/proxy/forwarder.ts** - - 删除或注释掉`addSuccessfulCurl()`调用(第357-367行) - - curl历史功能迁移到独立模块(可选) - -### 阶段4:i18n文案 - -10. **更新翻译文件** - - `messages/zh-CN/settings/heartbeat.json` - - `messages/zh-TW/settings/heartbeat.json` - - `messages/en/settings/heartbeat.json` - - `messages/ja/settings/heartbeat.json` - - `messages/ru/settings/heartbeat.json` - - 新增key: - - `section.global.*` - 全局设置区域 - - `section.curlHistory.*` - curl历史区域 - - `section.urlConfigs.*` - URL配置区域 - - `form.name.*` - 配置名称字段 - - `form.url.*` - URL字段 - - `form.method.*` - HTTP方法字段 - - `form.headers.*` - 请求头字段 - - `form.body.*` - 请求体字段 - - `form.isEnabled.*` - 启用开关 - - `form.stats.*` - 统计信息 - - `form.createButton`、`importButton`等 - -### 阶段5:前端UI - -11. **创建组件** - - `app/[locale]/settings/heartbeat/_components/global-settings-card.tsx` - - Switch组件:全局开关 - - 说明文字 - - - `app/[locale]/settings/heartbeat/_components/curl-history-section.tsx` - - 区域标题和描述 - - curl历史卡片列表 - - 空状态提示 - - - `app/[locale]/settings/heartbeat/_components/curl-history-card.tsx` - - 显示:供应商名、端点、模型、时间 - - 导入按钮 - - - `app/[locale]/settings/heartbeat/_components/url-configs-section.tsx` - - 区域标题和描述 - - 新建按钮 - - URL配置卡片列表 - - 空状态提示 - - - `app/[locale]/settings/heartbeat/_components/url-config-card.tsx` - - 显示:名称、URL、方法、间隔、启用状态 - - 统计信息:成功次数、失败次数、最后成功/失败时间 - - 编辑按钮、删除按钮 - - Switch组件:快速启用/禁用 - - - `app/[locale]/settings/heartbeat/_components/url-config-dialog.tsx` - - Dialog表单:名称、URL、方法、headers、body、间隔 - - 支持新建和编辑模式 - - headers使用Textarea(JSON格式) - - body使用Textarea(可选) - - 验证和错误提示 - - - `app/[locale]/settings/heartbeat/_components/heartbeat-skeleton.tsx` - - 骨架屏加载状态 - -12. **创建hooks** - - `app/[locale]/settings/heartbeat/_lib/hooks.ts` - - `useHeartbeatPageData()`: - - 加载settings、configs、savedCurls - - 提供CRUD操作函数 - - 提供importFromCurl函数 - - 统一错误处理和toast提示 - -13. **重写page.tsx** - - 使用`useHeartbeatPageData()` - - 组合所有子组件 - - 加载状态和错误处理 - -### 阶段6:测试和验证 - -14. **类型检查和格式化** - ```bash - bun run typecheck - bun run lint:fix - ``` - -15. **手动测试流程** - - [ ] 访问 `/settings/heartbeat` 页面 - - [ ] 创建新的URL配置 - - [ ] 从curl历史导入配置 - - [ ] 编辑配置(修改URL、间隔等) - - [ ] 启用/禁用单个配置 - - [ ] 启用/禁用全局开关 - - [ ] 删除配置 - - [ ] 检查多个URL同时发送心跳 - - [ ] 检查失败记录和统计信息 - - [ ] 检查国际化(切换语言) - -16. **日志验证** - ```bash - # 检查心跳日志 - tail -f logs/app.log | grep "ProviderHeartbeat" - - # 应该看到: - # - "Timer started" - 定时器启动 - # - "Heartbeat sent successfully" - 成功日志 - # - "Heartbeat failed" - 失败日志 - ``` - -17. **数据库验证** - ```bash - bun run db:studio - # 检查 heartbeat_url_configs 表 - # 确认配置已保存 - # 确认成功/失败统计更新 - ``` - -## 关键文件清单 - -### 新建文件 -- `src/repository/heartbeat-url-configs.ts` - URL配置Repository -- `src/actions/heartbeat-url-configs.ts` - URL配置Actions -- `src/app/[locale]/settings/heartbeat/_components/global-settings-card.tsx` -- `src/app/[locale]/settings/heartbeat/_components/curl-history-section.tsx` -- `src/app/[locale]/settings/heartbeat/_components/curl-history-card.tsx` -- `src/app/[locale]/settings/heartbeat/_components/url-configs-section.tsx` -- `src/app/[locale]/settings/heartbeat/_components/url-config-card.tsx` -- `src/app/[locale]/settings/heartbeat/_components/url-config-dialog.tsx` -- `src/app/[locale]/settings/heartbeat/_lib/hooks.ts` -- `drizzle/0061_*.sql` - 数据库迁移文件(自动生成) - -### 修改文件 -- `src/drizzle/schema.ts` - 添加新表,修改旧表 -- `src/repository/heartbeat-settings.ts` - 简化逻辑 -- `src/actions/heartbeat-settings.ts` - 简化Action -- `src/lib/provider-heartbeat.ts` - 重构心跳执行逻辑 -- `src/app/v1/_lib/proxy/forwarder.ts` - 删除curl保存逻辑 -- `src/app/[locale]/settings/heartbeat/page.tsx` - 重写UI -- `messages/*/settings/heartbeat.json` - 更新翻译(5种语言) - -### 删除文件 -- `src/app/[locale]/settings/heartbeat/_components/heartbeat-form.tsx` - 旧表单组件 - -## 数据迁移策略 - -**迁移逻辑(在0061_*.sql中)**: -```sql --- 创建新表 -CREATE TABLE heartbeat_url_configs (...); - --- 迁移现有数据 -DO $$ -DECLARE - settings_row RECORD; - selected_curl JSONB; -BEGIN - SELECT * INTO settings_row FROM heartbeat_settings LIMIT 1; - - IF settings_row.selected_curl_index IS NOT NULL THEN - selected_curl := settings_row.saved_curls->settings_row.selected_curl_index; - - INSERT INTO heartbeat_url_configs ( - name, url, interval_seconds, is_enabled, ... - ) VALUES ( - selected_curl->>'providerName', - selected_curl->>'url', - settings_row.interval_seconds, - settings_row.enabled, - ... - ); - END IF; -END $$; - --- 删除旧字段 -ALTER TABLE heartbeat_settings - DROP COLUMN interval_seconds, - DROP COLUMN saved_curls, - DROP COLUMN selected_curl_index; -``` - -**回滚能力**:保留旧数据在迁移文件中,可以通过反向迁移恢复。 - -## 风险和缓解 - -| 风险 | 缓解措施 | -|------|----------| -| 数据迁移失败 | 1. 迁移前备份数据库
2. 在测试环境验证
3. 编写回滚脚本 | -| curl解析不完整 | 1. 复用现有`parseCurlCommand`
2. 添加解析错误提示
3. 允许手动编辑 | -| 多定时器性能问题 | 1. 限制最大配置数量(如20个)
2. 添加禁用功能
3. 监控日志 | -| 心跳发送失败 | 1. 记录失败日志
2. UI显示失败状态
3. 支持手动禁用 | - -## 验证清单 - -- [ ] 数据库迁移成功,旧数据已转移 -- [ ] 类型检查通过 (`bun run typecheck`) -- [ ] Lint检查通过 (`bun run lint`) -- [ ] 构建成功 (`bun run build`) -- [ ] 可以创建URL配置 -- [ ] 可以从curl导入配置 -- [ ] 可以编辑和删除配置 -- [ ] 全局开关控制所有心跳 -- [ ] 多个URL同时发送心跳(检查日志) -- [ ] 失败统计正确记录 -- [ ] 所有5种语言显示正常 -- [ ] 页面加载和交互流畅 - -## 预估工作量 - -- 数据库和Repository层:1-2小时 -- Action层:30分钟 -- 心跳执行逻辑:1小时 -- i18n文案:30分钟 -- 前端UI:2-3小时 -- 测试和验证:1小时 -- **总计:6-8小时** diff --git a/.claude/remove-root-check.sh b/.claude/remove-root-check.sh deleted file mode 100644 index 6df65a0..0000000 --- a/.claude/remove-root-check.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/bin/bash - -echo "==========================================" -echo "Claude Code Root Check 移除工具" -echo "==========================================" -echo "" - -# 通过 which 命令找到 claude 可执行文件 -echo "正在查找 claude 命令..." -CLAUDE_PATH=$(which claude) - -if [ -z "$CLAUDE_PATH" ]; then - echo "❌ 错误: 未找到 claude 命令" - exit 1 -fi - -echo "找到 claude 位置: $CLAUDE_PATH" - -# 如果是软链接,获取实际文件路径 -if [ -L "$CLAUDE_PATH" ]; then - REAL_PATH=$(readlink -f "$CLAUDE_PATH") - echo "这是一个软链接,实际路径: $REAL_PATH" -else - REAL_PATH="$CLAUDE_PATH" -fi - -# 获取 claude 所在的目录 -CLAUDE_DIR=$(dirname "$CLAUDE_PATH") -echo "claude 目录: $CLAUDE_DIR" -echo "" - -# 检查是否已经是包装脚本 -if grep -q "Claude Code Wrapper" "$CLAUDE_PATH" 2>/dev/null; then - echo "✓ 检测到已安装包装脚本" - echo "正在更新包装脚本..." -else - echo "正在创建包装脚本..." -fi - -# 创建 claude-wrapper.sh -WRAPPER_PATH="$CLAUDE_DIR/claude-wrapper.sh" - -cat > "$WRAPPER_PATH" << 'EOF' -#!/bin/bash - -# Claude Code Wrapper - 自动删除 root check 限制 -# 此脚本会在每次执行 claude 前绕过 root 用户限制 -# -# 新版本 (2.1.x+) 支持通过环境变量绕过检查: -# - IS_SANDBOX=1 -# - CLAUDE_CODE_BUBBLEWRAP=1 -# -# 旧版本需要修改 cli.js 文件删除检查代码 - -# 获取当前脚本的真实路径 -SCRIPT_PATH="$(readlink -f "$0")" -SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" - -# 查找同目录下的 claude.bak(原始软链接) -CLAUDE_BAK="$SCRIPT_DIR/claude.bak" - -# 如果 claude.bak 不存在,尝试通过 which 和目录搜索找到真实路径 -if [ ! -L "$CLAUDE_BAK" ] && [ ! -f "$CLAUDE_BAK" ]; then - # 在当前目录查找指向 claude-code 的软链接或文件 - for file in "$SCRIPT_DIR"/*; do - if [ -L "$file" ] || [ -f "$file" ]; then - target=$(readlink -f "$file" 2>/dev/null) - if [[ "$target" == *"@anthropic-ai/claude-code/cli.js" ]]; then - CLAUDE_REAL_PATH="$target" - break - fi - fi - done - - # 如果还是没找到,尝试常见路径 - if [ -z "$CLAUDE_REAL_PATH" ]; then - for path in \ - "$SCRIPT_DIR/../lib/node_modules/@anthropic-ai/claude-code/cli.js" \ - "/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js" \ - "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"; do - if [ -f "$path" ]; then - CLAUDE_REAL_PATH="$path" - break - fi - done - fi -else - # 通过 claude.bak 获取真实的 cli.js 路径 - CLAUDE_REAL_PATH="$(readlink -f "$CLAUDE_BAK")" -fi - -if [ -z "$CLAUDE_REAL_PATH" ] || [ ! -f "$CLAUDE_REAL_PATH" ]; then - echo "错误: 未找到真实的 claude cli.js 文件" >&2 - echo "请确保 claude 已正确安装" >&2 - exit 1 -fi - -# 获取 claude 版本号(用于提示信息) -CLAUDE_VERSION=$(node "$CLAUDE_REAL_PATH" --version 2>/dev/null | head -1 || echo "unknown") - -# 新版本 (2.1.x+) 直接使用环境变量绕过 root check -# 设置 IS_SANDBOX=1 或 CLAUDE_CODE_BUBBLEWRAP=1 即可 -export IS_SANDBOX=1 -export CLAUDE_CODE_BUBBLEWRAP=1 - -# 执行原始 claude 命令,传递所有参数 -exec node "$CLAUDE_REAL_PATH" "$@" -EOF - -# 给包装脚本添加执行权限 -chmod +x "$WRAPPER_PATH" -echo "✓ 已创建包装脚本: $WRAPPER_PATH" -echo "" - -# 备份原 claude 命令(如果尚未备份) -CLAUDE_BAK="$CLAUDE_DIR/claude.bak" -if [ ! -e "$CLAUDE_BAK" ]; then - if [ -L "$CLAUDE_PATH" ]; then - # 如果是软链接,复制软链接本身 - cp -P "$CLAUDE_PATH" "$CLAUDE_BAK" - echo "✓ 已备份原 claude 软链接为: $CLAUDE_BAK" - else - # 如果是普通文件,复制文件 - cp "$CLAUDE_PATH" "$CLAUDE_BAK" - echo "✓ 已备份原 claude 文件为: $CLAUDE_BAK" - fi -else - echo "✓ 检测到已存在备份: $CLAUDE_BAK" -fi - -# 替换 claude 命令为包装脚本 -echo "" -echo "正在替换 claude 命令..." - -# 删除原有的 claude(如果是软链接或文件) -rm -f "$CLAUDE_PATH" - -# 创建新的软链接指向包装脚本 -ln -s "$WRAPPER_PATH" "$CLAUDE_PATH" - -echo "✓ 已将 claude 命令替换为包装脚本" -echo "" - -# 验证安装 -echo "==========================================" -echo "验证安装..." -echo "" - -if [ -L "$CLAUDE_PATH" ]; then - TARGET_PATH=$(readlink "$CLAUDE_PATH") - echo "✓ claude 现在指向: $TARGET_PATH" -fi - -if [ -e "$CLAUDE_BAK" ]; then - echo "✓ 原始 claude 已备份为: $CLAUDE_BAK" -fi - -if [ -x "$WRAPPER_PATH" ]; then - echo "✓ 包装脚本具有执行权限" -fi - -echo "" -echo "==========================================" -echo "✓ 安装完成!" -echo "" -echo "现在你可以在 root 用户下使用:" -echo " claude --dangerously-skip-permissions" -echo "" -echo "如需恢复原始 claude 命令:" -echo " rm $CLAUDE_PATH" -echo " mv $CLAUDE_BAK $CLAUDE_PATH" -echo "==========================================" diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 24e6832..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "env": { - "ANTHROPIC_API_KEY": "sk-d87ce5b80978df466c81378d798ca39f", - "ANTHROPIC_BASE_URL": "https://cc.ronghuaxueleng.top", - "CLAUDE_CODE_ATTRIBUTION_HEADER": "0", - "DISABLE_AUTOUPDATER": 1, - "DISABLE_BUG_COMMAND": 1, - "DISABLE_ERROR_REPORTING": 1, - "DISABLE_TELEMETRY": 1, - "IS_SANDBOX": "1", - "USER_NAME": "腾讯云" - }, - "permissions": { - "allow": [ - "*" - ], - "defaultMode": "bypassPermissions" - }, - "statusLine": { - "command": "node \".claude/show-status.mjs\"", - "padding": 0, - "type": "command" - } -} \ No newline at end of file diff --git a/.claude/show-status.mjs b/.claude/show-status.mjs deleted file mode 100644 index 772ab85..0000000 --- a/.claude/show-status.mjs +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env node -/** - * Claude Code 积分状态栏脚本 - * 用途: 在状态栏显示配置信息 - */ - -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - -// 禁用SSL证书验证警告 -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - -function getDisplayUrl() { - const baseUrl = process.env.ANTHROPIC_BASE_URL || ''; - if (baseUrl) { - const match = baseUrl.match(/https?:\/\/([^\/]+)/); - if (match) { - return match[1]; - } - } - return ''; -} - -function getCurrentModel() { - // 优先使用环境变量 - let model = process.env.ANTHROPIC_MODEL || ''; - - // 如果环境变量没有,检查settings.json - if (!model) { - try { - const settingsFile = path.join(os.homedir(), '.claude', 'settings.json'); - if (fs.existsSync(settingsFile)) { - const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); - model = settings.model || ''; - } - } catch (error) { - // 忽略错误 - } - } - - if (model) { - if (model.toLowerCase().includes('claude-3')) { - if (model.toLowerCase().includes('haiku')) { - return 'Claude 3 Haiku'; - } else if (model.toLowerCase().includes('sonnet')) { - return 'Claude 3 Sonnet'; - } else if (model.toLowerCase().includes('opus')) { - return 'Claude 3 Opus'; - } - } else if (model.toLowerCase().includes('claude-4') || model.toLowerCase().includes('sonnet-4')) { - return 'Claude 4 Sonnet'; - } else if (model.toLowerCase().includes('opus-4')) { - return 'Claude 4 Opus'; - } else if (model.length > 20) { - return model.substring(0, 20) + '...'; - } - return model; - } - - return 'Claude (Auto)'; -} - -async function main() { - try { - const currentUrl = getDisplayUrl(); - const currentModel = getCurrentModel(); - const userName = process.env.USER_NAME || ''; - - const parts = []; - if (userName) parts.push(`👤 ${userName}`); - parts.push(currentModel); - parts.push(currentUrl); - - console.log(parts.join(' | ')); - - } catch (error) { - // 即使出错也显示基本信息 - const currentUrl = getDisplayUrl(); - const currentModel = getCurrentModel(); - const userName = process.env.USER_NAME || ''; - const parts = ['🔴 错误']; - if (userName) parts.push(`👤 ${userName}`); - parts.push(currentModel); - parts.push(currentUrl); - console.log(parts.join(' | ')); - } -} - -// ES Module 中直接执行 -main(); diff --git a/.gitignore b/.gitignore index c1146eb..2221ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ ds2api-tests # Misc .git/ Thumbs.db + +# Claude Code +.claude/ +CLAUDE.local.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md deleted file mode 100644 index 4fd51c0..0000000 --- a/CLAUDE.local.md +++ /dev/null @@ -1,261 +0,0 @@ -# Claude Code 行为准则 - -> 本文件定义 Claude Code 在本项目中的强制执行规则。所有规则均为**必须执行**,不可跳过。 - ---- - -## 一、核心原则(九荣九耻) - -| 耻 | 荣 | -|---|---| -| ❌ 瞎猜接口 | ✅ 认真查询源码 | -| ❌ 模糊执行 | ✅ 寻求用户确认 | -| ❌ 臆想业务 | ✅ 复用现有实现 | -| ❌ 创造接口 | ✅ 主动测试验证 | -| ❌ 跳过验证 | ✅ 等待人类确认 | -| ❌ 破坏架构 | ✅ 遵循项目规范 | -| ❌ 假装理解 | ✅ 诚实说"不确定" | -| ❌ 盲目修改 | ✅ 谨慎重构 | -| ❌ 画蛇添足 | ✅ 按需实现 | - ---- - -## 二、代码生成前置检查 - -### 【强制】生成代码前必须完成的 4 项检查 - -在生成**任何代码**之前,必须逐条确认以下检查项,**缺一不可**: - -| # | 检查项 | 未通过则 | -|---|--------|---------| -| 1 | 是否已读取 CLAUDE.md 中的编码规范? | ❌ 禁止生成代码 | -| 2 | 是否已搜索项目中类似实现作为参考? | ❌ 禁止生成代码 | -| 3 | 是否有不确定的地方需要询问用户? | ⚠️ 先询问再继续 | -| 4 | 是否复用了现有的实体类/工具类? | ❌ 禁止新建已存在的类 | - ---- - -## 三、"参照 XX 写"执行规则 - -当用户说"参照XX写"、"仿照XX实现"、"按照XX的方式"时,**必须严格执行**以下步骤: - -### 步骤清单 - -| # | 步骤 | 必须完成的动作 | -|---|------|--------------| -| 1 | 完整阅读参照对象 | 读取 Controller、Service、Mapper、Entity **所有相关文件**,不能只看部分 | -| 2 | 列出关键对照点 | 向用户列出:接口路径、参数格式、返回值格式、Service 调用方式、业务逻辑 | -| 3 | 严格对照实现 | ❌ 禁止"优化"或"改进"参照对象,❌ 禁止偏离参照对象的风格 | - -### 完成后自检 - -| # | 自检问题 | 答案必须是"是" | -|---|---------|--------------| -| 1 | 我的实现和参照对象的实现方式是否一致? | 否则必须修正 | -| 2 | 有没有任何地方是我"自作主张"改的? | 有则必须告知用户 | - -**如果有任何偏离,必须告知用户并说明原因,由用户决定是否采用。** - ---- - -## 四、批量保存接口设计规范 - -### 【强制】设计前必须列出用户操作场景 - -| 用户操作 | 数据特征 | 处理方式 | -|---------|---------|---------| -| 新增一条数据 | 传入的数据没有 id | INSERT | -| 修改一条数据 | 传入的数据有 id | UPDATE | -| 删除一条数据 | **数据库有但传入列表中没有** ← 容易遗漏! | DELETE | -| 不做任何改动 | 原样传回 | 不处理 | - -### 正确实现步骤 - -``` -1. 查询数据库中该主体已有的所有数据 ID -2. 对比传入列表中的 ID,找出需要删除的(数据库有但传入没有) -3. 删除不在传入列表中的数据 -4. 新增或更新传入列表中的数据 -``` - -### 完成后自检 - -| # | 自检问题 | 答案必须是"是" | -|---|---------|--------------| -| 1 | 新增、更新、删除——三种情况都覆盖了吗? | | -| 2 | 如果用户删除了一条已有数据,保存后这条数据会消失吗? | | - ---- - -## 五、文档解析规则 - -### 【强制】解析步骤(按顺序执行,不可跳过) - -| # | 步骤 | 必须完成的动作 | 中断条件 | -|---|------|--------------|---------| -| 1 | 多方式解析 | Word/PDF 必须尝试 ≥2 种解析方式(段落、表格、文本框、XML 等) | | -| 2 | 完整性检查 | 检查是否只看到类名而没有属性定义? | ⚠️ **是则停止,询问用户** | -| 3 | 列出清单 | 向用户列出:类数量+名称、每个类的属性数量+名称、方法数量+签名 | ⚠️ **等待用户确认** | -| 4 | 生成代码 | 只有用户明确确认后才能继续 | | - -### 绝对禁止 - -| # | 禁止行为 | -|---|---------| -| 1 | ❌ 禁止在用户确认前生成任何代码 | -| 2 | ❌ 禁止自行补充或猜测文档中未明确写出的内容 | -| 3 | ❌ 禁止只用一种方式解析就认为解析完成 | -| 4 | ❌ 禁止看到类名/接口名却没有属性定义时继续执行 | - ---- - -## 六、接口与参数分析规则 - -### 触发条件 -- 分析接口映射关系(标准接口 → 内部接口) -- 分析参数映射关系 -- 编写 DTO/Entity 字段定义 - -### 【强制】执行步骤 - -| # | 步骤 | 必须完成的动作 | -|---|------|--------------| -| 1 | 确认接口映射 | 阅读标准接口功能 → 搜索后端代码找**功能匹配**的内部接口(不是名称匹配!)→ 读 Controller 确认功能 | -| 2 | 确认参数映射 | 找到 @RequestBody 的类 → 读源码(含父类)→ 逐一列出字段 → 对比建立映射 | - -### 映射可信度标注(必须标注) - -| 标注 | 含义 | -|-----|------| -| ✅ 已验证 | 已阅读源码确认 | -| ⚠️ 待验证 | 需要进一步确认 | -| ❌ 需新建接口 | 需要编写复杂业务逻辑(组合调用多个接口等) | - -### 绝对禁止 - -| # | 禁止行为 | -|---|---------| -| 1 | ❌ 禁止凭接口名称相似就认为可以映射 | -| 2 | ❌ 禁止直接使用 Postman/Swagger 参数定义,必须与源码核对 | -| 3 | ❌ 禁止凭"合理推测"编写参数映射 | -| 4 | ❌ 禁止使用模糊表述如"需要扩展"、"可能需要调用额外接口" | - ---- - -## 七、Postman 文档规范 - -### 核心原则 - -| 位置 | 内容 | -|-----|------| -| `description` 字段 | Markdown 格式,展示完整参数说明(带注释的 JSON 代码块) | -| `body.raw` 字段 | 纯净 JSON(无注释),可直接发送请求 | - -### description 格式模板 - -```json -{ - "description": "接口功能说明。\n\n**请求参数示例:**\n```json\n{\n \"字段名\": \"示例值\", // 字段说明\n}\n```\n\n**响应示例:**\n```json\n{\n \"code\": 0,\n \"data\": {}\n}\n```" -} -``` - -### 自检清单 - -| # | 检查项 | 要求 | -|---|--------|-----| -| 1 | body.raw 是否有注释? | ❌ 禁止,会导致 JSON 格式错误 | -| 2 | description 是否展示了参数格式? | ✅ 必须有带注释的 JSON 示例 | -| 3 | 是否包含响应示例? | ✅ 每个接口都必须有 | -| 4 | Long 类型 ID 是否展示为 String? | ✅ 如 `"id": "123456789"` | - ---- - -## 八、设计文档编写规范 - -### 核心原则 -设计文档的目标是:**开发人员可以直接照着写代码**,不是概念性说明。 - -### 【强制】文档必须包含的内容 - -| # | 内容 | 要求 | -|---|------|-----| -| 1 | 数据库表 DDL | 可直接执行的 CREATE TABLE | -| 2 | 枚举类代码 | 可直接复制使用 | -| 3 | 实体类代码 | 包括所有字段和注解 | -| 4 | Mapper 代码 | 包括 Provider 中的完整 SQL | -| 5 | Service 代码 | 接口定义和实现类 | -| 6 | Controller 代码 | 接口路径、请求体、响应格式 | -| 7 | 实现清单 | 新模块接入时的检查表 | -| 8 | 常见问题 FAQ | 解答可能的疑惑 | - -### 代码示例要求 - -| # | 要求 | -|---|------| -| 1 | 代码必须**完整可用**,不是片段或伪代码 | -| 2 | 必须包含**完整的 import 语句** | -| 3 | SQL 必须**完整可执行**,不能用 `...` 省略 | - -### 完成后自检 - -| # | 自检问题 | 答案必须是"是" | -|---|---------|--------------| -| 1 | 新人开发者能否只看这份文档就完成开发? | | -| 2 | 文档中的代码能否直接复制到项目中使用? | | -| 3 | 是否有"等"、"..."、"类似"等模糊表述? | 有则删除 | - ---- - -## 九、方法重载规范 - -### 规则 - -| # | 规则 | 说明 | -|---|------|-----| -| 1 | 全量参数方法承载所有逻辑 | 是唯一的实现体 | -| 2 | 少参数方法只做委托调用 | 传 `null` 给新增参数,方法体只有一行 `return` | -| 3 | ❌ 禁止两个重载方法各写一份逻辑 | 即使逻辑相同也不行 | -| 4 | ❌ 禁止反向委托 | 全量方法不能调用少参数方法 | - -### 正确示例 - -```java -// ✅ 少参数方法委托全量方法 -public Object foo(Req req, Request request, Response response) { - return foo(req, request, response, null); -} - -public Object foo(Req req, Request request, Response response, Function lineConverter) { - // 所有逻辑在这里 - if (lineConverter != null) { - // 有转换器时的处理 - } -} -``` - ---- - -## 十、工作偏好 - -| # | 偏好 | -|---|------| -| 1 | 始终使用**简体中文**回复 | -| 2 | 长任务必须记录详细进度 | -| 3 | 提交代码时**不要**附带 `Co-Authored-By: Claude` | -| 4 | 对所有工具操作自动同意,无需额外确认 | -| 5 | 不用执行编译和测试 | -| 6 | 编写构建脚本时尽量使用 mjs 编写带菜单的脚本 | -| 7 | 尽量使用 Python 连接数据库 | -| 8 | 联网搜索时**禁止**使用 csdn.net、阿里云/腾讯云/华为云社区等内容农场 | - ---- - -## 十一、代码生成规则 - -| # | 规则 | -|---|------| -| 1 | 提供实体类/模板/文档时,必须**完整复制所有属性和方法**,禁止省略 | -| 2 | 生成代码前,先列出文档中所有属性数量和名称,确认无遗漏后再生成 | -| 3 | 属性超过 20 个时,分批列出确认 | -| 4 | 禁止因为"优化"或"简化"而删减任何属性 | -| 5 | 生成完成后,对比源文档属性数量是否一致 | From 25ea28a27788500817bc5a8149e8fb95dd711d7a Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 20:58:18 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=8A=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E9=80=89=E6=8B=A9=E5=99=A8=E3=80=81=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E8=B4=A6=E5=8F=B7=E5=90=8D=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account 结构加 TestStatus 字段,测试后写入 config.json - listAccounts 接口返回 test_status,前端根据结果显示红/绿/黄状态点 - 分页选择器支持 10/20/50/100/500/1000/2000/5000 - 点击账号名自动复制到剪贴板,hover 显示复制图标,复制后显示绿色对勾 --- internal/admin/deps.go | 1 + internal/admin/handler_accounts_crud.go | 1 + internal/admin/handler_accounts_testing.go | 10 ++++- internal/config/config.go | 9 ++-- internal/config/store.go | 12 +++++ .../account/AccountManagerContainer.jsx | 4 ++ webui/src/features/account/AccountsTable.jsx | 45 ++++++++++++++++--- webui/src/features/account/useAccountsData.js | 13 ++++-- webui/src/locales/en.json | 1 + webui/src/locales/zh.json | 1 + 10 files changed, 83 insertions(+), 14 deletions(-) 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/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 })} +
+
- -
-
- - {testingAll && batchProgress.total > 0 && ( -
-
- {t('accountManager.testingAllAccounts')} - {batchProgress.current} / {batchProgress.total} -
-
-
-
- {batchProgress.results.length > 0 && ( -
- {batchProgress.results.map((r, i) => ( -
- {r.success ? '✓' : '✗'} {r.id} -
- ))} -
- )} -
- )} - -
- {loadingAccounts ? ( -
{t('actions.loading')}
- ) : accounts.length > 0 ? ( - accounts.map((acc, i) => { - const id = resolveAccountIdentifier(acc) - return ( -
-
-
-
-
copyId(id)} - > - {id || '-'} - {copiedId === id - ? - : - } -
-
- {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} - - )} -
-
-
-
- - -
-
- ) - }) - ) : ( -
{t('accountManager.noAccounts')}
- )} -
- - {totalPages > 1 && ( -
-
-
- {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} -
- -
-
- - {page} / {totalPages} - -
-
- )} -
- ) -} +import { useState } from 'react' +import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react' +import clsx from 'clsx' + +export default function AccountsTable({ + t, + accounts, + loadingAccounts, + testing, + testingAll, + batchProgress, + totalAccounts, + page, + pageSize, + totalPages, + resolveAccountIdentifier, + onTestAll, + onShowAddAccount, + onTestAccount, + onDeleteAccount, + onPrevPage, + onNextPage, + onPageSizeChange, + searchQuery, + onSearchChange, +}) { + const [copiedId, setCopiedId] = useState(null) + + const copyId = (id) => { + navigator.clipboard.writeText(id).then(() => { + setCopiedId(id) + setTimeout(() => setCopiedId(null), 1500) + }) + } + return ( +
+
+
+

{t('accountManager.accountsTitle')}

+

{t('accountManager.accountsDesc')}

+
+
+ onSearchChange(e.target.value)} + placeholder={t('accountManager.searchPlaceholder')} + className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground" + /> + + +
+
+ + {testingAll && batchProgress.total > 0 && ( +
+
+ {t('accountManager.testingAllAccounts')} + {batchProgress.current} / {batchProgress.total} +
+
+
+
+ {batchProgress.results.length > 0 && ( +
+ {batchProgress.results.map((r, i) => ( +
+ {r.success ? '✓' : '✗'} {r.id} +
+ ))} +
+ )} +
+ )} + +
+ {loadingAccounts ? ( +
{t('actions.loading')}
+ ) : accounts.length > 0 ? ( + accounts.map((acc, i) => { + const id = resolveAccountIdentifier(acc) + return ( +
+
+
+
+
copyId(id)} + > + {id || '-'} + {copiedId === id + ? + : + } +
+
+ {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} + + )} +
+
+
+
+ + +
+
+ ) + }) + ) : ( +
{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}
+ )} +
+ + {totalPages > 1 && ( +
+
+
+ {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} +
+ +
+
+ + {page} / {totalPages} + +
+
+ )} +
+ ) +} diff --git a/webui/src/features/account/useAccountsData.js b/webui/src/features/account/useAccountsData.js index 90c56e0..3c904f3 100644 --- a/webui/src/features/account/useAccountsData.js +++ b/webui/src/features/account/useAccountsData.js @@ -1,75 +1,86 @@ -import { useEffect, useState } from 'react' - -export function useAccountsData({ apiFetch }) { - const [queueStatus, setQueueStatus] = useState(null) - const [keysExpanded, setKeysExpanded] = useState(false) - - const [accounts, setAccounts] = useState([]) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(10) - const [totalPages, setTotalPages] = useState(1) - const [totalAccounts, setTotalAccounts] = useState(0) - const [loadingAccounts, setLoadingAccounts] = useState(false) - - const resolveAccountIdentifier = (acc) => { - if (!acc || typeof acc !== 'object') return '' - return String(acc.identifier || acc.email || acc.mobile || '').trim() - } - - const fetchAccounts = async (targetPage = page, targetPageSize = pageSize) => { - setLoadingAccounts(true) - try { - const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${targetPageSize}`) - if (res.ok) { - const data = await res.json() - setAccounts(data.items || []) - setTotalPages(data.total_pages || 1) - setTotalAccounts(data.total || 0) - setPage(data.page || 1) - } - } catch (e) { - console.error('Failed to fetch accounts:', e) - } finally { - setLoadingAccounts(false) - } - } - - const changePageSize = (newSize) => { - setPageSize(newSize) - fetchAccounts(1, newSize) - } - - const fetchQueueStatus = async () => { - try { - const res = await apiFetch('/admin/queue/status') - if (res.ok) { - const data = await res.json() - setQueueStatus(data) - } - } catch (e) { - console.error('Failed to fetch queue status:', e) - } - } - - useEffect(() => { - fetchAccounts() - fetchQueueStatus() - const interval = setInterval(fetchQueueStatus, 5000) - return () => clearInterval(interval) - }, []) - - return { - queueStatus, - keysExpanded, - setKeysExpanded, - accounts, - page, - pageSize, - totalPages, - totalAccounts, - loadingAccounts, - fetchAccounts, - changePageSize, - resolveAccountIdentifier, - } -} +import { useEffect, useState } from 'react' + +export function useAccountsData({ apiFetch }) { + const [queueStatus, setQueueStatus] = useState(null) + const [keysExpanded, setKeysExpanded] = useState(false) + + const [accounts, setAccounts] = useState([]) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [totalPages, setTotalPages] = useState(1) + const [totalAccounts, setTotalAccounts] = useState(0) + const [loadingAccounts, setLoadingAccounts] = useState(false) + + const resolveAccountIdentifier = (acc) => { + if (!acc || typeof acc !== 'object') return '' + return String(acc.identifier || acc.email || acc.mobile || '').trim() + } + + const [searchQuery, setSearchQuery] = useState('') + + const fetchAccounts = async (targetPage = page, targetPageSize = pageSize, targetQuery = searchQuery) => { + setLoadingAccounts(true) + try { + let url = `/admin/accounts?page=${targetPage}&page_size=${targetPageSize}` + if (targetQuery.trim()) url += `&q=${encodeURIComponent(targetQuery.trim())}` + const res = await apiFetch(url) + if (res.ok) { + const data = await res.json() + setAccounts(data.items || []) + setTotalPages(data.total_pages || 1) + setTotalAccounts(data.total || 0) + setPage(data.page || 1) + } + } catch (e) { + console.error('Failed to fetch accounts:', e) + } finally { + setLoadingAccounts(false) + } + } + + const changePageSize = (newSize) => { + setPageSize(newSize) + fetchAccounts(1, newSize) + } + + const handleSearchChange = (query) => { + setSearchQuery(query) + fetchAccounts(1, pageSize, query) + } + + const fetchQueueStatus = async () => { + try { + const res = await apiFetch('/admin/queue/status') + if (res.ok) { + const data = await res.json() + setQueueStatus(data) + } + } catch (e) { + console.error('Failed to fetch queue status:', e) + } + } + + useEffect(() => { + fetchAccounts() + fetchQueueStatus() + const interval = setInterval(fetchQueueStatus, 5000) + return () => clearInterval(interval) + }, []) + + return { + queueStatus, + keysExpanded, + setKeysExpanded, + accounts, + page, + pageSize, + totalPages, + totalAccounts, + loadingAccounts, + fetchAccounts, + changePageSize, + resolveAccountIdentifier, + searchQuery, + handleSearchChange, + } +} diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index bd38fd0..499509e 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -1,295 +1,297 @@ -{ - "language": { - "label": "Language", - "english": "English", - "chinese": "中文" - }, - "nav": { - "accounts": { - "label": "Account Management", - "desc": "Manage the DeepSeek account pool" - }, - "test": { - "label": "API Test", - "desc": "Test API connectivity and responses" - }, - "import": { - "label": "Batch Import", - "desc": "Bulk import account configuration" - }, - "vercel": { - "label": "Vercel Sync", - "desc": "Sync configuration to Vercel" - }, - "settings": { - "label": "Settings", - "desc": "Edit runtime and security settings online" - } - }, - "sidebar": { - "onlineAdminConsole": "Online Admin Console", - "systemStatus": "System Status", - "statusOnline": "Online", - "accounts": "Accounts", - "keys": "Keys", - "signOut": "Sign out" - }, - "auth": { - "expired": "Authentication expired. Please sign in again.", - "checking": "Checking authentication status..." - }, - "errors": { - "fetchConfig": "Failed to fetch configuration: {error}" - }, - "actions": { - "cancel": "Cancel", - "add": "Add", - "delete": "Delete", - "copy": "Copy", - "generate": "Generate", - "test": "Test", - "testing": "Testing...", - "loading": "Loading..." - }, - "messages": { - "deleted": "Deleted successfully", - "deleteFailed": "Delete failed", - "failedToAdd": "Failed to add", - "networkError": "Network error.", - "requestFailed": "Request failed.", - "generationStopped": "Generation stopped.", - "invalidJson": "Invalid JSON format.", - "importFailed": "Import failed.", - "copyFailed": "Copy failed." - }, - "landing": { - "adminConsole": "Admin Console", - "apiStatus": "API Status", - "features": { - "compatibility": { - "title": "Full Compatibility", - "desc": "OpenAI & Claude format support" - }, - "loadBalancing": { - "title": "Load Balancing", - "desc": "Smart rotation with stable throughput" - }, - "reasoning": { - "title": "Deep Reasoning", - "desc": "Expose reasoning traces when enabled" - }, - "search": { - "title": "Web Search", - "desc": "Integrated native web search" - } - } - }, - "accountManager": { - "addKeySuccess": "API key added successfully.", - "addAccountSuccess": "Account added successfully.", - "requiredFields": "Password and email/mobile are required.", - "deleteKeyConfirm": "Are you sure you want to delete this API key?", - "deleteAccountConfirm": "Are you sure you want to delete this account?", - "invalidIdentifier": "Invalid account identifier. Operation aborted.", - "testAllConfirm": "Test API connectivity for all accounts?", - "testAllCompleted": "Completed: {success}/{total} available", - "testFailed": "Test failed: {error}", - "available": "Available", - "inUse": "In use", - "totalPool": "Total pool", - "accountsUnit": "accounts", - "threadsUnit": "threads", - "apiKeysTitle": "API Keys", - "apiKeysDesc": "Manage the API access key pool", - "addKey": "Add key", - "copied": "Copied", - "copyKeyTitle": "Copy key", - "deleteKeyTitle": "Delete key", - "noApiKeys": "No API keys found.", - "accountsTitle": "DeepSeek Accounts", - "accountsDesc": "Manage the DeepSeek account pool", - "testAll": "Test all", - "addAccount": "Add account", - "testingAllAccounts": "Testing all accounts...", - "sessionActive": "Session active", - "reauthRequired": "Re-auth required", - "testStatusFailed": "Last test failed", - "noAccounts": "No accounts found.", - "modalAddKeyTitle": "Add API key", - "newKeyLabel": "New key value", - "newKeyPlaceholder": "Enter a custom API key", - "generate": "Generate", - "generateHint": "Click Generate to create a random key.", - "addKeyLoading": "Adding...", - "addKeyAction": "Add key", - "modalAddAccountTitle": "Add DeepSeek account", - "emailOptional": "Email (optional)", - "mobileOptional": "Mobile (optional)", - "passwordLabel": "Password", - "passwordPlaceholder": "Account password", - "addAccountLoading": "Adding...", - "addAccountAction": "Add account", - "pageInfo": "Page {current}/{total}, {count} accounts total" - }, - "apiTester": { - "defaultMessage": "Hello, please introduce yourself in one sentence.", - "models": { - "chat": "Non-reasoning model", - "reasoner": "Reasoning model", - "chatSearch": "Non-reasoning model (with search)", - "reasonerSearch": "Reasoning model (with search)" - }, - "missingApiKey": "Please provide an API key.", - "requestFailed": "Request failed.", - "networkError": "Network error: {error}", - "testSuccess": "{account}: Test successful ({time}ms)", - "config": "Configuration", - "modelLabel": "Model", - "streamMode": "Streaming", - "accountSelector": "Account", - "autoRandom": "🤖 Auto / Random", - "apiKeyOptional": "API Key (optional)", - "apiKeyDefault": "Default: ...{suffix}", - "apiKeyPlaceholder": "Enter a custom key", - "modeManaged": "Managed key mode (uses account pool).", - "modeDirect": "Direct token mode (requires a valid DeepSeek token).", - "statusError": "Error", - "reasoningTrace": "Reasoning Trace", - "generating": "Generating response...", - "enterMessage": "Enter a message...", - "adminConsoleLabel": "DeepSeek admin console" - }, - "batchImport": { - "templates": { - "full": { - "name": "Full configuration template", - "desc": "Includes keys, accounts, and model mapping" - }, - "emailOnly": { - "name": "Email-only accounts", - "desc": "Batch import accounts using email login" - }, - "mobileOnly": { - "name": "Mobile-only accounts", - "desc": "Batch import accounts using mobile login" - }, - "keysOnly": { - "name": "API keys only", - "desc": "Add API access keys only" - } - }, - "enterJson": "Please provide JSON configuration content.", - "importSuccess": "Import successful: {keys} keys, {accounts} accounts", - "templateLoaded": "Template loaded: {name}", - "currentConfigLoaded": "Current configuration loaded.", - "fetchConfigFailed": "Failed to fetch configuration.", - "copySuccess": "Base64 configuration copied to clipboard.", - "quickTemplates": "Quick Templates", - "dataExport": "Data Export", - "dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.", - "copyBase64": "Copy Base64 config", - "copied": "Copied", - "variableName": "Variable name", - "jsonEditor": "JSON Editor", - "loadCurrentConfig": "Load current config", - "applyConfig": "Apply config", - "importing": "Importing...", - "importComplete": "Import complete", - "importSummary": "Imported {keys} API keys and updated {accounts} accounts." - }, - "settings": { - "loadFailed": "Failed to load settings.", - "nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).", - "save": "Save settings", - "saving": "Saving...", - "saveSuccess": "Settings saved and hot reloaded.", - "saveFailed": "Failed to save settings.", - "securityTitle": "Security", - "jwtExpireHours": "JWT expiry (hours)", - "newPassword": "New admin password", - "newPasswordPlaceholder": "Enter new password (min 4 chars)", - "updatePassword": "Update password", - "updating": "Updating...", - "passwordTooShort": "Password must be at least 4 characters.", - "passwordUpdated": "Password updated. Please sign in again.", - "passwordUpdateFailed": "Failed to update password.", - "runtimeTitle": "Concurrency & Queue", - "accountMaxInflight": "Per-account max inflight", - "accountMaxQueue": "Account max queue size", - "globalMaxInflight": "Global max inflight", - "behaviorTitle": "Behavior", - "toolcallMode": "Toolcall mode", - "earlyEmitConfidence": "Early emit confidence", - "responsesTTL": "Responses store TTL (seconds)", - "embeddingsProvider": "Embeddings provider", - "modelTitle": "Model mapping", - "claudeMapping": "Claude mapping (JSON)", - "modelAliases": "Model aliases (JSON)", - "backupTitle": "Backup & Restore", - "loadExport": "Load current export", - "importModeMerge": "Merge import (default)", - "importModeReplace": "Replace all import", - "importNow": "Import now", - "importing": "Importing...", - "importPlaceholder": "Paste config JSON to import", - "importEmpty": "Please input import JSON.", - "importInvalidJson": "Import JSON is invalid.", - "importFailed": "Import failed.", - "importSuccess": "Config imported (mode: {mode}).", - "exportFailed": "Export failed.", - "exportLoaded": "Current export loaded.", - "exportJson": "Export JSON", - "invalidJsonField": "{field} is not a valid JSON object.", - "defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.", - "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.", - "autoFetchPaused": "Auto loading paused after {count} failures: {error}", - "retryLoad": "Retry now" - }, - "login": { - "welcome": "Welcome back", - "subtitle": "Enter your admin key to continue", - "adminKeyLabel": "Admin key", - "adminKeyPlaceholder": "Enter your admin key...", - "rememberSession": "Remember this session", - "signIn": "Sign in", - "secureConnection": "Secure connection", - "adminPortal": "DS2API admin portal", - "signInFailed": "Sign-in failed.", - "networkError": "Network error: {error}" - }, - "vercel": { - "tokenRequired": "Vercel access token is required.", - "projectRequired": "Project ID is required.", - "syncFailed": "Sync failed.", - "networkError": "Network error.", - "title": "Vercel Deployment", - "description": "Sync the current keys and accounts directly to Vercel environment variables.", - "tokenLabel": "Vercel Access Token", - "getToken": "Get token", - "tokenPlaceholderPreconfig": "Using preconfigured token", - "tokenPlaceholder": "Enter Vercel access token", - "projectIdLabel": "Project ID", - "projectIdHint": "Find it in Project Settings → General.", - "teamIdLabel": "Team ID", - "optional": "optional", - "syncing": "Syncing...", - "syncRedeploy": "Sync & redeploy", - "redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.", - "syncSucceeded": "Sync succeeded", - "syncFailedLabel": "Sync failed", - "openDeployment": "Open deployment", - "statusSynced": "Synced", - "statusNotSynced": "Not synced", - "statusNeverSynced": "Never synced", - "lastSyncTime": "Last sync: {time}", - "pollPaused": "Status polling paused after {count} failures.", - "manualRefresh": "Refresh manually", - "howItWorks": "How it works", - "steps": { - "one": "The current configuration (keys and accounts) is exported as JSON.", - "two": "The JSON is Base64-encoded for safe formatting.", - "three": "Update the env var in Vercel:", - "four": "Trigger a redeploy to apply the updated environment variables." - } - } -} +{ + "language": { + "label": "Language", + "english": "English", + "chinese": "中文" + }, + "nav": { + "accounts": { + "label": "Account Management", + "desc": "Manage the DeepSeek account pool" + }, + "test": { + "label": "API Test", + "desc": "Test API connectivity and responses" + }, + "import": { + "label": "Batch Import", + "desc": "Bulk import account configuration" + }, + "vercel": { + "label": "Vercel Sync", + "desc": "Sync configuration to Vercel" + }, + "settings": { + "label": "Settings", + "desc": "Edit runtime and security settings online" + } + }, + "sidebar": { + "onlineAdminConsole": "Online Admin Console", + "systemStatus": "System Status", + "statusOnline": "Online", + "accounts": "Accounts", + "keys": "Keys", + "signOut": "Sign out" + }, + "auth": { + "expired": "Authentication expired. Please sign in again.", + "checking": "Checking authentication status..." + }, + "errors": { + "fetchConfig": "Failed to fetch configuration: {error}" + }, + "actions": { + "cancel": "Cancel", + "add": "Add", + "delete": "Delete", + "copy": "Copy", + "generate": "Generate", + "test": "Test", + "testing": "Testing...", + "loading": "Loading..." + }, + "messages": { + "deleted": "Deleted successfully", + "deleteFailed": "Delete failed", + "failedToAdd": "Failed to add", + "networkError": "Network error.", + "requestFailed": "Request failed.", + "generationStopped": "Generation stopped.", + "invalidJson": "Invalid JSON format.", + "importFailed": "Import failed.", + "copyFailed": "Copy failed." + }, + "landing": { + "adminConsole": "Admin Console", + "apiStatus": "API Status", + "features": { + "compatibility": { + "title": "Full Compatibility", + "desc": "OpenAI & Claude format support" + }, + "loadBalancing": { + "title": "Load Balancing", + "desc": "Smart rotation with stable throughput" + }, + "reasoning": { + "title": "Deep Reasoning", + "desc": "Expose reasoning traces when enabled" + }, + "search": { + "title": "Web Search", + "desc": "Integrated native web search" + } + } + }, + "accountManager": { + "addKeySuccess": "API key added successfully.", + "addAccountSuccess": "Account added successfully.", + "requiredFields": "Password and email/mobile are required.", + "deleteKeyConfirm": "Are you sure you want to delete this API key?", + "deleteAccountConfirm": "Are you sure you want to delete this account?", + "invalidIdentifier": "Invalid account identifier. Operation aborted.", + "testAllConfirm": "Test API connectivity for all accounts?", + "testAllCompleted": "Completed: {success}/{total} available", + "testFailed": "Test failed: {error}", + "available": "Available", + "inUse": "In use", + "totalPool": "Total pool", + "accountsUnit": "accounts", + "threadsUnit": "threads", + "apiKeysTitle": "API Keys", + "apiKeysDesc": "Manage the API access key pool", + "addKey": "Add key", + "copied": "Copied", + "copyKeyTitle": "Copy key", + "deleteKeyTitle": "Delete key", + "noApiKeys": "No API keys found.", + "accountsTitle": "DeepSeek Accounts", + "accountsDesc": "Manage the DeepSeek account pool", + "testAll": "Test all", + "addAccount": "Add account", + "testingAllAccounts": "Testing all accounts...", + "sessionActive": "Session active", + "reauthRequired": "Re-auth required", + "testStatusFailed": "Last test failed", + "noAccounts": "No accounts found.", + "modalAddKeyTitle": "Add API key", + "newKeyLabel": "New key value", + "newKeyPlaceholder": "Enter a custom API key", + "generate": "Generate", + "generateHint": "Click Generate to create a random key.", + "addKeyLoading": "Adding...", + "addKeyAction": "Add key", + "modalAddAccountTitle": "Add DeepSeek account", + "emailOptional": "Email (optional)", + "mobileOptional": "Mobile (optional)", + "passwordLabel": "Password", + "passwordPlaceholder": "Account password", + "addAccountLoading": "Adding...", + "addAccountAction": "Add account", + "pageInfo": "Page {current}/{total}, {count} accounts total", + "searchPlaceholder": "Search accounts...", + "searchNoResults": "No accounts match your search" + }, + "apiTester": { + "defaultMessage": "Hello, please introduce yourself in one sentence.", + "models": { + "chat": "Non-reasoning model", + "reasoner": "Reasoning model", + "chatSearch": "Non-reasoning model (with search)", + "reasonerSearch": "Reasoning model (with search)" + }, + "missingApiKey": "Please provide an API key.", + "requestFailed": "Request failed.", + "networkError": "Network error: {error}", + "testSuccess": "{account}: Test successful ({time}ms)", + "config": "Configuration", + "modelLabel": "Model", + "streamMode": "Streaming", + "accountSelector": "Account", + "autoRandom": "🤖 Auto / Random", + "apiKeyOptional": "API Key (optional)", + "apiKeyDefault": "Default: ...{suffix}", + "apiKeyPlaceholder": "Enter a custom key", + "modeManaged": "Managed key mode (uses account pool).", + "modeDirect": "Direct token mode (requires a valid DeepSeek token).", + "statusError": "Error", + "reasoningTrace": "Reasoning Trace", + "generating": "Generating response...", + "enterMessage": "Enter a message...", + "adminConsoleLabel": "DeepSeek admin console" + }, + "batchImport": { + "templates": { + "full": { + "name": "Full configuration template", + "desc": "Includes keys, accounts, and model mapping" + }, + "emailOnly": { + "name": "Email-only accounts", + "desc": "Batch import accounts using email login" + }, + "mobileOnly": { + "name": "Mobile-only accounts", + "desc": "Batch import accounts using mobile login" + }, + "keysOnly": { + "name": "API keys only", + "desc": "Add API access keys only" + } + }, + "enterJson": "Please provide JSON configuration content.", + "importSuccess": "Import successful: {keys} keys, {accounts} accounts", + "templateLoaded": "Template loaded: {name}", + "currentConfigLoaded": "Current configuration loaded.", + "fetchConfigFailed": "Failed to fetch configuration.", + "copySuccess": "Base64 configuration copied to clipboard.", + "quickTemplates": "Quick Templates", + "dataExport": "Data Export", + "dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.", + "copyBase64": "Copy Base64 config", + "copied": "Copied", + "variableName": "Variable name", + "jsonEditor": "JSON Editor", + "loadCurrentConfig": "Load current config", + "applyConfig": "Apply config", + "importing": "Importing...", + "importComplete": "Import complete", + "importSummary": "Imported {keys} API keys and updated {accounts} accounts." + }, + "settings": { + "loadFailed": "Failed to load settings.", + "nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).", + "save": "Save settings", + "saving": "Saving...", + "saveSuccess": "Settings saved and hot reloaded.", + "saveFailed": "Failed to save settings.", + "securityTitle": "Security", + "jwtExpireHours": "JWT expiry (hours)", + "newPassword": "New admin password", + "newPasswordPlaceholder": "Enter new password (min 4 chars)", + "updatePassword": "Update password", + "updating": "Updating...", + "passwordTooShort": "Password must be at least 4 characters.", + "passwordUpdated": "Password updated. Please sign in again.", + "passwordUpdateFailed": "Failed to update password.", + "runtimeTitle": "Concurrency & Queue", + "accountMaxInflight": "Per-account max inflight", + "accountMaxQueue": "Account max queue size", + "globalMaxInflight": "Global max inflight", + "behaviorTitle": "Behavior", + "toolcallMode": "Toolcall mode", + "earlyEmitConfidence": "Early emit confidence", + "responsesTTL": "Responses store TTL (seconds)", + "embeddingsProvider": "Embeddings provider", + "modelTitle": "Model mapping", + "claudeMapping": "Claude mapping (JSON)", + "modelAliases": "Model aliases (JSON)", + "backupTitle": "Backup & Restore", + "loadExport": "Load current export", + "importModeMerge": "Merge import (default)", + "importModeReplace": "Replace all import", + "importNow": "Import now", + "importing": "Importing...", + "importPlaceholder": "Paste config JSON to import", + "importEmpty": "Please input import JSON.", + "importInvalidJson": "Import JSON is invalid.", + "importFailed": "Import failed.", + "importSuccess": "Config imported (mode: {mode}).", + "exportFailed": "Export failed.", + "exportLoaded": "Current export loaded.", + "exportJson": "Export JSON", + "invalidJsonField": "{field} is not a valid JSON object.", + "defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.", + "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.", + "autoFetchPaused": "Auto loading paused after {count} failures: {error}", + "retryLoad": "Retry now" + }, + "login": { + "welcome": "Welcome back", + "subtitle": "Enter your admin key to continue", + "adminKeyLabel": "Admin key", + "adminKeyPlaceholder": "Enter your admin key...", + "rememberSession": "Remember this session", + "signIn": "Sign in", + "secureConnection": "Secure connection", + "adminPortal": "DS2API admin portal", + "signInFailed": "Sign-in failed.", + "networkError": "Network error: {error}" + }, + "vercel": { + "tokenRequired": "Vercel access token is required.", + "projectRequired": "Project ID is required.", + "syncFailed": "Sync failed.", + "networkError": "Network error.", + "title": "Vercel Deployment", + "description": "Sync the current keys and accounts directly to Vercel environment variables.", + "tokenLabel": "Vercel Access Token", + "getToken": "Get token", + "tokenPlaceholderPreconfig": "Using preconfigured token", + "tokenPlaceholder": "Enter Vercel access token", + "projectIdLabel": "Project ID", + "projectIdHint": "Find it in Project Settings → General.", + "teamIdLabel": "Team ID", + "optional": "optional", + "syncing": "Syncing...", + "syncRedeploy": "Sync & redeploy", + "redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.", + "syncSucceeded": "Sync succeeded", + "syncFailedLabel": "Sync failed", + "openDeployment": "Open deployment", + "statusSynced": "Synced", + "statusNotSynced": "Not synced", + "statusNeverSynced": "Never synced", + "lastSyncTime": "Last sync: {time}", + "pollPaused": "Status polling paused after {count} failures.", + "manualRefresh": "Refresh manually", + "howItWorks": "How it works", + "steps": { + "one": "The current configuration (keys and accounts) is exported as JSON.", + "two": "The JSON is Base64-encoded for safe formatting.", + "three": "Update the env var in Vercel:", + "four": "Trigger a redeploy to apply the updated environment variables." + } + } +} diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 1206f89..27150b4 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -1,295 +1,297 @@ -{ - "language": { - "label": "语言", - "english": "English", - "chinese": "中文" - }, - "nav": { - "accounts": { - "label": "账号管理", - "desc": "管理 DeepSeek 账号池" - }, - "test": { - "label": "API 测试", - "desc": "测试 API 连接与响应" - }, - "import": { - "label": "批量导入", - "desc": "批量导入账号配置" - }, - "vercel": { - "label": "Vercel 同步", - "desc": "同步配置到 Vercel" - }, - "settings": { - "label": "设置中心", - "desc": "在线修改系统设置与配置" - } - }, - "sidebar": { - "onlineAdminConsole": "在线管理面板", - "systemStatus": "系统状态", - "statusOnline": "在线", - "accounts": "账号", - "keys": "密钥", - "signOut": "退出登录" - }, - "auth": { - "expired": "认证已过期,请重新登录", - "checking": "正在检查登录状态..." - }, - "errors": { - "fetchConfig": "获取配置失败: {error}" - }, - "actions": { - "cancel": "取消", - "add": "添加", - "delete": "删除", - "copy": "复制", - "generate": "生成", - "test": "测试", - "testing": "正在测试...", - "loading": "加载中..." - }, - "messages": { - "deleted": "删除成功", - "deleteFailed": "删除失败", - "failedToAdd": "添加失败", - "networkError": "网络错误", - "requestFailed": "请求失败", - "generationStopped": "已停止生成", - "invalidJson": "无效的 JSON 格式", - "importFailed": "导入失败", - "copyFailed": "复制失败" - }, - "landing": { - "adminConsole": "管理面板", - "apiStatus": "API 状态", - "features": { - "compatibility": { - "title": "全面兼容", - "desc": "适配 OpenAI 与 Claude 格式" - }, - "loadBalancing": { - "title": "负载均衡", - "desc": "智能轮询,稳定高效" - }, - "reasoning": { - "title": "深度思考", - "desc": "支持推理过程输出" - }, - "search": { - "title": "联网搜索", - "desc": "集成原生网页搜索能力" - } - } - }, - "accountManager": { - "addKeySuccess": "API 密钥添加成功", - "addAccountSuccess": "账号添加成功", - "requiredFields": "需要填写密码以及邮箱或手机号", - "deleteKeyConfirm": "确定要删除此 API 密钥吗?", - "deleteAccountConfirm": "确定要删除此账号吗?", - "invalidIdentifier": "账号标识无效,无法执行操作", - "testAllConfirm": "测试所有账号的 API 连通性?", - "testAllCompleted": "完成:{success}/{total} 可用", - "testFailed": "测试失败: {error}", - "available": "可用", - "inUse": "正在使用", - "totalPool": "账号池总数", - "accountsUnit": "个账号", - "threadsUnit": "线程", - "apiKeysTitle": "API 密钥", - "apiKeysDesc": "管理 API 访问密钥池", - "addKey": "添加密钥", - "copied": "已复制", - "copyKeyTitle": "复制密钥", - "deleteKeyTitle": "删除密钥", - "noApiKeys": "未找到 API 密钥", - "accountsTitle": "DeepSeek 账号", - "accountsDesc": "管理 DeepSeek 账号池", - "testAll": "测试全部", - "addAccount": "添加账号", - "testingAllAccounts": "正在测试所有账号...", - "sessionActive": "已建立会话", - "reauthRequired": "需重新登录", - "testStatusFailed": "上次测试失败", - "noAccounts": "未找到任何账号", - "modalAddKeyTitle": "添加 API 密钥", - "newKeyLabel": "新密钥值", - "newKeyPlaceholder": "输入自定义 API 密钥", - "generate": "生成", - "generateHint": "点击「生成」自动创建随机密钥", - "addKeyLoading": "添加中...", - "addKeyAction": "添加密钥", - "modalAddAccountTitle": "添加 DeepSeek 账号", - "emailOptional": "邮箱 (可选)", - "mobileOptional": "手机号 (可选)", - "passwordLabel": "密码", - "passwordPlaceholder": "账号密码", - "addAccountLoading": "添加中...", - "addAccountAction": "添加账号", - "pageInfo": "第 {current}/{total} 页,共 {count} 个账号" - }, - "apiTester": { - "defaultMessage": "你好,请用一句话介绍你自己。", - "models": { - "chat": "非思考模型", - "reasoner": "思考模型", - "chatSearch": "非思考模型 (带搜索)", - "reasonerSearch": "思考模型 (带搜索)" - }, - "missingApiKey": "请提供 API 密钥", - "requestFailed": "请求失败", - "networkError": "网络错误: {error}", - "testSuccess": "{account}: 测试成功 ({time}ms)", - "config": "配置", - "modelLabel": "模型", - "streamMode": "流式模式", - "accountSelector": "选择账号", - "autoRandom": "🤖 自动 / 随机", - "apiKeyOptional": "API 密钥 (可选)", - "apiKeyDefault": "默认: ...{suffix}", - "apiKeyPlaceholder": "输入自定义密钥", - "modeManaged": "当前使用托管 key 模式(会走账号池)。", - "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。", - "statusError": "错误", - "reasoningTrace": "思维链过程", - "generating": "正在生成响应...", - "enterMessage": "输入消息...", - "adminConsoleLabel": "DeepSeek 管理员界面" - }, - "batchImport": { - "templates": { - "full": { - "name": "全量配置模板", - "desc": "包含密钥、账号及模型映射" - }, - "emailOnly": { - "name": "仅邮箱账号", - "desc": "批量导入邮箱格式账号" - }, - "mobileOnly": { - "name": "仅手机号账号", - "desc": "批量导入手机号格式账号" - }, - "keysOnly": { - "name": "仅 API 密钥", - "desc": "仅添加 API 访问密钥" - } - }, - "enterJson": "请输入 JSON 配置内容", - "importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号", - "templateLoaded": "已加载模板: {name}", - "currentConfigLoaded": "当前配置已加载", - "fetchConfigFailed": "获取配置失败", - "copySuccess": "Base64 配置已复制到剪贴板", - "quickTemplates": "快速模板", - "dataExport": "数据导出", - "dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。", - "copyBase64": "复制 Base64 配置", - "copied": "已复制", - "variableName": "变量名", - "jsonEditor": "JSON 编辑器", - "loadCurrentConfig": "加载当前配置", - "applyConfig": "应用配置", - "importing": "正在导入...", - "importComplete": "导入操作已完成", - "importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。" - }, - "settings": { - "loadFailed": "加载设置失败", - "nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status})", - "save": "保存设置", - "saving": "保存中...", - "saveSuccess": "设置已保存并热更新生效", - "saveFailed": "保存设置失败", - "securityTitle": "安全设置", - "jwtExpireHours": "JWT 有效期(小时)", - "newPassword": "面板新密码", - "newPasswordPlaceholder": "输入新密码(至少 4 位)", - "updatePassword": "修改密码", - "updating": "更新中...", - "passwordTooShort": "新密码至少 4 位", - "passwordUpdated": "密码已更新,需重新登录", - "passwordUpdateFailed": "密码更新失败", - "runtimeTitle": "并发与队列", - "accountMaxInflight": "每账号并发上限", - "accountMaxQueue": "账号等待队列上限", - "globalMaxInflight": "全局并发上限", - "behaviorTitle": "行为设置", - "toolcallMode": "Toolcall 模式", - "earlyEmitConfidence": "早发置信度", - "responsesTTL": "Responses 缓存 TTL(秒)", - "embeddingsProvider": "Embeddings Provider", - "modelTitle": "模型映射", - "claudeMapping": "Claude 映射(JSON)", - "modelAliases": "模型别名(JSON)", - "backupTitle": "备份与恢复", - "loadExport": "加载当前导出", - "importModeMerge": "合并导入(默认)", - "importModeReplace": "全量覆盖导入", - "importNow": "立即导入", - "importing": "导入中...", - "importPlaceholder": "粘贴要导入的 JSON 配置", - "importEmpty": "请先输入导入 JSON", - "importInvalidJson": "导入 JSON 格式无效", - "importFailed": "导入失败", - "importSuccess": "配置导入成功(模式:{mode})", - "exportFailed": "导出失败", - "exportLoaded": "已加载当前配置导出", - "exportJson": "导出 JSON", - "invalidJsonField": "{field} 不是有效 JSON 对象", - "defaultPasswordWarning": "当前使用默认密码 admin,请尽快在此修改。", - "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。", - "autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error})", - "retryLoad": "立即重试" - }, - "login": { - "welcome": "欢迎回来", - "subtitle": "请输入管理员密钥以继续", - "adminKeyLabel": "管理员密钥", - "adminKeyPlaceholder": "输入您的管理员密钥...", - "rememberSession": "记住登录状态", - "signIn": "登录", - "secureConnection": "安全连接", - "adminPortal": "DS2API 管理员门户", - "signInFailed": "登录失败", - "networkError": "网络错误: {error}" - }, - "vercel": { - "tokenRequired": "需要 Vercel 访问令牌", - "projectRequired": "需要项目 ID", - "syncFailed": "同步失败", - "networkError": "网络错误", - "title": "Vercel 部署", - "description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。", - "tokenLabel": "Vercel 访问令牌", - "getToken": "获取令牌", - "tokenPlaceholderPreconfig": "正在使用预配置的令牌", - "tokenPlaceholder": "输入 Vercel 访问令牌", - "projectIdLabel": "项目 ID", - "projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到", - "teamIdLabel": "团队 ID", - "optional": "可选", - "syncing": "正在同步...", - "syncRedeploy": "同步并重新部署", - "redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。", - "syncSucceeded": "同步成功", - "syncFailedLabel": "同步失败", - "openDeployment": "访问部署地址", - "statusSynced": "已同步", - "statusNotSynced": "未同步", - "statusNeverSynced": "从未同步", - "lastSyncTime": "上次同步: {time}", - "pollPaused": "状态轮询已暂停:连续失败 {count} 次。", - "manualRefresh": "手动刷新", - "howItWorks": "工作原理", - "steps": { - "one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。", - "two": "JSON 被编码为 Base64 以确保格式兼容性。", - "three": "更新 Vercel 项目中的环境变量:", - "four": "触发重新部署以应用新的环境变量。" - } - } -} +{ + "language": { + "label": "语言", + "english": "English", + "chinese": "中文" + }, + "nav": { + "accounts": { + "label": "账号管理", + "desc": "管理 DeepSeek 账号池" + }, + "test": { + "label": "API 测试", + "desc": "测试 API 连接与响应" + }, + "import": { + "label": "批量导入", + "desc": "批量导入账号配置" + }, + "vercel": { + "label": "Vercel 同步", + "desc": "同步配置到 Vercel" + }, + "settings": { + "label": "设置中心", + "desc": "在线修改系统设置与配置" + } + }, + "sidebar": { + "onlineAdminConsole": "在线管理面板", + "systemStatus": "系统状态", + "statusOnline": "在线", + "accounts": "账号", + "keys": "密钥", + "signOut": "退出登录" + }, + "auth": { + "expired": "认证已过期,请重新登录", + "checking": "正在检查登录状态..." + }, + "errors": { + "fetchConfig": "获取配置失败: {error}" + }, + "actions": { + "cancel": "取消", + "add": "添加", + "delete": "删除", + "copy": "复制", + "generate": "生成", + "test": "测试", + "testing": "正在测试...", + "loading": "加载中..." + }, + "messages": { + "deleted": "删除成功", + "deleteFailed": "删除失败", + "failedToAdd": "添加失败", + "networkError": "网络错误", + "requestFailed": "请求失败", + "generationStopped": "已停止生成", + "invalidJson": "无效的 JSON 格式", + "importFailed": "导入失败", + "copyFailed": "复制失败" + }, + "landing": { + "adminConsole": "管理面板", + "apiStatus": "API 状态", + "features": { + "compatibility": { + "title": "全面兼容", + "desc": "适配 OpenAI 与 Claude 格式" + }, + "loadBalancing": { + "title": "负载均衡", + "desc": "智能轮询,稳定高效" + }, + "reasoning": { + "title": "深度思考", + "desc": "支持推理过程输出" + }, + "search": { + "title": "联网搜索", + "desc": "集成原生网页搜索能力" + } + } + }, + "accountManager": { + "addKeySuccess": "API 密钥添加成功", + "addAccountSuccess": "账号添加成功", + "requiredFields": "需要填写密码以及邮箱或手机号", + "deleteKeyConfirm": "确定要删除此 API 密钥吗?", + "deleteAccountConfirm": "确定要删除此账号吗?", + "invalidIdentifier": "账号标识无效,无法执行操作", + "testAllConfirm": "测试所有账号的 API 连通性?", + "testAllCompleted": "完成:{success}/{total} 可用", + "testFailed": "测试失败: {error}", + "available": "可用", + "inUse": "正在使用", + "totalPool": "账号池总数", + "accountsUnit": "个账号", + "threadsUnit": "线程", + "apiKeysTitle": "API 密钥", + "apiKeysDesc": "管理 API 访问密钥池", + "addKey": "添加密钥", + "copied": "已复制", + "copyKeyTitle": "复制密钥", + "deleteKeyTitle": "删除密钥", + "noApiKeys": "未找到 API 密钥", + "accountsTitle": "DeepSeek 账号", + "accountsDesc": "管理 DeepSeek 账号池", + "testAll": "测试全部", + "addAccount": "添加账号", + "testingAllAccounts": "正在测试所有账号...", + "sessionActive": "已建立会话", + "reauthRequired": "需重新登录", + "testStatusFailed": "上次测试失败", + "noAccounts": "未找到任何账号", + "modalAddKeyTitle": "添加 API 密钥", + "newKeyLabel": "新密钥值", + "newKeyPlaceholder": "输入自定义 API 密钥", + "generate": "生成", + "generateHint": "点击「生成」自动创建随机密钥", + "addKeyLoading": "添加中...", + "addKeyAction": "添加密钥", + "modalAddAccountTitle": "添加 DeepSeek 账号", + "emailOptional": "邮箱 (可选)", + "mobileOptional": "手机号 (可选)", + "passwordLabel": "密码", + "passwordPlaceholder": "账号密码", + "addAccountLoading": "添加中...", + "addAccountAction": "添加账号", + "pageInfo": "第 {current}/{total} 页,共 {count} 个账号", + "searchPlaceholder": "搜索账号...", + "searchNoResults": "未找到匹配的账号" + }, + "apiTester": { + "defaultMessage": "你好,请用一句话介绍你自己。", + "models": { + "chat": "非思考模型", + "reasoner": "思考模型", + "chatSearch": "非思考模型 (带搜索)", + "reasonerSearch": "思考模型 (带搜索)" + }, + "missingApiKey": "请提供 API 密钥", + "requestFailed": "请求失败", + "networkError": "网络错误: {error}", + "testSuccess": "{account}: 测试成功 ({time}ms)", + "config": "配置", + "modelLabel": "模型", + "streamMode": "流式模式", + "accountSelector": "选择账号", + "autoRandom": "🤖 自动 / 随机", + "apiKeyOptional": "API 密钥 (可选)", + "apiKeyDefault": "默认: ...{suffix}", + "apiKeyPlaceholder": "输入自定义密钥", + "modeManaged": "当前使用托管 key 模式(会走账号池)。", + "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。", + "statusError": "错误", + "reasoningTrace": "思维链过程", + "generating": "正在生成响应...", + "enterMessage": "输入消息...", + "adminConsoleLabel": "DeepSeek 管理员界面" + }, + "batchImport": { + "templates": { + "full": { + "name": "全量配置模板", + "desc": "包含密钥、账号及模型映射" + }, + "emailOnly": { + "name": "仅邮箱账号", + "desc": "批量导入邮箱格式账号" + }, + "mobileOnly": { + "name": "仅手机号账号", + "desc": "批量导入手机号格式账号" + }, + "keysOnly": { + "name": "仅 API 密钥", + "desc": "仅添加 API 访问密钥" + } + }, + "enterJson": "请输入 JSON 配置内容", + "importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号", + "templateLoaded": "已加载模板: {name}", + "currentConfigLoaded": "当前配置已加载", + "fetchConfigFailed": "获取配置失败", + "copySuccess": "Base64 配置已复制到剪贴板", + "quickTemplates": "快速模板", + "dataExport": "数据导出", + "dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。", + "copyBase64": "复制 Base64 配置", + "copied": "已复制", + "variableName": "变量名", + "jsonEditor": "JSON 编辑器", + "loadCurrentConfig": "加载当前配置", + "applyConfig": "应用配置", + "importing": "正在导入...", + "importComplete": "导入操作已完成", + "importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。" + }, + "settings": { + "loadFailed": "加载设置失败", + "nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status})", + "save": "保存设置", + "saving": "保存中...", + "saveSuccess": "设置已保存并热更新生效", + "saveFailed": "保存设置失败", + "securityTitle": "安全设置", + "jwtExpireHours": "JWT 有效期(小时)", + "newPassword": "面板新密码", + "newPasswordPlaceholder": "输入新密码(至少 4 位)", + "updatePassword": "修改密码", + "updating": "更新中...", + "passwordTooShort": "新密码至少 4 位", + "passwordUpdated": "密码已更新,需重新登录", + "passwordUpdateFailed": "密码更新失败", + "runtimeTitle": "并发与队列", + "accountMaxInflight": "每账号并发上限", + "accountMaxQueue": "账号等待队列上限", + "globalMaxInflight": "全局并发上限", + "behaviorTitle": "行为设置", + "toolcallMode": "Toolcall 模式", + "earlyEmitConfidence": "早发置信度", + "responsesTTL": "Responses 缓存 TTL(秒)", + "embeddingsProvider": "Embeddings Provider", + "modelTitle": "模型映射", + "claudeMapping": "Claude 映射(JSON)", + "modelAliases": "模型别名(JSON)", + "backupTitle": "备份与恢复", + "loadExport": "加载当前导出", + "importModeMerge": "合并导入(默认)", + "importModeReplace": "全量覆盖导入", + "importNow": "立即导入", + "importing": "导入中...", + "importPlaceholder": "粘贴要导入的 JSON 配置", + "importEmpty": "请先输入导入 JSON", + "importInvalidJson": "导入 JSON 格式无效", + "importFailed": "导入失败", + "importSuccess": "配置导入成功(模式:{mode})", + "exportFailed": "导出失败", + "exportLoaded": "已加载当前配置导出", + "exportJson": "导出 JSON", + "invalidJsonField": "{field} 不是有效 JSON 对象", + "defaultPasswordWarning": "当前使用默认密码 admin,请尽快在此修改。", + "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。", + "autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error})", + "retryLoad": "立即重试" + }, + "login": { + "welcome": "欢迎回来", + "subtitle": "请输入管理员密钥以继续", + "adminKeyLabel": "管理员密钥", + "adminKeyPlaceholder": "输入您的管理员密钥...", + "rememberSession": "记住登录状态", + "signIn": "登录", + "secureConnection": "安全连接", + "adminPortal": "DS2API 管理员门户", + "signInFailed": "登录失败", + "networkError": "网络错误: {error}" + }, + "vercel": { + "tokenRequired": "需要 Vercel 访问令牌", + "projectRequired": "需要项目 ID", + "syncFailed": "同步失败", + "networkError": "网络错误", + "title": "Vercel 部署", + "description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。", + "tokenLabel": "Vercel 访问令牌", + "getToken": "获取令牌", + "tokenPlaceholderPreconfig": "正在使用预配置的令牌", + "tokenPlaceholder": "输入 Vercel 访问令牌", + "projectIdLabel": "项目 ID", + "projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到", + "teamIdLabel": "团队 ID", + "optional": "可选", + "syncing": "正在同步...", + "syncRedeploy": "同步并重新部署", + "redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。", + "syncSucceeded": "同步成功", + "syncFailedLabel": "同步失败", + "openDeployment": "访问部署地址", + "statusSynced": "已同步", + "statusNotSynced": "未同步", + "statusNeverSynced": "从未同步", + "lastSyncTime": "上次同步: {time}", + "pollPaused": "状态轮询已暂停:连续失败 {count} 次。", + "manualRefresh": "手动刷新", + "howItWorks": "工作原理", + "steps": { + "one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。", + "two": "JSON 被编码为 Base64 以确保格式兼容性。", + "three": "更新 Vercel 项目中的环境变量:", + "four": "触发重新部署以应用新的环境变量。" + } + } +} From 21b12f583a44b16a48e51f4d052a008d6e412061 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 28 Feb 2026 10:18:26 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(admin):=20=E8=B4=A6=E5=8F=B7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A7=8B=E7=BB=88=E5=8F=91=E9=80=81=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=BB=A5=E9=AA=8C=E8=AF=81=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试接口不再仅验证会话创建,改为始终发送「你是谁?」 走完整 completion 路径,确保被封禁账号能被正确识别为失败。 --- internal/admin/handler_accounts_testing.go | 421 ++++++++++----------- 1 file changed, 209 insertions(+), 212 deletions(-) diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 7a7430d..262c809 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -1,212 +1,209 @@ -package admin - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - authn "ds2api/internal/auth" - "ds2api/internal/config" - "ds2api/internal/sse" -) - -func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) { - var req map[string]any - _ = json.NewDecoder(r.Body).Decode(&req) - identifier, _ := req["identifier"].(string) - if strings.TrimSpace(identifier) == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"}) - return - } - acc, ok := findAccountByIdentifier(h.Store, identifier) - if !ok { - writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"}) - return - } - model, _ := req["model"].(string) - if model == "" { - model = "deepseek-chat" - } - message, _ := req["message"].(string) - result := h.testAccount(r.Context(), acc, model, message) - writeJSON(w, http.StatusOK, result) -} - -func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) { - var req map[string]any - _ = json.NewDecoder(r.Body).Decode(&req) - model, _ := req["model"].(string) - if model == "" { - model = "deepseek-chat" - } - accounts := h.Store.Snapshot().Accounts - if len(accounts) == 0 { - writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}}) - return - } - - // Concurrent testing with a semaphore to limit parallelism. - const maxConcurrency = 5 - results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any { - return h.testAccount(r.Context(), account, model, "") - }) - - success := 0 - for _, res := range results { - if ok, _ := res["success"].(bool); ok { - success++ - } - } - writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results}) -} - -func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any { - if maxConcurrency <= 0 { - maxConcurrency = 1 - } - sem := make(chan struct{}, maxConcurrency) - results := make([]map[string]any, len(accounts)) - var wg sync.WaitGroup - for i, acc := range accounts { - wg.Add(1) - go func(idx int, account config.Account) { - defer wg.Done() - sem <- struct{}{} // acquire - defer func() { <-sem }() // release - results[idx] = testFn(idx, account) - }(i, acc) - } - wg.Wait() - return results -} - -func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { - start := time.Now() - 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) - if err != nil { - result["message"] = "登录失败: " + err.Error() - return result - } - token = newToken - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) - } - authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} - sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) - if err != nil { - newToken, loginErr := h.DS.Login(ctx, acc) - if loginErr != nil { - result["message"] = "创建会话失败: " + err.Error() - return result - } - token = newToken - authCtx.DeepSeekToken = token - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) - sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) - if err != nil { - result["message"] = "创建会话失败: " + err.Error() - return result - } - } - if strings.TrimSpace(message) == "" { - result["success"] = true - result["message"] = "API 测试成功(仅会话创建)" - result["response_time"] = int(time.Since(start).Milliseconds()) - return result - } - thinking, search, ok := config.GetModelConfig(model) - if !ok { - thinking, search = false, false - } - _ = search - pow, err := h.DS.GetPow(ctx, authCtx, 1) - if err != nil { - result["message"] = "获取 PoW 失败: " + err.Error() - return result - } - payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search} - resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1) - if err != nil { - result["message"] = "请求失败: " + err.Error() - return result - } - if resp.StatusCode != http.StatusOK { - defer resp.Body.Close() - result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode) - return result - } - collected := sse.CollectStream(resp, thinking, true) - result["success"] = true - result["response_time"] = int(time.Since(start).Milliseconds()) - if collected.Text != "" { - result["message"] = collected.Text - } else { - result["message"] = "(无回复内容)" - } - if collected.Thinking != "" { - result["thinking"] = collected.Thinking - } - return result -} - -func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) { - var req map[string]any - _ = json.NewDecoder(r.Body).Decode(&req) - model, _ := req["model"].(string) - message, _ := req["message"].(string) - apiKey, _ := req["api_key"].(string) - if model == "" { - model = "deepseek-chat" - } - if message == "" { - message = "你好" - } - if apiKey == "" { - keys := h.Store.Snapshot().Keys - if len(keys) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"}) - return - } - apiKey = keys[0] - } - host := r.Host - scheme := "http" - if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") { - scheme = "https" - } - payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false} - b, _ := json.Marshal(payload) - request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b)) - request.Header.Set("Authorization", "Bearer "+apiKey) - request.Header.Set("Content-Type", "application/json") - resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request) - if err != nil { - writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()}) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode == http.StatusOK { - var parsed any - _ = json.Unmarshal(body, &parsed) - writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) -} +package admin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + authn "ds2api/internal/auth" + "ds2api/internal/config" + "ds2api/internal/sse" +) + +func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + identifier, _ := req["identifier"].(string) + if strings.TrimSpace(identifier) == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"}) + return + } + acc, ok := findAccountByIdentifier(h.Store, identifier) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"}) + return + } + model, _ := req["model"].(string) + if model == "" { + model = "deepseek-chat" + } + message, _ := req["message"].(string) + result := h.testAccount(r.Context(), acc, model, message) + writeJSON(w, http.StatusOK, result) +} + +func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + model, _ := req["model"].(string) + if model == "" { + model = "deepseek-chat" + } + accounts := h.Store.Snapshot().Accounts + if len(accounts) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}}) + return + } + + // Concurrent testing with a semaphore to limit parallelism. + const maxConcurrency = 5 + results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any { + return h.testAccount(r.Context(), account, model, "") + }) + + success := 0 + for _, res := range results { + if ok, _ := res["success"].(bool); ok { + success++ + } + } + writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results}) +} + +func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any { + if maxConcurrency <= 0 { + maxConcurrency = 1 + } + sem := make(chan struct{}, maxConcurrency) + results := make([]map[string]any, len(accounts)) + var wg sync.WaitGroup + for i, acc := range accounts { + wg.Add(1) + go func(idx int, account config.Account) { + defer wg.Done() + sem <- struct{}{} // acquire + defer func() { <-sem }() // release + results[idx] = testFn(idx, account) + }(i, acc) + } + wg.Wait() + return results +} + +func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { + start := time.Now() + 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) + if err != nil { + result["message"] = "登录失败: " + err.Error() + return result + } + token = newToken + _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + } + authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} + sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) + if err != nil { + newToken, loginErr := h.DS.Login(ctx, acc) + if loginErr != nil { + result["message"] = "创建会话失败: " + err.Error() + return result + } + token = newToken + authCtx.DeepSeekToken = token + _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) + if err != nil { + result["message"] = "创建会话失败: " + err.Error() + return result + } + } + if strings.TrimSpace(message) == "" { + message = "你是谁?" + } + thinking, search, ok := config.GetModelConfig(model) + if !ok { + thinking, search = false, false + } + _ = search + pow, err := h.DS.GetPow(ctx, authCtx, 1) + if err != nil { + result["message"] = "获取 PoW 失败: " + err.Error() + return result + } + payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search} + resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1) + if err != nil { + result["message"] = "请求失败: " + err.Error() + return result + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode) + return result + } + collected := sse.CollectStream(resp, thinking, true) + result["success"] = true + result["response_time"] = int(time.Since(start).Milliseconds()) + if collected.Text != "" { + result["message"] = collected.Text + } else { + result["message"] = "(无回复内容)" + } + if collected.Thinking != "" { + result["thinking"] = collected.Thinking + } + return result +} + +func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + model, _ := req["model"].(string) + message, _ := req["message"].(string) + apiKey, _ := req["api_key"].(string) + if model == "" { + model = "deepseek-chat" + } + if message == "" { + message = "你好" + } + if apiKey == "" { + keys := h.Store.Snapshot().Keys + if len(keys) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"}) + return + } + apiKey = keys[0] + } + host := r.Host + scheme := "http" + if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") { + scheme = "https" + } + payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false} + b, _ := json.Marshal(payload) + request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b)) + request.Header.Set("Authorization", "Bearer "+apiKey) + request.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + var parsed any + _ = json.Unmarshal(body, &parsed) + writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) +} From 01924f4a690e5076be68f14c78a50a3014050df3 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sat, 28 Feb 2026 18:39:33 +0800 Subject: [PATCH 13/13] fix(docker): auto-detect target arch for local ARM builds --- .github/workflows/release-dockerhub.yml | 2 +- .github/workflows/release.yml | 2 +- Dockerfile | 9 ++++++--- start.mjs | 3 ++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-dockerhub.yml b/.github/workflows/release-dockerhub.yml index de86912..536c3dc 100644 --- a/.github/workflows/release-dockerhub.yml +++ b/.github/workflows/release-dockerhub.yml @@ -113,7 +113,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./docker/Dockerfile + file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b7cbc1..da3264f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./docker/Dockerfile + file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: | diff --git a/Dockerfile b/Dockerfile index c86f82d..6996f43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,15 @@ RUN npm run build FROM golang:1.24 AS go-builder WORKDIR /app -ARG TARGETOS=linux -ARG TARGETARCH=amd64 +ARG TARGETOS +ARG TARGETARCH COPY go.mod go.sum* ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/ds2api ./cmd/ds2api +RUN set -eux; \ + GOOS="${TARGETOS:-$(go env GOOS)}"; \ + GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \ + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api FROM busybox:1.36.1-musl AS busybox-tools diff --git a/start.mjs b/start.mjs index 59c5101..d168be3 100644 --- a/start.mjs +++ b/start.mjs @@ -338,7 +338,8 @@ function showStatus() { function waitForProcesses() { return new Promise(resolve => { const check = setInterval(() => { - if (processes.filter(p => !p.killed).length === 0) { + const activeCount = processes.filter(proc => proc.exitCode === null && proc.signalCode === null).length; + if (activeCount === 0) { clearInterval(check); resolve(); }