mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 21:17:30 +00:00
Merge branch 'main' into dev
This commit is contained in:
@@ -55,12 +55,15 @@ coverage/
|
|||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
# Build files
|
# Build files
|
||||||
dist/
|
# dist/ # 前端构建阶段需要复制源文件,所以不能忽略
|
||||||
build/
|
build/
|
||||||
*.pid
|
*.pid
|
||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
|
|
||||||
|
# 但可以忽略本地已构建的 dist 目录
|
||||||
|
web/admin-spa/dist/
|
||||||
|
|
||||||
# CI/CD
|
# CI/CD
|
||||||
.travis.yml
|
.travis.yml
|
||||||
.gitlab-ci.yml
|
.gitlab-ci.yml
|
||||||
|
|||||||
59
.github/workflows/auto-release-pipeline.yml
vendored
59
.github/workflows/auto-release-pipeline.yml
vendored
@@ -103,52 +103,7 @@ jobs:
|
|||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Check if frontend build is needed
|
# 前端构建已移至 Docker 构建流程中
|
||||||
id: check_frontend
|
|
||||||
if: steps.check.outputs.needs_bump == 'true'
|
|
||||||
run: |
|
|
||||||
# 检查 web/admin-spa 目录是否有变更
|
|
||||||
FRONTEND_CHANGES=false
|
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD)
|
|
||||||
|
|
||||||
while IFS= read -r file; do
|
|
||||||
if [[ "$file" =~ ^web/admin-spa/ ]] &&
|
|
||||||
[[ ! "$file" =~ ^web/admin-spa/dist/ ]] &&
|
|
||||||
[[ ! "$file" =~ \.(md|txt)$ ]] &&
|
|
||||||
[[ "$file" != "web/admin-spa/.gitignore" ]]; then
|
|
||||||
echo "Found frontend change in: $file"
|
|
||||||
FRONTEND_CHANGES=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done <<< "$CHANGED_FILES"
|
|
||||||
|
|
||||||
echo "needs_build=$FRONTEND_CHANGES" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
if: steps.check.outputs.needs_bump == 'true' && steps.check_frontend.outputs.needs_build == 'true'
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: web/admin-spa/package-lock.json
|
|
||||||
|
|
||||||
- name: Build admin-spa
|
|
||||||
if: steps.check.outputs.needs_bump == 'true' && steps.check_frontend.outputs.needs_build == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Building admin-spa frontend..."
|
|
||||||
cd web/admin-spa
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
# 确认 dist 目录已创建
|
|
||||||
if [ -d "web/admin-spa/dist" ]; then
|
|
||||||
echo "✅ Frontend build successful"
|
|
||||||
ls -la web/admin-spa/dist/
|
|
||||||
else
|
|
||||||
echo "❌ Frontend build failed - dist directory not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update VERSION file
|
- name: Update VERSION file
|
||||||
if: steps.check.outputs.needs_bump == 'true'
|
if: steps.check.outputs.needs_bump == 'true'
|
||||||
@@ -159,19 +114,9 @@ jobs:
|
|||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
# 检查是否需要添加 dist 目录
|
# 提交VERSION文件 - 添加 [skip ci] 以避免再次触发
|
||||||
if [ "${{ steps.check_frontend.outputs.needs_build }}" = "true" ] && [ -d "web/admin-spa/dist" ]; then
|
|
||||||
git add -f web/admin-spa/dist/
|
|
||||||
echo "Added frontend dist directory to commit"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 提交VERSION文件和可能的dist目录 - 添加 [skip ci] 以避免再次触发
|
|
||||||
git add VERSION
|
git add VERSION
|
||||||
if [ "${{ steps.check_frontend.outputs.needs_build }}" = "true" ]; then
|
|
||||||
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} and rebuild frontend [skip ci]"
|
|
||||||
else
|
|
||||||
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
|
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install git-cliff
|
- name: Install git-cliff
|
||||||
if: steps.check.outputs.needs_bump == 'true'
|
if: steps.check.outputs.needs_bump == 'true'
|
||||||
|
|||||||
23
Dockerfile
23
Dockerfile
@@ -1,4 +1,22 @@
|
|||||||
# 🐳 使用官方 Node.js 18 Alpine 镜像
|
# 🎯 前端构建阶段
|
||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
|
# 📁 设置工作目录
|
||||||
|
WORKDIR /app/web/admin-spa
|
||||||
|
|
||||||
|
# 📦 复制前端依赖文件
|
||||||
|
COPY web/admin-spa/package*.json ./
|
||||||
|
|
||||||
|
# 🔽 安装前端依赖
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 📋 复制前端源代码
|
||||||
|
COPY web/admin-spa/ ./
|
||||||
|
|
||||||
|
# 🏗️ 构建前端
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 🐳 主应用阶段
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
# 📋 设置标签
|
# 📋 设置标签
|
||||||
@@ -26,6 +44,9 @@ RUN npm ci --only=production && \
|
|||||||
# 📋 复制应用代码
|
# 📋 复制应用代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# 📦 从构建阶段复制前端产物
|
||||||
|
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
||||||
|
|
||||||
# 🔧 复制并设置启动脚本权限
|
# 🔧 复制并设置启动脚本权限
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -269,7 +269,17 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第四步:启动服务
|
### 第四步:安装前端依赖并构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装前端依赖
|
||||||
|
npm run install:web
|
||||||
|
|
||||||
|
# 构建前端(生成 dist 目录)
|
||||||
|
npm run build:web
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第五步:启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 初始化
|
# 初始化
|
||||||
@@ -526,10 +536,14 @@ git add package-lock.json
|
|||||||
# 3. 安装新的依赖(如果有)
|
# 3. 安装新的依赖(如果有)
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 4. 重启服务
|
# 4. 安装并构建前端
|
||||||
|
npm run install:web
|
||||||
|
npm run build:web
|
||||||
|
|
||||||
|
# 5. 重启服务
|
||||||
npm run service:restart:daemon
|
npm run service:restart:daemon
|
||||||
|
|
||||||
# 5. 检查服务状态
|
# 6. 检查服务状态
|
||||||
npm run service:status
|
npm run service:status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -458,6 +458,10 @@ EOF
|
|||||||
print_info "安装Web界面依赖..."
|
print_info "安装Web界面依赖..."
|
||||||
npm run install:web
|
npm run install:web
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
print_info "构建前端界面..."
|
||||||
|
npm run build:web
|
||||||
|
|
||||||
# 创建systemd服务文件(Linux)
|
# 创建systemd服务文件(Linux)
|
||||||
if [[ "$OS" == "debian" || "$OS" == "redhat" || "$OS" == "arch" ]]; then
|
if [[ "$OS" == "debian" || "$OS" == "redhat" || "$OS" == "arch" ]]; then
|
||||||
create_systemd_service
|
create_systemd_service
|
||||||
@@ -547,6 +551,10 @@ update_service() {
|
|||||||
npm install
|
npm install
|
||||||
npm run install:web
|
npm run install:web
|
||||||
|
|
||||||
|
# 构建前端
|
||||||
|
print_info "构建前端界面..."
|
||||||
|
npm run build:web
|
||||||
|
|
||||||
# 启动服务
|
# 启动服务
|
||||||
start_service
|
start_service
|
||||||
|
|
||||||
@@ -938,7 +946,14 @@ handle_menu_choice() {
|
|||||||
create_symlink() {
|
create_symlink() {
|
||||||
# 获取脚本的绝对路径
|
# 获取脚本的绝对路径
|
||||||
local script_path=""
|
local script_path=""
|
||||||
if command_exists realpath; then
|
|
||||||
|
# 优先使用项目中的 manage.sh(在 app/scripts 目录下)
|
||||||
|
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then
|
||||||
|
script_path="$APP_DIR/scripts/manage.sh"
|
||||||
|
elif [ -f "/app/scripts/manage.sh" ] && [ "$(basename "$0")" = "manage.sh" ]; then
|
||||||
|
# Docker 容器中的路径
|
||||||
|
script_path="/app/scripts/manage.sh"
|
||||||
|
elif command_exists realpath; then
|
||||||
script_path="$(realpath "$0")"
|
script_path="$(realpath "$0")"
|
||||||
elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then
|
elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then
|
||||||
script_path="$(readlink -f "$0")"
|
script_path="$(readlink -f "$0")"
|
||||||
@@ -950,11 +965,22 @@ create_symlink() {
|
|||||||
local symlink_path="/usr/bin/crs"
|
local symlink_path="/usr/bin/crs"
|
||||||
|
|
||||||
print_info "创建命令行快捷方式..."
|
print_info "创建命令行快捷方式..."
|
||||||
|
print_info "APP_DIR: $APP_DIR"
|
||||||
print_info "脚本路径: $script_path"
|
print_info "脚本路径: $script_path"
|
||||||
|
|
||||||
# 检查脚本文件是否存在
|
# 检查脚本文件是否存在
|
||||||
if [ ! -f "$script_path" ]; then
|
if [ ! -f "$script_path" ]; then
|
||||||
print_error "找不到脚本文件: $script_path"
|
print_error "找不到脚本文件: $script_path"
|
||||||
|
print_info "当前目录: $(pwd)"
|
||||||
|
print_info "脚本参数 \$0: $0"
|
||||||
|
if [ -n "$APP_DIR" ]; then
|
||||||
|
print_info "检查项目目录结构:"
|
||||||
|
ls -la "$APP_DIR/" 2>/dev/null | head -5
|
||||||
|
if [ -d "$APP_DIR/scripts" ]; then
|
||||||
|
print_info "scripts 目录内容:"
|
||||||
|
ls -la "$APP_DIR/scripts/" 2>/dev/null | grep manage.sh
|
||||||
|
fi
|
||||||
|
fi
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1004,7 +1030,15 @@ load_config() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$INSTALL_DIR" ]; then
|
if [ -n "$INSTALL_DIR" ]; then
|
||||||
|
# 检查是否使用了标准的安装结构(项目在 app 子目录)
|
||||||
|
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
|
||||||
APP_DIR="$INSTALL_DIR/app"
|
APP_DIR="$INSTALL_DIR/app"
|
||||||
|
# 检查是否直接克隆了项目(项目在根目录)
|
||||||
|
elif [ -f "$INSTALL_DIR/package.json" ]; then
|
||||||
|
APP_DIR="$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
APP_DIR="$INSTALL_DIR/app"
|
||||||
|
fi
|
||||||
|
|
||||||
# 加载.env配置
|
# 加载.env配置
|
||||||
if [ -f "$APP_DIR/.env" ]; then
|
if [ -f "$APP_DIR/.env" ]; then
|
||||||
@@ -1077,6 +1111,12 @@ main() {
|
|||||||
;;
|
;;
|
||||||
symlink)
|
symlink)
|
||||||
# 单独创建软链接
|
# 单独创建软链接
|
||||||
|
# 确保 APP_DIR 已设置
|
||||||
|
if [ -z "$APP_DIR" ]; then
|
||||||
|
print_error "请先安装项目后再创建软链接"
|
||||||
|
print_info "运行: $0 install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
create_symlink
|
create_symlink
|
||||||
;;
|
;;
|
||||||
help)
|
help)
|
||||||
|
|||||||
@@ -1327,19 +1327,20 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req,
|
|||||||
try {
|
try {
|
||||||
const { state } = req.body;
|
const { state } = req.body;
|
||||||
|
|
||||||
// 使用固定的 localhost:45462 作为回调地址
|
// 使用新的 codeassist.google.com 回调地址
|
||||||
const redirectUri = 'http://localhost:45462';
|
const redirectUri = 'https://codeassist.google.com/authcode';
|
||||||
|
|
||||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`);
|
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`);
|
||||||
|
|
||||||
const { authUrl, state: authState } = await geminiAccountService.generateAuthUrl(state, redirectUri);
|
const { authUrl, state: authState, codeVerifier, redirectUri: finalRedirectUri } = await geminiAccountService.generateAuthUrl(state, redirectUri);
|
||||||
|
|
||||||
// 创建 OAuth 会话
|
// 创建 OAuth 会话,包含 codeVerifier
|
||||||
const sessionId = authState;
|
const sessionId = authState;
|
||||||
await redis.setOAuthSession(sessionId, {
|
await redis.setOAuthSession(sessionId, {
|
||||||
state: authState,
|
state: authState,
|
||||||
type: 'gemini',
|
type: 'gemini',
|
||||||
redirectUri: redirectUri, // 保存固定的 redirect_uri 用于 token 交换
|
redirectUri: finalRedirectUri,
|
||||||
|
codeVerifier: codeVerifier, // 保存 PKCE code verifier
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1389,11 +1390,20 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
|
|||||||
return res.status(400).json({ error: 'Authorization code is required' });
|
return res.status(400).json({ error: 'Authorization code is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用固定的 localhost:45462 作为 redirect_uri
|
let redirectUri = 'https://codeassist.google.com/authcode';
|
||||||
const redirectUri = 'http://localhost:45462';
|
let codeVerifier = null;
|
||||||
logger.info(`Using fixed redirect_uri: ${redirectUri}`);
|
|
||||||
|
|
||||||
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri);
|
// 如果提供了 sessionId,从 OAuth 会话中获取信息
|
||||||
|
if (sessionId) {
|
||||||
|
const sessionData = await redis.getOAuthSession(sessionId);
|
||||||
|
if (sessionData) {
|
||||||
|
redirectUri = sessionData.redirectUri || redirectUri;
|
||||||
|
codeVerifier = sessionData.codeVerifier;
|
||||||
|
logger.info(`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier);
|
||||||
|
|
||||||
// 清理 OAuth 会话
|
// 清理 OAuth 会话
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -1731,7 +1741,7 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
searchPatterns = [pattern];
|
searchPatterns = [pattern];
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📊 Searching patterns:`, searchPatterns);
|
logger.info('📊 Searching patterns:', searchPatterns);
|
||||||
|
|
||||||
// 获取所有匹配的keys
|
// 获取所有匹配的keys
|
||||||
const allKeys = [];
|
const allKeys = [];
|
||||||
|
|||||||
@@ -77,19 +77,31 @@ function createOAuth2Client(redirectUri = null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成授权 URL
|
// 生成授权 URL (支持 PKCE)
|
||||||
async function generateAuthUrl(state = null, redirectUri = null) {
|
async function generateAuthUrl(state = null, redirectUri = null) {
|
||||||
const oAuth2Client = createOAuth2Client(redirectUri);
|
// 使用新的 redirect URI
|
||||||
|
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode';
|
||||||
|
const oAuth2Client = createOAuth2Client(finalRedirectUri);
|
||||||
|
|
||||||
|
// 生成 PKCE code verifier
|
||||||
|
const codeVerifier = await oAuth2Client.generateCodeVerifierAsync();
|
||||||
|
const stateValue = state || crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
|
redirect_uri: finalRedirectUri,
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
scope: OAUTH_SCOPES,
|
scope: OAUTH_SCOPES,
|
||||||
prompt: 'select_account',
|
code_challenge_method: 'S256',
|
||||||
state: state || uuidv4()
|
code_challenge: codeVerifier.codeChallenge,
|
||||||
|
state: stateValue,
|
||||||
|
prompt: 'select_account'
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authUrl,
|
authUrl,
|
||||||
state: state || authUrl.split('state=')[1].split('&')[0]
|
state: stateValue,
|
||||||
|
codeVerifier: codeVerifier.codeVerifier,
|
||||||
|
redirectUri: finalRedirectUri
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,12 +157,22 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 交换授权码获取 tokens
|
// 交换授权码获取 tokens (支持 PKCE)
|
||||||
async function exchangeCodeForTokens(code, redirectUri = null) {
|
async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) {
|
||||||
const oAuth2Client = createOAuth2Client(redirectUri);
|
const oAuth2Client = createOAuth2Client(redirectUri);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tokens } = await oAuth2Client.getToken(code);
|
const tokenParams = {
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果提供了 codeVerifier,添加到参数中
|
||||||
|
if (codeVerifier) {
|
||||||
|
tokenParams.codeVerifier = codeVerifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tokens } = await oAuth2Client.getToken(tokenParams);
|
||||||
|
|
||||||
// 转换为兼容格式
|
// 转换为兼容格式
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
.accounts-container[data-v-981f21d9]{min-height:calc(100vh - 300px)}.table-container[data-v-981f21d9]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-981f21d9]{transition:all .2s ease}.table-row[data-v-981f21d9]:hover{background-color:#00000005}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
pre[data-v-2c02f1f7]{white-space:pre-wrap;word-wrap:break-word}.tab-content[data-v-277a81a9]{min-height:calc(100vh - 300px)}.table-container[data-v-277a81a9]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-277a81a9]{transition:all .2s ease}.table-row[data-v-277a81a9]:hover{background-color:#00000005}.loading-spinner[data-v-277a81a9]{width:24px;height:24px;border:2px solid #e5e7eb;border-top:2px solid #3b82f6;border-radius:50%;animation:spin-277a81a9 1s linear infinite}@keyframes spin-277a81a9{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.api-key-date-picker[data-v-277a81a9] .el-input__inner{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.api-key-date-picker[data-v-277a81a9] .el-input__inner:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1));--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.api-key-date-picker[data-v-277a81a9] .el-range-separator{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.custom-date-picker[data-v-e2cbd0e3] .el-input__inner{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.custom-date-picker[data-v-e2cbd0e3] .el-input__inner:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1));--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.custom-date-picker[data-v-e2cbd0e3] .el-input__inner{font-size:13px;padding:0 10px}.custom-date-picker[data-v-e2cbd0e3] .el-range-separator{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1));padding:0 2px}.custom-date-picker[data-v-e2cbd0e3] .el-range-input{font-size:13px}@keyframes spin-e2cbd0e3{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin[data-v-e2cbd0e3]{animation:spin-e2cbd0e3 1s linear infinite}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{c as b,r as x,q as f,x as a,z as s,L as i,Q as y,u as o,P as m,Y as _,K as u,aq as c,O as g,y as n}from"./vue-vendor-CKToUHZx.js";import{_ as v,u as w}from"./index-9AMT1Op2.js";/* empty css */import"./element-plus-B8Fs_0jW.js";import"./vendor-BDiMbLwQ.js";const h={class:"flex items-center justify-center min-h-screen p-6"},k={class:"glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl"},L={class:"text-center mb-8"},S={class:"w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden"},V=["src"],I={key:1,class:"fas fa-cloud text-3xl text-gray-700"},N={key:1,class:"w-12 h-12 bg-gray-300/50 rounded animate-pulse"},q={key:0,class:"text-3xl font-bold text-white mb-2 header-title"},D={key:1,class:"h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"},E=["disabled"],j={key:0,class:"fas fa-sign-in-alt mr-2"},B={key:1,class:"loading-spinner mr-2"},M={key:0,class:"mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm"},F={__name:"LoginView",setup(O){const e=w(),d=b(()=>e.oemLoading),l=x({username:"",password:""});f(()=>{e.loadOemSettings()});const p=async()=>{await e.login(l.value)};return(T,t)=>(n(),a("div",h,[s("div",k,[s("div",L,[s("div",S,[d.value?(n(),a("div",N)):(n(),a(y,{key:0},[o(e).oemSettings.siteIconData||o(e).oemSettings.siteIcon?(n(),a("img",{key:0,src:o(e).oemSettings.siteIconData||o(e).oemSettings.siteIcon,alt:"Logo",class:"w-12 h-12 object-contain",onError:t[0]||(t[0]=r=>r.target.style.display="none")},null,40,V)):(n(),a("i",I))],64))]),!d.value&&o(e).oemSettings.siteName?(n(),a("h1",q,m(o(e).oemSettings.siteName),1)):d.value?(n(),a("div",D)):i("",!0),t[3]||(t[3]=s("p",{class:"text-gray-600 text-lg"}," 管理后台 ",-1))]),s("form",{class:"space-y-6",onSubmit:_(p,["prevent"])},[s("div",null,[t[4]||(t[4]=s("label",{class:"block text-sm font-semibold text-gray-900 mb-3"},"用户名",-1)),u(s("input",{"onUpdate:modelValue":t[1]||(t[1]=r=>l.value.username=r),type:"text",required:"",class:"form-input w-full",placeholder:"请输入用户名"},null,512),[[c,l.value.username]])]),s("div",null,[t[5]||(t[5]=s("label",{class:"block text-sm font-semibold text-gray-900 mb-3"},"密码",-1)),u(s("input",{"onUpdate:modelValue":t[2]||(t[2]=r=>l.value.password=r),type:"password",required:"",class:"form-input w-full",placeholder:"请输入密码"},null,512),[[c,l.value.password]])]),s("button",{type:"submit",disabled:o(e).loginLoading,class:"btn btn-primary w-full py-4 px-6 text-lg font-semibold"},[o(e).loginLoading?i("",!0):(n(),a("i",j)),o(e).loginLoading?(n(),a("div",B)):i("",!0),g(" "+m(o(e).loginLoading?"登录中...":"登录"),1)],8,E)],32),o(e).loginError?(n(),a("div",M,[t[6]||(t[6]=s("i",{class:"fas fa-exclamation-triangle mr-2"},null,-1)),g(m(o(e).loginError),1)])):i("",!0)])]))}},P=v(F,[["__scopeId","data-v-82195a01"]]);export{P as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/* empty css */import{_ as r}from"./index-9AMT1Op2.js";import{x as t,y as s,z as o,Q as d,L as a,A as c,C as g,P as i}from"./vue-vendor-CKToUHZx.js";const u={class:"flex items-center gap-4"},f={class:"w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden"},y=["src"],m={key:1,class:"fas fa-cloud text-xl text-gray-700"},h={key:1,class:"w-8 h-8 bg-gray-300/50 rounded animate-pulse"},x={class:"flex flex-col justify-center min-h-[48px]"},b={class:"flex items-center gap-3"},k={key:1,class:"h-8 w-64 bg-gray-300/50 rounded animate-pulse"},_={key:0,class:"text-gray-600 text-sm leading-tight mt-0.5"},S={__name:"LogoTitle",props:{loading:{type:Boolean,default:!1},title:{type:String,default:""},subtitle:{type:String,default:""},logoSrc:{type:String,default:""},titleClass:{type:String,default:"text-gray-900"}},setup(e){const n=l=>{l.target.style.display="none"};return(l,p)=>(s(),t("div",u,[o("div",f,[e.loading?(s(),t("div",h)):(s(),t(d,{key:0},[e.logoSrc?(s(),t("img",{key:0,src:e.logoSrc,alt:"Logo",class:"w-8 h-8 object-contain",onError:n},null,40,y)):(s(),t("i",m))],64))]),o("div",x,[o("div",b,[!e.loading&&e.title?(s(),t("h1",{key:0,class:g(["text-2xl font-bold header-title leading-tight",e.titleClass])},i(e.title),3)):e.loading?(s(),t("div",k)):a("",!0),c(l.$slots,"after-title",{},void 0,!0)]),e.subtitle?(s(),t("p",_,i(e.subtitle),1)):a("",!0)])]))}},C=r(S,[["__scopeId","data-v-718feedc"]]);export{C as L};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.user-menu-dropdown[data-v-590374fc]{margin-top:8px}.fade-enter-active[data-v-590374fc],.fade-leave-active[data-v-590374fc]{transition:opacity .3s}.fade-enter-from[data-v-590374fc],.fade-leave-to[data-v-590374fc]{opacity:0}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.settings-container[data-v-d29d5f49]{min-height:calc(100vh - 300px)}.card[data-v-d29d5f49]{background:#fff;border-radius:12px;box-shadow:0 2px 12px #0000001a;border:1px solid #e5e7eb}.table-container[data-v-d29d5f49]{overflow:hidden;border-radius:8px;border:1px solid #f3f4f6}.table-row[data-v-d29d5f49]{transition:background-color .2s ease}.table-row[data-v-d29d5f49]:hover{background-color:#f9fafb}.form-input[data-v-d29d5f49]{width:100%;border-radius:.5rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));padding:.5rem 1rem;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.form-input[data-v-d29d5f49]:focus{border-color:transparent;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn[data-v-d29d5f49]{display:inline-flex;align-items:center;justify-content:center;border-radius:.5rem;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.btn[data-v-d29d5f49]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-offset-width: 2px}.btn-primary[data-v-d29d5f49]{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.btn-primary[data-v-d29d5f49]:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.btn-primary[data-v-d29d5f49]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn-success[data-v-d29d5f49]{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.btn-success[data-v-d29d5f49]:hover{--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.btn-success[data-v-d29d5f49]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1))}.loading-spinner[data-v-d29d5f49]{height:1.25rem;width:1.25rem}@keyframes spin-d29d5f49{to{transform:rotate(360deg)}}.loading-spinner[data-v-d29d5f49]{animation:spin-d29d5f49 1s linear infinite;border-radius:9999px;border-width:2px;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-border-opacity: 1;border-top-color:rgb(37 99 235 / var(--tw-border-opacity, 1))}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import{aR as k,r as x,aW as C,q as O,x as m,z as e,u as i,K as T,aq as N,L as _,O as v,C as j,P as S,y as g}from"./vue-vendor-CKToUHZx.js";import{s as c}from"./toast-BvwA7Mwb.js";import{a as D,_ as F}from"./index-9AMT1Op2.js";import"./element-plus-B8Fs_0jW.js";import"./vendor-BDiMbLwQ.js";const E=k("settings",()=>{const l=x({siteName:"Claude Relay Service",siteIcon:"",siteIconData:"",updatedAt:null}),r=x(!1),p=x(!1),d=async()=>{r.value=!0;try{const s=await D.get("/admin/oem-settings");return s&&s.success&&(l.value={...l.value,...s.data},f()),s}catch(s){throw console.error("Failed to load OEM settings:",s),s}finally{r.value=!1}},a=async s=>{p.value=!0;try{const o=await D.put("/admin/oem-settings",s);return o&&o.success&&(l.value={...l.value,...o.data},f()),o}catch(o){throw console.error("Failed to save OEM settings:",o),o}finally{p.value=!1}},w=async()=>{const s={siteName:"Claude Relay Service",siteIcon:"",siteIconData:"",updatedAt:null};return l.value={...s},await a(s)},f=()=>{if(l.value.siteName&&(document.title=`${l.value.siteName} - 管理后台`),l.value.siteIconData||l.value.siteIcon){const s=document.querySelector('link[rel="icon"]')||document.createElement("link");s.rel="icon",s.href=l.value.siteIconData||l.value.siteIcon,document.querySelector('link[rel="icon"]')||document.head.appendChild(s)}};return{oemSettings:l,loading:r,saving:p,loadOemSettings:d,saveOemSettings:a,resetOemSettings:w,applyOemSettings:f,formatDateTime:s=>s?new Date(s).toLocaleString("zh-CN",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"",validateIconFile:s=>{const o=[];return s.size>350*1024&&o.push("图标文件大小不能超过 350KB"),["image/x-icon","image/png","image/jpeg","image/jpg","image/svg+xml"].includes(s.type)||o.push("不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件"),{isValid:o.length===0,errors:o}},fileToBase64:s=>new Promise((o,n)=>{const t=new FileReader;t.onload=u=>o(u.target.result),t.onerror=n,t.readAsDataURL(s)})}}),B={class:"settings-container"},V={class:"card p-6"},R={key:0,class:"text-center py-12"},M={key:1,class:"table-container"},A={class:"min-w-full"},q={class:"divide-y divide-gray-200/50"},z={class:"table-row"},K={class:"px-6 py-4"},L={class:"table-row"},U={class:"px-6 py-4"},$={class:"space-y-3"},P={key:0,class:"inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg"},W=["src"],G={class:"px-6 py-6",colspan:"2"},H={class:"flex items-center justify-between"},J={class:"flex gap-3"},Q=["disabled"],X={key:0,class:"loading-spinner mr-2"},Y={key:1,class:"fas fa-save mr-2"},Z=["disabled"],ee={key:0,class:"text-sm text-gray-500"},te={__name:"SettingsView",setup(l){const r=E(),{loading:p,saving:d,oemSettings:a}=C(r),w=x();O(async()=>{try{await r.loadOemSettings()}catch{c("加载设置失败","error")}});const f=async()=>{try{const n={siteName:a.value.siteName,siteIcon:a.value.siteIcon,siteIconData:a.value.siteIconData},t=await r.saveOemSettings(n);t&&t.success?c("OEM设置保存成功","success"):c((t==null?void 0:t.message)||"保存失败","error")}catch{c("保存OEM设置失败","error")}},b=async()=>{if(confirm(`确定要重置为默认设置吗?
|
|
||||||
|
|
||||||
这将清除所有自定义的网站名称和图标设置。`))try{const n=await r.resetOemSettings();n&&n.success?c("已重置为默认设置","success"):c("重置失败","error")}catch{c("重置失败","error")}},h=async n=>{const t=n.target.files[0];if(!t)return;const u=r.validateIconFile(t);if(!u.isValid){u.errors.forEach(y=>c(y,"error"));return}try{const y=await r.fileToBase64(t);a.value.siteIconData=y}catch{c("文件读取失败","error")}n.target.value=""},I=()=>{a.value.siteIcon="",a.value.siteIconData=""},s=()=>{console.warn("Icon failed to load")},o=r.formatDateTime;return(n,t)=>(g(),m("div",B,[e("div",V,[t[12]||(t[12]=e("div",{class:"flex flex-col md:flex-row justify-between items-center gap-4 mb-6"},[e("div",null,[e("h3",{class:"text-xl font-bold text-gray-900 mb-2"}," 其他设置 "),e("p",{class:"text-gray-600"}," 自定义网站名称和图标 ")])],-1)),i(p)?(g(),m("div",R,t[2]||(t[2]=[e("div",{class:"loading-spinner mx-auto mb-4"},null,-1),e("p",{class:"text-gray-500"}," 正在加载设置... ",-1)]))):(g(),m("div",M,[e("table",A,[e("tbody",q,[e("tr",z,[t[4]||(t[4]=e("td",{class:"px-6 py-4 whitespace-nowrap w-48"},[e("div",{class:"flex items-center"},[e("div",{class:"w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3"},[e("i",{class:"fas fa-font text-white text-xs"})]),e("div",null,[e("div",{class:"text-sm font-semibold text-gray-900"}," 网站名称 "),e("div",{class:"text-xs text-gray-500"}," 品牌标识 ")])])],-1)),e("td",K,[T(e("input",{"onUpdate:modelValue":t[0]||(t[0]=u=>i(a).siteName=u),type:"text",class:"form-input w-full max-w-md",placeholder:"Claude Relay Service",maxlength:"100"},null,512),[[N,i(a).siteName]]),t[3]||(t[3]=e("p",{class:"text-xs text-gray-500 mt-1"}," 将显示在浏览器标题和页面头部 ",-1))])]),e("tr",L,[t[9]||(t[9]=e("td",{class:"px-6 py-4 whitespace-nowrap w-48"},[e("div",{class:"flex items-center"},[e("div",{class:"w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3"},[e("i",{class:"fas fa-image text-white text-xs"})]),e("div",null,[e("div",{class:"text-sm font-semibold text-gray-900"}," 网站图标 "),e("div",{class:"text-xs text-gray-500"}," Favicon ")])])],-1)),e("td",U,[e("div",$,[i(a).siteIconData||i(a).siteIcon?(g(),m("div",P,[e("img",{src:i(a).siteIconData||i(a).siteIcon,alt:"图标预览",class:"w-8 h-8",onError:s},null,40,W),t[6]||(t[6]=e("span",{class:"text-sm text-gray-600"},"当前图标",-1)),e("button",{class:"text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors",onClick:I},t[5]||(t[5]=[e("i",{class:"fas fa-trash mr-1"},null,-1),v("删除 ",-1)]))])):_("",!0),e("div",null,[e("input",{ref_key:"iconFileInput",ref:w,type:"file",accept:".ico,.png,.jpg,.jpeg,.svg",class:"hidden",onChange:h},null,544),e("button",{class:"btn btn-success px-4 py-2",onClick:t[1]||(t[1]=u=>n.$refs.iconFileInput.click())},t[7]||(t[7]=[e("i",{class:"fas fa-upload mr-2"},null,-1),v(" 上传图标 ",-1)])),t[8]||(t[8]=e("span",{class:"text-xs text-gray-500 ml-3"},"支持 .ico, .png, .jpg, .svg 格式,最大 350KB",-1))])])])]),e("tr",null,[e("td",G,[e("div",H,[e("div",J,[e("button",{disabled:i(d),class:j(["btn btn-primary px-6 py-3",{"opacity-50 cursor-not-allowed":i(d)}]),onClick:f},[i(d)?(g(),m("div",X)):(g(),m("i",Y)),v(" "+S(i(d)?"保存中...":"保存设置"),1)],10,Q),e("button",{class:"btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3",disabled:i(d),onClick:b},t[10]||(t[10]=[e("i",{class:"fas fa-undo mr-2"},null,-1),v(" 重置为默认 ",-1)]),8,Z)]),i(a).updatedAt?(g(),m("div",ee,[t[11]||(t[11]=e("i",{class:"fas fa-clock mr-1"},null,-1)),v(" 最后更新:"+S(i(o)(i(a).updatedAt)),1)])):_("",!0)])])])])])]))])]))}},le=F(te,[["__scopeId","data-v-d29d5f49"]]);export{le as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.tutorial-container[data-v-508c8654]{min-height:calc(100vh - 300px)}.tutorial-content[data-v-508c8654]{animation:fadeIn-508c8654 .3s ease-in-out}@keyframes fadeIn-508c8654{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}code[data-v-508c8654]{font-family:Fira Code,Monaco,Menlo,Ubuntu Mono,monospace}.tutorial-content h4[data-v-508c8654]{scroll-margin-top:100px}.tutorial-content .bg-gradient-to-r[data-v-508c8654]{transition:all .2s ease}.tutorial-content .bg-gradient-to-r[data-v-508c8654]:hover{transform:translateY(-1px);box-shadow:0 4px 12px #0000001a}
|
|
||||||
File diff suppressed because one or more lines are too long
2
web/admin-spa/dist/assets/index-9AMT1Op2.js
vendored
2
web/admin-spa/dist/assets/index-9AMT1Op2.js
vendored
File diff suppressed because one or more lines are too long
5
web/admin-spa/dist/assets/index-V6aqLFfH.css
vendored
5
web/admin-spa/dist/assets/index-V6aqLFfH.css
vendored
File diff suppressed because one or more lines are too long
31
web/admin-spa/dist/index.html
vendored
31
web/admin-spa/dist/index.html
vendored
@@ -1,31 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Claude Relay Service - 管理后台</title>
|
|
||||||
|
|
||||||
<!-- Google Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Font Awesome -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
|
|
||||||
<!-- 预连接到CDN域名,加速资源加载 -->
|
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
|
||||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
|
||||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
|
||||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
|
||||||
<script type="module" crossorigin src="/admin-next/assets/index-9AMT1Op2.js"></script>
|
|
||||||
<link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js">
|
|
||||||
<link rel="stylesheet" crossorigin href="/admin-next/assets/element-plus-CPnoEkWW.css">
|
|
||||||
<link rel="stylesheet" crossorigin href="/admin-next/assets/index-V6aqLFfH.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -183,7 +183,9 @@
|
|||||||
class="form-input flex-1"
|
class="form-input flex-1"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">请选择分组</option>
|
<option value="">
|
||||||
|
请选择分组
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="group in filteredGroups"
|
v-for="group in filteredGroups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
@@ -191,14 +193,19 @@
|
|||||||
>
|
>
|
||||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||||
</option>
|
</option>
|
||||||
<option value="__new__">+ 新建分组</option>
|
<option value="__new__">
|
||||||
|
+ 新建分组
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
@click="refreshGroups"
|
@click="refreshGroups"
|
||||||
>
|
>
|
||||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
|
<i
|
||||||
|
class="fas fa-sync-alt"
|
||||||
|
:class="{ 'animate-spin': loadingGroups }"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,92 +295,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">模型映射表 (可选)</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">支持的模型 (可选)--注意,ClaudeCode必须加上hiku模型!</label>
|
||||||
<div class="bg-blue-50 p-3 rounded-lg mb-3">
|
<div class="mb-2 flex gap-2">
|
||||||
<p class="text-xs text-blue-700">
|
|
||||||
<i class="fas fa-info-circle mr-1" />
|
|
||||||
留空表示支持所有模型且不修改请求。配置映射后,左侧模型会被识别为支持的模型,右侧是实际发送的模型。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模型映射表 -->
|
|
||||||
<div class="space-y-2 mb-3">
|
|
||||||
<div
|
|
||||||
v-for="(mapping, index) in modelMappings"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="mapping.from"
|
|
||||||
type="text"
|
|
||||||
class="form-input flex-1"
|
|
||||||
placeholder="原始模型名称"
|
|
||||||
>
|
|
||||||
<i class="fas fa-arrow-right text-gray-400" />
|
|
||||||
<input
|
|
||||||
v-model="mapping.to"
|
|
||||||
type="text"
|
|
||||||
class="form-input flex-1"
|
|
||||||
placeholder="映射后的模型名称"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
@click="removeModelMapping(index)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 添加映射按钮 -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-4 py-2 border-2 border-dashed border-gray-300 text-gray-600 rounded-lg hover:border-gray-400 hover:text-gray-700 transition-colors"
|
|
||||||
@click="addModelMapping"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus mr-2" />
|
|
||||||
添加模型映射
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 快捷添加按钮 -->
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||||
@click="addPresetMapping('claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20241022')"
|
@click="addPresetModel('claude-sonnet-4-20250514')"
|
||||||
>
|
>
|
||||||
+ Sonnet 3.5
|
+ claude-sonnet-4-20250514
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
@click="addPresetMapping('claude-3-opus-20240229', 'claude-3-opus-20240229')"
|
@click="addPresetModel('claude-opus-4-20250514')"
|
||||||
>
|
>
|
||||||
+ Opus 3
|
+ claude-opus-4-20250514
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
@click="addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')"
|
@click="addPresetModel('claude-3-5-haiku-20241022')"
|
||||||
>
|
>
|
||||||
+ Haiku 3.5
|
+ claude-3-5-haiku-20241022
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1 text-xs bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors"
|
|
||||||
@click="addPresetMapping('claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022')"
|
|
||||||
>
|
|
||||||
+ Sonnet 4 → 3.5
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1 text-xs bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
|
||||||
@click="addPresetMapping('claude-opus-4-20250514', 'claude-3-opus-20240229')"
|
|
||||||
>
|
|
||||||
+ Opus 4 → 3
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.supportedModels"
|
||||||
|
rows="3"
|
||||||
|
class="form-input w-full resize-none"
|
||||||
|
placeholder="每行一个模型,留空表示支持所有模型。特别注意,ClaudeCode必须加上hiku模型!"
|
||||||
|
/>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
留空表示支持所有模型。如果指定模型,请求中的模型不在列表内将不会调度到此账号
|
留空表示支持所有模型。如果指定模型,请求中的模型不在列表内将不会调度到此账号
|
||||||
</p>
|
</p>
|
||||||
@@ -617,7 +568,9 @@
|
|||||||
class="form-input flex-1"
|
class="form-input flex-1"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">请选择分组</option>
|
<option value="">
|
||||||
|
请选择分组
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="group in filteredGroups"
|
v-for="group in filteredGroups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
@@ -625,14 +578,19 @@
|
|||||||
>
|
>
|
||||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||||
</option>
|
</option>
|
||||||
<option value="__new__">+ 新建分组</option>
|
<option value="__new__">
|
||||||
|
+ 新建分组
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
@click="refreshGroups"
|
@click="refreshGroups"
|
||||||
>
|
>
|
||||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
|
<i
|
||||||
|
class="fas fa-sync-alt"
|
||||||
|
:class="{ 'animate-spin': loadingGroups }"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -697,92 +655,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">模型映射表 (可选)</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">支持的模型 (可选)</label>
|
||||||
<div class="bg-blue-50 p-3 rounded-lg mb-3">
|
<div class="mb-2 flex gap-2">
|
||||||
<p class="text-xs text-blue-700">
|
|
||||||
<i class="fas fa-info-circle mr-1" />
|
|
||||||
留空表示支持所有模型且不修改请求。配置映射后,左侧模型会被识别为支持的模型,右侧是实际发送的模型。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模型映射表 -->
|
|
||||||
<div class="space-y-2 mb-3">
|
|
||||||
<div
|
|
||||||
v-for="(mapping, index) in modelMappings"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="mapping.from"
|
|
||||||
type="text"
|
|
||||||
class="form-input flex-1"
|
|
||||||
placeholder="原始模型名称"
|
|
||||||
>
|
|
||||||
<i class="fas fa-arrow-right text-gray-400" />
|
|
||||||
<input
|
|
||||||
v-model="mapping.to"
|
|
||||||
type="text"
|
|
||||||
class="form-input flex-1"
|
|
||||||
placeholder="映射后的模型名称"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
@click="removeModelMapping(index)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 添加映射按钮 -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-4 py-2 border-2 border-dashed border-gray-300 text-gray-600 rounded-lg hover:border-gray-400 hover:text-gray-700 transition-colors"
|
|
||||||
@click="addModelMapping"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus mr-2" />
|
|
||||||
添加模型映射
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 快捷添加按钮 -->
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||||
@click="addPresetMapping('claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20241022')"
|
@click="addPresetModel('claude-sonnet-4-20250514')"
|
||||||
>
|
>
|
||||||
+ Sonnet 3.5
|
+ claude-sonnet-4-20250514
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
@click="addPresetMapping('claude-3-opus-20240229', 'claude-3-opus-20240229')"
|
@click="addPresetModel('claude-opus-4-20250514')"
|
||||||
>
|
>
|
||||||
+ Opus 3
|
+ claude-opus-4-20250514
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
@click="addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')"
|
@click="addPresetModel('claude-3-5-haiku-20241022')"
|
||||||
>
|
>
|
||||||
+ Haiku 3.5
|
+ claude-3-5-haiku-20241022
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1 text-xs bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors"
|
|
||||||
@click="addPresetMapping('claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022')"
|
|
||||||
>
|
|
||||||
+ Sonnet 4 → 3.5
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1 text-xs bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
|
||||||
@click="addPresetMapping('claude-opus-4-20250514', 'claude-3-opus-20240229')"
|
|
||||||
>
|
|
||||||
+ Opus 4 → 3
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.supportedModels"
|
||||||
|
rows="3"
|
||||||
|
class="form-input w-full resize-none"
|
||||||
|
placeholder="每行一个模型,留空表示支持所有模型。特别注意,ClaudeCode必须加上hiku模型!"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -968,32 +870,11 @@ const form = ref({
|
|||||||
apiUrl: props.account?.apiUrl || '',
|
apiUrl: props.account?.apiUrl || '',
|
||||||
apiKey: props.account?.apiKey || '',
|
apiKey: props.account?.apiKey || '',
|
||||||
priority: props.account?.priority || 50,
|
priority: props.account?.priority || 50,
|
||||||
|
supportedModels: props.account?.supportedModels?.join('\n') || '',
|
||||||
userAgent: props.account?.userAgent || '',
|
userAgent: props.account?.userAgent || '',
|
||||||
rateLimitDuration: props.account?.rateLimitDuration || 60
|
rateLimitDuration: props.account?.rateLimitDuration || 60
|
||||||
})
|
})
|
||||||
|
|
||||||
// 模型映射表数据
|
|
||||||
const modelMappings = ref([])
|
|
||||||
|
|
||||||
// 初始化模型映射表
|
|
||||||
const initModelMappings = () => {
|
|
||||||
if (props.account?.supportedModels) {
|
|
||||||
// 如果是对象格式(新的映射表)
|
|
||||||
if (typeof props.account.supportedModels === 'object' && !Array.isArray(props.account.supportedModels)) {
|
|
||||||
modelMappings.value = Object.entries(props.account.supportedModels).map(([from, to]) => ({
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
}))
|
|
||||||
} else if (Array.isArray(props.account.supportedModels)) {
|
|
||||||
// 如果是数组格式(旧格式),转换为映射表
|
|
||||||
modelMappings.value = props.account.supportedModels.map(model => ({
|
|
||||||
from: model,
|
|
||||||
to: model
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const errors = ref({
|
const errors = ref({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -1188,7 +1069,9 @@ const createAccount = async () => {
|
|||||||
data.apiUrl = form.value.apiUrl
|
data.apiUrl = form.value.apiUrl
|
||||||
data.apiKey = form.value.apiKey
|
data.apiKey = form.value.apiKey
|
||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.supportedModels = convertMappingsToObject() || {}
|
data.supportedModels = form.value.supportedModels
|
||||||
|
? form.value.supportedModels.split('\n').filter(m => m.trim())
|
||||||
|
: []
|
||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
data.rateLimitDuration = form.value.rateLimitDuration || 60
|
data.rateLimitDuration = form.value.rateLimitDuration || 60
|
||||||
}
|
}
|
||||||
@@ -1305,7 +1188,9 @@ const updateAccount = async () => {
|
|||||||
data.apiKey = form.value.apiKey
|
data.apiKey = form.value.apiKey
|
||||||
}
|
}
|
||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.supportedModels = convertMappingsToObject() || {}
|
data.supportedModels = form.value.supportedModels
|
||||||
|
? form.value.supportedModels.split('\n').filter(m => m.trim())
|
||||||
|
: []
|
||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
data.rateLimitDuration = form.value.rateLimitDuration || 60
|
data.rateLimitDuration = form.value.rateLimitDuration || 60
|
||||||
}
|
}
|
||||||
@@ -1421,44 +1306,28 @@ watch(() => form.value.groupId, (newGroupId) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加模型映射
|
// 添加预设模型
|
||||||
const addModelMapping = () => {
|
const addPresetModel = (modelName) => {
|
||||||
modelMappings.value.push({ from: '', to: '' })
|
// 获取当前模型列表
|
||||||
}
|
const currentModels = form.value.supportedModels
|
||||||
|
? form.value.supportedModels.split('\n').filter(m => m.trim())
|
||||||
|
: []
|
||||||
|
|
||||||
// 移除模型映射
|
// 检查是否已存在
|
||||||
const removeModelMapping = (index) => {
|
if (currentModels.includes(modelName)) {
|
||||||
modelMappings.value.splice(index, 1)
|
showToast(`模型 ${modelName} 已存在`, 'info')
|
||||||
}
|
|
||||||
|
|
||||||
// 添加预设映射
|
|
||||||
const addPresetMapping = (from, to) => {
|
|
||||||
// 检查是否已存在相同的映射
|
|
||||||
const exists = modelMappings.value.some(mapping => mapping.from === from)
|
|
||||||
if (exists) {
|
|
||||||
showToast(`模型 ${from} 的映射已存在`, 'info')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelMappings.value.push({ from, to })
|
// 添加到列表
|
||||||
showToast(`已添加映射: ${from} → ${to}`, 'success')
|
currentModels.push(modelName)
|
||||||
}
|
form.value.supportedModels = currentModels.join('\n')
|
||||||
|
showToast(`已添加模型 ${modelName}`, 'success')
|
||||||
// 将模型映射表转换为对象格式
|
|
||||||
const convertMappingsToObject = () => {
|
|
||||||
const mapping = {}
|
|
||||||
modelMappings.value.forEach(item => {
|
|
||||||
if (item.from && item.to) {
|
|
||||||
mapping[item.from] = item.to
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Object.keys(mapping).length > 0 ? mapping : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听账户变化,更新表单
|
// 监听账户变化,更新表单
|
||||||
watch(() => props.account, (newAccount) => {
|
watch(() => props.account, (newAccount) => {
|
||||||
if (newAccount) {
|
if (newAccount) {
|
||||||
initModelMappings()
|
|
||||||
// 重新初始化代理配置
|
// 重新初始化代理配置
|
||||||
const proxyConfig = newAccount.proxy && newAccount.proxy.host && newAccount.proxy.port
|
const proxyConfig = newAccount.proxy && newAccount.proxy.host && newAccount.proxy.port
|
||||||
? {
|
? {
|
||||||
@@ -1493,6 +1362,7 @@ watch(() => props.account, (newAccount) => {
|
|||||||
apiUrl: newAccount.apiUrl || '',
|
apiUrl: newAccount.apiUrl || '',
|
||||||
apiKey: '', // 编辑模式不显示现有的 API Key
|
apiKey: '', // 编辑模式不显示现有的 API Key
|
||||||
priority: newAccount.priority || 50,
|
priority: newAccount.priority || 50,
|
||||||
|
supportedModels: newAccount.supportedModels?.join('\n') || '',
|
||||||
userAgent: newAccount.userAgent || '',
|
userAgent: newAccount.userAgent || '',
|
||||||
rateLimitDuration: newAccount.rateLimitDuration || 60
|
rateLimitDuration: newAccount.rateLimitDuration || 60
|
||||||
}
|
}
|
||||||
@@ -1514,7 +1384,4 @@ watch(() => props.account, (newAccount) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// 初始化时调用
|
|
||||||
initModelMappings()
|
|
||||||
</script>
|
</script>
|
||||||
@@ -38,7 +38,9 @@
|
|||||||
v-if="showCreateForm"
|
v-if="showCreateForm"
|
||||||
class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200"
|
class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200"
|
||||||
>
|
>
|
||||||
<h4 class="text-lg font-semibold text-gray-900 mb-4">创建新分组</h4>
|
<h4 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
创建新分组
|
||||||
|
</h4>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||||
@@ -113,7 +115,9 @@
|
|||||||
class="text-center py-8"
|
class="text-center py-8"
|
||||||
>
|
>
|
||||||
<div class="loading-spinner-lg mx-auto mb-4" />
|
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||||
<p class="text-gray-500">加载中...</p>
|
<p class="text-gray-500">
|
||||||
|
加载中...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -121,7 +125,9 @@
|
|||||||
class="text-center py-8 bg-gray-50 rounded-lg"
|
class="text-center py-8 bg-gray-50 rounded-lg"
|
||||||
>
|
>
|
||||||
<i class="fas fa-layer-group text-4xl text-gray-300 mb-4" />
|
<i class="fas fa-layer-group text-4xl text-gray-300 mb-4" />
|
||||||
<p class="text-gray-500">暂无分组</p>
|
<p class="text-gray-500">
|
||||||
|
暂无分组
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -135,8 +141,12 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
|
<h4 class="font-semibold text-gray-900">
|
||||||
<p class="text-sm text-gray-500 mt-1">{{ group.description || '暂无描述' }}</p>
|
{{ group.name }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ group.description || '暂无描述' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 ml-4">
|
<div class="flex items-center gap-2 ml-4">
|
||||||
<span
|
<span
|
||||||
@@ -194,7 +204,9 @@
|
|||||||
>
|
>
|
||||||
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
|
<h3 class="text-lg font-bold text-gray-900">
|
||||||
|
编辑分组
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
@click="cancelEdit"
|
@click="cancelEdit"
|
||||||
|
|||||||
@@ -213,19 +213,16 @@
|
|||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-medium text-green-900 mb-2">
|
<p class="font-medium text-blue-900 mb-2">
|
||||||
在浏览器中打开链接并完成授权
|
在浏览器中打开链接并完成授权
|
||||||
</p>
|
</p>
|
||||||
<ol class="text-sm text-green-800 space-y-1 list-decimal list-inside mb-3">
|
<p class="text-sm text-blue-700 mb-2">
|
||||||
<li>点击上方的授权链接,在新页面中完成Google账号登录</li>
|
请在新标签页中打开授权链接,登录您的 Gemini 账户并授权。
|
||||||
<li>点击“登录”按钮后可能会加载很慢(这是正常的)</li>
|
</p>
|
||||||
<li>如果超过1分钟还在加载,请按 F5 刷新页面</li>
|
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
|
||||||
<li>授权完成后会跳转到 http://localhost:45462 (可能显示无法访问)</li>
|
<p class="text-xs text-yellow-800">
|
||||||
</ol>
|
<i class="fas fa-exclamation-triangle mr-1" />
|
||||||
<div class="bg-green-100 p-3 rounded border border-green-300">
|
<strong>注意:</strong>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||||
<p class="text-xs text-green-700">
|
|
||||||
<i class="fas fa-lightbulb mr-1" />
|
|
||||||
<strong>提示:</strong>如果页面一直无法跳转,可以打开浏览器开发者工具(F12),F5刷新一下授权页再点击页面的登录按钮,在“网络”标签中找到以 localhost:45462 开头的请求,复制其完整URL。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,31 +237,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-medium text-green-900 mb-2">
|
<p class="font-medium text-green-900 mb-2">
|
||||||
复制oauth后的链接
|
输入 Authorization Code
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-green-700 mb-3">
|
<p class="text-sm text-green-700 mb-3">
|
||||||
复制浏览器地址栏的完整链接并粘贴到下方输入框:
|
授权完成后,页面会显示一个 Authorization Code,请将其复制并粘贴到下方输入框:
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
<i class="fas fa-key text-green-500 mr-2" />复制oauth后的链接
|
<i class="fas fa-key text-green-500 mr-2" />Authorization Code
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="authCode"
|
v-model="authCode"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="form-input w-full resize-none font-mono text-sm"
|
class="form-input w-full resize-none font-mono text-sm"
|
||||||
placeholder="粘贴以 http://localhost:45462 开头的完整链接..."
|
placeholder="粘贴从Gemini页面获取的Authorization Code..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 space-y-1">
|
<div class="mt-2 space-y-1">
|
||||||
<p class="text-xs text-gray-600">
|
<p class="text-xs text-gray-600">
|
||||||
<i class="fas fa-check-circle text-green-500 mr-1" />
|
<i class="fas fa-check-circle text-green-500 mr-1" />
|
||||||
支持粘贴完整链接,系统会自动提取授权码
|
请粘贴从Gemini页面复制的Authorization Code
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-600">
|
|
||||||
<i class="fas fa-check-circle text-green-500 mr-1" />
|
|
||||||
也可以直接粘贴授权码(code参数的值)
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -534,6 +534,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
@@ -551,6 +552,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close', 'success'])
|
const emit = defineEmits(['close', 'success'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
const apiKeysStore = useApiKeysStore()
|
const apiKeysStore = useApiKeysStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -636,6 +638,8 @@ const updateApiKey = async () => {
|
|||||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
|
claudeAccountId: form.claudeAccountId || null,
|
||||||
|
geminiAccountId: form.geminiAccountId || null,
|
||||||
tags: form.tags
|
tags: form.tags
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,25 +651,6 @@ const updateApiKey = async () => {
|
|||||||
data.enableClientRestriction = form.enableClientRestriction
|
data.enableClientRestriction = form.enableClientRestriction
|
||||||
data.allowedClients = form.allowedClients
|
data.allowedClients = form.allowedClients
|
||||||
|
|
||||||
// 处理Claude账户绑定(区分OAuth和Console)
|
|
||||||
if (form.claudeAccountId) {
|
|
||||||
if (form.claudeAccountId.startsWith('console:')) {
|
|
||||||
// Claude Console账户
|
|
||||||
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
|
|
||||||
data.claudeAccountId = null; // 清空OAuth绑定
|
|
||||||
} else {
|
|
||||||
// Claude OAuth账户
|
|
||||||
data.claudeAccountId = form.claudeAccountId;
|
|
||||||
data.claudeConsoleAccountId = null; // 清空Console绑定
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data.claudeAccountId = null;
|
|
||||||
data.claudeConsoleAccountId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini账户绑定
|
|
||||||
data.geminiAccountId = form.geminiAccountId || null;
|
|
||||||
|
|
||||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -762,15 +747,7 @@ onMounted(async () => {
|
|||||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||||
form.permissions = props.apiKey.permissions || 'all'
|
form.permissions = props.apiKey.permissions || 'all'
|
||||||
// 处理Claude账户绑定初始化
|
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||||
if (props.apiKey.claudeAccountId) {
|
|
||||||
form.claudeAccountId = props.apiKey.claudeAccountId;
|
|
||||||
} else if (props.apiKey.claudeConsoleAccountId) {
|
|
||||||
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`;
|
|
||||||
} else {
|
|
||||||
form.claudeAccountId = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||||
form.allowedClients = props.apiKey.allowedClients || []
|
form.allowedClients = props.apiKey.allowedClients || []
|
||||||
|
|||||||
360
web/admin-spa/src/components/apikeys/UsageDetailModal.vue
Normal file
360
web/admin-spa/src/components/apikeys/UsageDetailModal.vue
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<!-- 背景遮罩 -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 模态框 -->
|
||||||
|
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 py-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white flex items-center">
|
||||||
|
<i class="fas fa-chart-line mr-2" />
|
||||||
|
使用统计详情 - {{ apiKey.name }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<div class="px-6 py-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
<!-- 总体统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<!-- 请求统计卡片 -->
|
||||||
|
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700">总请求数</span>
|
||||||
|
<i class="fas fa-paper-plane text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">
|
||||||
|
{{ formatNumber(totalRequests) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">
|
||||||
|
今日: {{ formatNumber(dailyRequests) }} 次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token统计卡片 -->
|
||||||
|
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700">总Token数</span>
|
||||||
|
<i class="fas fa-coins text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">
|
||||||
|
{{ formatNumber(totalTokens) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">
|
||||||
|
今日: {{ formatNumber(dailyTokens) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 费用统计卡片 -->
|
||||||
|
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-lg p-4 border border-yellow-200">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700">总费用</span>
|
||||||
|
<i class="fas fa-dollar-sign text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">
|
||||||
|
${{ totalCost.toFixed(4) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">
|
||||||
|
今日: ${{ dailyCost.toFixed(4) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 平均统计卡片 -->
|
||||||
|
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700">平均速率</span>
|
||||||
|
<i class="fas fa-tachometer-alt text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">RPM:</span>
|
||||||
|
<span class="font-semibold">{{ rpm }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">TPM:</span>
|
||||||
|
<span class="font-semibold">{{ tpm }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token详细分布 -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center">
|
||||||
|
<i class="fas fa-chart-pie text-indigo-500 mr-2" />
|
||||||
|
Token 使用分布
|
||||||
|
</h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-arrow-down text-green-500 mr-2" />
|
||||||
|
<span class="text-sm text-gray-600">输入 Token</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold text-gray-900">
|
||||||
|
{{ formatNumber(inputTokens) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-arrow-up text-blue-500 mr-2" />
|
||||||
|
<span class="text-sm text-gray-600">输出 Token</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold text-gray-900">
|
||||||
|
{{ formatNumber(outputTokens) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cacheCreateTokens > 0"
|
||||||
|
class="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-save text-purple-500 mr-2" />
|
||||||
|
<span class="text-sm text-gray-600">缓存创建 Token</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold text-purple-600">
|
||||||
|
{{ formatNumber(cacheCreateTokens) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cacheReadTokens > 0"
|
||||||
|
class="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-download text-purple-500 mr-2" />
|
||||||
|
<span class="text-sm text-gray-600">缓存读取 Token</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold text-purple-600">
|
||||||
|
{{ formatNumber(cacheReadTokens) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 限制信息 -->
|
||||||
|
<div
|
||||||
|
v-if="hasLimits"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center">
|
||||||
|
<i class="fas fa-shield-alt text-red-500 mr-2" />
|
||||||
|
限制设置
|
||||||
|
</h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-if="apiKey.dailyCostLimit > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600">每日费用限制</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
${{ apiKey.dailyCostLimit.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
|
:class="dailyCostPercentage >= 100 ? 'bg-red-500' : dailyCostPercentage >= 80 ? 'bg-yellow-500' : 'bg-green-500'"
|
||||||
|
:style="{ width: Math.min(dailyCostPercentage, 100) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 text-right">
|
||||||
|
已使用 {{ dailyCostPercentage.toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="apiKey.concurrencyLimit > 0"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600">并发限制</span>
|
||||||
|
<span class="font-semibold text-purple-600">
|
||||||
|
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="apiKey.rateLimitWindow > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600">时间窗口</span>
|
||||||
|
<span class="font-semibold text-indigo-600">
|
||||||
|
{{ apiKey.rateLimitWindow }} 分钟
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求次数限制 -->
|
||||||
|
<div
|
||||||
|
v-if="apiKey.rateLimitRequests > 0"
|
||||||
|
class="space-y-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600">请求限制</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ apiKey.currentWindowRequests || 0 }} / {{ apiKey.rateLimitRequests }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
|
:class="windowRequestProgressColor"
|
||||||
|
:style="{ width: windowRequestProgress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token使用量限制 -->
|
||||||
|
<div
|
||||||
|
v-if="apiKey.tokenLimit > 0"
|
||||||
|
class="space-y-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600">Token限制</span>
|
||||||
|
<span class="font-semibold text-gray-900">
|
||||||
|
{{ formatTokenCount(apiKey.currentWindowTokens || 0) }} / {{ formatTokenCount(apiKey.tokenLimit) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
|
:class="windowTokenProgressColor"
|
||||||
|
:style="{ width: windowTokenProgress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部按钮 -->
|
||||||
|
<div class="bg-gray-50 px-6 py-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalRequests = computed(() => (props.apiKey.usage?.total?.requests) || 0)
|
||||||
|
const dailyRequests = computed(() => (props.apiKey.usage?.daily?.requests) || 0)
|
||||||
|
const totalTokens = computed(() => (props.apiKey.usage?.total?.tokens) || 0)
|
||||||
|
const dailyTokens = computed(() => (props.apiKey.usage?.daily?.tokens) || 0)
|
||||||
|
const totalCost = computed(() => (props.apiKey.usage?.total?.cost) || 0)
|
||||||
|
const dailyCost = computed(() => props.apiKey.dailyCost || 0)
|
||||||
|
const inputTokens = computed(() => (props.apiKey.usage?.total?.inputTokens) || 0)
|
||||||
|
const outputTokens = computed(() => (props.apiKey.usage?.total?.outputTokens) || 0)
|
||||||
|
const cacheCreateTokens = computed(() => (props.apiKey.usage?.total?.cacheCreateTokens) || 0)
|
||||||
|
const cacheReadTokens = computed(() => (props.apiKey.usage?.total?.cacheReadTokens) || 0)
|
||||||
|
const rpm = computed(() => (props.apiKey.usage?.averages?.rpm) || 0)
|
||||||
|
const tpm = computed(() => (props.apiKey.usage?.averages?.tpm) || 0)
|
||||||
|
|
||||||
|
const hasLimits = computed(() => {
|
||||||
|
return props.apiKey.dailyCostLimit > 0 ||
|
||||||
|
props.apiKey.concurrencyLimit > 0 ||
|
||||||
|
props.apiKey.rateLimitWindow > 0 ||
|
||||||
|
props.apiKey.tokenLimit > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const dailyCostPercentage = computed(() => {
|
||||||
|
if (!props.apiKey.dailyCostLimit || props.apiKey.dailyCostLimit === 0) return 0
|
||||||
|
return (dailyCost.value / props.apiKey.dailyCostLimit) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
// 窗口请求进度
|
||||||
|
const windowRequestProgress = computed(() => {
|
||||||
|
if (!props.apiKey.rateLimitRequests || props.apiKey.rateLimitRequests === 0) return 0
|
||||||
|
const percentage = ((props.apiKey.currentWindowRequests || 0) / props.apiKey.rateLimitRequests) * 100
|
||||||
|
return Math.min(percentage, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const windowRequestProgressColor = computed(() => {
|
||||||
|
const progress = windowRequestProgress.value
|
||||||
|
if (progress >= 100) return 'bg-red-500'
|
||||||
|
if (progress >= 80) return 'bg-yellow-500'
|
||||||
|
return 'bg-blue-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 窗口Token进度
|
||||||
|
const windowTokenProgress = computed(() => {
|
||||||
|
if (!props.apiKey.tokenLimit || props.apiKey.tokenLimit === 0) return 0
|
||||||
|
const percentage = ((props.apiKey.currentWindowTokens || 0) / props.apiKey.tokenLimit) * 100
|
||||||
|
return Math.min(percentage, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const windowTokenProgressColor = computed(() => {
|
||||||
|
const progress = windowTokenProgress.value
|
||||||
|
if (progress >= 100) return 'bg-red-500'
|
||||||
|
if (progress >= 80) return 'bg-yellow-500'
|
||||||
|
return 'bg-purple-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (!num && num !== 0) return '0'
|
||||||
|
return num.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化Token数量(使用K/M单位)
|
||||||
|
const formatTokenCount = (count) => {
|
||||||
|
if (count >= 1000000) {
|
||||||
|
return (count / 1000000).toFixed(1) + 'M'
|
||||||
|
} else if (count >= 1000) {
|
||||||
|
return (count / 1000).toFixed(1) + 'K'
|
||||||
|
}
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 添加过渡动画 */
|
||||||
|
.transform {
|
||||||
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -38,8 +38,12 @@
|
|||||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||||
@change="filterByGroup"
|
@change="filterByGroup"
|
||||||
>
|
>
|
||||||
<option value="all">所有账户</option>
|
<option value="all">
|
||||||
<option value="ungrouped">未分组账户</option>
|
所有账户
|
||||||
|
</option>
|
||||||
|
<option value="ungrouped">
|
||||||
|
未分组账户
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="group in accountGroups"
|
v-for="group in accountGroups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
@@ -177,7 +181,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="text-sm font-semibold text-gray-900 truncate" :title="account.name">
|
<div
|
||||||
|
class="text-sm font-semibold text-gray-900 truncate"
|
||||||
|
:title="account.name"
|
||||||
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -206,7 +213,10 @@
|
|||||||
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
|
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 truncate" :title="account.id">
|
<div
|
||||||
|
class="text-xs text-gray-500 truncate"
|
||||||
|
:title="account.id"
|
||||||
|
>
|
||||||
{{ account.id }}
|
{{ account.id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +231,7 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-robot text-yellow-700 text-xs" />
|
<i class="fas fa-robot text-yellow-700 text-xs" />
|
||||||
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
|
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
|
||||||
<span class="w-px h-4 bg-yellow-300 mx-1"></span>
|
<span class="w-px h-4 bg-yellow-300 mx-1" />
|
||||||
<span class="text-xs font-medium text-yellow-700">
|
<span class="text-xs font-medium text-yellow-700">
|
||||||
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -232,7 +242,7 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-terminal text-purple-700 text-xs" />
|
<i class="fas fa-terminal text-purple-700 text-xs" />
|
||||||
<span class="text-xs font-semibold text-purple-800">Console</span>
|
<span class="text-xs font-semibold text-purple-800">Console</span>
|
||||||
<span class="w-px h-4 bg-purple-300 mx-1"></span>
|
<span class="w-px h-4 bg-purple-300 mx-1" />
|
||||||
<span class="text-xs font-medium text-purple-700">API Key</span>
|
<span class="text-xs font-medium text-purple-700">API Key</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -241,7 +251,7 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-brain text-indigo-700 text-xs" />
|
<i class="fas fa-brain text-indigo-700 text-xs" />
|
||||||
<span class="text-xs font-semibold text-indigo-800">Claude</span>
|
<span class="text-xs font-semibold text-indigo-800">Claude</span>
|
||||||
<span class="w-px h-4 bg-indigo-300 mx-1"></span>
|
<span class="w-px h-4 bg-indigo-300 mx-1" />
|
||||||
<span class="text-xs font-medium text-indigo-700">
|
<span class="text-xs font-medium text-indigo-700">
|
||||||
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -615,8 +625,8 @@
|
|||||||
:class="account.schedulable
|
:class="account.schedulable
|
||||||
? 'text-gray-600 bg-gray-50 hover:bg-gray-100'
|
? 'text-gray-600 bg-gray-50 hover:bg-gray-100'
|
||||||
: 'text-green-600 bg-green-50 hover:bg-green-100'"
|
: 'text-green-600 bg-green-50 hover:bg-green-100'"
|
||||||
@click="toggleSchedulable(account)"
|
|
||||||
:disabled="account.isTogglingSchedulable"
|
:disabled="account.isTogglingSchedulable"
|
||||||
|
@click="toggleSchedulable(account)"
|
||||||
>
|
>
|
||||||
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
|
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
|
||||||
{{ account.schedulable ? '暂停' : '启用' }}
|
{{ account.schedulable ? '暂停' : '启用' }}
|
||||||
|
|||||||
@@ -185,14 +185,23 @@
|
|||||||
<i class="fas fa-key text-white text-xs" />
|
<i class="fas fa-key text-white text-xs" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="text-sm font-semibold text-gray-900 truncate" :title="key.name">
|
<div
|
||||||
|
class="text-sm font-semibold text-gray-900 truncate"
|
||||||
|
:title="key.name"
|
||||||
|
>
|
||||||
{{ key.name }}
|
{{ key.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 truncate" :title="key.id">
|
<div
|
||||||
|
class="text-xs text-gray-500 truncate"
|
||||||
|
:title="key.id"
|
||||||
|
>
|
||||||
{{ key.id }}
|
{{ key.id }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 mt-1 truncate">
|
<div class="text-xs text-gray-500 mt-1 truncate">
|
||||||
<span v-if="key.claudeAccountId" :title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`">
|
<span
|
||||||
|
v-if="key.claudeAccountId"
|
||||||
|
:title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`"
|
||||||
|
>
|
||||||
<i class="fas fa-link mr-1" />
|
<i class="fas fa-link mr-1" />
|
||||||
{{ getBoundAccountName(key.claudeAccountId) }}
|
{{ getBoundAccountName(key.claudeAccountId) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -232,98 +241,100 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4">
|
<td class="px-3 py-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-2">
|
||||||
<!-- 请求统计 -->
|
<!-- 今日使用统计 -->
|
||||||
<div class="flex justify-between text-sm">
|
<div class="mb-2">
|
||||||
<span class="text-gray-600">请求数:</span>
|
<div class="flex justify-between items-center text-sm mb-1">
|
||||||
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }}</span>
|
<span class="text-gray-600">今日请求</span>
|
||||||
|
<span class="font-semibold text-gray-900">{{ formatNumber((key.usage?.daily?.requests) || 0) }}次</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Token统计 -->
|
<div class="flex justify-between items-center text-sm">
|
||||||
<div class="flex justify-between text-sm">
|
<span class="text-gray-600">今日费用</span>
|
||||||
<span class="text-gray-600">Token:</span>
|
<span class="font-semibold text-green-600">${{ (key.dailyCost || 0).toFixed(4) }}</span>
|
||||||
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 费用统计 -->
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-gray-600">费用:</span>
|
|
||||||
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 每日费用限制 -->
|
|
||||||
|
<!-- 每日费用限制进度条 -->
|
||||||
<div
|
<div
|
||||||
v-if="key.dailyCostLimit > 0"
|
v-if="key.dailyCostLimit > 0"
|
||||||
class="flex justify-between text-sm"
|
class="space-y-1"
|
||||||
>
|
>
|
||||||
<span class="text-gray-600">今日费用:</span>
|
<div class="flex justify-between items-center text-xs">
|
||||||
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
|
<span class="text-gray-500">费用限额</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 并发限制 -->
|
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
<div class="flex justify-between text-sm">
|
<div
|
||||||
<span class="text-gray-600">并发限制:</span>
|
:class="getDailyCostProgressColor(key)"
|
||||||
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
|
class="h-1.5 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: getDailyCostProgress(key) + '%' }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 当前并发数 -->
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-gray-600">当前并发:</span>
|
|
||||||
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
|
|
||||||
{{ key.currentConcurrency || 0 }}
|
|
||||||
<span
|
|
||||||
v-if="key.concurrencyLimit > 0"
|
|
||||||
class="text-xs text-gray-500"
|
|
||||||
>/ {{ key.concurrencyLimit }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 时间窗口限流 -->
|
|
||||||
|
<!-- 时间窗口限制进度条 -->
|
||||||
<div
|
<div
|
||||||
v-if="key.rateLimitWindow > 0"
|
v-if="key.rateLimitWindow > 0"
|
||||||
class="flex justify-between text-sm"
|
class="space-y-1"
|
||||||
>
|
>
|
||||||
<span class="text-gray-600">时间窗口:</span>
|
<div class="flex justify-between items-center text-xs">
|
||||||
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
|
<span class="text-gray-500">窗口限制</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
{{ key.rateLimitWindow }}分钟
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 请求次数限制 -->
|
<!-- 请求次数限制 -->
|
||||||
<div
|
<div
|
||||||
v-if="key.rateLimitRequests > 0"
|
v-if="key.rateLimitRequests > 0"
|
||||||
class="flex justify-between text-sm"
|
class="space-y-0.5"
|
||||||
>
|
>
|
||||||
<span class="text-gray-600">请求限制:</span>
|
<div class="flex justify-between items-center text-xs">
|
||||||
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} 次/窗口</span>
|
<span class="text-gray-400">请求</span>
|
||||||
|
<span class="text-gray-600">
|
||||||
|
{{ key.currentWindowRequests || 0 }}/{{ key.rateLimitRequests }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 输入/输出Token -->
|
<div class="w-full bg-gray-200 rounded-full h-1">
|
||||||
<div class="flex justify-between text-xs text-gray-500">
|
|
||||||
<span>输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
|
|
||||||
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- 缓存Token细节 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0"
|
:class="getWindowRequestProgressColor(key)"
|
||||||
class="flex justify-between text-xs text-orange-500"
|
class="h-1 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: getWindowRequestProgress(key) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token使用量限制 -->
|
||||||
|
<div
|
||||||
|
v-if="key.tokenLimit > 0"
|
||||||
|
class="space-y-0.5"
|
||||||
>
|
>
|
||||||
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
|
<div class="flex justify-between items-center text-xs">
|
||||||
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
|
<span class="text-gray-400">Token</span>
|
||||||
|
<span class="text-gray-600">
|
||||||
|
{{ formatTokenCount(key.currentWindowTokens || 0) }}/{{ formatTokenCount(key.tokenLimit) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- RPM/TPM -->
|
<div class="w-full bg-gray-200 rounded-full h-1">
|
||||||
<div class="flex justify-between text-xs text-blue-600">
|
<div
|
||||||
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
|
:class="getWindowTokenProgressColor(key)"
|
||||||
<span>TPM: {{ (key.usage && key.usage.averages && key.usage.averages.tpm) || 0 }}</span>
|
class="h-1 rounded-full transition-all duration-300"
|
||||||
</div>
|
:style="{ width: getWindowTokenProgress(key) + '%' }"
|
||||||
<!-- 今日统计 -->
|
/>
|
||||||
<div class="pt-1 border-t border-gray-100">
|
|
||||||
<div class="flex justify-between text-xs text-green-600">
|
|
||||||
<span>今日: {{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }}次</span>
|
|
||||||
<span>{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}T</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 模型分布按钮 -->
|
</div>
|
||||||
<div class="pt-2">
|
|
||||||
|
<!-- 查看详情按钮 -->
|
||||||
|
<div class="pt-1">
|
||||||
<button
|
<button
|
||||||
v-if="key && key.id"
|
class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 w-full justify-center py-1 hover:bg-blue-50 rounded transition-colors"
|
||||||
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
|
@click="showUsageDetails(key)"
|
||||||
@click="toggleApiKeyModelStats(key.id)"
|
|
||||||
>
|
>
|
||||||
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']" />
|
<i class="fas fa-chart-line" />
|
||||||
模型使用分布
|
查看详细统计
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,6 +384,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
v-if="key && key.id"
|
||||||
|
class="text-indigo-600 hover:text-indigo-900 font-medium hover:bg-indigo-50 px-2 py-1 rounded transition-colors text-xs"
|
||||||
|
title="模型使用分布"
|
||||||
|
@click="toggleApiKeyModelStats(key.id)"
|
||||||
|
>
|
||||||
|
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down']" />
|
||||||
|
<span class="hidden xl:inline ml-1">模型</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-2 py-1 rounded transition-colors text-xs"
|
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-2 py-1 rounded transition-colors text-xs"
|
||||||
title="复制统计页面链接"
|
title="复制统计页面链接"
|
||||||
@@ -666,25 +686,104 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统计信息 -->
|
<!-- 统计信息 -->
|
||||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
|
<!-- 今日使用 -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="text-xs text-gray-600">今日使用</span>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-800"
|
||||||
|
@click="showUsageDetails(key)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-line mr-1" />详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
使用量
|
|
||||||
</p>
|
|
||||||
<p class="text-sm font-semibold text-gray-900">
|
<p class="text-sm font-semibold text-gray-900">
|
||||||
{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }} 次
|
{{ formatNumber((key.usage?.daily?.requests) || 0) }} 次
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 mt-0.5">
|
<p class="text-xs text-gray-500">
|
||||||
{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }} tokens
|
请求
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-green-600">
|
||||||
|
${{ (key.dailyCost || 0).toFixed(4) }}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
费用
|
费用
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm font-semibold text-green-600">
|
</div>
|
||||||
{{ calculateApiKeyCost(key.usage) }}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<!-- 限制进度 -->
|
||||||
|
<div
|
||||||
|
v-if="key.dailyCostLimit > 0"
|
||||||
|
class="space-y-1"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center text-xs">
|
||||||
|
<span class="text-gray-500">每日费用限额</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
:class="getDailyCostProgressColor(key)"
|
||||||
|
class="h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: getDailyCostProgress(key) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端时间窗口限制 -->
|
||||||
|
<div
|
||||||
|
v-if="key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)"
|
||||||
|
class="space-y-1"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-gray-500 mb-1">
|
||||||
|
窗口限制 ({{ key.rateLimitWindow }}分钟)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="key.rateLimitRequests > 0"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 w-10">请求</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
:class="getWindowRequestProgressColor(key)"
|
||||||
|
class="h-1.5 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: getWindowRequestProgress(key) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-600 w-16 text-right">
|
||||||
|
{{ key.currentWindowRequests || 0 }}/{{ key.rateLimitRequests }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="key.tokenLimit > 0"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 w-10">Token</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
:class="getWindowTokenProgressColor(key)"
|
||||||
|
class="h-1.5 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: getWindowTokenProgress(key) + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-600 w-16 text-right">
|
||||||
|
{{ formatTokenCount(key.currentWindowTokens || 0) }}/{{ formatTokenCount(key.tokenLimit) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -720,10 +819,10 @@
|
|||||||
<div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
|
<div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
class="flex-1 px-3 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors flex items-center justify-center gap-1"
|
class="flex-1 px-3 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors flex items-center justify-center gap-1"
|
||||||
@click="toggleExpanded(key.id)"
|
@click="showUsageDetails(key)"
|
||||||
>
|
>
|
||||||
<i :class="['fas', expandedKeys.includes(key.id) ? 'fa-chevron-up' : 'fa-chevron-down']" />
|
<i class="fas fa-chart-line" />
|
||||||
{{ expandedKeys.includes(key.id) ? '收起' : '详情' }}
|
查看详情
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 px-3 py-2 text-xs text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
class="flex-1 px-3 py-2 text-xs text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
@@ -747,77 +846,6 @@
|
|||||||
<i class="fas fa-trash" />
|
<i class="fas fa-trash" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开的详细统计 -->
|
|
||||||
<div
|
|
||||||
v-if="expandedKeys.includes(key.id)"
|
|
||||||
class="mt-3 pt-3 border-t border-gray-100"
|
|
||||||
>
|
|
||||||
<h5 class="text-xs font-semibold text-gray-700 mb-2">
|
|
||||||
详细信息
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<!-- 更多统计数据 -->
|
|
||||||
<div class="space-y-2 text-xs">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600">并发限制:</span>
|
|
||||||
<span class="font-medium text-purple-600">{{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600">当前并发:</span>
|
|
||||||
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
|
|
||||||
{{ key.currentConcurrency || 0 }}
|
|
||||||
<span v-if="key.concurrencyLimit > 0" class="text-xs text-gray-500">/ {{ key.concurrencyLimit }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="key.dailyCostLimit > 0" class="flex justify-between">
|
|
||||||
<span class="text-gray-600">今日费用:</span>
|
|
||||||
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
|
|
||||||
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="key.rateLimitWindow > 0" class="flex justify-between">
|
|
||||||
<span class="text-gray-600">时间窗口:</span>
|
|
||||||
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="key.rateLimitRequests > 0" class="flex justify-between">
|
|
||||||
<span class="text-gray-600">请求限制:</span>
|
|
||||||
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} 次/窗口</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Token 细节 -->
|
|
||||||
<div class="pt-2 mt-2 border-t border-gray-100">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600">输入 Token:</span>
|
|
||||||
<span class="font-medium">{{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600">输出 Token:</span>
|
|
||||||
<span class="font-medium">{{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0" class="flex justify-between">
|
|
||||||
<span class="text-gray-600">缓存创建:</span>
|
|
||||||
<span class="font-medium text-purple-600">{{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between">
|
|
||||||
<span class="text-gray-600">缓存读取:</span>
|
|
||||||
<span class="font-medium text-purple-600">{{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 今日统计 -->
|
|
||||||
<div class="pt-2 mt-2 border-t border-gray-100">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600">今日请求:</span>
|
|
||||||
<span class="font-medium text-green-600">{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.requests) || 0) }} 次</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600">今日 Token:</span>
|
|
||||||
<span class="font-medium text-green-600">{{ formatNumber((key.usage && key.usage.daily && key.usage.daily.tokens) || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -959,6 +987,13 @@
|
|||||||
@close="closeExpiryEdit"
|
@close="closeExpiryEdit"
|
||||||
@save="handleSaveExpiry"
|
@save="handleSaveExpiry"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 使用详情弹窗 -->
|
||||||
|
<UsageDetailModal
|
||||||
|
:show="showUsageDetailModal"
|
||||||
|
:api-key="selectedApiKeyForDetail || {}"
|
||||||
|
@close="showUsageDetailModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -973,6 +1008,7 @@ import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
|
|||||||
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
||||||
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
|
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
|
||||||
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
|
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
|
||||||
|
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
@@ -988,18 +1024,13 @@ const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23,
|
|||||||
const accounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
|
const accounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
|
||||||
const editingExpiryKey = ref(null)
|
const editingExpiryKey = ref(null)
|
||||||
const expiryEditModalRef = ref(null)
|
const expiryEditModalRef = ref(null)
|
||||||
|
const showUsageDetailModal = ref(false)
|
||||||
// 分页相关
|
const selectedApiKeyForDetail = ref(null)
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageSize = ref(10)
|
|
||||||
const pageSizeOptions = [5, 10, 20, 50, 100]
|
|
||||||
|
|
||||||
// 标签相关
|
// 标签相关
|
||||||
const selectedTagFilter = ref('')
|
const selectedTagFilter = ref('')
|
||||||
const availableTags = ref([])
|
const availableTags = ref([])
|
||||||
|
|
||||||
// 移动端展开状态
|
|
||||||
const expandedKeys = ref([])
|
|
||||||
|
|
||||||
// 分页相关
|
// 分页相关
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
@@ -1017,8 +1048,8 @@ const renewingApiKey = ref(null)
|
|||||||
const newApiKeyData = ref(null)
|
const newApiKeyData = ref(null)
|
||||||
const batchApiKeyData = ref([])
|
const batchApiKeyData = ref([])
|
||||||
|
|
||||||
// 计算筛选和排序后的API Keys(未分页)
|
// 计算排序后的API Keys
|
||||||
const filteredAndSortedApiKeys = computed(() => {
|
const sortedApiKeys = computed(() => {
|
||||||
// 先进行标签筛选
|
// 先进行标签筛选
|
||||||
let filteredKeys = apiKeys.value
|
let filteredKeys = apiKeys.value
|
||||||
if (selectedTagFilter.value) {
|
if (selectedTagFilter.value) {
|
||||||
@@ -1109,17 +1140,8 @@ const loadAccounts = async () => {
|
|||||||
apiClient.get('/admin/account-groups')
|
apiClient.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
// 合并Claude OAuth账户和Claude Console账户
|
|
||||||
const claudeAccounts = []
|
|
||||||
|
|
||||||
if (claudeData.success) {
|
if (claudeData.success) {
|
||||||
claudeData.data?.forEach(account => {
|
accounts.value.claude = claudeData.data || []
|
||||||
claudeAccounts.push({
|
|
||||||
...account,
|
|
||||||
platform: 'claude-oauth',
|
|
||||||
isDedicated: account.accountType === 'dedicated'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (claudeConsoleData.success) {
|
if (claudeConsoleData.success) {
|
||||||
@@ -1132,10 +1154,7 @@ const loadAccounts = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (geminiData.success) {
|
if (geminiData.success) {
|
||||||
accounts.value.gemini = (geminiData.data || []).map(account => ({
|
accounts.value.gemini = geminiData.data || []
|
||||||
...account,
|
|
||||||
isDedicated: account.accountType === 'dedicated'
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupsData.success) {
|
if (groupsData.success) {
|
||||||
@@ -1165,9 +1184,6 @@ const loadApiKeys = async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
availableTags.value = Array.from(tagsSet).sort()
|
availableTags.value = Array.from(tagsSet).sort()
|
||||||
|
|
||||||
// 重置到第一页
|
|
||||||
currentPage.value = 1
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载 API Keys 失败', 'error')
|
showToast('加载 API Keys 失败', 'error')
|
||||||
@@ -1184,8 +1200,6 @@ const sortApiKeys = (field) => {
|
|||||||
apiKeysSortBy.value = field
|
apiKeysSortBy.value = field
|
||||||
apiKeysSortOrder.value = 'asc'
|
apiKeysSortOrder.value = 'asc'
|
||||||
}
|
}
|
||||||
// 排序时重置到第一页
|
|
||||||
currentPage.value = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化数字
|
// 格式化数字
|
||||||
@@ -1202,28 +1216,43 @@ const calculateApiKeyCost = (usage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取绑定账户名称
|
// 获取绑定账户名称
|
||||||
const getBoundAccountName = (claudeAccountId, claudeConsoleAccountId) => {
|
const getBoundAccountName = (accountId) => {
|
||||||
// 优先显示Claude OAuth账户
|
if (!accountId) return '未知账户'
|
||||||
if (claudeAccountId) {
|
|
||||||
const claudeAccount = accounts.value.claude.find(acc => acc.id === claudeAccountId)
|
// 检查是否是分组
|
||||||
|
if (accountId.startsWith('group:')) {
|
||||||
|
const groupId = accountId.substring(6) // 移除 'group:' 前缀
|
||||||
|
|
||||||
|
// 从Claude分组中查找
|
||||||
|
const claudeGroup = accounts.value.claudeGroups.find(g => g.id === groupId)
|
||||||
|
if (claudeGroup) {
|
||||||
|
return `分组-${claudeGroup.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从Gemini分组中查找
|
||||||
|
const geminiGroup = accounts.value.geminiGroups.find(g => g.id === groupId)
|
||||||
|
if (geminiGroup) {
|
||||||
|
return `分组-${geminiGroup.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果找不到分组,返回分组ID的前8位
|
||||||
|
return `分组-${groupId.substring(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从Claude账户列表中查找
|
||||||
|
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
|
||||||
if (claudeAccount) {
|
if (claudeAccount) {
|
||||||
return claudeAccount.name
|
return `账户-${claudeAccount.name}`
|
||||||
}
|
|
||||||
// 如果找不到,返回账户ID的前8位
|
|
||||||
return `账户-${claudeAccountId.substring(0, 8)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其次显示Claude Console账户
|
// 从Gemini账户列表中查找
|
||||||
if (claudeConsoleAccountId) {
|
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
|
||||||
const consoleAccount = accounts.value.claude.find(acc => acc.id === claudeConsoleAccountId)
|
if (geminiAccount) {
|
||||||
if (consoleAccount) {
|
return `账户-${geminiAccount.name}`
|
||||||
return `${consoleAccount.name} (Console)`
|
|
||||||
}
|
|
||||||
// 如果找不到,返回账户ID的前8位
|
|
||||||
return `Console-${claudeConsoleAccountId.substring(0, 8)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '未知账户'
|
// 如果找不到,返回账户ID的前8位
|
||||||
|
return `账户-${accountId.substring(0, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查API Key是否过期
|
// 检查API Key是否过期
|
||||||
@@ -1504,7 +1533,7 @@ const deleteApiKey = async (keyId) => {
|
|||||||
const copyApiStatsLink = (apiKey) => {
|
const copyApiStatsLink = (apiKey) => {
|
||||||
// 构建统计页面的完整URL
|
// 构建统计页面的完整URL
|
||||||
const baseUrl = window.location.origin
|
const baseUrl = window.location.origin
|
||||||
const statsUrl = `${baseUrl}/admin-next/api-stats?apiId=${apiKey.id}`
|
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
|
||||||
|
|
||||||
// 使用传统的textarea方法复制到剪贴板
|
// 使用传统的textarea方法复制到剪贴板
|
||||||
const textarea = document.createElement('textarea')
|
const textarea = document.createElement('textarea')
|
||||||
@@ -1575,15 +1604,6 @@ const handleSaveExpiry = async ({ keyId, expiresAt }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换移动端卡片展开状态
|
|
||||||
const toggleExpanded = (keyId) => {
|
|
||||||
const index = expandedKeys.value.indexOf(keyId)
|
|
||||||
if (index > -1) {
|
|
||||||
expandedKeys.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
expandedKeys.value.push(keyId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化日期时间
|
// 格式化日期时间
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
@@ -1598,25 +1618,67 @@ const formatDate = (dateString) => {
|
|||||||
}).replace(/\//g, '-')
|
}).replace(/\//g, '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示API Key详情
|
// 获取每日费用进度
|
||||||
const showApiKey = async (apiKey) => {
|
const getDailyCostProgress = (key) => {
|
||||||
try {
|
if (!key.dailyCostLimit || key.dailyCostLimit === 0) return 0
|
||||||
// 重新获取API Key的完整信息(包含实际的key值)
|
const percentage = ((key.dailyCost || 0) / key.dailyCostLimit) * 100
|
||||||
const response = await apiClient.get(`/admin/api-keys/${apiKey.id}`)
|
return Math.min(percentage, 100)
|
||||||
if (response.success && response.data) {
|
|
||||||
newApiKeyData.value = {
|
|
||||||
...response.data,
|
|
||||||
key: response.data.key || response.data.apiKey // 兼容不同的字段名
|
|
||||||
}
|
}
|
||||||
showNewApiKeyModal.value = true
|
|
||||||
} else {
|
// 获取每日费用进度条颜色
|
||||||
showToast('获取API Key信息失败', 'error')
|
const getDailyCostProgressColor = (key) => {
|
||||||
|
const progress = getDailyCostProgress(key)
|
||||||
|
if (progress >= 100) return 'bg-red-500'
|
||||||
|
if (progress >= 80) return 'bg-yellow-500'
|
||||||
|
return 'bg-green-500'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching API key:', error)
|
// 显示使用详情
|
||||||
showToast('获取API Key信息失败', 'error')
|
const showUsageDetails = (apiKey) => {
|
||||||
|
selectedApiKeyForDetail.value = apiKey
|
||||||
|
showUsageDetailModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化Token数量(使用K/M单位)
|
||||||
|
const formatTokenCount = (count) => {
|
||||||
|
if (count >= 1000000) {
|
||||||
|
return (count / 1000000).toFixed(1) + 'M'
|
||||||
|
} else if (count >= 1000) {
|
||||||
|
return (count / 1000).toFixed(1) + 'K'
|
||||||
}
|
}
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取窗口请求进度
|
||||||
|
const getWindowRequestProgress = (key) => {
|
||||||
|
if (!key.rateLimitRequests || key.rateLimitRequests === 0) return 0
|
||||||
|
const percentage = ((key.currentWindowRequests || 0) / key.rateLimitRequests) * 100
|
||||||
|
return Math.min(percentage, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取窗口请求进度条颜色
|
||||||
|
const getWindowRequestProgressColor = (key) => {
|
||||||
|
const progress = getWindowRequestProgress(key)
|
||||||
|
if (progress >= 100) return 'bg-red-500'
|
||||||
|
if (progress >= 80) return 'bg-yellow-500'
|
||||||
|
return 'bg-blue-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取窗口Token进度
|
||||||
|
const getWindowTokenProgress = (key) => {
|
||||||
|
if (!key.tokenLimit || key.tokenLimit === 0) return 0
|
||||||
|
const percentage = ((key.currentWindowTokens || 0) / key.tokenLimit) * 100
|
||||||
|
return Math.min(percentage, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取窗口Token进度条颜色
|
||||||
|
const getWindowTokenProgressColor = (key) => {
|
||||||
|
const progress = getWindowTokenProgress(key)
|
||||||
|
if (progress >= 100) return 'bg-red-500'
|
||||||
|
if (progress >= 80) return 'bg-yellow-500'
|
||||||
|
return 'bg-purple-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 监听筛选条件变化,重置页码
|
// 监听筛选条件变化,重置页码
|
||||||
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
|
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
|
||||||
@@ -1673,5 +1735,4 @@ onMounted(async () => {
|
|||||||
.api-key-date-picker :deep(.el-range-separator) {
|
.api-key-date-picker :deep(.el-range-separator) {
|
||||||
@apply text-gray-500;
|
@apply text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -180,8 +180,12 @@
|
|||||||
<i class="fas fa-font text-white text-sm" />
|
<i class="fas fa-font text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900">网站名称</h4>
|
<h4 class="text-sm font-semibold text-gray-900">
|
||||||
<p class="text-xs text-gray-500">品牌标识</p>
|
网站名称
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
品牌标识
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -205,8 +209,12 @@
|
|||||||
<i class="fas fa-image text-white text-sm" />
|
<i class="fas fa-image text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900">网站图标</h4>
|
<h4 class="text-sm font-semibold text-gray-900">
|
||||||
<p class="text-xs text-gray-500">Favicon</p>
|
网站图标
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Favicon
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user