Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
千羽
2025-08-04 14:47:32 +09:00
committed by GitHub
52 changed files with 1094 additions and 582 deletions

View File

@@ -55,12 +55,15 @@ coverage/
.nyc_output/
# Build files
dist/
# dist/ # 前端构建阶段需要复制源文件,所以不能忽略
build/
*.pid
*.seed
*.pid.lock
# 但可以忽略本地已构建的 dist 目录
web/admin-spa/dist/
# CI/CD
.travis.yml
.gitlab-ci.yml

View File

@@ -103,52 +103,7 @@ jobs:
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Check if frontend build is needed
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
# 前端构建已移至 Docker 构建流程中
- name: Update VERSION file
if: steps.check.outputs.needs_bump == 'true'
@@ -159,19 +114,9 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# 检查是否需要添加 dist 目录
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] 以避免再次触发
# 提交VERSION文件 - 添加 [skip ci] 以避免再次触发
git add VERSION
if [ "${{ steps.check_frontend.outputs.needs_build }}" = "true" ]; then
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} and rebuild frontend [skip ci]"
else
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]"
- name: Install git-cliff
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
# 📋 设置标签
@@ -26,6 +44,9 @@ RUN npm ci --only=production && \
# 📋 复制应用代码
COPY . .
# 📦 从构建阶段复制前端产物
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
# 🔧 复制并设置启动脚本权限
COPY docker-entrypoint.sh /usr/local/bin/
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
# 初始化
@@ -526,10 +536,14 @@ git add package-lock.json
# 3. 安装新的依赖(如果有)
npm install
# 4. 重启服务
# 4. 安装并构建前端
npm run install:web
npm run build:web
# 5. 重启服务
npm run service:restart:daemon
# 5. 检查服务状态
# 6. 检查服务状态
npm run service:status
```

View File

@@ -1 +1 @@
1.1.67
1.1.69

View File

@@ -458,6 +458,10 @@ EOF
print_info "安装Web界面依赖..."
npm run install:web
# 构建前端
print_info "构建前端界面..."
npm run build:web
# 创建systemd服务文件Linux
if [[ "$OS" == "debian" || "$OS" == "redhat" || "$OS" == "arch" ]]; then
create_systemd_service
@@ -547,6 +551,10 @@ update_service() {
npm install
npm run install:web
# 构建前端
print_info "构建前端界面..."
npm run build:web
# 启动服务
start_service

View File

@@ -269,6 +269,28 @@ router.post('/api/user-stats', async (req, res) => {
}
}
// 获取当前使用量
let currentWindowRequests = 0;
let currentWindowTokens = 0;
let currentDailyCost = 0;
try {
// 获取当前时间窗口的请求次数和Token使用量
if (fullKeyData.rateLimitWindow > 0) {
const client = redis.getClientSafe();
const requestCountKey = `rate_limit:requests:${keyId}`;
const tokenCountKey = `rate_limit:tokens:${keyId}`;
currentWindowRequests = parseInt(await client.get(requestCountKey) || '0');
currentWindowTokens = parseInt(await client.get(tokenCountKey) || '0');
}
// 获取当日费用
currentDailyCost = await redis.getDailyCost(keyId) || 0;
} catch (error) {
logger.warn(`Failed to get current usage for key ${keyId}:`, error);
}
// 构建响应数据只返回该API Key自己的信息确保不泄露其他信息
const responseData = {
id: keyId,
@@ -296,13 +318,17 @@ router.post('/api/user-stats', async (req, res) => {
}
},
// 限制信息(显示配置,不显示当前使用量)
// 限制信息(显示配置当前使用量)
limits: {
tokenLimit: fullKeyData.tokenLimit || 0,
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
dailyCostLimit: fullKeyData.dailyCostLimit || 0
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
// 当前使用量
currentWindowRequests: currentWindowRequests,
currentWindowTokens: currentWindowTokens,
currentDailyCost: currentDailyCost
},
// 绑定的账户信息只显示ID不显示敏感信息

View File

@@ -192,6 +192,7 @@ class ApiKeyService {
async getAllApiKeys() {
try {
const apiKeys = await redis.getAllApiKeys();
const client = redis.getClientSafe();
// 为每个key添加使用统计和当前并发数
for (const key of apiKeys) {
@@ -207,6 +208,19 @@ class ApiKeyService {
key.permissions = key.permissions || 'all'; // 兼容旧数据
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0);
key.dailyCost = await redis.getDailyCost(key.id) || 0;
// 获取当前时间窗口的请求次数和Token使用量
if (key.rateLimitWindow > 0) {
const requestCountKey = `rate_limit:requests:${key.id}`;
const tokenCountKey = `rate_limit:tokens:${key.id}`;
key.currentWindowRequests = parseInt(await client.get(requestCountKey) || '0');
key.currentWindowTokens = parseInt(await client.get(tokenCountKey) || '0');
} else {
key.currentWindowRequests = 0;
key.currentWindowTokens = 0;
}
try {
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
} catch (e) {

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.loading-spinner[data-v-ab3ae7fd]{width:24px;height:24px;border:2px solid #e5e7eb;border-top:2px solid #3b82f6;border-radius:50%;animation:spin-ab3ae7fd 1s linear infinite}@keyframes spin-ab3ae7fd{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.accounts-container[data-v-ab3ae7fd]{min-height:calc(100vh - 300px)}.table-container[data-v-ab3ae7fd]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-ab3ae7fd]{transition:all .2s ease}.table-row[data-v-ab3ae7fd]:hover{background-color:#00000005}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
pre[data-v-2c02f1f7],pre[data-v-cff10d08]{white-space:pre-wrap;word-wrap:break-word}@keyframes fadeIn-060bf571{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.animate-fadeIn[data-v-060bf571]{animation:fadeIn-060bf571 .2s ease-out}.loading-spinner[data-v-060bf571]{width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top:2px solid white;border-radius:50%;animation:spin-060bf571 .8s linear infinite;display:inline-block}@keyframes spin-060bf571{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.tab-content[data-v-9c252b1b]{min-height:calc(100vh - 300px)}.table-container[data-v-9c252b1b]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-9c252b1b]{transition:all .2s ease}.table-row[data-v-9c252b1b]:hover{background-color:#00000005}.loading-spinner[data-v-9c252b1b]{width:24px;height:24px;border:2px solid #e5e7eb;border-top:2px solid #3b82f6;border-radius:50%;animation:spin-9c252b1b 1s linear infinite}@keyframes spin-9c252b1b{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.api-key-date-picker[data-v-9c252b1b] .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-9c252b1b] .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-9c252b1b] .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-a1b368e2] .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-a1b368e2] .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-a1b368e2] .el-input__inner{font-size:13px;padding:0 10px}.custom-date-picker[data-v-a1b368e2] .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-a1b368e2] .el-range-input{font-size:13px}@keyframes spin-a1b368e2{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin[data-v-a1b368e2]{animation:spin-a1b368e2 1s linear infinite}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{c as x,r as b,q as f,x as a,z as t,L as i,Q as y,u as o,P as d,Y as w,K as u,aq as c,O as p,y as n}from"./vue-vendor-CKToUHZx.js";import{_,u as v}from"./index-D1PUfDNP.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-4 sm:p-6"},k={class:"glass-strong rounded-xl sm:rounded-2xl md:rounded-3xl p-6 sm:p-8 md:p-10 w-full max-w-md shadow-2xl"},L={class:"text-center mb-6 sm:mb-8"},S={class:"w-16 h-16 sm:w-20 sm:h-20 mx-auto mb-4 sm:mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl sm:rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden"},V=["src"],I={key:1,class:"fas fa-cloud text-2xl sm:text-3xl text-gray-700"},N={key:1,class:"w-10 h-10 sm:w-12 sm:h-12 bg-gray-300/50 rounded animate-pulse"},q={key:0,class:"text-2xl sm:text-3xl font-bold text-white mb-2 header-title"},D={key:1,class:"h-8 sm:h-9 w-48 sm: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-4 sm:mt-6 p-3 sm:p-4 bg-red-500/20 border border-red-500/30 rounded-lg sm:rounded-xl text-red-800 text-xs sm:text-sm text-center backdrop-blur-sm"},F={__name:"LoginView",setup(O){const e=v(),m=x(()=>e.oemLoading),l=b({username:"",password:""});f(()=>{e.loadOemSettings()});const g=async()=>{await e.login(l.value)};return(T,s)=>(n(),a("div",h,[t("div",k,[t("div",L,[t("div",S,[m.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-10 h-10 sm:w-12 sm:h-12 object-contain",onError:s[0]||(s[0]=r=>r.target.style.display="none")},null,40,V)):(n(),a("i",I))],64))]),!m.value&&o(e).oemSettings.siteName?(n(),a("h1",q,d(o(e).oemSettings.siteName),1)):m.value?(n(),a("div",D)):i("",!0),s[3]||(s[3]=t("p",{class:"text-gray-600 text-base sm:text-lg"}," 管理后台 ",-1))]),t("form",{class:"space-y-4 sm:space-y-6",onSubmit:w(g,["prevent"])},[t("div",null,[s[4]||(s[4]=t("label",{class:"block text-sm font-semibold text-gray-900 mb-2 sm:mb-3"},"用户名",-1)),u(t("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>l.value.username=r),type:"text",required:"",class:"form-input w-full",placeholder:"请输入用户名"},null,512),[[c,l.value.username]])]),t("div",null,[s[5]||(s[5]=t("label",{class:"block text-sm font-semibold text-gray-900 mb-2 sm:mb-3"},"密码",-1)),u(t("input",{"onUpdate:modelValue":s[2]||(s[2]=r=>l.value.password=r),type:"password",required:"",class:"form-input w-full",placeholder:"请输入密码"},null,512),[[c,l.value.password]])]),t("button",{type:"submit",disabled:o(e).loginLoading,class:"btn btn-primary w-full py-3 sm:py-4 px-4 sm:px-6 text-base sm:text-lg font-semibold"},[o(e).loginLoading?i("",!0):(n(),a("i",j)),o(e).loginLoading?(n(),a("div",B)):i("",!0),p(" "+d(o(e).loginLoading?"登录中...":"登录"),1)],8,E)],32),o(e).loginError?(n(),a("div",M,[s[6]||(s[6]=t("i",{class:"fas fa-exclamation-triangle mr-2"},null,-1)),p(d(o(e).loginError),1)])):i("",!0)])]))}},P=_(F,[["__scopeId","data-v-d68c64ae"]]);export{P as default};

View File

@@ -1 +0,0 @@
@keyframes pulse-718feedc{0%{opacity:.7}50%{opacity:.4}to{opacity:.7}}.animate-pulse[data-v-718feedc]{animation:pulse-718feedc 2s cubic-bezier(.4,0,.6,1) infinite}.header-title[data-v-718feedc]{text-shadow:0 1px 2px rgba(0,0,0,.1)}

View File

@@ -1 +0,0 @@
/* empty css */import{_ as r}from"./index-D1PUfDNP.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-9c585a16]{margin-top:8px}.fade-enter-active[data-v-9c585a16],.fade-leave-active[data-v-9c585a16]{transition:opacity .3s}.fade-enter-from[data-v-9c585a16],.fade-leave-to[data-v-9c585a16]{opacity:0}

View File

@@ -1 +0,0 @@
.settings-container[data-v-b9afc490]{min-height:calc(100vh - 300px)}.card[data-v-b9afc490]{background:#fff;border-radius:12px;box-shadow:0 2px 12px #0000001a;border:1px solid #e5e7eb}.table-container[data-v-b9afc490]{overflow:hidden;border-radius:8px;border:1px solid #f3f4f6}.table-row[data-v-b9afc490]{transition:background-color .2s ease}.table-row[data-v-b9afc490]:hover{background-color:#f9fafb}.form-input[data-v-b9afc490]{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-b9afc490]: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-b9afc490]{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-b9afc490]: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-b9afc490]{--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-b9afc490]:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.btn-primary[data-v-b9afc490]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn-success[data-v-b9afc490]{--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-b9afc490]:hover{--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.btn-success[data-v-b9afc490]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1))}.loading-spinner[data-v-b9afc490]{height:1.25rem;width:1.25rem}@keyframes spin-b9afc490{to{transform:rotate(360deg)}}.loading-spinner[data-v-b9afc490]{animation:spin-b9afc490 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))}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.tutorial-container[data-v-573d0b94]{min-height:calc(100vh - 300px)}.tutorial-content[data-v-573d0b94]{animation:fadeIn-573d0b94 .3s ease-in-out}@keyframes fadeIn-573d0b94{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}code[data-v-573d0b94]{font-family:Fira Code,Monaco,Menlo,Ubuntu Mono,monospace}.tutorial-content h4[data-v-573d0b94]{scroll-margin-top:100px}.tutorial-content .bg-gradient-to-r[data-v-573d0b94]{transition:all .2s ease}.tutorial-content .bg-gradient-to-r[data-v-573d0b94]: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

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,22 +0,0 @@
let e=null,r=0;function c(n,s="info",a="",i=3e3){e||(e=document.createElement("div"),e.id="toast-container",e.style.cssText="position: fixed; top: 20px; right: 20px; z-index: 10000;",document.body.appendChild(e));const o=++r,t=document.createElement("div");t.className=`toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm toast-${s}`,t.style.cssText=`
position: relative;
min-width: 320px;
max-width: 500px;
margin-bottom: 16px;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
`;const l={success:"fas fa-check-circle",error:"fas fa-times-circle",warning:"fas fa-exclamation-triangle",info:"fas fa-info-circle"};return t.innerHTML=`
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
<i class="${l[s]} text-lg"></i>
</div>
<div class="flex-1 min-w-0">
${a?`<h4 class="font-semibold text-sm mb-1">${a}</h4>`:""}
<p class="text-sm opacity-90 leading-relaxed">${n}</p>
</div>
<button onclick="this.parentElement.parentElement.remove()"
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">
<i class="fas fa-times"></i>
</button>
</div>
`,e.appendChild(t),setTimeout(()=>{t.style.transform="translateX(0)"},10),i>0&&setTimeout(()=>{t.style.transform="translateX(100%)",setTimeout(()=>{t.remove()},300)},i),o}export{c as s};

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-D1PUfDNP.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-Db9Zyv8s.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -183,7 +183,9 @@
class="form-input flex-1"
required
>
<option value="">请选择分组</option>
<option value="">
请选择分组
</option>
<option
v-for="group in filteredGroups"
:key="group.id"
@@ -191,14 +193,19 @@
>
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
</option>
<option value="__new__">+ 新建分组</option>
<option value="__new__">
+ 新建分组
</option>
</select>
<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"
@click="refreshGroups"
>
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
<i
class="fas fa-sync-alt"
:class="{ 'animate-spin': loadingGroups }"
/>
</button>
</div>
</div>
@@ -561,7 +568,9 @@
class="form-input flex-1"
required
>
<option value="">请选择分组</option>
<option value="">
请选择分组
</option>
<option
v-for="group in filteredGroups"
:key="group.id"
@@ -569,14 +578,19 @@
>
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
</option>
<option value="__new__">+ 新建分组</option>
<option value="__new__">
+ 新建分组
</option>
</select>
<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"
@click="refreshGroups"
>
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
<i
class="fas fa-sync-alt"
:class="{ 'animate-spin': loadingGroups }"
/>
</button>
</div>
</div>

View File

@@ -38,7 +38,9 @@
v-if="showCreateForm"
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>
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
@@ -113,7 +115,9 @@
class="text-center py-8"
>
<div class="loading-spinner-lg mx-auto mb-4" />
<p class="text-gray-500">加载中...</p>
<p class="text-gray-500">
加载中...
</p>
</div>
<div
@@ -121,7 +125,9 @@
class="text-center py-8 bg-gray-50 rounded-lg"
>
<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
@@ -135,8 +141,12 @@
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
<p class="text-sm text-gray-500 mt-1">{{ group.description || '暂无描述' }}</p>
<h4 class="font-semibold text-gray-900">
{{ group.name }}
</h4>
<p class="text-sm text-gray-500 mt-1">
{{ group.description || '暂无描述' }}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<span
@@ -194,7 +204,9 @@
>
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
<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
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="cancelEdit"

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">
{{ formatTokenCount(totalTokens) }}
</div>
<div class="text-xs text-gray-600 mt-1">
今日: {{ formatTokenCount(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">
{{ formatTokenCount(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">
{{ formatTokenCount(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">
{{ formatTokenCount(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">
{{ formatTokenCount(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

@@ -6,64 +6,131 @@
<i class="fas fa-shield-alt mr-2 md:mr-3 text-red-500 text-sm md:text-base" />
限制配置
</h3>
<div class="space-y-2 md:space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">Token 限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">并发限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">速率限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
: '无限制' }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">每日费用限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">{{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">模型限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
<div class="space-y-4 md:space-y-5">
<!-- 每日费用限制 -->
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600 text-sm md:text-base font-medium">每日费用限制</span>
<span class="text-xs md:text-sm text-gray-500">
<span v-if="statsData.limits.dailyCostLimit > 0">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{ statsData.limits.dailyCostLimit.toFixed(2) }}
</span>
<span v-else class="flex items-center gap-1">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / <i class="fas fa-infinity" />
</span>
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有模型
</span>
</span>
</div>
<div v-if="statsData.limits.dailyCostLimit > 0" class="w-full bg-gray-200 rounded-full h-2">
<div
:class="getDailyCostProgressColor()"
class="h-2 rounded-full transition-all duration-300"
:style="{ width: getDailyCostProgress() + '%' }"
/>
</div>
<div v-else class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: 0%" />
</div>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">客户端限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
<!-- 时间窗口限制 -->
<div v-if="statsData.limits.rateLimitWindow > 0 && (statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600 text-sm md:text-base font-medium">
时间窗口限制 ({{ statsData.limits.rateLimitWindow }}分钟)
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</div>
<!-- 请求次数限制 -->
<div v-if="statsData.limits.rateLimitRequests > 0" class="space-y-1.5 mb-3">
<div class="flex justify-between items-center text-xs md:text-sm">
<span class="text-gray-500">请求次数</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowRequests) }} / {{ formatNumber(statsData.limits.rateLimitRequests) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowRequestProgressColor()"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowRequestProgress() + '%' }"
/>
</div>
</div>
<!-- Token使用量限制 -->
<div v-if="statsData.limits.tokenLimit > 0" class="space-y-1.5">
<div class="flex justify-between items-center text-xs md:text-sm">
<span class="text-gray-500">Token 使用量</span>
<span class="text-gray-700">
{{ formatNumber(statsData.limits.currentWindowTokens) }} / {{ formatNumber(statsData.limits.tokenLimit) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowTokenProgressColor()"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowTokenProgress() + '%' }"
/>
</div>
</div>
<div class="mt-2 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系任一达到限制即触发限流
</div>
</div>
<!-- 其他限制信息 -->
<div class="pt-2 border-t border-gray-100 space-y-2">
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">并发限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span v-if="statsData.limits.concurrencyLimit > 0">
{{ statsData.limits.concurrencyLimit }}
</span>
<span v-else class="flex items-center gap-1">
<i class="fas fa-infinity text-gray-400" />
</span>
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">模型限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有模型
</span>
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 text-sm md:text-base">客户端限制</span>
<span class="font-medium text-gray-900 text-sm md:text-base">
<span
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
</span>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</span>
</span>
</div>
</div>
</div>
</div>
@@ -158,6 +225,51 @@ const formatNumber = (num) => {
return num.toLocaleString()
}
}
// 获取每日费用进度
const getDailyCostProgress = () => {
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0) return 0
const percentage = (statsData.value.limits.currentDailyCost / statsData.value.limits.dailyCostLimit) * 100
return Math.min(percentage, 100)
}
// 获取每日费用进度条颜色
const getDailyCostProgressColor = () => {
const progress = getDailyCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 获取窗口请求进度
const getWindowRequestProgress = () => {
if (!statsData.value.limits.rateLimitRequests || statsData.value.limits.rateLimitRequests === 0) return 0
const percentage = (statsData.value.limits.currentWindowRequests / statsData.value.limits.rateLimitRequests) * 100
return Math.min(percentage, 100)
}
// 获取窗口请求进度条颜色
const getWindowRequestProgressColor = () => {
const progress = getWindowRequestProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-blue-500'
}
// 获取窗口Token进度
const getWindowTokenProgress = () => {
if (!statsData.value.limits.tokenLimit || statsData.value.limits.tokenLimit === 0) return 0
const percentage = (statsData.value.limits.currentWindowTokens / statsData.value.limits.tokenLimit) * 100
return Math.min(percentage, 100)
}
// 获取窗口Token进度条颜色
const getWindowTokenProgressColor = () => {
const progress = getWindowTokenProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-purple-500'
}
</script>
<style scoped>

View File

@@ -311,6 +311,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
// 天粒度的预设时间范围需要传递startDate和endDate参数
const now = new Date()
let startDate, endDate
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
if (option) {
if (dateFilter.value.preset === 'today') {
// 今日从系统时区的今天0点到23:59
startDate = getSystemTimezoneDay(now, true)
endDate = getSystemTimezoneDay(now, false)
} else {
// 7天或30天从N天前的0点到今天的23:59
const daysAgo = new Date()
daysAgo.setDate(daysAgo.getDate() - (option.days - 1))
startDate = getSystemTimezoneDay(daysAgo, true)
endDate = getSystemTimezoneDay(now, false)
}
url += `&startDate=${encodeURIComponent(startDate.toISOString())}`
url += `&endDate=${encodeURIComponent(endDate.toISOString())}`
}
}
const response = await apiClient.get(url)

View File

@@ -38,8 +38,12 @@
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
@change="filterByGroup"
>
<option value="all">所有账户</option>
<option value="ungrouped">未分组账户</option>
<option value="all">
所有账户
</option>
<option value="ungrouped">
未分组账户
</option>
<option
v-for="group in accountGroups"
:key="group.id"
@@ -177,7 +181,10 @@
</div>
<div class="min-w-0">
<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 }}
</div>
<span
@@ -206,7 +213,10 @@
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
</span>
</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 }}
</div>
</div>
@@ -221,7 +231,7 @@
>
<i class="fas fa-robot text-yellow-700 text-xs" />
<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">
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
</span>
@@ -232,7 +242,7 @@
>
<i class="fas fa-terminal text-purple-700 text-xs" />
<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>
</div>
<div
@@ -241,7 +251,7 @@
>
<i class="fas fa-brain text-indigo-700 text-xs" />
<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">
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
</span>
@@ -525,10 +535,10 @@
今日使用
</p>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber(account.usage?.dailyRequests || 0) }}
{{ formatNumber(account.usage?.daily?.requests || 0) }}
</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ formatNumber(account.usage?.dailyTokens || 0) }} tokens
{{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens
</p>
</div>
<div>
@@ -536,10 +546,10 @@
总使用量
</p>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber(account.usage?.totalRequests || 0) }}
{{ formatNumber(account.usage?.total?.requests || 0) }}
</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ formatNumber(account.usage?.totalTokens || 0) }} tokens
{{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens
</p>
</div>
</div>
@@ -548,25 +558,37 @@
<div class="space-y-2 mb-3">
<!-- 会话窗口 -->
<div
v-if="account.sessionWindow"
class="flex items-center justify-between text-xs"
v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow"
class="bg-gray-50 rounded-lg p-2 space-y-1.5"
>
<span class="text-gray-500">会话窗口</span>
<div class="flex items-center gap-2">
<span
:class="[
'font-medium',
account.sessionWindow.remaining <= 20 ? 'text-orange-600' : 'text-gray-900'
]"
>
{{ account.sessionWindow.remaining || 0 }} / {{ account.sessionWindow.total || 0 }}
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 font-medium">会话窗口</span>
<span class="text-gray-700 font-medium">
{{ account.sessionWindow.progress }}%
</span>
</div>
<div class="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">
{{ formatSessionWindow(account.sessionWindow.windowStart, account.sessionWindow.windowEnd) }}
</span>
<span
v-if="account.sessionWindow.remainingTime > 0"
class="text-indigo-600 font-medium"
>
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</span>
<span
v-else
class="text-gray-500"
>
已结束
</span>
<div class="w-20 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300"
:style="{ width: `${getSessionWindowPercentage(account)}%` }"
/>
</div>
</div>
</div>
@@ -615,8 +637,8 @@
:class="account.schedulable
? 'text-gray-600 bg-gray-50 hover:bg-gray-100'
: 'text-green-600 bg-green-50 hover:bg-green-100'"
@click="toggleSchedulable(account)"
:disabled="account.isTogglingSchedulable"
@click="toggleSchedulable(account)"
>
<i :class="['fas', account.schedulable ? 'fa-pause' : 'fa-play']" />
{{ account.schedulable ? '暂停' : '启用' }}

View File

@@ -185,14 +185,23 @@
<i class="fas fa-key text-white text-xs" />
</div>
<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 }}
</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 }}
</div>
<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" />
{{ getBoundAccountName(key.claudeAccountId) }}
</span>
@@ -232,98 +241,100 @@
</span>
</td>
<td class="px-3 py-4">
<div class="space-y-1">
<!-- 请求统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">请求数:</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }}</span>
</div>
<!-- Token统计 -->
<div class="flex justify-between text-sm">
<span class="text-gray-600">Token:</span>
<span class="font-medium text-gray-900">{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }}</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 class="space-y-2">
<!-- 今日使用统计 -->
<div class="mb-2">
<div class="flex justify-between items-center text-sm mb-1">
<span class="text-gray-600">今日请求</span>
<span class="font-semibold text-gray-900">{{ formatNumber((key.usage?.daily?.requests) || 0) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">今日费用</span>
<span class="font-semibold text-green-600">${{ (key.dailyCost || 0).toFixed(4) }}</span>
</div>
</div>
<!-- 模型分布按钮 -->
<div class="pt-2">
<button
v-if="key && key.id"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
@click="toggleApiKeyModelStats(key.id)"
<!-- 每日费用限制进度条 -->
<div
v-if="key.dailyCostLimit > 0"
class="space-y-1"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">费用限额</span>
<span class="text-gray-700">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-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>
</div>
</div>
@@ -373,6 +384,15 @@
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm">
<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
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-2 py-1 rounded transition-colors text-xs"
title="复制统计页面链接"
@@ -472,10 +492,10 @@
:default-time="defaultTime"
size="small"
style="width: 280px;"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
class="api-key-date-picker"
:clearable="true"
:unlink-panels="false"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
/>
</div>
</div>
@@ -526,7 +546,7 @@
<i class="fas fa-coins text-yellow-500 mr-1 text-xs" />
总Token:
</span>
<span class="font-semibold text-gray-900">{{ formatNumber(stat.allTokens) }}</span>
<span class="font-semibold text-gray-900">{{ formatTokenCount(stat.allTokens) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
@@ -541,14 +561,14 @@
<i class="fas fa-arrow-down text-green-500 mr-1" />
输入:
</span>
<span class="font-medium">{{ formatNumber(stat.inputTokens) }}</span>
<span class="font-medium">{{ formatTokenCount(stat.inputTokens) }}</span>
</div>
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-up text-blue-500 mr-1" />
输出:
</span>
<span class="font-medium">{{ formatNumber(stat.outputTokens) }}</span>
<span class="font-medium">{{ formatTokenCount(stat.outputTokens) }}</span>
</div>
<div
v-if="stat.cacheCreateTokens > 0"
@@ -558,7 +578,7 @@
<i class="fas fa-save mr-1" />
缓存创建:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheCreateTokens) }}</span>
<span class="font-medium">{{ formatTokenCount(stat.cacheCreateTokens) }}</span>
</div>
<div
v-if="stat.cacheReadTokens > 0"
@@ -568,7 +588,7 @@
<i class="fas fa-download mr-1" />
缓存读取:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
<span class="font-medium">{{ formatTokenCount(stat.cacheReadTokens) }}</span>
</div>
</div>
</div>
@@ -603,7 +623,7 @@
总请求: <span class="font-semibold text-gray-800">{{ apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.requests, 0) }}</span>
</span>
<span class="text-gray-600">
总Token: <span class="font-semibold text-gray-800">{{ formatNumber(apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.allTokens, 0)) }}</span>
总Token: <span class="font-semibold text-gray-800">{{ formatTokenCount(apiKeyModelStats[key.id].reduce((sum, stat) => sum + stat.allTokens, 0)) }}</span>
</span>
</div>
</div>
@@ -666,25 +686,104 @@
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<p class="text-xs text-gray-500">
使用量
</p>
<p class="text-sm font-semibold text-gray-900">
{{ formatNumber((key.usage && key.usage.total && key.usage.total.requests) || 0) }} 次
</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ formatNumber((key.usage && key.usage.total && key.usage.total.tokens) || 0) }} tokens
</p>
<div class="space-y-2 mb-3">
<!-- 今日使用 -->
<div class="bg-gray-50 rounded-lg p-3">
<div class="flex justify-between items-center mb-2">
<span class="text-xs text-gray-600">今日使用</span>
<button
class="text-xs text-blue-600 hover:text-blue-800"
@click="showUsageDetails(key)"
>
<i class="fas fa-chart-line mr-1" />详情
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<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>
<p class="text-xs text-gray-500">
费用
</p>
<p class="text-sm font-semibold text-green-600">
{{ calculateApiKeyCost(key.usage) }}
</p>
<!-- 限制进度 -->
<div
v-if="key.dailyCostLimit > 0"
class="space-y-1"
>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">每日费用限额</span>
<span class="text-gray-700">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
:class="getDailyCostProgressColor(key)"
class="h-2 rounded-full transition-all duration-300"
:style="{ width: getDailyCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 移动端时间窗口限制 -->
<div
v-if="key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)"
class="space-y-1"
>
<div class="text-xs text-gray-500 mb-1">
窗口限制 ({{ key.rateLimitWindow }}分钟)
</div>
<div
v-if="key.rateLimitRequests > 0"
class="flex items-center gap-2"
>
<span class="text-xs text-gray-500 w-10">请求</span>
<div class="flex-1">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowRequestProgressColor(key)"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowRequestProgress(key) + '%' }"
/>
</div>
</div>
<span class="text-xs text-gray-600 w-16 text-right">
{{ key.currentWindowRequests || 0 }}/{{ key.rateLimitRequests }}
</span>
</div>
<div
v-if="key.tokenLimit > 0"
class="flex items-center gap-2"
>
<span class="text-xs text-gray-500 w-10">Token</span>
<div class="flex-1">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
:class="getWindowTokenProgressColor(key)"
class="h-1.5 rounded-full transition-all duration-300"
:style="{ width: getWindowTokenProgress(key) + '%' }"
/>
</div>
</div>
<span class="text-xs text-gray-600 w-16 text-right">
{{ formatTokenCount(key.currentWindowTokens || 0) }}/{{ formatTokenCount(key.tokenLimit) }}
</span>
</div>
</div>
</div>
@@ -720,10 +819,10 @@
<div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
<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"
@click="toggleExpanded(key.id)"
@click="showUsageDetails(key)"
>
<i :class="['fas', expandedKeys.includes(key.id) ? 'fa-chevron-up' : 'fa-chevron-down']" />
{{ expandedKeys.includes(key.id) ? '收起' : '详情' }}
<i class="fas fa-chart-line" />
查看详情
</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"
@@ -747,77 +846,6 @@
<i class="fas fa-trash" />
</button>
</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>
@@ -959,6 +987,13 @@
@close="closeExpiryEdit"
@save="handleSaveExpiry"
/>
<!-- 使用详情弹窗 -->
<UsageDetailModal
:show="showUsageDetailModal"
:api-key="selectedApiKeyForDetail || {}"
@close="showUsageDetailModal = false"
/>
</div>
</template>
@@ -973,6 +1008,7 @@ import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
// 响应式数据
const clientsStore = useClientsStore()
@@ -988,13 +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 editingExpiryKey = ref(null)
const expiryEditModalRef = ref(null)
const showUsageDetailModal = ref(false)
const selectedApiKeyForDetail = ref(null)
// 标签相关
const selectedTagFilter = ref('')
const availableTags = ref([])
// 移动端展开状态
const expandedKeys = ref([])
// 分页相关
const currentPage = ref(1)
@@ -1183,16 +1219,36 @@ const calculateApiKeyCost = (usage) => {
const getBoundAccountName = (accountId) => {
if (!accountId) return '未知账户'
// 检查是否是分组
if (accountId.startsWith('group:')) {
const groupId = accountId.substring(6) // 移除 'group:' 前缀
// 从Claude分组中查找
const claudeGroup = accounts.value.claudeGroups.find(g => g.id === groupId)
if (claudeGroup) {
return `分组-${claudeGroup.name}`
}
// 从Gemini分组中查找
const geminiGroup = accounts.value.geminiGroups.find(g => g.id === groupId)
if (geminiGroup) {
return `分组-${geminiGroup.name}`
}
// 如果找不到分组返回分组ID的前8位
return `分组-${groupId.substring(0, 8)}`
}
// 从Claude账户列表中查找
const claudeAccount = accounts.value.claude.find(acc => acc.id === accountId)
if (claudeAccount) {
return claudeAccount.name
return `账户-${claudeAccount.name}`
}
// 从Gemini账户列表中查找
const geminiAccount = accounts.value.gemini.find(acc => acc.id === accountId)
if (geminiAccount) {
return geminiAccount.name
return `账户-${geminiAccount.name}`
}
// 如果找不到返回账户ID的前8位
@@ -1477,7 +1533,7 @@ const deleteApiKey = async (keyId) => {
const copyApiStatsLink = (apiKey) => {
// 构建统计页面的完整URL
const baseUrl = window.location.origin
const statsUrl = `${baseUrl}/admin/api-stats?apiId=${apiKey.id}`
const statsUrl = `${baseUrl}/admin-next/api-stats?apiId=${apiKey.id}`
// 使用传统的textarea方法复制到剪贴板
const textarea = document.createElement('textarea')
@@ -1548,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) => {
@@ -1571,26 +1618,68 @@ const formatDate = (dateString) => {
}).replace(/\//g, '-')
}
// 显示API Key详情
const showApiKey = async (apiKey) => {
try {
// 重新获取API Key的完整信息包含实际的key值
const response = await apiClient.get(`/admin/api-keys/${apiKey.id}`)
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 getDailyCostProgress = (key) => {
if (!key.dailyCostLimit || key.dailyCostLimit === 0) return 0
const percentage = ((key.dailyCost || 0) / key.dailyCostLimit) * 100
return Math.min(percentage, 100)
}
// 获取每日费用进度条颜色
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], () => {
currentPage.value = 1

View File

@@ -180,8 +180,12 @@
<i class="fas fa-font text-white text-sm" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900">网站名称</h4>
<p class="text-xs text-gray-500">品牌标识</p>
<h4 class="text-sm font-semibold text-gray-900">
网站名称
</h4>
<p class="text-xs text-gray-500">
品牌标识
</p>
</div>
</div>
<div class="space-y-2">
@@ -205,8 +209,12 @@
<i class="fas fa-image text-white text-sm" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900">网站图标</h4>
<p class="text-xs text-gray-500">Favicon</p>
<h4 class="text-sm font-semibold text-gray-900">
网站图标
</h4>
<p class="text-xs text-gray-500">
Favicon
</p>
</div>
</div>
<div class="space-y-3">

View File

@@ -1132,7 +1132,42 @@ const tutorialSystems = [
// 当前基础URL
const currentBaseUrl = computed(() => {
return window.location.origin
// 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境
let origin = ''
if (window.location.origin) {
// 现代浏览器直接支持 origin
origin = window.location.origin
} else {
// 旧版浏览器或特殊环境的兼容处理
const protocol = window.location.protocol
const hostname = window.location.hostname
const port = window.location.port
origin = protocol + '//' + hostname
// 只有在非默认端口时才添加端口号
if (port &&
((protocol === 'http:' && port !== '80') ||
(protocol === 'https:' && port !== '443'))) {
origin += ':' + port
}
}
// 如果还是获取不到,使用当前页面的 URL 推导
if (!origin) {
const currentUrl = window.location.href
const pathStart = currentUrl.indexOf('/', 8) // 跳过 http:// 或 https://
if (pathStart !== -1) {
origin = currentUrl.substring(0, pathStart)
} else {
// 最后的降级方案,使用相对路径
console.warn('无法获取完整的 origin将使用相对路径')
return '/api'
}
}
return origin + '/api'
})
</script>