Merge branch 'docker'

This commit is contained in:
root
2026-02-27 20:59:16 +08:00
17 changed files with 942 additions and 88 deletions

View File

@@ -1,20 +1,20 @@
#### 💻 变更类型 | Change Type
<!-- For change type, change [ ] to [x]. -->
- [ ] ✨ feat
- [ ] 🐛 fix
- [ ] ♻️ refactor
- [ ] 💄 style
- [ ] 👷 build
- [ ] ⚡️ perf
- [ ] 📝 docs
- [ ] 🔨 chore
#### 🔀 变更说明 | Description of Change
#### 📝 补充信息 | Additional Information
#### 💻 变更类型 | Change Type
<!-- For change type, change [ ] to [x]. -->
- [ ] ✨ feat
- [ ] 🐛 fix
- [ ] ♻️ refactor
- [ ] 💄 style
- [ ] 👷 build
- [ ] ⚡️ perf
- [ ] 📝 docs
- [ ] 🔨 chore
#### 🔀 变更说明 | Description of Change
<!-- Thank you for your Pull Request. Please provide a description above. -->
#### 📝 补充信息 | Additional Information
<!-- Add any other context about the Pull Request here. -->

127
.github/workflows/release-dockerhub.yml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: Release to Docker Hub
on:
workflow_dispatch:
inputs:
version_type:
description: '版本类型'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: get_version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
TAG_VERSION=${LATEST_TAG#v}
if [ -f VERSION ]; then
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
else
FILE_VERSION="0.0.0"
fi
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
else
VERSION="$TAG_VERSION"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
id: next_version
env:
VERSION_TYPE: ${{ github.event.inputs.version_type }}
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
case "$VERSION_TYPE" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
*)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
- name: Commit VERSION and create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
# Docker 构建并推送到 Docker Hub
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_tag }}
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_version }}
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:latest
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

128
.github/workflows/release.yml vendored Normal file
View File

@@ -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

40
.gitignore vendored
View File

@@ -2,37 +2,6 @@
config.json
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
@@ -44,7 +13,6 @@ env/
# Logs
*.log
logs/
uvicorn.log
artifacts/
# Vercel
@@ -56,8 +24,6 @@ webui/node_modules/
webui/dist/
.npm
.pnpm-store/
# 保留 webui/package-lock.json 用于 CI 缓存
# package-lock.json # 如果有根目录的可以忽略
yarn.lock
pnpm-lock.yaml
@@ -86,7 +52,9 @@ coverage*.out
cover/
# Misc
*.pyc
*.pyo
.git/
Thumbs.db
# Claude Code
.claude/
CLAUDE.local.md

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

View File

@@ -1,18 +1,14 @@
services:
ds2api:
build: .
image: ds2api:latest
container_name: ds2api
ports:
- "${PORT:-5001}:${PORT:-5001}"
env_file:
- .env
environment:
- HOST=0.0.0.0
restart: unless-stopped
healthcheck:
test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
services:
ds2api:
image: crpi-cnazxqmg4avmg4fq.cn-beijing.personal.cr.aliyuncs.com/ronghuaxueleng/ds2api:latest
container_name: ds2api
restart: always
ports:
- "6011:5001"
volumes:
- ./config.json:/app/config.json # 配置文件
- ./.env:/app/.env # 环境变量
environment:
- TZ=Asia/Shanghai
- LOG_LEVEL=INFO
- DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api}

View File

@@ -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

View File

@@ -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})

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()

565
start.mjs Normal file
View File

@@ -0,0 +1,565 @@
#!/usr/bin/env node
/**
* DS2API 启动脚本 - 交互式菜单
*
* 使用方法:
* node start.mjs # 显示交互式菜单
* node start.mjs dev # 开发模式(后端 + 前端热重载)
* node start.mjs prod # 生产模式(编译后运行)
* node start.mjs build # 编译后端二进制
* node start.mjs webui # 构建前端静态文件
* node start.mjs install # 安装前端依赖
* node start.mjs stop # 停止所有服务
* node start.mjs status # 查看服务状态
*/
import { spawn, execSync } from 'child_process';
import { createInterface } from 'readline';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 判断是否为 Windows
const isWindows = process.platform === 'win32';
// 编译产物路径
const BINARY = join(__dirname, isWindows ? 'ds2api.exe' : 'ds2api');
// 配置(从环境变量读取,与 Go 主程序保持一致)
const CONFIG = {
port: process.env.PORT || '5001',
frontendPort: 5173,
logLevel: process.env.LOG_LEVEL || 'INFO',
adminKey: process.env.DS2API_ADMIN_KEY || 'admin',
webuiDir: join(__dirname, 'webui'),
staticAdminDir: process.env.DS2API_STATIC_ADMIN_DIR || join(__dirname, 'static', 'admin'),
};
// 国内镜像配置
const MIRRORS = {
goproxy: process.env.GOPROXY || 'https://goproxy.cn,direct',
npm: process.env.NPM_REGISTRY || 'https://registry.npmmirror.com',
};
// 存储子进程
const processes = [];
// 颜色输出
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
};
const log = {
info: (msg) => console.log(`${colors.cyan}[INFO]${colors.reset} ${msg}`),
success: (msg) => console.log(`${colors.green}[OK]${colors.reset} ${msg}`),
warn: (msg) => console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`),
error: (msg) => console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`),
title: (msg) => console.log(`\n${colors.bright}${colors.magenta}${msg}${colors.reset}`),
};
// 清理并退出
function cleanup() {
console.log('\n');
log.info('正在关闭所有服务...');
processes.forEach(proc => {
if (proc && !proc.killed) {
proc.kill('SIGTERM');
}
});
log.success('已退出');
process.exit(0);
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// 检查命令是否存在
function commandExists(cmd) {
try {
execSync(`${isWindows ? 'where' : 'which'} ${cmd}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// 检查 Go 是否安装
function checkGo() {
return commandExists('go');
}
// 获取 Go 版本
function getGoVersion() {
try {
return execSync('go version', { encoding: 'utf-8' }).trim();
} catch {
return null;
}
}
// 检查前端依赖是否已安装
function checkFrontendDeps() {
if (!existsSync(CONFIG.webuiDir)) return null;
return existsSync(join(CONFIG.webuiDir, 'node_modules'));
}
// 检查前端是否已构建
function checkWebuiBuilt() {
return existsSync(join(CONFIG.staticAdminDir, 'index.html'));
}
// 检查后端二进制是否存在
function binaryExists() {
return existsSync(BINARY);
}
// 查找占用端口的进程 PID
function findPidByPort(port) {
try {
if (isWindows) {
const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
encoding: 'utf-8',
shell: true,
stdio: ['pipe', 'pipe', 'ignore'],
});
const pids = new Set();
for (const line of output.trim().split('\n')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') pids.add(pid);
}
return [...pids];
} else {
const output = execSync(`lsof -ti :${port}`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
});
return output.trim().split('\n').filter(Boolean);
}
} catch {
return [];
}
}
// 获取运行中的服务状态
function getRunningStatus() {
const backendPids = findPidByPort(CONFIG.port);
const frontendPids = findPidByPort(CONFIG.frontendPort);
return {
backend: backendPids,
frontend: frontendPids,
isRunning: backendPids.length > 0 || frontendPids.length > 0,
};
}
// 停止服务
async function stopServices() {
const running = getRunningStatus();
if (!running.isRunning) {
log.warn('没有检测到正在运行的服务');
return;
}
log.title('========== 停止服务 ==========');
const killProcess = async (pid) => {
try {
if (isWindows) {
try {
execSync(`taskkill /PID ${pid}`, { stdio: 'ignore', shell: true });
} catch {
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', shell: true });
}
} else {
execSync(`kill -15 ${pid}`, { stdio: 'ignore' });
await new Promise(r => setTimeout(r, 500));
try {
execSync(`kill -0 ${pid}`, { stdio: 'ignore' });
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
} catch { /* 进程已退出 */ }
}
} catch { /* 进程可能已退出 */ }
};
if (running.backend.length > 0) {
log.info(`停止后端服务 (端口 ${CONFIG.port}, PID: ${running.backend.join(', ')})...`);
for (const pid of running.backend) await killProcess(pid);
log.success('后端服务已停止');
}
if (running.frontend.length > 0) {
log.info(`停止前端服务 (端口 ${CONFIG.frontendPort}, PID: ${running.frontend.join(', ')})...`);
for (const pid of running.frontend) await killProcess(pid);
log.success('前端服务已停止');
}
}
// 安装前端依赖
async function installFrontendDeps() {
if (!existsSync(CONFIG.webuiDir)) {
log.warn('webui 目录不存在,跳过前端依赖安装');
return;
}
log.info(`安装前端依赖 (npm ci, registry: ${MIRRORS.npm})...`);
return new Promise((resolve, reject) => {
const proc = spawn('npm', ['ci', '--registry', MIRRORS.npm], {
cwd: CONFIG.webuiDir,
stdio: 'inherit',
shell: true,
});
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败')));
});
}
// 确保前端依赖已安装
async function ensureFrontendDeps() {
if (checkFrontendDeps() === false) {
log.warn('检测到前端依赖未安装,正在安装...');
await installFrontendDeps();
}
}
// 编译后端二进制
async function buildBackend() {
if (!checkGo()) throw new Error('未找到 Go请先安装 Go (https://go.dev/dl/)');
log.info(`编译后端二进制 (GOPROXY: ${MIRRORS.goproxy})...`);
return new Promise((resolve, reject) => {
const proc = spawn('go', ['build', '-o', BINARY, './cmd/ds2api'], {
cwd: __dirname,
stdio: 'inherit',
shell: true,
env: { ...process.env, GOPROXY: MIRRORS.goproxy },
});
proc.on('close', code => code === 0 ? resolve() : reject(new Error('后端编译失败')));
});
}
// 构建前端静态文件
async function buildWebui() {
if (!existsSync(CONFIG.webuiDir)) {
log.warn('webui 目录不存在');
return;
}
await ensureFrontendDeps();
log.info('构建前端静态文件...');
return new Promise((resolve, reject) => {
const proc = spawn(
'npm', ['run', 'build', '--', '--outDir', CONFIG.staticAdminDir, '--emptyOutDir'],
{ cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true }
);
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败')));
});
}
// 启动后端开发模式go run无需预编译
async function startBackendDev() {
if (!checkGo()) throw new Error('未找到 Go请先安装 Go (https://go.dev/dl/)');
log.info(`启动后端go run... http://localhost:${CONFIG.port}`);
const proc = spawn('go', ['run', './cmd/ds2api'], {
cwd: __dirname,
stdio: 'inherit',
shell: true,
env: {
...process.env,
PORT: CONFIG.port,
LOG_LEVEL: CONFIG.logLevel,
DS2API_ADMIN_KEY: CONFIG.adminKey,
GOPROXY: MIRRORS.goproxy,
},
});
processes.push(proc);
return proc;
}
// 启动后端(生产模式:运行编译好的二进制)
async function startBackendProd() {
if (!binaryExists()) {
log.warn('未找到编译产物,正在编译...');
await buildBackend();
}
log.info(`启动后端(二进制)... http://localhost:${CONFIG.port}`);
const proc = spawn(BINARY, [], {
cwd: __dirname,
stdio: 'inherit',
shell: false,
env: {
...process.env,
PORT: CONFIG.port,
LOG_LEVEL: CONFIG.logLevel,
DS2API_ADMIN_KEY: CONFIG.adminKey,
},
});
processes.push(proc);
return proc;
}
// 启动前端开发服务器
async function startFrontend() {
if (!existsSync(CONFIG.webuiDir)) {
log.warn('webui 目录不存在,跳过前端启动');
return null;
}
await ensureFrontendDeps();
log.info(`启动前端开发服务器... http://localhost:${CONFIG.frontendPort}`);
const proc = spawn('npm', ['run', 'dev'], {
cwd: CONFIG.webuiDir,
stdio: 'inherit',
shell: true,
});
processes.push(proc);
return proc;
}
// 显示状态信息
function showStatus() {
console.log('\n' + '─'.repeat(50));
log.success(`后端 API: http://localhost:${CONFIG.port}`);
log.success(`管理界面: http://localhost:${CONFIG.port}/admin`);
if (existsSync(CONFIG.webuiDir)) {
log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`);
}
console.log('─'.repeat(50));
log.info('按 Ctrl+C 停止所有服务\n');
}
// 等待进程退出
function waitForProcesses() {
return new Promise(resolve => {
const check = setInterval(() => {
if (processes.filter(p => !p.killed).length === 0) {
clearInterval(check);
resolve();
}
}, 1000);
});
}
// 交互式菜单
async function showMenu() {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
console.clear();
log.title('╔══════════════════════════════════════════╗');
log.title('║ DS2API 启动脚本 (Go) ║');
log.title('╚══════════════════════════════════════════╝');
// 环境状态
const goVersion = getGoVersion();
const frontendDeps = checkFrontendDeps();
const webuiBuilt = checkWebuiBuilt();
const hasBinary = binaryExists();
const running = getRunningStatus();
const ok = (v) => v ? `${colors.green}${colors.reset}` : `${colors.yellow}${colors.reset}`;
console.log(`\n${colors.bright}环境状态:${colors.reset}`);
console.log(` Go: ${goVersion ? `${colors.green}${goVersion}${colors.reset}` : `${colors.red}未安装${colors.reset}`}`);
console.log(` 前端依赖: ${frontendDeps === null ? `${colors.dim}N/A${colors.reset}` : frontendDeps ? `${colors.green}已安装${colors.reset}` : `${colors.yellow}未安装${colors.reset}`}`);
console.log(` 前端构建: ${ok(webuiBuilt)} ${webuiBuilt ? `(${CONFIG.staticAdminDir})` : '未构建'}`);
console.log(` 后端二进制: ${ok(hasBinary)} ${hasBinary ? BINARY : '未编译'}`);
console.log(`\n${colors.bright}服务状态:${colors.reset}`);
console.log(` 后端 (:${CONFIG.port}): ${running.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
console.log(` 前端 (:${CONFIG.frontendPort}): ${running.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
console.log(`\n${colors.bright}环境变量:${colors.reset}`);
console.log(` PORT: ${colors.cyan}${CONFIG.port}${colors.reset}`);
console.log(` LOG_LEVEL: ${colors.cyan}${CONFIG.logLevel}${colors.reset}`);
console.log(` DS2API_ADMIN_KEY: ${colors.cyan}${CONFIG.adminKey}${colors.reset}`);
console.log(` GOPROXY: ${colors.cyan}${MIRRORS.goproxy}${colors.reset}`);
console.log(` NPM_REGISTRY: ${colors.cyan}${MIRRORS.npm}${colors.reset}`);
console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=密钥 PORT=5001 node start.mjs${colors.reset}`);
console.log(`
${colors.bright}请选择操作:${colors.reset}
${colors.cyan}1.${colors.reset} 开发模式 (go run + 前端热重载)
${colors.cyan}2.${colors.reset} 仅后端 (go run无需编译)
${colors.cyan}3.${colors.reset} 仅前端 (npm dev)
${colors.cyan}4.${colors.reset} 生产模式 (编译后运行,前端已嵌入)
${colors.cyan}5.${colors.reset} 编译后端 (go build)
${colors.cyan}6.${colors.reset} 构建前端 (npm build → static/admin)
${colors.cyan}7.${colors.reset} 安装前端依赖 (npm ci)
${colors.red}8.${colors.reset} 停止所有服务
${colors.cyan}0.${colors.reset} 退出
`);
const choice = await question(`${colors.yellow}请输入选项 [1]: ${colors.reset}`);
rl.close();
switch (choice.trim() || '1') {
case '1':
log.title('========== 开发模式 ==========');
await startBackendDev();
await new Promise(r => setTimeout(r, 1500));
await startFrontend();
showStatus();
await waitForProcesses();
break;
case '2':
log.title('========== 仅后端 (go run) ==========');
await startBackendDev();
showStatus();
await waitForProcesses();
break;
case '3':
log.title('========== 仅前端 ==========');
await startFrontend();
showStatus();
await waitForProcesses();
break;
case '4':
log.title('========== 生产模式 ==========');
await startBackendProd();
showStatus();
await waitForProcesses();
break;
case '5':
log.title('========== 编译后端 ==========');
await buildBackend();
log.success(`编译完成:${BINARY}`);
break;
case '6':
log.title('========== 构建前端 ==========');
await buildWebui();
log.success('前端构建完成!');
break;
case '7':
log.title('========== 安装前端依赖 ==========');
await installFrontendDeps();
log.success('前端依赖安装完成!');
break;
case '8':
await stopServices();
break;
case '0':
log.info('再见!');
process.exit(0);
break;
default:
log.warn('无效选项');
await showMenu();
}
}
// 命令行参数处理
async function main() {
const cmd = process.argv[2];
if (!checkGo() && !['install', 'webui', 'stop', 'status', 'help', '-h', '--help'].includes(cmd)) {
log.error('未找到 Go请先安装 Go: https://go.dev/dl/');
if (!cmd) {
// 无 Go 时仍允许进入菜单(可以只操作前端)
} else {
process.exit(1);
}
}
switch (cmd) {
case 'dev':
log.title('========== 开发模式 ==========');
await startBackendDev();
await new Promise(r => setTimeout(r, 1500));
await startFrontend();
showStatus();
await waitForProcesses();
break;
case 'prod':
log.title('========== 生产模式 ==========');
await startBackendProd();
showStatus();
await waitForProcesses();
break;
case 'build':
await buildBackend();
log.success(`编译完成:${BINARY}`);
break;
case 'webui':
await buildWebui();
log.success('前端构建完成!');
break;
case 'install':
await installFrontendDeps();
log.success('前端依赖安装完成!');
break;
case 'stop':
await stopServices();
break;
case 'status': {
const status = getRunningStatus();
const goVer = getGoVersion();
console.log(`\n${colors.bright}环境:${colors.reset}`);
console.log(` Go: ${goVer || `${colors.red}未安装${colors.reset}`}`);
console.log(`\n${colors.bright}服务状态:${colors.reset}`);
console.log(` 后端 (:${CONFIG.port}): ${status.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
console.log(` 前端 (:${CONFIG.frontendPort}): ${status.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}\n`);
break;
}
case 'help':
case '-h':
case '--help':
console.log(`
${colors.bright}DS2API 启动脚本 (Go)${colors.reset}
${colors.cyan}使用方法:${colors.reset}
node start.mjs 显示交互式菜单
node start.mjs dev 开发模式 (go run + 前端热重载)
node start.mjs prod 生产模式 (编译产物,前端已嵌入)
node start.mjs build 编译后端二进制 (go build)
node start.mjs webui 构建前端静态文件
node start.mjs install 安装前端依赖 (npm ci)
node start.mjs stop 停止所有服务
node start.mjs status 查看服务状态
${colors.cyan}常用环境变量:${colors.reset}
PORT 后端端口 (默认: 5001)
LOG_LEVEL 日志级别: DEBUG|INFO|WARN|ERROR (默认: INFO)
DS2API_ADMIN_KEY 管理员密钥 (默认: admin)
DS2API_CONFIG_PATH 配置文件路径 (默认: config.json)
GOPROXY Go 模块代理 (默认: https://goproxy.cn,direct)
NPM_REGISTRY npm 镜像源 (默认: https://registry.npmmirror.com)
${colors.cyan}示例:${colors.reset}
DS2API_ADMIN_KEY=mykey PORT=8080 node start.mjs dev
GOPROXY=off NPM_REGISTRY=https://registry.npmjs.org node start.mjs dev
`);
break;
default:
await showMenu();
}
}
main().catch(e => {
log.error(e.message);
process.exit(1);
});

View File

@@ -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}
/>
<AddKeyModal

View File

@@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight, Play, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react'
import clsx from 'clsx'
export default function AccountsTable({
@@ -10,6 +11,7 @@ export default function AccountsTable({
batchProgress,
totalAccounts,
page,
pageSize,
totalPages,
resolveAccountIdentifier,
onTestAll,
@@ -18,7 +20,16 @@ export default function AccountsTable({
onDeleteAccount,
onPrevPage,
onNextPage,
onPageSizeChange,
}) {
const [copiedId, setCopiedId] = useState(null)
const copyId = (id) => {
navigator.clipboard.writeText(id).then(() => {
setCopiedId(id)
setTimeout(() => setCopiedId(null), 1500)
})
}
return (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -83,12 +94,23 @@ export default function AccountsTable({
<div className="flex items-center gap-3 min-w-0">
<div className={clsx(
"w-2 h-2 rounded-full shrink-0",
acc.has_token ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-amber-500"
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
"bg-amber-500"
)} />
<div className="min-w-0">
<div className="font-medium truncate">{id || '-'}</div>
<div
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
onClick={() => copyId(id)}
>
<span className="truncate">{id || '-'}</span>
{copiedId === id
? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{acc.token_preview}
@@ -122,8 +144,19 @@ export default function AccountsTable({
{totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
<div className="flex items-center gap-3">
<div className="text-sm text-muted-foreground">
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
</div>
<select
value={pageSize}
onChange={e => onPageSizeChange(Number(e.target.value))}
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
>
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<button

View File

@@ -6,7 +6,7 @@ export function useAccountsData({ apiFetch }) {
const [accounts, setAccounts] = useState([])
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [pageSize, setPageSize] = useState(10)
const [totalPages, setTotalPages] = useState(1)
const [totalAccounts, setTotalAccounts] = useState(0)
const [loadingAccounts, setLoadingAccounts] = useState(false)
@@ -16,10 +16,10 @@ export function useAccountsData({ apiFetch }) {
return String(acc.identifier || acc.email || acc.mobile || '').trim()
}
const fetchAccounts = async (targetPage = page) => {
const fetchAccounts = async (targetPage = page, targetPageSize = pageSize) => {
setLoadingAccounts(true)
try {
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`)
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${targetPageSize}`)
if (res.ok) {
const data = await res.json()
setAccounts(data.items || [])
@@ -34,6 +34,11 @@ export function useAccountsData({ apiFetch }) {
}
}
const changePageSize = (newSize) => {
setPageSize(newSize)
fetchAccounts(1, newSize)
}
const fetchQueueStatus = async () => {
try {
const res = await apiFetch('/admin/queue/status')
@@ -59,10 +64,12 @@ export function useAccountsData({ apiFetch }) {
setKeysExpanded,
accounts,
page,
pageSize,
totalPages,
totalAccounts,
loadingAccounts,
fetchAccounts,
changePageSize,
resolveAccountIdentifier,
}
}

View File

@@ -113,6 +113,7 @@
"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",

View File

@@ -113,6 +113,7 @@
"testingAllAccounts": "正在测试所有账号...",
"sessionActive": "已建立会话",
"reauthRequired": "需重新登录",
"testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
"newKeyLabel": "新密钥值",