mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'dev'
This commit is contained in:
76
.dockerignore
Normal file
76
.dockerignore
Normal file
@@ -0,0 +1,76 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Data files
|
||||
data/
|
||||
temp/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# GitHub
|
||||
.github/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
README_EN.md
|
||||
CHANGELOG.md
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Development files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Docker files
|
||||
docker-compose.yml
|
||||
docker-compose.*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Test files
|
||||
test/
|
||||
tests/
|
||||
__tests__/
|
||||
*.test.js
|
||||
*.spec.js
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
build/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# CI/CD
|
||||
.travis.yml
|
||||
.gitlab-ci.yml
|
||||
azure-pipelines.yml
|
||||
|
||||
# Package manager files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# CLI
|
||||
cli/
|
||||
@@ -11,6 +11,10 @@ ADMIN_SESSION_TIMEOUT=86400000
|
||||
API_KEY_PREFIX=cr_
|
||||
ENCRYPTION_KEY=your-encryption-key-here
|
||||
|
||||
# 👤 管理员凭据(可选,不设置则自动生成)
|
||||
# ADMIN_USERNAME=cr_admin_custom
|
||||
# ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# 📊 Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
109
.github/DOCKER_HUB_SETUP.md
vendored
Normal file
109
.github/DOCKER_HUB_SETUP.md
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
# Docker Hub 自动发布配置指南
|
||||
|
||||
本文档说明如何配置 GitHub Actions 自动构建并发布 Docker 镜像到 Docker Hub。
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
1. Docker Hub 账号
|
||||
2. GitHub 仓库的管理员权限
|
||||
|
||||
## 🔐 配置 GitHub Secrets
|
||||
|
||||
在 GitHub 仓库中配置以下 secrets:
|
||||
|
||||
1. 进入仓库设置:`Settings` → `Secrets and variables` → `Actions`
|
||||
2. 点击 `New repository secret`
|
||||
3. 添加以下 secrets:
|
||||
|
||||
### 必需的 Secrets
|
||||
|
||||
| Secret 名称 | 说明 | 如何获取 |
|
||||
|------------|------|---------|
|
||||
| `DOCKERHUB_USERNAME` | Docker Hub 用户名 | 你的 Docker Hub 登录用户名 |
|
||||
| `DOCKERHUB_TOKEN` | Docker Hub Access Token | 见下方说明 |
|
||||
|
||||
### 获取 Docker Hub Access Token
|
||||
|
||||
1. 登录 [Docker Hub](https://hub.docker.com/)
|
||||
2. 点击右上角头像 → `Account Settings`
|
||||
3. 选择 `Security` → `Access Tokens`
|
||||
4. 点击 `New Access Token`
|
||||
5. 填写描述(如:`GitHub Actions`)
|
||||
6. 选择权限:`Read, Write, Delete`
|
||||
7. 点击 `Generate`
|
||||
8. **立即复制 token**(只显示一次)
|
||||
|
||||
## 🚀 工作流程说明
|
||||
|
||||
### 触发条件
|
||||
|
||||
- **自动触发**:推送到 `main` 分支
|
||||
- **版本发布**:创建 `v*` 格式的 tag(如 `v1.0.0`)
|
||||
- **手动触发**:在 Actions 页面手动运行
|
||||
|
||||
### 镜像标签策略
|
||||
|
||||
工作流会自动创建以下标签:
|
||||
|
||||
- `latest`:始终指向 main 分支的最新构建
|
||||
- `main`:main 分支的构建
|
||||
- `v1.0.0`:版本标签(当创建 tag 时)
|
||||
- `1.0`:主次版本标签
|
||||
- `1`:主版本标签
|
||||
- `main-sha-xxxxxxx`:包含 commit SHA 的标签
|
||||
|
||||
### 支持的平台
|
||||
|
||||
- `linux/amd64`:Intel/AMD 架构
|
||||
- `linux/arm64`:ARM64 架构(如 Apple Silicon, 树莓派等)
|
||||
|
||||
## 📦 使用发布的镜像
|
||||
|
||||
```bash
|
||||
# 拉取最新版本
|
||||
docker pull weishaw/claude-relay-service:latest
|
||||
|
||||
# 拉取特定版本
|
||||
docker pull weishaw/claude-relay-service:v1.0.0
|
||||
|
||||
# 运行容器
|
||||
docker run -d \
|
||||
--name claude-relay \
|
||||
-p 3000:3000 \
|
||||
-v ./data:/app/data \
|
||||
-v ./logs:/app/logs \
|
||||
-e ADMIN_USERNAME=my_admin \
|
||||
-e ADMIN_PASSWORD=my_password \
|
||||
weishaw/claude-relay-service:latest
|
||||
```
|
||||
|
||||
## 🔍 验证配置
|
||||
|
||||
1. 推送代码到 main 分支
|
||||
2. 在 GitHub 仓库页面点击 `Actions` 标签
|
||||
3. 查看 `Docker Build & Push` 工作流运行状态
|
||||
4. 成功后在 Docker Hub 查看镜像
|
||||
|
||||
## 🛡️ 安全功能
|
||||
|
||||
- **漏洞扫描**:使用 Trivy 自动扫描镜像漏洞
|
||||
- **扫描报告**:上传到 GitHub Security 标签页
|
||||
- **自动更新 README**:同步更新 Docker Hub 的项目描述
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### 构建失败
|
||||
|
||||
- 检查 secrets 是否正确配置
|
||||
- 确认 Docker Hub token 有足够权限
|
||||
- 查看 Actions 日志详细错误信息
|
||||
|
||||
### 镜像推送失败
|
||||
|
||||
- 确认 Docker Hub 用户名正确
|
||||
- 检查是否达到 Docker Hub 免费账户限制
|
||||
- Token 可能过期,需要重新生成
|
||||
|
||||
### 多平台构建慢
|
||||
|
||||
这是正常的,因为需要模拟不同架构。可以在不需要时修改 `platforms` 配置。
|
||||
114
.github/WORKFLOW_USAGE.md
vendored
Normal file
114
.github/WORKFLOW_USAGE.md
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
# GitHub Actions 工作流使用指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本项目配置了自动化 CI/CD 流程,每次推送到 main 分支都会自动构建并发布 Docker 镜像到 Docker Hub。
|
||||
|
||||
## 🚀 工作流程
|
||||
|
||||
### 1. Docker 构建和发布 (`docker-publish.yml`)
|
||||
|
||||
**功能:**
|
||||
- 自动构建多平台 Docker 镜像(amd64, arm64)
|
||||
- 推送到 Docker Hub
|
||||
- 执行安全漏洞扫描
|
||||
- 更新 Docker Hub 描述
|
||||
|
||||
**触发条件:**
|
||||
- 推送到 `main` 分支
|
||||
- 创建版本标签(如 `v1.0.0`)
|
||||
- Pull Request(仅构建,不推送)
|
||||
- 手动触发
|
||||
|
||||
### 2. 发布管理 (`release.yml`)
|
||||
|
||||
**功能:**
|
||||
- 自动创建 GitHub Release
|
||||
- 生成更新日志
|
||||
- 关联 Docker 镜像版本
|
||||
|
||||
**触发条件:**
|
||||
- 创建版本标签(如 `v1.0.0`)
|
||||
|
||||
## 📝 版本发布流程
|
||||
|
||||
### 1. 常规更新(推送到 main)
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: 修复登录问题"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**结果:**
|
||||
- 自动构建并推送 `latest` 标签到 Docker Hub
|
||||
- 更新 `main` 标签
|
||||
|
||||
### 2. 版本发布
|
||||
|
||||
```bash
|
||||
# 创建版本标签
|
||||
git tag -a v1.0.0 -m "Release version 1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
**结果:**
|
||||
- 构建并推送以下标签到 Docker Hub:
|
||||
- `v1.0.0`(完整版本)
|
||||
- `1.0`(主次版本)
|
||||
- `1`(主版本)
|
||||
- `latest`(最新版本)
|
||||
- 创建 GitHub Release
|
||||
- 生成更新日志
|
||||
|
||||
## 🔧 手动触发构建
|
||||
|
||||
1. 访问仓库的 Actions 页面
|
||||
2. 选择 "Docker Build & Push" 工作流
|
||||
3. 点击 "Run workflow"
|
||||
4. 选择分支并运行
|
||||
|
||||
## 📊 查看构建状态
|
||||
|
||||
- **Actions 页面**:查看所有工作流运行历史
|
||||
- **README 徽章**:实时显示构建状态
|
||||
- **Docker Hub**:查看镜像标签和拉取次数
|
||||
|
||||
## 🛡️ 安全扫描
|
||||
|
||||
每次构建都会运行 Trivy 安全扫描:
|
||||
- 扫描结果上传到 GitHub Security 标签页
|
||||
- 发现高危漏洞会在 Actions 日志中警告
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 如何回滚到之前的版本?
|
||||
|
||||
```bash
|
||||
# 使用特定版本标签
|
||||
docker pull weishaw/claude-relay-service:v1.0.0
|
||||
|
||||
# 或在 docker-compose.yml 中指定版本
|
||||
image: weishaw/claude-relay-service:v1.0.0
|
||||
```
|
||||
|
||||
### Q: 如何跳过自动构建?
|
||||
|
||||
在 commit 消息中添加 `[skip ci]`:
|
||||
```bash
|
||||
git commit -m "docs: 更新文档 [skip ci]"
|
||||
```
|
||||
|
||||
### Q: 构建失败如何调试?
|
||||
|
||||
1. 查看 Actions 日志详细错误信息
|
||||
2. 在本地测试 Docker 构建:
|
||||
```bash
|
||||
docker build -t test .
|
||||
```
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [Docker Hub 配置指南](.github/DOCKER_HUB_SETUP.md)
|
||||
- [GitHub Actions 文档](https://docs.github.com/en/actions)
|
||||
- [Docker 官方文档](https://docs.docker.com/)
|
||||
68
.github/cliff.toml
vendored
Normal file
68
.github/cliff.toml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# git-cliff configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
"""
|
||||
# template for the changelog body
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/orhun/git-cliff/commit/{{ commit.id }}))
|
||||
{%- endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
"""
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))" },
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^docs", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^style", group = "Styling" },
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore", group = "Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "Security" },
|
||||
]
|
||||
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "v[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
||||
101
.github/workflows/docker-publish.yml
vendored
Normal file
101
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
test:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.IMAGE_NAME }}:latest
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
update-description:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service
|
||||
readme-filepath: ./README.md
|
||||
short-description: "Claude Code API Relay Service - 多账户管理的Claude API中转服务"
|
||||
48
.github/workflows/release.yml
vendored
Normal file
48
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
uses: orhun/git-cliff-action@v3
|
||||
with:
|
||||
config: .github/cliff.toml
|
||||
args: --latest --strip header
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: |
|
||||
## 🐳 Docker 镜像
|
||||
|
||||
```bash
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:${{ github.ref_name }}
|
||||
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/claude-relay-service:latest
|
||||
```
|
||||
|
||||
## 📦 主要更新
|
||||
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
## 📋 完整更新日志
|
||||
|
||||
查看 [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
|
||||
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
@@ -29,6 +29,10 @@ RUN npm ci --only=production && \
|
||||
# 📋 复制应用代码
|
||||
COPY --chown=claude:nodejs . .
|
||||
|
||||
# 🔧 复制并设置启动脚本权限
|
||||
COPY --chown=claude:nodejs docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# 📁 创建必要目录
|
||||
RUN mkdir -p logs data temp && \
|
||||
chown -R claude:nodejs logs data temp
|
||||
@@ -44,5 +48,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# 🚀 启动应用
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "src/app.js"]
|
||||
122
README.md
122
README.md
@@ -6,6 +6,8 @@
|
||||
[](https://nodejs.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.docker.com/)
|
||||
[](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/docker-publish.yml)
|
||||
[](https://hub.docker.com/r/weishaw/claude-relay-service)
|
||||
|
||||
**🔐 自行搭建Claude API中转服务,支持多账户管理**
|
||||
|
||||
@@ -157,7 +159,7 @@ sudo systemctl start redis
|
||||
|
||||
```bash
|
||||
# 下载项目
|
||||
git clone https://github.com/Wei-Shaw/claude-relay-service.git
|
||||
git clone https://github.com/Wei-Shaw//claude-relay-service.git
|
||||
cd claude-relay-service
|
||||
|
||||
# 安装依赖
|
||||
@@ -202,6 +204,9 @@ module.exports = {
|
||||
```bash
|
||||
# 初始化
|
||||
npm run setup # 会随机生成后台账号密码信息,存储在 data/init.json
|
||||
# 或者通过环境变量预设管理员凭据:
|
||||
# export ADMIN_USERNAME=cr_admin_custom
|
||||
# export ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# 启动服务
|
||||
npm run service:start:daemon # 后台运行(推荐)
|
||||
@@ -212,13 +217,126 @@ npm run service:status
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker 部署(推荐)
|
||||
|
||||
### 使用 Docker Hub 镜像(最简单)
|
||||
|
||||
> 🚀 推荐使用官方镜像,自动构建,始终保持最新版本
|
||||
|
||||
```bash
|
||||
# 拉取镜像(支持 amd64 和 arm64)
|
||||
docker pull weishaw/claude-relay-service:latest
|
||||
|
||||
# 使用 docker run 运行
|
||||
docker run -d \
|
||||
--name claude-relay \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
-e ADMIN_USERNAME=my_admin \
|
||||
-e ADMIN_PASSWORD=my_secure_password \
|
||||
weishaw/claude-relay-service:latest
|
||||
|
||||
# 或使用 docker-compose(推荐)
|
||||
# 创建 docker-compose.yml 文件:
|
||||
cat > docker-compose.yml << 'EOF'
|
||||
version: '3.8'
|
||||
services:
|
||||
claude-relay:
|
||||
image: weishaw/claude-relay-service:latest
|
||||
container_name: claude-relay-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: claude-relay-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
EOF
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/Wei-Shaw//claude-relay-service.git
|
||||
cd claude-relay-service
|
||||
|
||||
# 2. 设置管理员账号密码(可选)
|
||||
# 方式一:自动生成(查看容器日志获取)
|
||||
docker-compose up -d
|
||||
|
||||
# 方式二:预设账号密码
|
||||
export ADMIN_USERNAME=cr_admin_custom
|
||||
export ADMIN_PASSWORD=your-secure-password
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 查看管理员凭据
|
||||
# 自动生成的情况下:
|
||||
docker logs claude-relay-service | grep "管理员"
|
||||
|
||||
# 或者直接查看挂载的文件:
|
||||
cat ./data/init.json
|
||||
```
|
||||
|
||||
### Docker Compose 配置
|
||||
|
||||
docker-compose.yml 已包含:
|
||||
- ✅ 自动初始化管理员账号
|
||||
- ✅ 数据持久化(logs和data目录自动挂载)
|
||||
- ✅ Redis数据库
|
||||
- ✅ 健康检查
|
||||
- ✅ 自动重启
|
||||
|
||||
### 管理员凭据获取方式
|
||||
|
||||
1. **查看容器日志**(推荐)
|
||||
```bash
|
||||
docker logs claude-relay-service
|
||||
```
|
||||
|
||||
2. **查看挂载的文件**
|
||||
```bash
|
||||
cat ./data/init.json
|
||||
```
|
||||
|
||||
3. **使用环境变量预设**
|
||||
```bash
|
||||
# 在 .env 文件中设置
|
||||
ADMIN_USERNAME=cr_admin_custom
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 开始使用
|
||||
|
||||
### 1. 打开管理界面
|
||||
|
||||
浏览器访问:`http://你的服务器IP:3000/web`
|
||||
|
||||
默认管理员账号:data/init.json 中寻找
|
||||
管理员账号:
|
||||
- 自动生成:查看 data/init.json
|
||||
- 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置
|
||||
- Docker 部署:查看容器日志 `docker logs claude-relay-service`
|
||||
|
||||
### 2. 添加Claude账户
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ services:
|
||||
- PORT=3000
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-} # 可选:预设管理员用户名
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # 可选:预设管理员密码
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
|
||||
31
docker-entrypoint.sh
Normal file
31
docker-entrypoint.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🚀 Claude Relay Service 启动中..."
|
||||
|
||||
# 检查是否需要初始化
|
||||
if [ ! -f "/app/data/init.json" ]; then
|
||||
echo "📋 首次启动,执行初始化设置..."
|
||||
|
||||
# 如果设置了环境变量,显示提示
|
||||
if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then
|
||||
echo "📌 检测到预设的管理员凭据"
|
||||
fi
|
||||
|
||||
# 执行初始化脚本
|
||||
node /app/scripts/setup.js
|
||||
|
||||
echo "✅ 初始化完成"
|
||||
else
|
||||
echo "✅ 检测到已有配置,跳过初始化"
|
||||
|
||||
# 如果 init.json 存在但环境变量也设置了,显示警告
|
||||
if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then
|
||||
echo "⚠️ 警告: 检测到环境变量 ADMIN_USERNAME/ADMIN_PASSWORD,但系统已初始化"
|
||||
echo " 如需使用新凭据,请删除 data/init.json 文件后重启容器"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动应用
|
||||
echo "🌐 启动 Claude Relay Service..."
|
||||
exec "$@"
|
||||
@@ -42,9 +42,14 @@ async function setup() {
|
||||
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
|
||||
}
|
||||
|
||||
// 3. 生成随机管理员凭据
|
||||
const adminUsername = `cr_admin_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const adminPassword = crypto.randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
|
||||
// 3. 生成或使用环境变量中的管理员凭据
|
||||
const adminUsername = process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || crypto.randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
|
||||
|
||||
// 如果使用了环境变量,显示提示
|
||||
if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) {
|
||||
console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据'));
|
||||
}
|
||||
|
||||
// 4. 创建初始化完成标记文件
|
||||
const initData = {
|
||||
@@ -65,7 +70,14 @@ async function setup() {
|
||||
console.log(chalk.yellow('📋 重要信息:\n'));
|
||||
console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`);
|
||||
console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`);
|
||||
console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。\n'));
|
||||
|
||||
// 如果是自动生成的凭据,强调需要保存
|
||||
if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) {
|
||||
console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。'));
|
||||
console.log(chalk.yellow('\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n'));
|
||||
} else {
|
||||
console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n'));
|
||||
}
|
||||
|
||||
console.log(chalk.blue('🚀 启动服务:\n'));
|
||||
console.log(' npm start - 启动生产服务');
|
||||
|
||||
@@ -270,6 +270,12 @@ class Application {
|
||||
logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`);
|
||||
});
|
||||
|
||||
const serverTimeout = 600000; // 默认10分钟
|
||||
this.server.timeout = serverTimeout;
|
||||
this.server.keepAliveTimeout = serverTimeout + 5000; // keepAlive 稍长一点
|
||||
logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout/1000}s)`);
|
||||
|
||||
|
||||
// 🔄 定期清理任务
|
||||
this.startCleanupTasks();
|
||||
|
||||
|
||||
@@ -139,18 +139,24 @@ class RedisClient {
|
||||
// 📊 使用统计相关操作(支持缓存token统计和模型信息)
|
||||
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
const key = `usage:${keyId}`;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别
|
||||
|
||||
const daily = `usage:daily:${keyId}:${today}`;
|
||||
const monthly = `usage:monthly:${keyId}:${currentMonth}`;
|
||||
const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key
|
||||
|
||||
// 按模型统计的键
|
||||
const modelDaily = `usage:model:daily:${model}:${today}`;
|
||||
const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`;
|
||||
const modelHourly = `usage:model:hourly:${model}:${currentHour}`; // 新增模型小时级别
|
||||
|
||||
// API Key级别的模型统计
|
||||
const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`;
|
||||
const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`;
|
||||
const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别
|
||||
|
||||
// 智能处理输入输出token分配
|
||||
const finalInputTokens = inputTokens || 0;
|
||||
@@ -218,13 +224,40 @@ class RedisClient {
|
||||
this.client.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(keyModelMonthly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(keyModelMonthly, 'requests', 1),
|
||||
|
||||
// 小时级别统计
|
||||
this.client.hincrby(hourly, 'tokens', coreTokens),
|
||||
this.client.hincrby(hourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(hourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(hourly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(hourly, 'requests', 1),
|
||||
// 按模型统计 - 每小时
|
||||
this.client.hincrby(modelHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(modelHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(modelHourly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(modelHourly, 'requests', 1),
|
||||
// API Key级别的模型统计 - 每小时
|
||||
this.client.hincrby(keyModelHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(keyModelHourly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(keyModelHourly, 'requests', 1),
|
||||
|
||||
// 设置过期时间
|
||||
this.client.expire(daily, 86400 * 32), // 32天过期
|
||||
this.client.expire(monthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(hourly, 86400 * 7), // 小时统计7天过期
|
||||
this.client.expire(modelDaily, 86400 * 32), // 模型每日统计32天过期
|
||||
this.client.expire(modelMonthly, 86400 * 365), // 模型每月统计1年过期
|
||||
this.client.expire(modelHourly, 86400 * 7), // 模型小时统计7天过期
|
||||
this.client.expire(keyModelDaily, 86400 * 32), // API Key模型每日统计32天过期
|
||||
this.client.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期
|
||||
this.client.expire(keyModelMonthly, 86400 * 365), // API Key模型每月统计1年过期
|
||||
this.client.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
|
||||
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
||||
const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
@@ -358,6 +359,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
activeApiKeys,
|
||||
totalClaudeAccounts: accounts.length,
|
||||
activeClaudeAccounts: activeAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedAccounts,
|
||||
totalTokensUsed,
|
||||
totalRequestsUsed,
|
||||
totalInputTokensUsed,
|
||||
@@ -528,22 +530,140 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => {
|
||||
// 获取使用趋势数据
|
||||
router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { days = 7 } = req.query;
|
||||
const daysCount = parseInt(days) || 7;
|
||||
const { days = 7, granularity = 'day', startDate, endDate } = req.query;
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
const trendData = [];
|
||||
const today = new Date();
|
||||
|
||||
// 获取过去N天的数据
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度统计
|
||||
let startTime, endTime;
|
||||
|
||||
// 汇总当天所有API Key的使用数据
|
||||
const pattern = `usage:daily:*:${dateStr}`;
|
||||
const keys = await client.keys(pattern);
|
||||
if (startDate && endDate) {
|
||||
// 使用自定义时间范围
|
||||
startTime = new Date(startDate);
|
||||
endTime = new Date(endDate);
|
||||
} else {
|
||||
// 默认最近24小时
|
||||
endTime = new Date();
|
||||
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 确保时间范围不超过24小时
|
||||
const timeDiff = endTime - startTime;
|
||||
if (timeDiff > 24 * 60 * 60 * 1000) {
|
||||
return res.status(400).json({
|
||||
error: '小时粒度查询时间范围不能超过24小时'
|
||||
});
|
||||
}
|
||||
|
||||
// 按小时遍历
|
||||
const currentHour = new Date(startTime);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
while (currentHour <= endTime) {
|
||||
const dateStr = currentHour.toISOString().split('T')[0];
|
||||
const hour = String(currentHour.getHours()).padStart(2, '0');
|
||||
const hourKey = `${dateStr}:${hour}`;
|
||||
|
||||
// 获取当前小时的模型统计数据
|
||||
const modelPattern = `usage:model:hourly:*:${hourKey}`;
|
||||
const modelKeys = await client.keys(modelPattern);
|
||||
|
||||
let hourInputTokens = 0;
|
||||
let hourOutputTokens = 0;
|
||||
let hourRequests = 0;
|
||||
let hourCacheCreateTokens = 0;
|
||||
let hourCacheReadTokens = 0;
|
||||
let hourCost = 0;
|
||||
|
||||
for (const modelKey of modelKeys) {
|
||||
const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/);
|
||||
if (!modelMatch) continue;
|
||||
|
||||
const model = modelMatch[1];
|
||||
const data = await client.hgetall(modelKey);
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const modelInputTokens = parseInt(data.inputTokens) || 0;
|
||||
const modelOutputTokens = parseInt(data.outputTokens) || 0;
|
||||
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
|
||||
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0;
|
||||
const modelRequests = parseInt(data.requests) || 0;
|
||||
|
||||
hourInputTokens += modelInputTokens;
|
||||
hourOutputTokens += modelOutputTokens;
|
||||
hourCacheCreateTokens += modelCacheCreateTokens;
|
||||
hourCacheReadTokens += modelCacheReadTokens;
|
||||
hourRequests += modelRequests;
|
||||
|
||||
const modelUsage = {
|
||||
input_tokens: modelInputTokens,
|
||||
output_tokens: modelOutputTokens,
|
||||
cache_creation_input_tokens: modelCacheCreateTokens,
|
||||
cache_read_input_tokens: modelCacheReadTokens
|
||||
};
|
||||
const modelCostResult = CostCalculator.calculateCost(modelUsage, model);
|
||||
hourCost += modelCostResult.costs.total;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有模型级别的数据,尝试API Key级别的数据
|
||||
if (modelKeys.length === 0) {
|
||||
const pattern = `usage:hourly:*:${hourKey}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key);
|
||||
if (data) {
|
||||
hourInputTokens += parseInt(data.inputTokens) || 0;
|
||||
hourOutputTokens += parseInt(data.outputTokens) || 0;
|
||||
hourRequests += parseInt(data.requests) || 0;
|
||||
hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
|
||||
hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const usage = {
|
||||
input_tokens: hourInputTokens,
|
||||
output_tokens: hourOutputTokens,
|
||||
cache_creation_input_tokens: hourCacheCreateTokens,
|
||||
cache_read_input_tokens: hourCacheReadTokens
|
||||
};
|
||||
const costResult = CostCalculator.calculateCost(usage, 'unknown');
|
||||
hourCost = costResult.costs.total;
|
||||
}
|
||||
|
||||
trendData.push({
|
||||
date: hourKey,
|
||||
hour: currentHour.toISOString(),
|
||||
inputTokens: hourInputTokens,
|
||||
outputTokens: hourOutputTokens,
|
||||
requests: hourRequests,
|
||||
cacheCreateTokens: hourCacheCreateTokens,
|
||||
cacheReadTokens: hourCacheReadTokens,
|
||||
totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens,
|
||||
cost: hourCost
|
||||
});
|
||||
|
||||
// 移到下一个小时
|
||||
currentHour.setHours(currentHour.getHours() + 1);
|
||||
}
|
||||
|
||||
} else {
|
||||
// 天粒度统计(保持原有逻辑)
|
||||
const daysCount = parseInt(days) || 7;
|
||||
const today = new Date();
|
||||
|
||||
// 获取过去N天的数据
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
// 汇总当天所有API Key的使用数据
|
||||
const pattern = `usage:daily:*:${dateStr}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
let dayInputTokens = 0;
|
||||
let dayOutputTokens = 0;
|
||||
@@ -553,7 +673,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
let dayCost = 0;
|
||||
|
||||
// 按模型统计使用量
|
||||
const modelUsageMap = new Map();
|
||||
// const modelUsageMap = new Map();
|
||||
|
||||
// 获取当天所有模型的使用数据
|
||||
const modelPattern = `usage:model:daily:*:${dateStr}`;
|
||||
@@ -630,10 +750,16 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 按日期正序排列
|
||||
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
res.json({ success: true, data: trendData });
|
||||
// 按日期正序排列
|
||||
if (granularity === 'hour') {
|
||||
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
|
||||
} else {
|
||||
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
res.json({ success: true, data: trendData, granularity });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get usage trend:', error);
|
||||
res.status(500).json({ error: 'Failed to get usage trend', message: error.message });
|
||||
@@ -833,6 +959,152 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
|
||||
});
|
||||
|
||||
|
||||
// 获取按API Key分组的使用趋势
|
||||
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { granularity = 'day', days = 7, startDate, endDate } = req.query;
|
||||
|
||||
logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`);
|
||||
|
||||
const client = redis.getClientSafe();
|
||||
const trendData = [];
|
||||
|
||||
// 获取所有API Keys
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
const apiKeyMap = new Map(apiKeys.map(key => [key.id, key]));
|
||||
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度统计
|
||||
let endTime, startTime;
|
||||
|
||||
if (startDate && endDate) {
|
||||
// 自定义时间范围
|
||||
startTime = new Date(startDate);
|
||||
endTime = new Date(endDate);
|
||||
} else {
|
||||
// 默认近24小时
|
||||
endTime = new Date();
|
||||
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 按小时遍历
|
||||
const currentHour = new Date(startTime);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
while (currentHour <= endTime) {
|
||||
const hourKey = currentHour.toISOString().split(':')[0].replace('T', ':');
|
||||
|
||||
// 获取这个小时所有API Key的数据
|
||||
const pattern = `usage:hourly:*:${hourKey}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
const hourData = {
|
||||
hour: currentHour.toISOString(),
|
||||
apiKeys: {}
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/);
|
||||
if (!match) continue;
|
||||
|
||||
const apiKeyId = match[1];
|
||||
const data = await client.hgetall(key);
|
||||
|
||||
if (data && apiKeyMap.has(apiKeyId)) {
|
||||
const totalTokens = (parseInt(data.inputTokens) || 0) +
|
||||
(parseInt(data.outputTokens) || 0) +
|
||||
(parseInt(data.cacheCreateTokens) || 0) +
|
||||
(parseInt(data.cacheReadTokens) || 0);
|
||||
|
||||
hourData.apiKeys[apiKeyId] = {
|
||||
name: apiKeyMap.get(apiKeyId).name,
|
||||
tokens: totalTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
trendData.push(hourData);
|
||||
currentHour.setHours(currentHour.getHours() + 1);
|
||||
}
|
||||
|
||||
} else {
|
||||
// 天粒度统计
|
||||
const daysCount = parseInt(days) || 7;
|
||||
const today = new Date();
|
||||
|
||||
// 获取过去N天的数据
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
// 获取这一天所有API Key的数据
|
||||
const pattern = `usage:daily:*:${dateStr}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
apiKeys: {}
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/);
|
||||
if (!match) continue;
|
||||
|
||||
const apiKeyId = match[1];
|
||||
const data = await client.hgetall(key);
|
||||
|
||||
if (data && apiKeyMap.has(apiKeyId)) {
|
||||
const totalTokens = (parseInt(data.inputTokens) || 0) +
|
||||
(parseInt(data.outputTokens) || 0) +
|
||||
(parseInt(data.cacheCreateTokens) || 0) +
|
||||
(parseInt(data.cacheReadTokens) || 0);
|
||||
|
||||
dayData.apiKeys[apiKeyId] = {
|
||||
name: apiKeyMap.get(apiKeyId).name,
|
||||
tokens: totalTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
trendData.push(dayData);
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间正序排列
|
||||
if (granularity === 'hour') {
|
||||
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
|
||||
} else {
|
||||
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
// 计算每个API Key的总token数,用于排序
|
||||
const apiKeyTotals = new Map();
|
||||
for (const point of trendData) {
|
||||
for (const [apiKeyId, data] of Object.entries(point.apiKeys)) {
|
||||
apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前10个使用量最多的API Key
|
||||
const topApiKeys = Array.from(apiKeyTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([apiKeyId]) => apiKeyId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trendData,
|
||||
granularity,
|
||||
topApiKeys,
|
||||
totalApiKeys: apiKeyTotals.size
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys usage trend:', error);
|
||||
res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 计算总体使用费用
|
||||
router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -217,7 +217,7 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
|
||||
try {
|
||||
const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'));
|
||||
const oldData = { ...initData }; // 备份旧数据
|
||||
// const oldData = { ...initData }; // 备份旧数据
|
||||
|
||||
// 更新 init.json
|
||||
initData.adminUsername = updatedUsername;
|
||||
@@ -252,12 +252,12 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
// 清除当前会话(强制用户重新登录)
|
||||
await redis.deleteSession(token);
|
||||
|
||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedAdminData.username}`);
|
||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password changed successfully. Please login again.',
|
||||
newUsername: updatedAdminData.username
|
||||
newUsername: updatedUsername
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -228,22 +228,35 @@ class ClaudeAccountService {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts();
|
||||
|
||||
// 处理返回数据,移除敏感信息
|
||||
return accounts.map(account => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description,
|
||||
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
|
||||
isActive: account.isActive === 'true',
|
||||
proxy: account.proxy ? JSON.parse(account.proxy) : null,
|
||||
status: account.status,
|
||||
errorMessage: account.errorMessage,
|
||||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||
createdAt: account.createdAt,
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
lastRefreshAt: account.lastRefreshAt,
|
||||
expiresAt: account.expiresAt
|
||||
// 处理返回数据,移除敏感信息并添加限流状态
|
||||
const processedAccounts = await Promise.all(accounts.map(async account => {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description,
|
||||
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
|
||||
isActive: account.isActive === 'true',
|
||||
proxy: account.proxy ? JSON.parse(account.proxy) : null,
|
||||
status: account.status,
|
||||
errorMessage: account.errorMessage,
|
||||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||
createdAt: account.createdAt,
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
lastRefreshAt: account.lastRefreshAt,
|
||||
expiresAt: account.expiresAt,
|
||||
// 添加限流状态信息
|
||||
rateLimitStatus: rateLimitInfo ? {
|
||||
isRateLimited: rateLimitInfo.isRateLimited,
|
||||
rateLimitedAt: rateLimitInfo.rateLimitedAt,
|
||||
minutesRemaining: rateLimitInfo.minutesRemaining
|
||||
} : null
|
||||
};
|
||||
}));
|
||||
|
||||
return processedAccounts;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude accounts:', error);
|
||||
throw error;
|
||||
@@ -405,8 +418,15 @@ class ClaudeAccountService {
|
||||
// 验证映射的账户是否仍然在共享池中且可用
|
||||
const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId);
|
||||
if (mappedAccount) {
|
||||
logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`);
|
||||
return mappedAccountId;
|
||||
// 如果映射的账户被限流了,删除映射并重新选择
|
||||
const isRateLimited = await this.isAccountRateLimited(mappedAccountId);
|
||||
if (isRateLimited) {
|
||||
logger.warn(`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`);
|
||||
await redis.deleteSessionAccountMapping(sessionHash);
|
||||
} else {
|
||||
logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`);
|
||||
return mappedAccountId;
|
||||
}
|
||||
} else {
|
||||
logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`);
|
||||
// 清理无效的映射
|
||||
@@ -415,21 +435,54 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 从共享池选择账户(负载均衡)
|
||||
const sortedAccounts = sharedAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
});
|
||||
const selectedAccountId = sortedAccounts[0].id;
|
||||
// 将账户分为限流和非限流两组
|
||||
const nonRateLimitedAccounts = [];
|
||||
const rateLimitedAccounts = [];
|
||||
|
||||
for (const account of sharedAccounts) {
|
||||
const isRateLimited = await this.isAccountRateLimited(account.id);
|
||||
if (isRateLimited) {
|
||||
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id);
|
||||
account._rateLimitInfo = rateLimitInfo; // 临时存储限流信息
|
||||
rateLimitedAccounts.push(account);
|
||||
} else {
|
||||
nonRateLimitedAccounts.push(account);
|
||||
}
|
||||
}
|
||||
|
||||
// 优先从非限流账户中选择
|
||||
let candidateAccounts = nonRateLimitedAccounts;
|
||||
|
||||
// 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先)
|
||||
if (candidateAccounts.length === 0) {
|
||||
logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool');
|
||||
candidateAccounts = rateLimitedAccounts.sort((a, b) => {
|
||||
const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime();
|
||||
const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime();
|
||||
return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先
|
||||
});
|
||||
} else {
|
||||
// 非限流账户按最近刷新时间排序
|
||||
candidateAccounts = candidateAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
});
|
||||
}
|
||||
|
||||
if (candidateAccounts.length === 0) {
|
||||
throw new Error('No available shared Claude accounts');
|
||||
}
|
||||
|
||||
const selectedAccountId = candidateAccounts[0].id;
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期
|
||||
logger.info(`🎯 Created new sticky session mapping for shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||||
logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||||
}
|
||||
|
||||
logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`);
|
||||
logger.info(`🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`);
|
||||
return selectedAccountId;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to select account for API key:', error);
|
||||
@@ -570,6 +623,118 @@ class ClaudeAccountService {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账号为限流状态
|
||||
async markAccountRateLimited(accountId, sessionHash = null) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
// 设置限流状态和时间
|
||||
accountData.rateLimitedAt = new Date().toISOString();
|
||||
accountData.rateLimitStatus = 'limited';
|
||||
await redis.setClaudeAccount(accountId, accountData);
|
||||
|
||||
// 如果有会话哈希,删除粘性会话映射
|
||||
if (sessionHash) {
|
||||
await redis.deleteSessionAccountMapping(sessionHash);
|
||||
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`);
|
||||
}
|
||||
|
||||
logger.warn(`🚫 Account marked as rate limited: ${accountData.name} (${accountId})`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账号的限流状态
|
||||
async removeAccountRateLimit(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
// 清除限流状态
|
||||
delete accountData.rateLimitedAt;
|
||||
delete accountData.rateLimitStatus;
|
||||
await redis.setClaudeAccount(accountId, accountData);
|
||||
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账号是否处于限流状态
|
||||
async isAccountRateLimited(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有限流状态
|
||||
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
|
||||
const rateLimitedAt = new Date(accountData.rateLimitedAt);
|
||||
const now = new Date();
|
||||
const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60);
|
||||
|
||||
// 如果限流超过1小时,自动解除
|
||||
if (hoursSinceRateLimit >= 1) {
|
||||
await this.removeAccountRateLimit(accountId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取账号的限流信息
|
||||
async getAccountRateLimitInfo(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
|
||||
const rateLimitedAt = new Date(accountData.rateLimitedAt);
|
||||
const now = new Date();
|
||||
const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60));
|
||||
const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit);
|
||||
|
||||
return {
|
||||
isRateLimited: minutesRemaining > 0,
|
||||
rateLimitedAt: accountData.rateLimitedAt,
|
||||
minutesSinceRateLimit,
|
||||
minutesRemaining
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesSinceRateLimit: 0,
|
||||
minutesRemaining: 0
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeAccountService();
|
||||
@@ -72,6 +72,35 @@ class ClaudeRelayService {
|
||||
clientResponse.removeListener('close', handleClientDisconnect);
|
||||
}
|
||||
|
||||
// 检查响应是否为限流错误
|
||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||
let isRateLimited = false;
|
||||
try {
|
||||
const responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
|
||||
if (responseBody && responseBody.error && responseBody.error.message &&
|
||||
responseBody.error.message.toLowerCase().includes('exceed your account\'s rate limit')) {
|
||||
isRateLimited = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,检查原始字符串
|
||||
if (response.body && response.body.toLowerCase().includes('exceed your account\'s rate limit')) {
|
||||
isRateLimited = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRateLimited) {
|
||||
logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`);
|
||||
// 标记账号为限流状态并删除粘性会话映射
|
||||
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
|
||||
}
|
||||
} else if (response.statusCode === 200 || response.statusCode === 201) {
|
||||
// 如果请求成功,检查并移除限流状态
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
|
||||
if (isRateLimited) {
|
||||
await claudeAccountService.removeAccountRateLimit(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
// 记录成功的API调用
|
||||
const inputTokens = requestBody.messages ?
|
||||
requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算
|
||||
@@ -408,7 +437,7 @@ class ClaudeRelayService {
|
||||
const proxyAgent = await this._getProxyAgent(accountId);
|
||||
|
||||
// 发送流式请求并捕获usage数据
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback);
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash);
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||
throw error;
|
||||
@@ -416,7 +445,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback) {
|
||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl);
|
||||
|
||||
@@ -457,6 +486,7 @@ class ClaudeRelayService {
|
||||
let buffer = '';
|
||||
let finalUsageReported = false; // 防止重复统计的标志
|
||||
let collectedUsageData = {}; // 收集来自不同事件的usage数据
|
||||
let rateLimitDetected = false; // 限流检测标志
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
res.on('data', (chunk) => {
|
||||
@@ -517,6 +547,13 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有限流错误
|
||||
if (data.type === 'error' && data.error && data.error.message &&
|
||||
data.error.message.toLowerCase().includes('exceed your account\'s rate limit')) {
|
||||
rateLimitDetected = true;
|
||||
logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`);
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// 忽略JSON解析错误,继续处理
|
||||
logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100));
|
||||
@@ -525,7 +562,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
res.on('end', async () => {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
responseStream.write(buffer);
|
||||
@@ -537,6 +574,18 @@ class ClaudeRelayService {
|
||||
logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.');
|
||||
}
|
||||
|
||||
// 处理限流状态
|
||||
if (rateLimitDetected || res.statusCode === 429) {
|
||||
// 标记账号为限流状态并删除粘性会话映射
|
||||
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
|
||||
} else if (res.statusCode === 200) {
|
||||
// 如果请求成功,检查并移除限流状态
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
|
||||
if (isRateLimited) {
|
||||
await claudeAccountService.removeAccountRateLimit(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('🌊 Claude stream response with usage capture completed');
|
||||
resolve();
|
||||
});
|
||||
|
||||
473
web/admin/app.js
473
web/admin/app.js
@@ -77,6 +77,15 @@ const app = createApp({
|
||||
usageTrendChart: null,
|
||||
trendPeriod: 7,
|
||||
trendData: [],
|
||||
trendGranularity: 'day', // 新增:趋势图粒度(day/hour)
|
||||
|
||||
// API Keys 使用趋势
|
||||
apiKeysUsageTrendChart: null,
|
||||
apiKeysTrendData: {
|
||||
data: [],
|
||||
topApiKeys: [],
|
||||
totalApiKeys: 0
|
||||
},
|
||||
|
||||
// 统一的日期筛选
|
||||
dateFilter: {
|
||||
@@ -91,6 +100,10 @@ const app = createApp({
|
||||
{ value: '30days', label: '近30天', days: 30 }
|
||||
]
|
||||
},
|
||||
defaultTime: [
|
||||
new Date(2000, 1, 1, 0, 0, 0),
|
||||
new Date(2000, 2, 1, 23, 59, 59),
|
||||
],
|
||||
showDateRangePicker: false, // 日期范围选择器显示状态
|
||||
dateRangeInputValue: '', // 日期范围显示文本
|
||||
|
||||
@@ -247,8 +260,11 @@ const app = createApp({
|
||||
// 初始化日期筛选器和图表数据
|
||||
this.initializeDateFilter();
|
||||
|
||||
// 预加载账号列表,以便在API Keys页面能正确显示绑定账号名称
|
||||
this.loadAccounts().then(() => {
|
||||
// 预加载账号列表和API Keys,以便正确显示绑定关系
|
||||
Promise.all([
|
||||
this.loadAccounts(),
|
||||
this.loadApiKeys()
|
||||
]).then(() => {
|
||||
// 根据当前活跃标签页加载数据
|
||||
this.loadCurrentTabData();
|
||||
});
|
||||
@@ -257,6 +273,7 @@ const app = createApp({
|
||||
this.waitForChartJS().then(() => {
|
||||
this.loadDashboardModelStats();
|
||||
this.loadUsageTrend();
|
||||
this.loadApiKeysUsageTrend();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -422,6 +439,10 @@ const app = createApp({
|
||||
// 验证账户类型切换
|
||||
if (this.editAccountForm.accountType === 'shared' &&
|
||||
this.editAccountForm.originalAccountType === 'dedicated') {
|
||||
// 确保API Keys数据已加载,以便正确计算绑定数量
|
||||
if (this.apiKeys.length === 0) {
|
||||
await this.loadApiKeys();
|
||||
}
|
||||
const boundKeysCount = this.getBoundApiKeysCount(this.editAccountForm.id);
|
||||
if (boundKeysCount > 0) {
|
||||
this.showToast(`无法切换到共享账户,该账户绑定了 ${boundKeysCount} 个API Key,请先解绑所有API Key`, 'error', '切换失败');
|
||||
@@ -756,6 +777,7 @@ const app = createApp({
|
||||
this.waitForChartJS().then(() => {
|
||||
this.loadDashboardModelStats();
|
||||
this.loadUsageTrend();
|
||||
this.loadApiKeysUsageTrend();
|
||||
});
|
||||
break;
|
||||
case 'apiKeys':
|
||||
@@ -766,7 +788,11 @@ const app = createApp({
|
||||
]);
|
||||
break;
|
||||
case 'accounts':
|
||||
this.loadAccounts();
|
||||
// 加载账户时同时加载API Keys,以便正确计算绑定数量
|
||||
Promise.all([
|
||||
this.loadAccounts(),
|
||||
this.loadApiKeys()
|
||||
]);
|
||||
break;
|
||||
case 'models':
|
||||
this.loadModelStats();
|
||||
@@ -819,6 +845,19 @@ const app = createApp({
|
||||
}
|
||||
this.usageTrendChart = null;
|
||||
}
|
||||
|
||||
// 清理API Keys使用趋势图表
|
||||
if (this.apiKeysUsageTrendChart) {
|
||||
try {
|
||||
// 先停止所有动画
|
||||
this.apiKeysUsageTrendChart.stop();
|
||||
// 再销毁图表
|
||||
this.apiKeysUsageTrendChart.destroy();
|
||||
} catch (error) {
|
||||
console.warn('Error destroying API keys usage trend chart:', error);
|
||||
}
|
||||
this.apiKeysUsageTrendChart = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 检查DOM元素是否存在且有效
|
||||
@@ -1017,6 +1056,7 @@ const app = createApp({
|
||||
activeApiKeys: overview.activeApiKeys || 0,
|
||||
totalAccounts: overview.totalClaudeAccounts || 0,
|
||||
activeAccounts: overview.activeClaudeAccounts || 0,
|
||||
rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0,
|
||||
todayRequests: recentActivity.requestsToday || 0,
|
||||
totalRequests: overview.totalRequestsUsed || 0,
|
||||
todayTokens: recentActivity.tokensToday || 0,
|
||||
@@ -1263,6 +1303,11 @@ const app = createApp({
|
||||
},
|
||||
|
||||
async deleteAccount(accountId) {
|
||||
// 确保API Keys数据已加载,以便正确计算绑定数量
|
||||
if (this.apiKeys.length === 0) {
|
||||
await this.loadApiKeys();
|
||||
}
|
||||
|
||||
// 检查是否有API Key绑定到此账号
|
||||
const boundKeysCount = this.getBoundApiKeysCount(accountId);
|
||||
if (boundKeysCount > 0) {
|
||||
@@ -1529,11 +1574,68 @@ const app = createApp({
|
||||
await this.loadUsageTrend();
|
||||
},
|
||||
|
||||
// 加载API Keys使用趋势数据
|
||||
async loadApiKeysUsageTrend() {
|
||||
console.log('Loading API keys usage trend data, granularity:', this.trendGranularity);
|
||||
try {
|
||||
let url = '/admin/api-keys-usage-trend?';
|
||||
|
||||
if (this.trendGranularity === 'hour') {
|
||||
// 小时粒度,传递开始和结束时间
|
||||
url += `granularity=hour`;
|
||||
if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) {
|
||||
url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`;
|
||||
url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`;
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
url += `granularity=day&days=${this.trendPeriod}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('API keys usage trend API error:', response.status, response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.apiKeysTrendData = {
|
||||
data: data.data || [],
|
||||
topApiKeys: data.topApiKeys || [],
|
||||
totalApiKeys: data.totalApiKeys || 0
|
||||
};
|
||||
console.log('Loaded API keys trend data:', this.apiKeysTrendData);
|
||||
this.updateApiKeysUsageTrendChart();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys usage trend:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 加载使用趋势数据
|
||||
async loadUsageTrend() {
|
||||
console.log('Loading usage trend data, period:', this.trendPeriod, 'authToken:', !!this.authToken);
|
||||
console.log('Loading usage trend data, period:', this.trendPeriod, 'granularity:', this.trendGranularity, 'authToken:', !!this.authToken);
|
||||
try {
|
||||
const response = await fetch('/admin/usage-trend?days=' + this.trendPeriod, {
|
||||
let url = '/admin/usage-trend?';
|
||||
|
||||
if (this.trendGranularity === 'hour') {
|
||||
// 小时粒度,传递开始和结束时间
|
||||
url += `granularity=hour`;
|
||||
if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) {
|
||||
url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`;
|
||||
url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`;
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
url += `granularity=day&days=${this.trendPeriod}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
});
|
||||
|
||||
@@ -1601,7 +1703,23 @@ const app = createApp({
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = this.trendData.map(item => item.date);
|
||||
// 根据粒度格式化标签
|
||||
const labels = this.trendData.map(item => {
|
||||
if (this.trendGranularity === 'hour') {
|
||||
// 小时粒度:从hour字段提取时间
|
||||
if (item.hour) {
|
||||
const date = new Date(item.hour);
|
||||
return `${String(date.getHours()).padStart(2, '0')}:00`;
|
||||
}
|
||||
// 后备方案:从date字段解析
|
||||
const [, time] = item.date.split(':');
|
||||
return `${time}:00`;
|
||||
} else {
|
||||
// 天粒度:显示日期
|
||||
return item.date;
|
||||
}
|
||||
});
|
||||
|
||||
const inputData = this.trendData.map(item => item.inputTokens || 0);
|
||||
const outputData = this.trendData.map(item => item.outputTokens || 0);
|
||||
const cacheCreateData = this.trendData.map(item => item.cacheCreateTokens || 0);
|
||||
@@ -1676,6 +1794,19 @@ const app = createApp({
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: this.trendGranularity === 'hour' ? '时间' : '日期'
|
||||
},
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxRotation: this.trendGranularity === 'hour' ? 45 : 0,
|
||||
minRotation: this.trendGranularity === 'hour' ? 45 : 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
@@ -1711,6 +1842,25 @@ const app = createApp({
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => {
|
||||
if (tooltipItems.length === 0) return '';
|
||||
const index = tooltipItems[0].dataIndex;
|
||||
const item = this.trendData[index];
|
||||
|
||||
if (this.trendGranularity === 'hour' && item.hour) {
|
||||
// 小时粒度:显示完整的日期时间
|
||||
const date = new Date(item.hour);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
// 天粒度:保持原有标签
|
||||
return tooltipItems[0].label;
|
||||
},
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || '';
|
||||
let value = context.parsed.y;
|
||||
@@ -1739,6 +1889,178 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
// 更新API Keys使用趋势图
|
||||
updateApiKeysUsageTrendChart() {
|
||||
// 检查Chart.js是否已加载
|
||||
if (typeof Chart === 'undefined') {
|
||||
console.warn('Chart.js not loaded yet, retrying...');
|
||||
setTimeout(() => this.updateApiKeysUsageTrendChart(), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 严格检查DOM元素是否有效
|
||||
if (!this.isElementValid('apiKeysUsageTrendChart')) {
|
||||
console.error('API keys usage trend chart canvas element not found or invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('apiKeysUsageTrendChart');
|
||||
|
||||
// 安全销毁现有图表
|
||||
if (this.apiKeysUsageTrendChart) {
|
||||
try {
|
||||
this.apiKeysUsageTrendChart.destroy();
|
||||
} catch (error) {
|
||||
console.warn('Error destroying API keys usage trend chart:', error);
|
||||
}
|
||||
this.apiKeysUsageTrendChart = null;
|
||||
}
|
||||
|
||||
// 如果没有数据,不创建图表
|
||||
if (!this.apiKeysTrendData.data || this.apiKeysTrendData.data.length === 0) {
|
||||
console.warn('No API keys trend data available, skipping chart creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备数据
|
||||
const labels = this.apiKeysTrendData.data.map(item => {
|
||||
if (this.trendGranularity === 'hour') {
|
||||
const date = new Date(item.hour);
|
||||
return `${String(date.getHours()).padStart(2, '0')}:00`;
|
||||
}
|
||||
return item.date;
|
||||
});
|
||||
|
||||
// 获取所有API Key的数据集
|
||||
const datasets = [];
|
||||
const colors = [
|
||||
'rgb(102, 126, 234)',
|
||||
'rgb(240, 147, 251)',
|
||||
'rgb(59, 130, 246)',
|
||||
'rgb(147, 51, 234)',
|
||||
'rgb(34, 197, 94)',
|
||||
'rgb(251, 146, 60)',
|
||||
'rgb(239, 68, 68)',
|
||||
'rgb(16, 185, 129)',
|
||||
'rgb(245, 158, 11)',
|
||||
'rgb(236, 72, 153)'
|
||||
];
|
||||
|
||||
// 只显示前10个使用量最多的API Key
|
||||
this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => {
|
||||
const data = this.apiKeysTrendData.data.map(item => {
|
||||
return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0;
|
||||
});
|
||||
|
||||
// 获取API Key名称
|
||||
const apiKeyName = this.apiKeysTrendData.data.find(item =>
|
||||
item.apiKeys[apiKeyId]
|
||||
)?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`;
|
||||
|
||||
datasets.push({
|
||||
label: apiKeyName,
|
||||
data: data,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length] + '20',
|
||||
tension: 0.3,
|
||||
fill: false
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// 最后一次检查元素有效性
|
||||
if (!this.isElementValid('apiKeysUsageTrendChart')) {
|
||||
throw new Error('Canvas element is not valid for chart creation');
|
||||
}
|
||||
|
||||
this.apiKeysUsageTrendChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false, // 禁用动画防止异步渲染问题
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: this.trendGranularity === 'hour' ? '时间' : '日期'
|
||||
},
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxRotation: this.trendGranularity === 'hour' ? 45 : 0,
|
||||
minRotation: this.trendGranularity === 'hour' ? 45 : 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token 数量'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => {
|
||||
if (tooltipItems.length === 0) return '';
|
||||
const index = tooltipItems[0].dataIndex;
|
||||
const item = this.apiKeysTrendData.data[index];
|
||||
|
||||
if (this.trendGranularity === 'hour' && item.hour) {
|
||||
const date = new Date(item.hour);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
return tooltipItems[0].label;
|
||||
},
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
return label + ': ' + value.toLocaleString() + ' tokens';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating API keys usage trend chart:', error);
|
||||
this.apiKeysUsageTrendChart = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 切换API Key模型统计展开状态
|
||||
toggleApiKeyModelStats(keyId) {
|
||||
if (!keyId) {
|
||||
@@ -1933,20 +2255,51 @@ const app = createApp({
|
||||
// 根据预设计算并设置自定义时间框的值
|
||||
const option = this.dateFilter.presetOptions.find(opt => opt.value === preset);
|
||||
if (option) {
|
||||
const today = new Date();
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (option.days - 1));
|
||||
const now = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
if (this.trendGranularity === 'hour') {
|
||||
// 小时粒度的预设处理
|
||||
if (preset === 'last24h') {
|
||||
endDate = new Date(now);
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
} else if (preset === 'yesterday') {
|
||||
// 昨天的00:00到23:59
|
||||
startDate = new Date(now);
|
||||
startDate.setDate(startDate.getDate() - 1);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
endDate = new Date(startDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
} else if (preset === 'dayBefore') {
|
||||
// 前天的00:00到23:59
|
||||
startDate = new Date(now);
|
||||
startDate.setDate(startDate.getDate() - 2);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
endDate = new Date(startDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
} else {
|
||||
// 天粒度的预设处理(保持原有逻辑)
|
||||
endDate = new Date(now);
|
||||
startDate = new Date(now);
|
||||
startDate.setDate(now.getDate() - (option.days - 1));
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
// 格式化为 Element Plus 需要的格式
|
||||
const formatDate = (date) => {
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + ' 00:00:00';
|
||||
String(date.getDate()).padStart(2, '0') + ' ' +
|
||||
String(date.getHours()).padStart(2, '0') + ':' +
|
||||
String(date.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(date.getSeconds()).padStart(2, '0');
|
||||
};
|
||||
|
||||
this.dateFilter.customRange = [
|
||||
formatDate(startDate),
|
||||
formatDate(today)
|
||||
formatDate(endDate)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2105,6 +2458,61 @@ const app = createApp({
|
||||
// 重新加载数据
|
||||
this.loadDashboardModelStats();
|
||||
this.loadUsageTrend();
|
||||
this.loadApiKeysUsageTrend();
|
||||
},
|
||||
|
||||
// 设置趋势图粒度
|
||||
setTrendGranularity(granularity) {
|
||||
console.log('Setting trend granularity to:', granularity);
|
||||
this.trendGranularity = granularity;
|
||||
|
||||
// 根据粒度更新预设选项
|
||||
if (granularity === 'hour') {
|
||||
this.dateFilter.presetOptions = [
|
||||
{ value: 'last24h', label: '近24小时', hours: 24 },
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
];
|
||||
|
||||
// 检查当前自定义日期范围是否超过24小时
|
||||
if (this.dateFilter.type === 'custom' && this.dateFilter.customRange && this.dateFilter.customRange.length === 2) {
|
||||
const start = new Date(this.dateFilter.customRange[0]);
|
||||
const end = new Date(this.dateFilter.customRange[1]);
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff > 24) {
|
||||
this.showToast('切换到小时粒度,日期范围已调整为近24小时', 'info');
|
||||
this.dateFilter.preset = 'last24h';
|
||||
this.setDateFilterPreset('last24h');
|
||||
}
|
||||
} else if (['today', '7days', '30days'].includes(this.dateFilter.preset)) {
|
||||
// 预设不兼容,切换到近24小时
|
||||
this.dateFilter.preset = 'last24h';
|
||||
this.setDateFilterPreset('last24h');
|
||||
}
|
||||
} else {
|
||||
// 恢复天粒度的选项
|
||||
this.dateFilter.presetOptions = [
|
||||
{ value: 'today', label: '今天', days: 1 },
|
||||
{ value: '7days', label: '近7天', days: 7 },
|
||||
{ value: '30days', label: '近30天', days: 30 }
|
||||
];
|
||||
|
||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
||||
if (['last24h', 'yesterday', 'dayBefore'].includes(this.dateFilter.preset)) {
|
||||
this.dateFilter.preset = '7days';
|
||||
this.setDateFilterPreset('7days');
|
||||
} else if (this.dateFilter.type === 'custom') {
|
||||
// 自定义日期范围在天粒度下通常不需要调整,因为24小时肯定在31天内
|
||||
// 只需要重新加载数据
|
||||
this.refreshChartsData();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载数据
|
||||
this.loadUsageTrend();
|
||||
this.loadApiKeysUsageTrend();
|
||||
},
|
||||
|
||||
// API Keys 日期筛选方法
|
||||
@@ -2293,22 +2701,47 @@ const app = createApp({
|
||||
// 检查日期范围限制
|
||||
const start = new Date(value[0]);
|
||||
const end = new Date(value[1]);
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
if (daysDiff > 31) {
|
||||
this.showToast('日期范围不能超过31天', 'warning', '范围限制');
|
||||
// 重置为默认7天
|
||||
this.dateFilter.customRange = null;
|
||||
this.dateFilter.type = 'preset';
|
||||
this.dateFilter.preset = '7days';
|
||||
return;
|
||||
if (this.trendGranularity === 'hour') {
|
||||
// 小时粒度:限制24小时
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60);
|
||||
if (hoursDiff > 24) {
|
||||
this.showToast('小时粒度下日期范围不能超过24小时', 'warning', '范围限制');
|
||||
// 调整结束时间为开始时间后24小时
|
||||
const newEnd = new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
||||
const formatDate = (date) => {
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + ' ' +
|
||||
String(date.getHours()).padStart(2, '0') + ':' +
|
||||
String(date.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(date.getSeconds()).padStart(2, '0');
|
||||
};
|
||||
this.dateFilter.customRange = [
|
||||
formatDate(start),
|
||||
formatDate(newEnd)
|
||||
];
|
||||
this.dateFilter.customEnd = newEnd.toISOString().split('T')[0];
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 天粒度:限制31天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
if (daysDiff > 31) {
|
||||
this.showToast('日期范围不能超过31天', 'warning', '范围限制');
|
||||
// 重置为默认7天
|
||||
this.dateFilter.customRange = null;
|
||||
this.dateFilter.type = 'preset';
|
||||
this.dateFilter.preset = '7days';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshChartsData();
|
||||
} else if (value === null) {
|
||||
// 清空时恢复默认
|
||||
this.dateFilter.type = 'preset';
|
||||
this.dateFilter.preset = '7days';
|
||||
this.dateFilter.preset = this.trendGranularity === 'hour' ? 'last24h' : '7days';
|
||||
this.dateFilter.customStart = '';
|
||||
this.dateFilter.customEnd = '';
|
||||
this.refreshChartsData();
|
||||
|
||||
@@ -160,7 +160,12 @@
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-600 mb-1">Claude账户</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeAccounts || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
活跃: {{ dashboardData.activeAccounts || 0 }}
|
||||
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
|
||||
| 限流: {{ dashboardData.rateLimitedAccounts }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon bg-gradient-to-br from-green-500 to-green-600">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
@@ -292,21 +297,53 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换按钮 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
@click="setTrendGranularity('day')"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
trendGranularity === 'day'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-calendar-day mr-1"></i>按天
|
||||
</button>
|
||||
<button
|
||||
@click="setTrendGranularity('hour')"
|
||||
:class="[
|
||||
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
trendGranularity === 'hour'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>按小时
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Element Plus 日期范围选择器 -->
|
||||
<el-date-picker
|
||||
v-model="dateFilter.customRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
:disabled-date="disabledDate"
|
||||
size="default"
|
||||
style="width: 350px;"
|
||||
class="custom-date-picker"
|
||||
></el-date-picker>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-date-picker
|
||||
:default-time="defaultTime"
|
||||
v-model="dateFilter.customRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
:disabled-date="disabledDate"
|
||||
size="default"
|
||||
style="width: 350px;"
|
||||
class="custom-date-picker"
|
||||
></el-date-picker>
|
||||
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
|
||||
<i class="fas fa-info-circle"></i> 最多24小时
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
|
||||
<i class="fas fa-sync-alt"></i>刷新
|
||||
@@ -368,6 +405,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Token消耗趋势图 -->
|
||||
<div class="mb-8">
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Keys Token 消耗趋势</h3>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
<span v-if="apiKeysTrendData.totalApiKeys > 10">
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key,显示使用量前 10 个
|
||||
</span>
|
||||
<span v-else>
|
||||
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 350px;">
|
||||
<canvas id="apiKeysUsageTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys 管理 -->
|
||||
@@ -782,6 +837,11 @@
|
||||
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||
{{ account.isActive ? '正常' : '异常' }}
|
||||
</span>
|
||||
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
|
||||
</span>
|
||||
<span v-if="account.accountType === 'dedicated'"
|
||||
class="text-xs text-gray-500">
|
||||
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
|
||||
@@ -1703,7 +1763,7 @@
|
||||
|
||||
<!-- 创建 API Key 模态框 -->
|
||||
<div v-if="showCreateApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
@@ -1719,7 +1779,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createApiKey" class="space-y-6">
|
||||
<form @submit.prevent="createApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
@@ -1806,7 +1866,7 @@
|
||||
|
||||
<!-- 编辑 API Key 模态框 -->
|
||||
<div v-if="showEditApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
@@ -1822,7 +1882,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateApiKey" class="space-y-6">
|
||||
<form @submit.prevent="updateApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
|
||||
<input
|
||||
@@ -1900,7 +1960,7 @@
|
||||
|
||||
<!-- 新创建的 API Key 展示弹窗 -->
|
||||
<div v-if="showNewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-lg p-8 mx-auto">
|
||||
<div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
@@ -1994,7 +2054,7 @@
|
||||
|
||||
<!-- 创建 Claude 账户模态框 -->
|
||||
<div v-if="showCreateAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
@@ -2350,7 +2410,7 @@
|
||||
|
||||
<!-- 编辑 Claude 账户模态框 -->
|
||||
<div v-if="showEditAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
@@ -2544,7 +2604,7 @@
|
||||
|
||||
<!-- 修改账户信息模态框 -->
|
||||
<div v-if="showChangePasswordModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
@@ -2560,7 +2620,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="changePassword" class="space-y-6">
|
||||
<form @submit.prevent="changePassword" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
|
||||
<input
|
||||
|
||||
@@ -378,6 +378,43 @@ body::before {
|
||||
|
||||
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
.modal-scroll-content {
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass, .glass-strong {
|
||||
margin: 16px;
|
||||
@@ -392,4 +429,8 @@ body::before {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user