Merge branch 'main' into dev

This commit is contained in:
shaw
2025-08-04 10:32:34 +08:00
35 changed files with 956 additions and 685 deletions

View File

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

View File

@@ -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 }} [skip ci]"
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]"
fi
- name: Install git-cliff - name: Install git-cliff
if: steps.check.outputs.needs_bump == 'true' if: steps.check.outputs.needs_bump == 'true'

View File

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

View File

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

View File

@@ -1 +1 @@
1.1.64 1.1.67

View File

@@ -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_DIR="$INSTALL_DIR/app" # 检查是否使用了标准的安装结构(项目在 app 子目录)
if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then
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)

View File

@@ -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 = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -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>如果页面一直无法跳转可以打开浏览器开发者工具F12F5刷新一下授权页再点击页面的登录按钮网络标签中找到以 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>

View File

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

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

View File

@@ -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 ? '暂停' : '启用' }}

View File

@@ -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>
</div> <span class="font-semibold text-gray-900">{{ formatNumber((key.usage?.daily?.requests) || 0) }}</span>
<!-- Token统计 --> </div>
<div class="flex justify-between text-sm"> <div class="flex justify-between items-center text-sm">
<span class="text-gray-600">Token:</span> <span class="text-gray-600">今日费用</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</span> <span class="font-semibold text-green-600">${{ (key.dailyCost || 0).toFixed(4) }}</span>
</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
v-if="key.dailyCostLimit > 0"
class="flex justify-between text-sm"
>
<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 class="flex justify-between text-sm">
<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 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
v-if="key.rateLimitWindow > 0"
class="flex justify-between text-sm"
>
<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 text-sm"
>
<span class="text-gray-600">请求限制:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} /窗口</span>
</div>
<!-- 输入/输出Token -->
<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
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="flex justify-between text-xs text-orange-500"
>
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
<!-- RPM/TPM -->
<div class="flex justify-between text-xs text-blue-600">
<span>RPM: {{ (key.usage && key.usage.averages && key.usage.averages.rpm) || 0 }}</span>
<span>TPM: {{ (key.usage && key.usage.averages && key.usage.averages.tpm) || 0 }}</span>
</div>
<!-- 今日统计 -->
<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 class="pt-2"> <!-- 每日费用限制进度条 -->
<button <div
v-if="key && key.id" v-if="key.dailyCostLimit > 0"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium" class="space-y-1"
@click="toggleApiKeyModelStats(key.id)" >
<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-1.5">
<div
:class="getDailyCostProgressColor(key)"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 时间窗口限制进度条 -->
<div
v-if="key.rateLimitWindow > 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.rateLimitWindow }}分钟
</span>
</div>
<!-- 请求次数限制 -->
<div
v-if="key.rateLimitRequests > 0"
class="space-y-0.5"
> >
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']" /> <div class="flex justify-between items-center text-xs">
模型使用分布 <span class="text-gray-400">请求</span>
<span class="text-gray-600">
{{ key.currentWindowRequests || 0 }}/{{ key.rateLimitRequests }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1">
<div
:class="getWindowRequestProgressColor(key)"
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"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-400">Token</span>
<span class="text-gray-600">
{{ formatTokenCount(key.currentWindowTokens || 0) }}/{{ formatTokenCount(key.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1">
<div
:class="getWindowTokenProgressColor(key)"
class="h-1 rounded-full transition-all duration-300"
:style="{ width: getWindowTokenProgress(key) + '%' }"
/>
</div>
</div>
</div>
<!-- 查看详情按钮 -->
<div class="pt-1">
<button
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"
@click="showUsageDetails(key)"
>
<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> <!-- 今日使用 -->
<p class="text-xs text-gray-500"> <div class="bg-gray-50 rounded-lg p-3">
使用量 <div class="flex justify-between items-center mb-2">
</p> <span class="text-xs text-gray-600">今日使用</span>
<p class="text-sm font-semibold text-gray-900"> <button
{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }} 次 class="text-xs text-blue-600 hover:text-blue-800"
</p> @click="showUsageDetails(key)"
<p class="text-xs text-gray-500 mt-0.5"> >
{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }} tokens <i class="fas fa-chart-line mr-1" />详情
</p> </button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber((key.usage?.daily?.requests) || 0) }} 次
</p>
<p class="text-xs text-gray-500">
请求
</p>
</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>
</div>
</div>
</div> </div>
<div>
<p class="text-xs text-gray-500"> <!-- 限制进度 -->
费用 <div
</p> v-if="key.dailyCostLimit > 0"
<p class="text-sm font-semibold text-green-600"> class="space-y-1"
{{ calculateApiKeyCost(key.usage) }} >
</p> <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 (claudeAccount) { if (accountId.startsWith('group:')) {
return claudeAccount.name const groupId = accountId.substring(6) // 移除 'group:' 前缀
// 从Claude分组中查找
const claudeGroup = accounts.value.claudeGroups.find(g => g.id === groupId)
if (claudeGroup) {
return `分组-${claudeGroup.name}`
} }
// 如果找不到返回账户ID的前8位
return `账户-${claudeAccountId.substring(0, 8)}` // 从Gemini分组中查找
const geminiGroup = accounts.value.geminiGroups.find(g => g.id === groupId)
if (geminiGroup) {
return `分组-${geminiGroup.name}`
}
// 如果找不到分组返回分组ID的前8位
return `分组-${groupId.substring(0, 8)}`
} }
// 其次显示Claude Console账户 // Claude账户列表中查找
if (claudeConsoleAccountId) { const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
const consoleAccount = accounts.value.claude.find(acc => acc.id === claudeConsoleAccountId) if (claudeAccount) {
if (consoleAccount) { return `账户-${claudeAccount.name}`
return `${consoleAccount.name} (Console)`
}
// 如果找不到返回账户ID的前8位
return `Console-${claudeConsoleAccountId.substring(0, 8)}`
} }
return '未知账户' // 从Gemini账户列表中查找
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
if (geminiAccount) {
return `账户-${geminiAccount.name}`
}
// 如果找不到返回账户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,26 +1618,68 @@ 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')
}
} catch (error) {
console.error('Error fetching API key:', error)
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'
}
// 显示使用详情
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], () => {
currentPage.value = 1 currentPage.value = 1
@@ -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>

View File

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