mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 08:55:23 +00:00
refactor: standardize code formatting and linting configuration
- Replace .eslintrc.js with .eslintrc.cjs for better ES module compatibility - Add .prettierrc configuration for consistent code formatting - Update package.json with new lint and format scripts - Add nodemon.json for development hot reloading configuration - Standardize code formatting across all JavaScript and Vue files - Update web admin SPA with improved linting rules and formatting - Add prettier configuration to web admin SPA 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
|
||||
|
||||
<!-- 全局组件 -->
|
||||
<ToastNotification ref="toastRef" />
|
||||
<ConfirmDialog ref="confirmRef" />
|
||||
@@ -21,7 +21,7 @@ const confirmRef = ref()
|
||||
onMounted(() => {
|
||||
// 检查本地存储的认证状态
|
||||
authStore.checkAuth()
|
||||
|
||||
|
||||
// 加载OEM设置(包括网站图标)
|
||||
authStore.loadOemSettings()
|
||||
})
|
||||
@@ -31,4 +31,4 @@ onMounted(() => {
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,475 +1,483 @@
|
||||
/* Glass效果 */
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.tab-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.tab-btn:hover::before {
|
||||
left: 100%;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
height 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(107, 114, 128, 0.3),
|
||||
0 4px 6px -2px rgba(107, 114, 128, 0.05);
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(107, 114, 128, 0.3),
|
||||
0 4px 6px -2px rgba(107, 114, 128, 0.05);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(107, 114, 128, 0.3),
|
||||
0 10px 10px -5px rgba(107, 114, 128, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(107, 114, 128, 0.3),
|
||||
0 10px 10px -5px rgba(107, 114, 128, 0.1);
|
||||
}
|
||||
|
||||
/* 表单输入 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
transform: scale(1.005);
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
.modal-scroll-content {
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* 标题渐变 */
|
||||
.header-title {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 2px solid white;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 2px solid white;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast通知 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 响应式设计 - 移动端优化 */
|
||||
@media (max-width: 640px) {
|
||||
/* 玻璃态容器 */
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 12px;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
.tab-btn {
|
||||
font-size: 12px;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-content {
|
||||
margin: 8px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(90vh - 100px);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 表单元素 */
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table-container table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-container th,
|
||||
.table-container td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Toast通知 */
|
||||
.toast {
|
||||
min-width: 280px;
|
||||
max-width: calc(100vw - 40px);
|
||||
right: 12px;
|
||||
top: 60px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
/* 玻璃态容器 */
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 12px;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
.tab-btn {
|
||||
font-size: 12px;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-content {
|
||||
margin: 8px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(90vh - 100px);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 表单元素 */
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table-container table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-container th,
|
||||
.table-container td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Toast通知 */
|
||||
.toast {
|
||||
min-width: 280px;
|
||||
max-width: calc(100vw - 40px);
|
||||
right: 12px;
|
||||
top: 60px;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 玻璃态容器 */
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
/* 模态框滚动内容 */
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
/* 玻璃态容器 */
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
/* 模态框滚动内容 */
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* 版本更新提醒动画 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* 用户菜单下拉框优化 */
|
||||
.user-menu-dropdown {
|
||||
min-width: 240px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
min-width: 240px;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -1,572 +1,614 @@
|
||||
/* 从原始 style.css 复制的全局样式 */
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--surface-color: rgba(255, 255, 255, 0.95);
|
||||
--glass-color: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--surface-color: rgba(255, 255, 255, 0.95);
|
||||
--glass-color: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 通用transition - 仅应用于特定元素 */
|
||||
body, div, button, input, select, textarea, table, tr, td, th, span, p, h1, h2, h3, h4, h5, h6 {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
body,
|
||||
div,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
table,
|
||||
tr,
|
||||
td,
|
||||
th,
|
||||
span,
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color) 0%,
|
||||
var(--secondary-color) 50%,
|
||||
var(--accent-color) 100%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.tab-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.tab-btn:hover::before {
|
||||
left: 100%;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
height 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* 表单输入框样式 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--surface-color);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
height 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(16, 185, 129, 0.3),
|
||||
0 4px 6px -2px rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(16, 185, 129, 0.3),
|
||||
0 10px 10px -5px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(239, 68, 68, 0.3),
|
||||
0 4px 6px -2px rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(239, 68, 68, 0.3),
|
||||
0 10px 10px -5px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
transform: scale(1.005);
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 2px solid white;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 2px solid white;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 10px;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
.modal-scroll-content {
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
max-height: calc(90vh - 160px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass, .glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 版本更新提醒动画 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* 用户菜单下拉框优化 */
|
||||
.user-menu-dropdown {
|
||||
min-width: 240px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
min-width: 240px;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Tab 内容区域样式 */
|
||||
.tab-content {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,116 +9,148 @@
|
||||
|
||||
/* 全局样式 */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color) 0%,
|
||||
var(--secondary-color) 50%,
|
||||
var(--accent-color) 100%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 通用transition - 仅应用于特定元素 */
|
||||
body, div, button, input, select, textarea, table, tr, td, th, span, p, h1, h2, h3, h4, h5, h6 {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
body,
|
||||
div,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
table,
|
||||
tr,
|
||||
td,
|
||||
th,
|
||||
span,
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Element Plus 主题覆盖 */
|
||||
.el-button--primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.el-button--primary:hover,
|
||||
.el-button--primary:focus {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
opacity: 0.9;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 10px;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* Vue过渡动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.glass, .glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
}
|
||||
.glass,
|
||||
.glass-strong {
|
||||
margin: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
font-size: 14px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.modal-scroll-content {
|
||||
max-height: calc(85vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--surface-color: rgba(255, 255, 255, 0.95);
|
||||
--glass-color: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--accent-color: #f093fb;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--surface-color: rgba(255, 255, 255, 0.95);
|
||||
--glass-color: rgba(255, 255, 255, 0.1);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +1,118 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-4xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-4xl overflow-y-auto p-4 sm:p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg sm:rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-white text-sm sm:text-base" />
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||
>
|
||||
<i class="fas fa-layer-group text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
|
||||
账户分组管理
|
||||
</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">账户分组管理</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 添加分组按钮 -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2"
|
||||
@click="showCreateForm = true"
|
||||
>
|
||||
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
|
||||
<i class="fas fa-plus mr-2" />
|
||||
创建新分组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 创建分组表单 -->
|
||||
<div
|
||||
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>
|
||||
<div v-if="showCreateForm" class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-900">创建新分组</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
>
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型 *</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="createForm.platform"
|
||||
type="radio"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
|
||||
<span class="text-sm text-gray-700">Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="createForm.platform"
|
||||
type="radio"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="gemini" />
|
||||
<span class="text-sm text-gray-700">Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
rows="2"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2"
|
||||
:disabled="!createForm.name || !createForm.platform || creating"
|
||||
@click="createGroup"
|
||||
>
|
||||
<div
|
||||
v-if="creating"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<div v-if="creating" class="loading-spinner mr-2" />
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary px-4 py-2"
|
||||
@click="cancelCreate"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 分组列表 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<div v-if="loading" class="py-8 text-center">
|
||||
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||
<p class="text-gray-500">
|
||||
加载中...
|
||||
</p>
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
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>
|
||||
|
||||
<div v-else-if="groups.length === 0" class="rounded-lg bg-gray-50 py-8 text-center">
|
||||
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300" />
|
||||
<p class="text-gray-500">暂无分组</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-4 grid-cols-1 md:grid-cols-2"
|
||||
>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-white rounded-lg border p-4 hover:shadow-md transition-shadow"
|
||||
class="rounded-lg border bg-white p-4 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">
|
||||
{{ group.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ group.description || '暂无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<div class="ml-4 flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
group.platform === 'claude'
|
||||
'rounded-full px-2 py-1 text-xs font-medium',
|
||||
group.platform === 'claude'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
]"
|
||||
@@ -161,7 +121,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>
|
||||
@@ -175,16 +135,16 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
class="text-blue-600 transition-colors hover:text-blue-800"
|
||||
title="编辑"
|
||||
@click="editGroup(group)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="删除"
|
||||
class="text-red-600 transition-colors hover:text-red-800"
|
||||
:disabled="group.memberCount > 0"
|
||||
title="删除"
|
||||
@click="deleteGroup(group)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
@@ -196,72 +156,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 编辑分组模态框 -->
|
||||
<div
|
||||
v-if="showEditForm"
|
||||
class="fixed inset-0 modal z-60 flex items-center justify-center p-3 sm:p-4"
|
||||
class="modal z-60 fixed inset-0 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
|
||||
<button class="text-gray-400 transition-colors hover:text-gray-600" @click="cancelEdit">
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型</label>
|
||||
<div class="px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-600">
|
||||
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
|
||||
<span class="text-xs text-gray-500 ml-2">(不可修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="2"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型</label>
|
||||
<div class="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
|
||||
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
|
||||
<span class="ml-2 text-xs text-gray-500">(不可修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2 flex-1"
|
||||
class="btn btn-primary flex-1 px-4 py-2"
|
||||
:disabled="!editForm.name || updating"
|
||||
@click="updateGroup"
|
||||
>
|
||||
<div
|
||||
v-if="updating"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<div v-if="updating" class="loading-spinner mr-2" />
|
||||
{{ updating ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary px-4 py-2 flex-1"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,7 +272,7 @@ const createGroup = async () => {
|
||||
showToast('请填写必填项', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await apiClient.post('/admin/account-groups', {
|
||||
@@ -333,7 +280,7 @@ const createGroup = async () => {
|
||||
platform: createForm.value.platform,
|
||||
description: createForm.value.description
|
||||
})
|
||||
|
||||
|
||||
showToast('分组创建成功', 'success')
|
||||
cancelCreate()
|
||||
await loadGroups()
|
||||
@@ -372,14 +319,14 @@ const updateGroup = async () => {
|
||||
showToast('请填写分组名称', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
updating.value = true
|
||||
try {
|
||||
await apiClient.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description
|
||||
})
|
||||
|
||||
|
||||
showToast('分组更新成功', 'success')
|
||||
cancelEdit()
|
||||
await loadGroups()
|
||||
@@ -408,11 +355,11 @@ const deleteGroup = async (group) => {
|
||||
showToast('分组内还有成员,无法删除', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
||||
showToast('分组删除成功', 'success')
|
||||
@@ -427,4 +374,4 @@ const deleteGroup = async (group) => {
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,66 +2,55 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Claude OAuth流程 -->
|
||||
<div v-if="platform === 'claude'">
|
||||
<div class="bg-blue-50 p-6 rounded-lg border border-blue-200">
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-link text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-blue-900 mb-3">
|
||||
Claude 账户授权
|
||||
</h4>
|
||||
<p class="text-sm text-blue-800 mb-4">
|
||||
请按照以下步骤完成 Claude 账户的授权:
|
||||
</p>
|
||||
|
||||
<h4 class="mb-3 font-semibold text-blue-900">Claude 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-blue-800">请按照以下步骤完成 Claude 账户的授权:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
<p class="mb-2 font-medium text-blue-900">点击下方按钮生成授权链接</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
:disabled="loading"
|
||||
@click="generateAuthUrl"
|
||||
>
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="fas fa-link mr-2"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
<input
|
||||
class="form-input flex-1 bg-gray-50 font-mono text-xs"
|
||||
readonly
|
||||
class="form-input flex-1 text-xs font-mono bg-gray-50"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
class="text-xs text-blue-600 hover:text-blue-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
@@ -71,56 +60,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 步骤2: 访问链接并授权 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 mb-2">
|
||||
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
|
||||
<p class="mb-2 text-sm text-blue-700">
|
||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||
</p>
|
||||
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
|
||||
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
|
||||
<p class="text-xs text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>注意:</strong>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
|
||||
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 mb-3">
|
||||
授权完成后,页面会显示一个 <strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
<p class="mb-2 font-medium text-blue-900">输入 Authorization Code</p>
|
||||
<p class="mb-3 text-sm text-blue-700">
|
||||
授权完成后,页面会显示一个
|
||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-key text-blue-500 mr-2" />Authorization Code
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
rows="3"
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
请粘贴从Claude页面复制的Authorization Code
|
||||
</p>
|
||||
@@ -133,69 +124,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Gemini OAuth流程 -->
|
||||
<div v-else-if="platform === 'gemini'">
|
||||
<div class="bg-green-50 p-6 rounded-lg border border-green-200">
|
||||
<div class="rounded-lg border border-green-200 bg-green-50 p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-500"
|
||||
>
|
||||
<i class="fas fa-robot text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-green-900 mb-3">
|
||||
Gemini 账户授权
|
||||
</h4>
|
||||
<p class="text-sm text-green-800 mb-4">
|
||||
请按照以下步骤完成 Gemini 账户的授权:
|
||||
</p>
|
||||
|
||||
<h4 class="mb-3 font-semibold text-green-900">Gemini 账户授权</h4>
|
||||
<p class="mb-4 text-sm text-green-800">请按照以下步骤完成 Gemini 账户的授权:</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 步骤1: 生成授权链接 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="rounded-lg border border-green-300 bg-white/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">
|
||||
点击下方按钮生成授权链接
|
||||
</p>
|
||||
<button
|
||||
<p class="mb-2 font-medium text-green-900">点击下方按钮生成授权链接</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary px-4 py-2 text-sm"
|
||||
:disabled="loading"
|
||||
@click="generateAuthUrl"
|
||||
>
|
||||
<i
|
||||
v-if="!loading"
|
||||
class="fas fa-link mr-2"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||
<div v-else class="loading-spinner mr-2" />
|
||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
<input
|
||||
class="form-input flex-1 bg-gray-50 font-mono text-xs"
|
||||
readonly
|
||||
class="form-input flex-1 text-xs font-mono bg-gray-50"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
type="text"
|
||||
:value="authUrl"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200"
|
||||
title="复制链接"
|
||||
@click="copyAuthUrl"
|
||||
>
|
||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
class="text-xs text-green-600 hover:text-green-700"
|
||||
@click="regenerateAuthUrl"
|
||||
>
|
||||
@@ -205,59 +185,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 步骤2: 操作说明 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="rounded-lg border border-green-300 bg-white/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-blue-900 mb-2">
|
||||
在浏览器中打开链接并完成授权
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 mb-2">
|
||||
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
|
||||
<p class="mb-2 text-sm text-blue-700">
|
||||
请在新标签页中打开授权链接,登录您的 Gemini 账户并授权。
|
||||
</p>
|
||||
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
|
||||
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
|
||||
<p class="text-xs text-yellow-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1" />
|
||||
<strong>注意:</strong>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
<strong>注意:</strong
|
||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 步骤3: 输入授权码 -->
|
||||
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
|
||||
<div class="rounded-lg border border-green-300 bg-white/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-green-900 mb-2">
|
||||
输入 Authorization Code
|
||||
</p>
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
<p class="mb-2 font-medium text-green-900">输入 Authorization Code</p>
|
||||
<p class="mb-3 text-sm text-green-700">
|
||||
授权完成后,页面会显示一个 Authorization Code,请将其复制并粘贴到下方输入框:
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-key text-green-500 mr-2" />Authorization Code
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-key mr-2 text-green-500" />Authorization Code
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
rows="3"
|
||||
<textarea
|
||||
v-model="authCode"
|
||||
class="form-input w-full resize-none font-mono text-sm"
|
||||
placeholder="粘贴从Gemini页面获取的Authorization Code..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-check-circle text-green-500 mr-1" />
|
||||
请粘贴从Gemini页面复制的Authorization Code
|
||||
<i class="fas fa-check-circle mr-1 text-green-500" />
|
||||
请粘贴从Gemini页面复制的Authorization Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,25 +250,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||
type="button"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="!canExchange || exchanging"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
type="button"
|
||||
@click="exchangeCode"
|
||||
>
|
||||
<div
|
||||
v-if="exchanging"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<div v-if="exchanging" class="loading-spinner mr-2" />
|
||||
{{ exchanging ? '验证中...' : '完成授权' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -330,15 +308,15 @@ const canExchange = computed(() => {
|
||||
// 监听授权码输入,自动提取URL中的code参数
|
||||
watch(authCode, (newValue) => {
|
||||
if (!newValue || typeof newValue !== 'string') return
|
||||
|
||||
|
||||
const trimmedValue = newValue.trim()
|
||||
|
||||
|
||||
// 如果内容为空,不处理
|
||||
if (!trimmedValue) return
|
||||
|
||||
|
||||
// 检查是否是 URL 格式(包含 http:// 或 https://)
|
||||
const isUrl = trimmedValue.startsWith('http://') || trimmedValue.startsWith('https://')
|
||||
|
||||
|
||||
// 如果是 URL 格式
|
||||
if (isUrl) {
|
||||
// 检查是否是正确的 localhost:45462 开头的 URL
|
||||
@@ -346,7 +324,7 @@ watch(authCode, (newValue) => {
|
||||
try {
|
||||
const url = new URL(trimmedValue)
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
|
||||
if (code) {
|
||||
// 成功提取授权码
|
||||
authCode.value = code
|
||||
@@ -367,7 +345,7 @@ watch(authCode, (newValue) => {
|
||||
try {
|
||||
const url = new URL(trimmedValue)
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
|
||||
if (code) {
|
||||
authCode.value = code
|
||||
showToast('成功提取授权码!', 'success')
|
||||
@@ -387,16 +365,18 @@ watch(authCode, (newValue) => {
|
||||
const generateAuthUrl = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const proxyConfig = props.proxy?.enabled ? {
|
||||
proxy: {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: parseInt(props.proxy.port),
|
||||
username: props.proxy.username || null,
|
||||
password: props.proxy.password || null
|
||||
}
|
||||
} : {}
|
||||
|
||||
const proxyConfig = props.proxy?.enabled
|
||||
? {
|
||||
proxy: {
|
||||
type: props.proxy.type,
|
||||
host: props.proxy.host,
|
||||
port: parseInt(props.proxy.port),
|
||||
username: props.proxy.username || null,
|
||||
password: props.proxy.password || null
|
||||
}
|
||||
}
|
||||
: {}
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
|
||||
authUrl.value = result.authUrl
|
||||
@@ -448,11 +428,11 @@ const copyAuthUrl = async () => {
|
||||
// 交换授权码
|
||||
const exchangeCode = async () => {
|
||||
if (!canExchange.value) return
|
||||
|
||||
|
||||
exchanging.value = true
|
||||
try {
|
||||
let data = {}
|
||||
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
// Claude使用sessionId和callbackUrl(即授权码)
|
||||
data = {
|
||||
@@ -466,7 +446,7 @@ const exchangeCode = async () => {
|
||||
sessionId: sessionId.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 添加代理配置(如果启用)
|
||||
if (props.proxy?.enabled) {
|
||||
data.proxy = {
|
||||
@@ -477,14 +457,14 @@ const exchangeCode = async () => {
|
||||
password: props.proxy.password || null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let tokenInfo
|
||||
if (props.platform === 'claude') {
|
||||
tokenInfo = await accountsStore.exchangeClaudeCode(data)
|
||||
} else if (props.platform === 'gemini') {
|
||||
tokenInfo = await accountsStore.exchangeGeminiCode(data)
|
||||
}
|
||||
|
||||
|
||||
emit('success', tokenInfo)
|
||||
} catch (error) {
|
||||
showToast(error.message || '授权失败,请检查授权码是否正确', 'error')
|
||||
@@ -492,4 +472,4 @@ const exchangeCode = async () => {
|
||||
exchanging.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,117 +1,97 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700">
|
||||
代理设置 (可选)
|
||||
</h4>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="proxy.enabled"
|
||||
<h4 class="text-sm font-semibold text-gray-700">代理设置 (可选)</h4>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="proxy.enabled"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700">启用代理</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="proxy.enabled"
|
||||
class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4"
|
||||
>
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="w-8 h-8 bg-gray-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-server text-white text-sm" />
|
||||
|
||||
<div v-if="proxy.enabled" class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="mb-3 flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-500">
|
||||
<i class="fas fa-server text-sm text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700">
|
||||
配置代理以访问受限的网络资源。支持 SOCKS5 和 HTTP 代理。
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
请确保代理服务器稳定可用,否则会影响账户的正常使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">代理类型</label>
|
||||
<select
|
||||
v-model="proxy.type"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<option value="socks5">
|
||||
SOCKS5
|
||||
</option>
|
||||
<option value="http">
|
||||
HTTP
|
||||
</option>
|
||||
<option value="https">
|
||||
HTTPS
|
||||
</option>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">代理类型</label>
|
||||
<select v-model="proxy.type" class="form-input w-full">
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">主机地址</label>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
type="text"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">主机地址</label>
|
||||
<input
|
||||
v-model="proxy.host"
|
||||
class="form-input w-full"
|
||||
>
|
||||
placeholder="例如: 192.168.1.100"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">端口</label>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
type="number"
|
||||
placeholder="例如: 1080"
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">端口</label>
|
||||
<input
|
||||
v-model="proxy.port"
|
||||
class="form-input w-full"
|
||||
>
|
||||
placeholder="例如: 1080"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="proxyAuth"
|
||||
<input
|
||||
id="proxyAuth"
|
||||
v-model="showAuth"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
<label
|
||||
for="proxyAuth"
|
||||
class="ml-2 text-sm text-gray-700 cursor-pointer"
|
||||
>
|
||||
/>
|
||||
<label class="ml-2 cursor-pointer text-sm text-gray-700" for="proxyAuth">
|
||||
需要身份验证
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showAuth"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
|
||||
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
type="text"
|
||||
placeholder="代理用户名"
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">用户名</label>
|
||||
<input
|
||||
v-model="proxy.username"
|
||||
class="form-input w-full"
|
||||
>
|
||||
placeholder="代理用户名"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">密码</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="代理密码"
|
||||
<input
|
||||
v-model="proxy.password"
|
||||
class="form-input w-full pr-10"
|
||||
>
|
||||
<button
|
||||
placeholder="代理密码"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
/>
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" />
|
||||
@@ -120,11 +100,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p class="text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
<strong>提示:</strong>代理设置将用于所有与此账户相关的API请求。请确保代理服务器支持HTTPS流量转发。
|
||||
<strong>提示:</strong
|
||||
>代理设置将用于所有与此账户相关的API请求。请确保代理服务器支持HTTPS流量转发。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +113,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -158,38 +139,60 @@ const showAuth = ref(!!(proxy.value.username || proxy.value.password))
|
||||
const showPassword = ref(false)
|
||||
|
||||
// 监听modelValue变化,只在真正需要更新时才更新
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
// 只有当值真正不同时才更新,避免循环
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(proxy.value)) {
|
||||
proxy.value = { ...newVal }
|
||||
showAuth.value = !!(newVal.username || newVal.password)
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
// 只有当值真正不同时才更新,避免循环
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(proxy.value)) {
|
||||
proxy.value = { ...newVal }
|
||||
showAuth.value = !!(newVal.username || newVal.password)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听各个字段单独变化,而不是整个对象
|
||||
watch(() => proxy.value.enabled, (newVal) => {
|
||||
emitUpdate()
|
||||
})
|
||||
watch(
|
||||
() => proxy.value.enabled,
|
||||
() => {
|
||||
emitUpdate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => proxy.value.type, (newVal) => {
|
||||
emitUpdate()
|
||||
})
|
||||
watch(
|
||||
() => proxy.value.type,
|
||||
() => {
|
||||
emitUpdate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => proxy.value.host, (newVal) => {
|
||||
emitUpdate()
|
||||
})
|
||||
watch(
|
||||
() => proxy.value.host,
|
||||
() => {
|
||||
emitUpdate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => proxy.value.port, (newVal) => {
|
||||
emitUpdate()
|
||||
})
|
||||
watch(
|
||||
() => proxy.value.port,
|
||||
() => {
|
||||
emitUpdate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => proxy.value.username, (newVal) => {
|
||||
emitUpdate()
|
||||
})
|
||||
watch(
|
||||
() => proxy.value.username,
|
||||
() => {
|
||||
emitUpdate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => proxy.value.password, (newVal) => {
|
||||
emitUpdate()
|
||||
})
|
||||
watch(
|
||||
() => proxy.value.password,
|
||||
() => {
|
||||
emitUpdate()
|
||||
}
|
||||
)
|
||||
|
||||
// 监听认证开关
|
||||
watch(showAuth, (newVal) => {
|
||||
@@ -207,17 +210,17 @@ function emitUpdate() {
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer)
|
||||
}
|
||||
|
||||
|
||||
// 设置新的定时器,延迟发送更新
|
||||
updateTimer = setTimeout(() => {
|
||||
const data = { ...proxy.value }
|
||||
|
||||
|
||||
// 如果不需要认证,清空用户名密码
|
||||
if (!showAuth.value) {
|
||||
data.username = ''
|
||||
data.password = ''
|
||||
}
|
||||
|
||||
|
||||
emit('update:modelValue', data)
|
||||
}, 100) // 100ms 延迟
|
||||
}
|
||||
@@ -228,4 +231,4 @@ onUnmounted(() => {
|
||||
clearTimeout(updateTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,122 +1,131 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-8"
|
||||
>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-white text-lg" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
|
||||
>
|
||||
<i class="fas fa-layer-group text-lg text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
批量创建成功
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
成功创建 {{ apiKeys.length }} 个 API Key
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900">批量创建成功</h3>
|
||||
<p class="text-sm text-gray-600">成功创建 {{ apiKeys.length }} 个 API Key</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
title="直接关闭(不推荐)"
|
||||
@click="handleDirectClose"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 警告提示 -->
|
||||
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
|
||||
<div class="mb-6 border-l-4 border-amber-400 bg-amber-50 p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="w-6 h-6 bg-amber-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-exclamation-triangle text-white text-sm" />
|
||||
<div
|
||||
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-amber-400"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle text-sm text-white" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h5 class="font-semibold text-amber-900 mb-1">
|
||||
重要提醒
|
||||
</h5>
|
||||
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
|
||||
<p class="text-sm text-amber-800">
|
||||
这是您唯一能看到所有 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API Key。请立即下载并妥善保存。
|
||||
这是您唯一能看到所有 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API
|
||||
Key。请立即下载并妥善保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 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="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-blue-600 font-medium">
|
||||
创建数量
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-blue-900 mt-1">
|
||||
<p class="text-xs font-medium text-blue-600">创建数量</p>
|
||||
<p class="mt-1 text-2xl font-bold text-blue-900">
|
||||
{{ apiKeys.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-blue-500 bg-opacity-20 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500 bg-opacity-20"
|
||||
>
|
||||
<i class="fas fa-key text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-green-600 font-medium">
|
||||
基础名称
|
||||
</p>
|
||||
<p class="text-lg font-bold text-green-900 mt-1 truncate">
|
||||
<p class="text-xs font-medium text-green-600">基础名称</p>
|
||||
<p class="mt-1 truncate text-lg font-bold text-green-900">
|
||||
{{ baseName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-green-500 bg-opacity-20 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500 bg-opacity-20"
|
||||
>
|
||||
<i class="fas fa-tag text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200">
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-purple-600 font-medium">
|
||||
权限范围
|
||||
</p>
|
||||
<p class="text-lg font-bold text-purple-900 mt-1">
|
||||
<p class="text-xs font-medium text-purple-600">权限范围</p>
|
||||
<p class="mt-1 text-lg font-bold text-purple-900">
|
||||
{{ getPermissionText() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-purple-500 bg-opacity-20 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500 bg-opacity-20"
|
||||
>
|
||||
<i class="fas fa-shield-alt text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-4 border border-orange-200">
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-orange-200 bg-gradient-to-br from-orange-50 to-orange-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-orange-600 font-medium">
|
||||
过期时间
|
||||
</p>
|
||||
<p class="text-lg font-bold text-orange-900 mt-1">
|
||||
<p class="text-xs font-medium text-orange-600">过期时间</p>
|
||||
<p class="mt-1 text-lg font-bold text-orange-900">
|
||||
{{ getExpiryText() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-orange-500 bg-opacity-20 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500 bg-opacity-20"
|
||||
>
|
||||
<i class="fas fa-clock text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- API Keys 预览 -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700">API Keys 预览</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
type="button"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<i :class="['fas', showPreview ? 'fa-eye-slash' : 'fa-eye']" />
|
||||
@@ -125,35 +134,35 @@
|
||||
<span class="text-xs text-gray-500">(最多显示前10个)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="bg-gray-900 rounded-lg p-4 max-h-48 overflow-y-auto custom-scrollbar"
|
||||
class="custom-scrollbar max-h-48 overflow-y-auto rounded-lg bg-gray-900 p-4"
|
||||
>
|
||||
<pre class="text-xs text-gray-300 font-mono">{{ getPreviewText() }}</pre>
|
||||
<pre class="font-mono text-xs text-gray-300">{{ getPreviewText() }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 btn btn-primary py-3 px-6 font-semibold flex items-center justify-center gap-2"
|
||||
<button
|
||||
class="btn btn-primary flex flex-1 items-center justify-center gap-2 px-6 py-3 font-semibold"
|
||||
@click="downloadApiKeys"
|
||||
>
|
||||
<i class="fas fa-download" />
|
||||
下载所有 API Keys
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-3 bg-gray-200 text-gray-800 rounded-xl font-semibold hover:bg-gray-300 transition-colors border border-gray-300"
|
||||
<button
|
||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300"
|
||||
@click="handleClose"
|
||||
>
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 额外提示 -->
|
||||
<div class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p class="text-xs text-blue-700 flex items-start">
|
||||
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p class="flex items-start text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
下载的文件格式为文本文件(.txt),每行包含一个 API Key。
|
||||
@@ -197,9 +206,9 @@ const getPermissionText = () => {
|
||||
if (props.apiKeys.length === 0) return '未知'
|
||||
const permissions = props.apiKeys[0].permissions
|
||||
const permissionMap = {
|
||||
'all': '全部服务',
|
||||
'claude': '仅 Claude',
|
||||
'gemini': '仅 Gemini'
|
||||
all: '全部服务',
|
||||
claude: '仅 Claude',
|
||||
gemini: '仅 Gemini'
|
||||
}
|
||||
return permissionMap[permissions] || permissions
|
||||
}
|
||||
@@ -209,11 +218,11 @@ const getExpiryText = () => {
|
||||
if (props.apiKeys.length === 0) return '未知'
|
||||
const expiresAt = props.apiKeys[0].expiresAt
|
||||
if (!expiresAt) return '永不过期'
|
||||
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24))
|
||||
|
||||
|
||||
if (diffDays <= 7) return `${diffDays}天`
|
||||
if (diffDays <= 30) return `${Math.ceil(diffDays / 7)}周`
|
||||
if (diffDays <= 365) return `${Math.ceil(diffDays / 30)}个月`
|
||||
@@ -228,44 +237,46 @@ const togglePreview = () => {
|
||||
// 获取预览文本
|
||||
const getPreviewText = () => {
|
||||
const previewKeys = props.apiKeys.slice(0, 10)
|
||||
const lines = previewKeys.map((key, index) => {
|
||||
const lines = previewKeys.map((key) => {
|
||||
return `${key.name}: ${key.apiKey || key.key || ''}`
|
||||
})
|
||||
|
||||
|
||||
if (props.apiKeys.length > 10) {
|
||||
lines.push(`... 还有 ${props.apiKeys.length - 10} 个 API Key`)
|
||||
}
|
||||
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// 下载 API Keys
|
||||
const downloadApiKeys = () => {
|
||||
// 生成文件内容
|
||||
const content = props.apiKeys.map(key => {
|
||||
return `${key.name}: ${key.apiKey || key.key || ''}`
|
||||
}).join('\n')
|
||||
|
||||
const content = props.apiKeys
|
||||
.map((key) => {
|
||||
return `${key.name}: ${key.apiKey || key.key || ''}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// 创建 Blob 对象
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
|
||||
|
||||
// 生成文件名(包含时间戳)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
link.download = `api-keys-${baseName.value}-${timestamp}.txt`
|
||||
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
|
||||
// 释放 URL 对象
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
|
||||
showToast('API Keys 文件已下载', 'success')
|
||||
}
|
||||
|
||||
@@ -306,9 +317,7 @@ const handleDirectClose = async () => {
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(
|
||||
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n确定要关闭吗?'
|
||||
)
|
||||
const confirmed = confirm('您还没有下载 API Keys,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
@@ -321,4 +330,4 @@ pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,62 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div class="modal-content w-full max-w-4xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
class="modal-content mx-auto flex max-h-[90vh] w-full max-w-4xl flex-col p-4 sm:p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg sm:rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-edit text-white text-sm sm:text-base" />
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||
>
|
||||
<i class="fas fa-edit text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
|
||||
编辑 API Key
|
||||
</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">编辑 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<form
|
||||
class="space-y-4 sm:space-y-6 modal-scroll-content custom-scrollbar flex-1"
|
||||
class="modal-scroll-content custom-scrollbar flex-1 space-y-4 sm:space-y-6"
|
||||
@submit.prevent="updateApiKey"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-xs sm:text-sm font-semibold text-gray-700 mb-1.5 sm:mb-3">名称</label>
|
||||
<input
|
||||
:value="form.name"
|
||||
type="text"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed text-sm"
|
||||
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm"
|
||||
>名称</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1 sm:mt-2">
|
||||
名称不可修改
|
||||
</p>
|
||||
<input
|
||||
class="form-input w-full cursor-not-allowed bg-gray-100 text-sm"
|
||||
disabled
|
||||
type="text"
|
||||
:value="form.name"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 sm:mt-2">名称不可修改</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 标签 -->
|
||||
<div>
|
||||
<label class="block text-xs sm:text-sm font-semibold text-gray-700 mb-1.5 sm:mb-3">标签</label>
|
||||
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm"
|
||||
>标签</label
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 已选择的标签 -->
|
||||
<div v-if="form.tags.length > 0">
|
||||
<div class="text-xs font-medium text-gray-600 mb-2">
|
||||
已选择的标签:
|
||||
</div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600">已选择的标签:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(tag, index) in form.tags"
|
||||
:key="'selected-' + index"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
|
||||
:key="'selected-' + index"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
class="ml-1 hover:text-blue-900"
|
||||
type="button"
|
||||
class="ml-1 hover:text-blue-900"
|
||||
@click="removeTag(index)"
|
||||
>
|
||||
<i class="fas fa-times text-xs" />
|
||||
@@ -62,428 +64,388 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 可选择的已有标签 -->
|
||||
<div v-if="unselectedTags.length > 0">
|
||||
<div class="text-xs font-medium text-gray-600 mb-2">
|
||||
点击选择已有标签:
|
||||
</div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600">点击选择已有标签:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in unselectedTags"
|
||||
:key="'available-' + tag"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
|
||||
@click="selectTag(tag)"
|
||||
>
|
||||
<i class="fas fa-tag text-gray-500 text-xs" />
|
||||
<i class="fas fa-tag text-xs text-gray-500" />
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 创建新标签 -->
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-600 mb-2">
|
||||
创建新标签:
|
||||
</div>
|
||||
<div class="mb-2 text-xs font-medium text-gray-600">创建新标签:</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newTag"
|
||||
type="text"
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="form-input flex-1"
|
||||
placeholder="输入新标签名称"
|
||||
type="text"
|
||||
@keypress.enter.prevent="addTag"
|
||||
>
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
|
||||
type="button"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
||||
@click="addTag"
|
||||
>
|
||||
<i class="fas fa-plus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
用于标记不同团队或用途,方便筛选管理
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-gray-500">用于标记不同团队或用途,方便筛选管理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 速率限制设置 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-tachometer-alt text-white text-xs" />
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-tachometer-alt text-xs text-white" />
|
||||
</div>
|
||||
<h4 class="font-semibold text-gray-800 text-sm">
|
||||
速率限制设置 (可选)
|
||||
</h4>
|
||||
<h4 class="text-sm font-semibold text-gray-800">速率限制设置 (可选)</h4>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
type="number"
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700"
|
||||
>时间窗口 (分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.rateLimitWindow"
|
||||
class="form-input w-full text-sm"
|
||||
min="1"
|
||||
placeholder="无限制"
|
||||
class="form-input w-full text-sm"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5 ml-2">
|
||||
时间段单位
|
||||
</p>
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500">时间段单位</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
type="number"
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700">请求次数限制</label>
|
||||
<input
|
||||
v-model="form.rateLimitRequests"
|
||||
class="form-input w-full text-sm"
|
||||
min="1"
|
||||
placeholder="无限制"
|
||||
class="form-input w-full text-sm"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5 ml-2">
|
||||
窗口内最大请求
|
||||
</p>
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大请求</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
type="number"
|
||||
placeholder="无限制"
|
||||
<label class="mb-1 block text-xs font-medium text-gray-700">Token 限制</label>
|
||||
<input
|
||||
v-model="form.tokenLimit"
|
||||
class="form-input w-full text-sm"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5 ml-2">
|
||||
窗口内最大Token
|
||||
</p>
|
||||
placeholder="无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大Token</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 示例说明 -->
|
||||
<div class="bg-blue-100 rounded-lg p-2">
|
||||
<h5 class="text-xs font-semibold text-blue-800 mb-1">
|
||||
💡 使用示例
|
||||
</h5>
|
||||
<div class="text-xs text-blue-700 space-y-0.5">
|
||||
<div><strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求</div>
|
||||
<div><strong>示例2:</strong> 时间窗口=1,Token=10000 → 每分钟最多10,000个Token</div>
|
||||
<div><strong>示例3:</strong> 窗口=30,请求=50,Token=100000 → 每30分钟50次请求且不超10万Token</div>
|
||||
<div class="rounded-lg bg-blue-100 p-2">
|
||||
<h5 class="mb-1 text-xs font-semibold text-blue-800">💡 使用示例</h5>
|
||||
<div class="space-y-0.5 text-xs text-blue-700">
|
||||
<div>
|
||||
<strong>示例1:</strong> 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
|
||||
</div>
|
||||
<div>
|
||||
<strong>示例2:</strong> 时间窗口=1,Token=10000 → 每分钟最多10,000个Token
|
||||
</div>
|
||||
<div>
|
||||
<strong>示例3:</strong> 窗口=30,请求=50,Token=100000 →
|
||||
每30分钟50次请求且不超10万Token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700"
|
||||
>每日费用限制 (美元)</label
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
|
||||
type="button"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
|
||||
@click="form.dailyCostLimit = '50'"
|
||||
>
|
||||
$50
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
|
||||
type="button"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
|
||||
@click="form.dailyCostLimit = '100'"
|
||||
>
|
||||
$100
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
|
||||
type="button"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
|
||||
@click="form.dailyCostLimit = '200'"
|
||||
>
|
||||
$200
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200"
|
||||
type="button"
|
||||
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
|
||||
@click="form.dailyCostLimit = ''"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0 表示无限制"
|
||||
<input
|
||||
v-model="form.dailyCostLimit"
|
||||
class="form-input w-full"
|
||||
>
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">
|
||||
设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">并发限制</label>
|
||||
<input
|
||||
v-model="form.concurrencyLimit"
|
||||
class="form-input w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
设置此 API Key 可同时处理的最大请求数
|
||||
</p>
|
||||
min="0"
|
||||
placeholder="0 表示无限制"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">设置此 API Key 可同时处理的最大请求数</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">服务权限</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="radio"
|
||||
value="all"
|
||||
class="mr-2"
|
||||
>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="all" />
|
||||
<span class="text-sm text-gray-700">全部服务</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="radio"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
|
||||
<span class="text-sm text-gray-700">仅 Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="radio"
|
||||
value="gemini"
|
||||
class="mr-2"
|
||||
>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
|
||||
<span class="text-sm text-gray-700">仅 Gemini</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
控制此 API Key 可以访问哪些服务
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500">控制此 API Key 可以访问哪些服务</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="text-sm font-semibold text-gray-700">专属账号绑定</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="刷新账号列表"
|
||||
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="accountsLoading"
|
||||
title="刷新账号列表"
|
||||
type="button"
|
||||
@click="refreshAccounts"
|
||||
>
|
||||
<i :class="['fas', accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt', 'text-xs']" />
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt',
|
||||
'text-xs'
|
||||
]"
|
||||
/>
|
||||
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Claude 专属账号</label>
|
||||
<AccountSelector
|
||||
v-model="form.claudeAccountId"
|
||||
platform="claude"
|
||||
:accounts="localAccounts.claude"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
placeholder="请选择Claude账号"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'gemini'"
|
||||
:groups="localAccounts.claudeGroups"
|
||||
placeholder="请选择Claude账号"
|
||||
platform="claude"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-600">Gemini 专属账号</label>
|
||||
<AccountSelector
|
||||
v-model="form.geminiAccountId"
|
||||
platform="gemini"
|
||||
:accounts="localAccounts.gemini"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
placeholder="请选择Gemini账号"
|
||||
default-option-text="使用共享账号池"
|
||||
:disabled="form.permissions === 'claude'"
|
||||
:groups="localAccounts.geminiGroups"
|
||||
placeholder="请选择Gemini账号"
|
||||
platform="gemini"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
修改绑定账号将影响此API Key的请求路由
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500">修改绑定账号将影响此API Key的请求路由</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
id="editEnableModelRestriction"
|
||||
v-model="form.enableModelRestriction"
|
||||
<div class="mb-3 flex items-center">
|
||||
<input
|
||||
id="editEnableModelRestriction"
|
||||
v-model="form.enableModelRestriction"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
/>
|
||||
<label
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700"
|
||||
for="editEnableModelRestriction"
|
||||
class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer"
|
||||
>
|
||||
启用模型限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.enableModelRestriction"
|
||||
class="space-y-3"
|
||||
>
|
||||
|
||||
<div v-if="form.enableModelRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
|
||||
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label>
|
||||
<div
|
||||
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2"
|
||||
>
|
||||
<span
|
||||
v-for="(model, index) in form.restrictedModels"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm text-red-800"
|
||||
>
|
||||
{{ model }}
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
class="ml-2 text-red-600 hover:text-red-800"
|
||||
type="button"
|
||||
@click="removeRestrictedModel(index)"
|
||||
>
|
||||
<i class="fas fa-times text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="form.restrictedModels.length === 0"
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400">
|
||||
暂无限制的模型
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<!-- 快速添加按钮 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="model in availableQuickModels"
|
||||
<button
|
||||
v-for="model in availableQuickModels"
|
||||
:key="model"
|
||||
class="flex-shrink-0 rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-200 sm:text-sm"
|
||||
type="button"
|
||||
class="px-3 py-1 text-xs sm:text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex-shrink-0"
|
||||
@click="quickAddRestrictedModel(model)"
|
||||
>
|
||||
{{ model }}
|
||||
</button>
|
||||
<span
|
||||
v-if="availableQuickModels.length === 0"
|
||||
class="text-gray-400 text-sm italic"
|
||||
class="text-sm italic text-gray-400"
|
||||
>
|
||||
所有常用模型已在限制列表中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
<input
|
||||
v-model="form.modelInput"
|
||||
type="text"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
class="form-input flex-1"
|
||||
placeholder="输入模型名称,按回车添加"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addRestrictedModel"
|
||||
>
|
||||
<button
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
|
||||
type="button"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
@click="addRestrictedModel"
|
||||
>
|
||||
<i class="fas fa-plus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
设置此API Key无法访问的模型,例如:claude-opus-4-20250514
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 客户端限制 -->
|
||||
<div>
|
||||
<div class="flex items-center mb-3">
|
||||
<input
|
||||
id="editEnableClientRestriction"
|
||||
v-model="form.enableClientRestriction"
|
||||
<div class="mb-3 flex items-center">
|
||||
<input
|
||||
id="editEnableClientRestriction"
|
||||
v-model="form.enableClientRestriction"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
>
|
||||
/>
|
||||
<label
|
||||
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700"
|
||||
for="editEnableClientRestriction"
|
||||
class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer"
|
||||
>
|
||||
启用客户端限制
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.enableClientRestriction"
|
||||
class="space-y-3"
|
||||
>
|
||||
|
||||
<div v-if="form.enableClientRestriction" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
勾选允许使用此API Key的客户端
|
||||
</p>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-600">允许的客户端</label>
|
||||
<p class="mb-3 text-xs text-gray-500">勾选允许使用此API Key的客户端</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="client in supportedClients"
|
||||
:key="client.id"
|
||||
class="flex items-start"
|
||||
>
|
||||
<input
|
||||
:id="`edit_client_${client.id}`"
|
||||
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
|
||||
<input
|
||||
:id="`edit_client_${client.id}`"
|
||||
v-model="form.allowedClients"
|
||||
class="mt-0.5 h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
|
||||
type="checkbox"
|
||||
:value="client.id"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
|
||||
>
|
||||
<label
|
||||
:for="`edit_client_${client.id}`"
|
||||
class="ml-2 flex-1 cursor-pointer"
|
||||
>
|
||||
/>
|
||||
<label class="ml-2 flex-1 cursor-pointer" :for="`edit_client_${client.id}`">
|
||||
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
|
||||
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
|
||||
<span class="block text-xs text-gray-500">{{ client.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
type="submit"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-save mr-2"
|
||||
/>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ loading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -496,7 +458,6 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||
import { apiClient } from '@/config/api'
|
||||
@@ -515,7 +476,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
// const authStore = useAuthStore()
|
||||
const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
@@ -531,7 +492,7 @@ const availableTags = ref([])
|
||||
|
||||
// 计算未选择的标签
|
||||
const unselectedTags = computed(() => {
|
||||
return availableTags.value.filter(tag => !form.tags.includes(tag))
|
||||
return availableTags.value.filter((tag) => !form.tags.includes(tag))
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
@@ -553,7 +514,6 @@ const form = reactive({
|
||||
tags: []
|
||||
})
|
||||
|
||||
|
||||
// 添加限制的模型
|
||||
const addRestrictedModel = () => {
|
||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||
@@ -568,14 +528,11 @@ const removeRestrictedModel = (index) => {
|
||||
}
|
||||
|
||||
// 常用模型列表
|
||||
const commonModels = ref([
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805'
|
||||
])
|
||||
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
|
||||
|
||||
// 可用的快捷模型(过滤掉已在限制列表中的)
|
||||
const availableQuickModels = computed(() => {
|
||||
return commonModels.value.filter(model => !form.restrictedModels.includes(model))
|
||||
return commonModels.value.filter((model) => !form.restrictedModels.includes(model))
|
||||
})
|
||||
|
||||
// 快速添加限制的模型
|
||||
@@ -609,57 +566,70 @@ const removeTag = (index) => {
|
||||
// 更新 API Key
|
||||
const updateApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const data = {
|
||||
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
|
||||
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : 0,
|
||||
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0,
|
||||
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
|
||||
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
|
||||
tokenLimit:
|
||||
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
|
||||
rateLimitWindow:
|
||||
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
|
||||
? parseInt(form.rateLimitWindow)
|
||||
: 0,
|
||||
rateLimitRequests:
|
||||
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
|
||||
? parseInt(form.rateLimitRequests)
|
||||
: 0,
|
||||
concurrencyLimit:
|
||||
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
|
||||
? parseInt(form.concurrencyLimit)
|
||||
: 0,
|
||||
dailyCostLimit:
|
||||
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
|
||||
? parseFloat(form.dailyCostLimit)
|
||||
: 0,
|
||||
permissions: form.permissions,
|
||||
tags: form.tags
|
||||
}
|
||||
|
||||
|
||||
// 处理Claude账户绑定(区分OAuth和Console)
|
||||
if (form.claudeAccountId) {
|
||||
if (form.claudeAccountId.startsWith('console:')) {
|
||||
// Claude Console账户
|
||||
data.claudeConsoleAccountId = form.claudeAccountId.substring(8);
|
||||
data.claudeAccountId = null; // 清空OAuth账号
|
||||
data.claudeConsoleAccountId = form.claudeAccountId.substring(8)
|
||||
data.claudeAccountId = null // 清空OAuth账号
|
||||
} else if (!form.claudeAccountId.startsWith('group:')) {
|
||||
// Claude OAuth账户(非分组)
|
||||
data.claudeAccountId = form.claudeAccountId;
|
||||
data.claudeConsoleAccountId = null; // 清空Console账号
|
||||
data.claudeAccountId = form.claudeAccountId
|
||||
data.claudeConsoleAccountId = null // 清空Console账号
|
||||
} else {
|
||||
// 分组
|
||||
data.claudeAccountId = form.claudeAccountId;
|
||||
data.claudeConsoleAccountId = null; // 清空Console账号
|
||||
data.claudeAccountId = form.claudeAccountId
|
||||
data.claudeConsoleAccountId = null // 清空Console账号
|
||||
}
|
||||
} else {
|
||||
// 使用共享池,清空所有绑定
|
||||
data.claudeAccountId = null;
|
||||
data.claudeConsoleAccountId = null;
|
||||
data.claudeAccountId = null
|
||||
data.claudeConsoleAccountId = null
|
||||
}
|
||||
|
||||
|
||||
// Gemini账户绑定
|
||||
if (form.geminiAccountId) {
|
||||
data.geminiAccountId = form.geminiAccountId;
|
||||
data.geminiAccountId = form.geminiAccountId
|
||||
} else {
|
||||
data.geminiAccountId = null;
|
||||
data.geminiAccountId = null
|
||||
}
|
||||
|
||||
|
||||
// 模型限制 - 始终提交这些字段
|
||||
data.enableModelRestriction = form.enableModelRestriction
|
||||
data.restrictedModels = form.restrictedModels
|
||||
|
||||
|
||||
// 客户端限制 - 始终提交这些字段
|
||||
data.enableClientRestriction = form.enableClientRestriction
|
||||
data.allowedClients = form.allowedClients
|
||||
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
emit('success')
|
||||
emit('close')
|
||||
@@ -683,12 +653,12 @@ const refreshAccounts = async () => {
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
|
||||
|
||||
if (claudeData.success) {
|
||||
claudeData.data?.forEach(account => {
|
||||
claudeData.data?.forEach((account) => {
|
||||
claudeAccounts.push({
|
||||
...account,
|
||||
platform: 'claude-oauth',
|
||||
@@ -696,9 +666,9 @@ const refreshAccounts = async () => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if (claudeConsoleData.success) {
|
||||
claudeConsoleData.data?.forEach(account => {
|
||||
claudeConsoleData.data?.forEach((account) => {
|
||||
claudeAccounts.push({
|
||||
...account,
|
||||
platform: 'claude-console',
|
||||
@@ -706,23 +676,23 @@ const refreshAccounts = async () => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
localAccounts.value.claude = claudeAccounts
|
||||
|
||||
|
||||
if (geminiData.success) {
|
||||
localAccounts.value.gemini = (geminiData.data || []).map(account => ({
|
||||
localAccounts.value.gemini = (geminiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
|
||||
localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini')
|
||||
}
|
||||
|
||||
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
@@ -736,7 +706,7 @@ onMounted(async () => {
|
||||
// 加载支持的客户端和已存在的标签
|
||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
|
||||
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
localAccounts.value = {
|
||||
@@ -746,7 +716,7 @@ onMounted(async () => {
|
||||
geminiGroups: props.accounts.geminiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
form.name = props.apiKey.name
|
||||
form.tokenLimit = props.apiKey.tokenLimit || ''
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
@@ -772,4 +742,4 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,39 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- 模态框内容 -->
|
||||
<div class="modal-content w-full max-w-lg p-8 mx-auto">
|
||||
<div class="modal-content mx-auto w-full max-w-lg p-8">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-amber-500 to-orange-600 rounded-xl flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-600"
|
||||
>
|
||||
<i class="fas fa-clock text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
修改过期时间
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900">修改过期时间</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
为 "{{ apiKey.name || 'API Key' }}" 设置新的过期时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 当前状态显示 -->
|
||||
<div class="bg-gradient-to-r from-gray-50 to-gray-100 rounded-lg p-4 border border-gray-200">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium mb-1">
|
||||
当前过期时间
|
||||
</p>
|
||||
<p class="mb-1 text-xs font-medium text-gray-600">当前过期时间</p>
|
||||
<p class="text-sm font-semibold text-gray-800">
|
||||
<template v-if="apiKey.expiresAt">
|
||||
{{ formatExpireDate(apiKey.expiresAt) }}
|
||||
@@ -54,26 +51,28 @@
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-white rounded-lg flex items-center justify-center shadow-sm">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm">
|
||||
<i
|
||||
:class="[
|
||||
'fas fa-hourglass-half text-lg',
|
||||
apiKey.expiresAt && isExpired(apiKey.expiresAt) ? 'text-red-500' : 'text-gray-400'
|
||||
apiKey.expiresAt && isExpired(apiKey.expiresAt)
|
||||
? 'text-red-500'
|
||||
: 'text-gray-400'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 快捷选项 -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">选择新的期限</label>
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">选择新的期限</label>
|
||||
<div class="mb-3 grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="option in quickOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm font-medium transition-all',
|
||||
'rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
localForm.expireDuration === option.value
|
||||
? 'bg-blue-500 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
@@ -84,7 +83,7 @@
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm font-medium transition-all',
|
||||
'rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
localForm.expireDuration === 'custom'
|
||||
? 'bg-blue-500 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
@@ -96,33 +95,28 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 自定义日期选择 -->
|
||||
<div
|
||||
v-if="localForm.expireDuration === 'custom'"
|
||||
class="animate-fadeIn"
|
||||
>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">选择日期和时间</label>
|
||||
<input
|
||||
v-model="localForm.customExpireDate"
|
||||
type="datetime-local"
|
||||
<div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn">
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">选择日期和时间</label>
|
||||
<input
|
||||
v-model="localForm.customExpireDate"
|
||||
class="form-input w-full"
|
||||
:min="minDateTime"
|
||||
type="datetime-local"
|
||||
@change="updateCustomExpiryPreview"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
选择一个未来的日期和时间作为过期时间
|
||||
</p>
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">选择一个未来的日期和时间作为过期时间</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 预览新的过期时间 -->
|
||||
<div
|
||||
<div
|
||||
v-if="localForm.expiresAt !== apiKey.expiresAt"
|
||||
class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-4 border border-blue-200"
|
||||
class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-blue-700 font-medium mb-1">
|
||||
<p class="mb-1 text-xs font-medium text-blue-700">
|
||||
<i class="fas fa-arrow-right mr-1" />
|
||||
新的过期时间
|
||||
</p>
|
||||
@@ -143,33 +137,27 @@
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-white rounded-lg flex items-center justify-center shadow-sm">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm">
|
||||
<i class="fas fa-check text-lg text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
|
||||
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 btn btn-primary py-2.5 px-4 font-semibold"
|
||||
class="btn btn-primary flex-1 px-4 py-2.5 font-semibold"
|
||||
:disabled="saving || localForm.expiresAt === apiKey.expiresAt"
|
||||
@click="handleSave"
|
||||
>
|
||||
<div
|
||||
v-if="saving"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-save mr-2"
|
||||
/>
|
||||
<div v-if="saving" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ saving ? '保存中...' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -224,23 +212,29 @@ const minDateTime = computed(() => {
|
||||
})
|
||||
|
||||
// 监听显示状态,初始化表单
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
initializeForm()
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
initializeForm()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 监听 apiKey 变化,重新初始化
|
||||
watch(() => props.apiKey?.id, (newId) => {
|
||||
if (newId && props.show) {
|
||||
initializeForm()
|
||||
watch(
|
||||
() => props.apiKey?.id,
|
||||
(newId) => {
|
||||
if (newId && props.show) {
|
||||
initializeForm()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 初始化表单
|
||||
const initializeForm = () => {
|
||||
saving.value = false
|
||||
|
||||
|
||||
if (props.apiKey.expiresAt) {
|
||||
localForm.expireDuration = 'custom'
|
||||
localForm.customExpireDate = new Date(props.apiKey.expiresAt).toISOString().slice(0, 16)
|
||||
@@ -255,23 +249,23 @@ const initializeForm = () => {
|
||||
// 选择快捷选项
|
||||
const selectQuickOption = (value) => {
|
||||
localForm.expireDuration = value
|
||||
|
||||
|
||||
if (!value) {
|
||||
localForm.expiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (value === 'custom') {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const now = new Date()
|
||||
const match = value.match(/(\d+)([dhmy])/)
|
||||
|
||||
|
||||
if (match) {
|
||||
const [, num, unit] = match
|
||||
const amount = parseInt(num)
|
||||
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
now.setDate(now.getDate() + amount)
|
||||
@@ -286,7 +280,7 @@ const selectQuickOption = (value) => {
|
||||
now.setFullYear(now.getFullYear() + amount)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
localForm.expiresAt = now.toISOString()
|
||||
}
|
||||
}
|
||||
@@ -320,12 +314,12 @@ const isExpired = (dateString) => {
|
||||
// 获取过期状态
|
||||
const getExpiryStatus = (expiresAt) => {
|
||||
if (!expiresAt) return null
|
||||
|
||||
|
||||
const now = new Date()
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const diffMs = expiryDate - now
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
|
||||
if (diffMs < 0) {
|
||||
return {
|
||||
text: '已过期',
|
||||
@@ -396,7 +390,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,100 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-lg overflow-y-auto p-8"
|
||||
>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-check text-white text-lg" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
|
||||
>
|
||||
<i class="fas fa-check text-lg text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
API Key 创建成功
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
请妥善保存您的 API Key
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
|
||||
<p class="text-sm text-gray-600">请妥善保存您的 API Key</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
title="直接关闭(不推荐)"
|
||||
@click="handleDirectClose"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 警告提示 -->
|
||||
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
|
||||
<div class="mb-6 border-l-4 border-amber-400 bg-amber-50 p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="w-6 h-6 bg-amber-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-exclamation-triangle text-white text-sm" />
|
||||
<div
|
||||
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-amber-400"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle text-sm text-white" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h5 class="font-semibold text-amber-900 mb-1">
|
||||
重要提醒
|
||||
</h5>
|
||||
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
|
||||
<p class="text-sm text-amber-800">
|
||||
这是您唯一能看到完整 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API Key。请立即复制并妥善保存。
|
||||
这是您唯一能看到完整 API Key 的机会。关闭此窗口后,系统将不再显示完整的 API
|
||||
Key。请立即复制并妥善保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- API Key 信息 -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="mb-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key 名称</label>
|
||||
<div class="p-3 bg-gray-50 rounded-lg border">
|
||||
<span class="text-gray-900 font-medium">{{ apiKey.name }}</span>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key 名称</label>
|
||||
<div class="rounded-lg border bg-gray-50 p-3">
|
||||
<span class="font-medium text-gray-900">{{ apiKey.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="apiKey.description">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">备注</label>
|
||||
<div class="p-3 bg-gray-50 rounded-lg border">
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">备注</label>
|
||||
<div class="rounded-lg border bg-gray-50 p-3">
|
||||
<span class="text-gray-700">{{ apiKey.description || '无描述' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key</label>
|
||||
<div class="relative">
|
||||
<div class="p-4 pr-14 bg-gray-900 rounded-lg border font-mono text-sm text-white break-all min-h-[60px] flex items-center">
|
||||
<div
|
||||
class="flex min-h-[60px] items-center break-all rounded-lg border bg-gray-900 p-4 pr-14 font-mono text-sm text-white"
|
||||
>
|
||||
{{ getDisplayedApiKey() }}
|
||||
</div>
|
||||
<div class="absolute top-3 right-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon-sm hover:bg-gray-800 bg-gray-700"
|
||||
<div class="absolute right-3 top-3">
|
||||
<button
|
||||
class="btn-icon-sm bg-gray-700 hover:bg-gray-800"
|
||||
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
|
||||
type="button"
|
||||
@click="toggleKeyVisibility"
|
||||
>
|
||||
<i :class="['fas', showFullKey ? 'fa-eye-slash' : 'fa-eye', 'text-gray-300']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
点击眼睛图标切换显示模式,使用下方按钮复制完整 API Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 btn btn-primary py-3 px-6 font-semibold flex items-center justify-center gap-2"
|
||||
<button
|
||||
class="btn btn-primary flex flex-1 items-center justify-center gap-2 px-6 py-3 font-semibold"
|
||||
@click="copyApiKey"
|
||||
>
|
||||
<i class="fas fa-copy" />
|
||||
复制 API Key
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-3 bg-gray-200 text-gray-800 rounded-xl font-semibold hover:bg-gray-300 transition-colors border border-gray-300"
|
||||
<button
|
||||
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300"
|
||||
@click="handleClose"
|
||||
>
|
||||
我已保存
|
||||
@@ -126,13 +129,15 @@ const toggleKeyVisibility = () => {
|
||||
const getDisplayedApiKey = () => {
|
||||
const key = props.apiKey.apiKey || props.apiKey.key || ''
|
||||
if (!key) return ''
|
||||
|
||||
|
||||
if (showFullKey.value) {
|
||||
return key
|
||||
} else {
|
||||
// 显示前8个字符和后4个字符,中间用●代替
|
||||
if (key.length <= 12) return key
|
||||
return key.substring(0, 8) + '●'.repeat(Math.max(0, key.length - 12)) + key.substring(key.length - 4)
|
||||
return (
|
||||
key.substring(0, 8) + '●'.repeat(Math.max(0, key.length - 12)) + key.substring(key.length - 4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +148,7 @@ const copyApiKey = async () => {
|
||||
showToast('API Key 不存在', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
showToast('API Key 已复制到剪贴板', 'success')
|
||||
@@ -202,9 +207,7 @@ const handleDirectClose = async () => {
|
||||
}
|
||||
} else {
|
||||
// 降级方案
|
||||
const confirmed = confirm(
|
||||
'您还没有保存API Key,关闭后将无法再次查看。\n\n确定要关闭吗?'
|
||||
)
|
||||
const confirmed = confirm('您还没有保存API Key,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
||||
if (confirmed) {
|
||||
emit('close')
|
||||
}
|
||||
@@ -217,4 +220,4 @@ pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,116 +1,92 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content mx-auto flex max-h-[90vh] w-full max-w-md flex-col p-8">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
|
||||
>
|
||||
<i class="fas fa-clock text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
续期 API Key
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
|
||||
<div class="modal-scroll-content custom-scrollbar flex-1 space-y-6">
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-info text-white text-sm" />
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-info text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-800 mb-1">
|
||||
API Key 信息
|
||||
</h4>
|
||||
<h4 class="mb-1 font-semibold text-gray-800">API Key 信息</h4>
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ apiKey.name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
当前过期时间:{{ apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期' }}
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
当前过期时间:{{
|
||||
apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
|
||||
<select
|
||||
v-model="form.renewDuration"
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">续期时长</label>
|
||||
<select
|
||||
v-model="form.renewDuration"
|
||||
class="form-input w-full"
|
||||
@change="updateRenewExpireAt"
|
||||
>
|
||||
<option value="7d">
|
||||
延长 7 天
|
||||
</option>
|
||||
<option value="30d">
|
||||
延长 30 天
|
||||
</option>
|
||||
<option value="90d">
|
||||
延长 90 天
|
||||
</option>
|
||||
<option value="180d">
|
||||
延长 180 天
|
||||
</option>
|
||||
<option value="365d">
|
||||
延长 365 天
|
||||
</option>
|
||||
<option value="custom">
|
||||
自定义日期
|
||||
</option>
|
||||
<option value="permanent">
|
||||
设为永不过期
|
||||
</option>
|
||||
<option value="7d">延长 7 天</option>
|
||||
<option value="30d">延长 30 天</option>
|
||||
<option value="90d">延长 90 天</option>
|
||||
<option value="180d">延长 180 天</option>
|
||||
<option value="365d">延长 365 天</option>
|
||||
<option value="custom">自定义日期</option>
|
||||
<option value="permanent">设为永不过期</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="form.renewDuration === 'custom'"
|
||||
class="mt-3"
|
||||
>
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
type="datetime-local"
|
||||
<div v-if="form.renewDuration === 'custom'" class="mt-3">
|
||||
<input
|
||||
v-model="form.customExpireDate"
|
||||
class="form-input w-full"
|
||||
:min="minDateTime"
|
||||
type="datetime-local"
|
||||
@change="updateCustomRenewExpireAt"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="form.newExpiresAt"
|
||||
class="text-xs text-gray-500 mt-2"
|
||||
>
|
||||
<p v-if="form.newExpiresAt" class="mt-2 text-xs text-gray-500">
|
||||
新的过期时间:{{ formatExpireDate(form.newExpiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="loading || !form.renewDuration"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
type="button"
|
||||
@click="renewApiKey"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-clock mr-2"
|
||||
/>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-clock mr-2" />
|
||||
{{ loading ? '续期中...' : '确认续期' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -122,7 +98,6 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -134,7 +109,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
@@ -174,28 +148,29 @@ const updateRenewExpireAt = () => {
|
||||
form.newExpiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (form.renewDuration === 'permanent') {
|
||||
form.newExpiresAt = null
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (form.renewDuration === 'custom') {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 计算新的过期时间
|
||||
const baseDate = props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
|
||||
? new Date(props.apiKey.expiresAt)
|
||||
: new Date()
|
||||
|
||||
const baseDate =
|
||||
props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
|
||||
? new Date(props.apiKey.expiresAt)
|
||||
: new Date()
|
||||
|
||||
const duration = form.renewDuration
|
||||
const match = duration.match(/(\d+)([dhmy])/)
|
||||
|
||||
|
||||
if (match) {
|
||||
const [, value, unit] = match
|
||||
const num = parseInt(value)
|
||||
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
baseDate.setDate(baseDate.getDate() + num)
|
||||
@@ -210,7 +185,7 @@ const updateRenewExpireAt = () => {
|
||||
baseDate.setFullYear(baseDate.getFullYear() + num)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
form.newExpiresAt = baseDate.toISOString()
|
||||
}
|
||||
}
|
||||
@@ -225,14 +200,14 @@ const updateCustomRenewExpireAt = () => {
|
||||
// 续期 API Key
|
||||
const renewApiKey = async () => {
|
||||
loading.value = true
|
||||
|
||||
|
||||
try {
|
||||
const data = {
|
||||
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
|
||||
}
|
||||
|
||||
|
||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
showToast('API Key 续期成功', 'success')
|
||||
emit('success')
|
||||
@@ -253,4 +228,4 @@ updateRenewExpireAt()
|
||||
|
||||
<style scoped>
|
||||
/* 表单样式由全局样式提供 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,88 +1,87 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<!-- 背景遮罩 -->
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-900 bg-opacity-50 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div class="fixed inset-0 bg-gray-900 bg-opacity-50 backdrop-blur-sm" @click="close" />
|
||||
|
||||
<!-- 模态框 -->
|
||||
<div class="modal-content w-[95%] sm:w-full max-w-2xl sm:max-w-3xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col relative">
|
||||
<div
|
||||
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg sm:rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-chart-line text-white text-sm sm:text-base" />
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||
>
|
||||
<i class="fas fa-chart-line text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
|
||||
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">
|
||||
使用统计详情 - {{ apiKey.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||
@click="close"
|
||||
>
|
||||
<button class="p-1 text-gray-400 transition-colors hover:text-gray-600" @click="close">
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="modal-scroll-content custom-scrollbar flex-1 overflow-y-auto">
|
||||
<!-- 总体统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- 请求统计卡片 -->
|
||||
<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">
|
||||
<div
|
||||
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<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">
|
||||
<div class="mt-1 text-xs text-gray-600">
|
||||
今日: {{ 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">
|
||||
<div
|
||||
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<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">
|
||||
<div class="mt-1 text-xs text-gray-600">
|
||||
今日: {{ 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">
|
||||
<div
|
||||
class="rounded-lg border border-yellow-200 bg-gradient-to-br from-yellow-50 to-yellow-100 p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<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 class="text-2xl font-bold text-gray-900">${{ totalCost.toFixed(4) }}</div>
|
||||
<div class="mt-1 text-xs text-gray-600">今日: ${{ 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">
|
||||
<div
|
||||
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<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="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">RPM:</span>
|
||||
<span class="font-semibold">{{ rpm }}</span>
|
||||
@@ -97,14 +96,14 @@
|
||||
|
||||
<!-- 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" />
|
||||
<h4 class="mb-3 flex items-center text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-chart-pie mr-2 text-indigo-500" />
|
||||
Token 使用分布
|
||||
</h4>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||
<div class="space-y-3 rounded-lg bg-gray-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-arrow-down text-green-500 mr-2" />
|
||||
<i class="fas fa-arrow-down mr-2 text-green-500" />
|
||||
<span class="text-sm text-gray-600">输入 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900">
|
||||
@@ -113,31 +112,25 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-arrow-up text-blue-500 mr-2" />
|
||||
<i class="fas fa-arrow-up mr-2 text-blue-500" />
|
||||
<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 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" />
|
||||
<i class="fas fa-save mr-2 text-purple-500" />
|
||||
<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 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" />
|
||||
<i class="fas fa-download mr-2 text-purple-500" />
|
||||
<span class="text-sm text-gray-600">缓存读取 Token</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-purple-600">
|
||||
@@ -148,33 +141,33 @@
|
||||
</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" />
|
||||
<div v-if="hasLimits" class="mb-6">
|
||||
<h4 class="mb-3 flex items-center text-sm font-semibold text-gray-700">
|
||||
<i class="fas fa-shield-alt mr-2 text-red-500" />
|
||||
限制设置
|
||||
</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="space-y-3 rounded-lg bg-gray-50 p-4">
|
||||
<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
|
||||
<div class="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="dailyCostPercentage >= 100 ? 'bg-red-500' : dailyCostPercentage >= 80 ? 'bg-yellow-500' : 'bg-green-500'"
|
||||
: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">
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
已使用 {{ dailyCostPercentage.toFixed(1) }}%
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,50 +182,42 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="apiKey.rateLimitWindow > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<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 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
|
||||
<div class="h-2 w-full rounded-full bg-gray-200">
|
||||
<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 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) }}
|
||||
{{ formatTokenCount(apiKey.currentWindowTokens || 0) }} /
|
||||
{{ formatTokenCount(apiKey.tokenLimit) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
<div class="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="windowTokenProgressColor"
|
||||
:style="{ width: windowTokenProgress + '%' }"
|
||||
@@ -245,12 +230,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="mt-4 sm:mt-6 flex justify-end gap-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary px-4 py-2 text-sm"
|
||||
@click="close"
|
||||
>
|
||||
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
|
||||
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
@@ -276,24 +257,26 @@ const props = defineProps({
|
||||
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 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 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
|
||||
return (
|
||||
props.apiKey.dailyCostLimit > 0 ||
|
||||
props.apiKey.concurrencyLimit > 0 ||
|
||||
props.apiKey.rateLimitWindow > 0 ||
|
||||
props.apiKey.tokenLimit > 0
|
||||
)
|
||||
})
|
||||
|
||||
const dailyCostPercentage = computed(() => {
|
||||
@@ -304,7 +287,8 @@ const dailyCostPercentage = computed(() => {
|
||||
// 窗口请求进度
|
||||
const windowRequestProgress = computed(() => {
|
||||
if (!props.apiKey.rateLimitRequests || props.apiKey.rateLimitRequests === 0) return 0
|
||||
const percentage = ((props.apiKey.currentWindowRequests || 0) / props.apiKey.rateLimitRequests) * 100
|
||||
const percentage =
|
||||
((props.apiKey.currentWindowRequests || 0) / props.apiKey.rateLimitRequests) * 100
|
||||
return Math.min(percentage, 100)
|
||||
})
|
||||
|
||||
@@ -352,4 +336,4 @@ const close = () => {
|
||||
|
||||
<style scoped>
|
||||
/* 使用项目的通用样式,不需要额外定义 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,58 +1,48 @@
|
||||
<template>
|
||||
<div class="api-input-wide-card glass-strong rounded-3xl p-6 mb-8 shadow-xl">
|
||||
<div class="api-input-wide-card glass-strong mb-8 rounded-3xl p-6 shadow-xl">
|
||||
<!-- 标题区域 -->
|
||||
<div class="wide-card-title text-center mb-6">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<div class="wide-card-title mb-6 text-center">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
<i class="fas fa-chart-line mr-3" />
|
||||
使用统计查询
|
||||
</h2>
|
||||
<p class="text-base text-gray-600">
|
||||
查询您的 API Key 使用情况和统计数据
|
||||
</p>
|
||||
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
|
||||
<!-- API Key 输入 -->
|
||||
<div class="lg:col-span-3">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700">
|
||||
<i class="fas fa-key mr-2" />
|
||||
输入您的 API Key
|
||||
</label>
|
||||
<input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
<input
|
||||
v-model="apiKey"
|
||||
class="wide-card-input w-full"
|
||||
:disabled="loading"
|
||||
placeholder="请输入您的 API Key (cr_...)"
|
||||
type="password"
|
||||
@keyup.enter="queryStats"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 查询按钮 -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="hidden lg:block text-sm font-medium mb-2 text-gray-700">
|
||||
|
||||
</label>
|
||||
<button
|
||||
<label class="mb-2 hidden text-sm font-medium text-gray-700 lg:block"> </label>
|
||||
<button
|
||||
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
|
||||
:disabled="loading || !apiKey.trim()"
|
||||
class="btn btn-primary btn-query w-full h-full flex items-center justify-center gap-2"
|
||||
@click="queryStats"
|
||||
>
|
||||
<i
|
||||
v-if="loading"
|
||||
class="fas fa-spinner loading-spinner"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-search"
|
||||
/>
|
||||
<i v-if="loading" class="fas fa-spinner loading-spinner" />
|
||||
<i v-else class="fas fa-search" />
|
||||
{{ loading ? '查询中...' : '查询统计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice mt-4">
|
||||
<i class="fas fa-shield-alt mr-2" />
|
||||
@@ -77,7 +67,7 @@ const { queryStats } = apiStatsStore
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
@@ -85,7 +75,7 @@ const { queryStats } = apiStatsStore
|
||||
}
|
||||
|
||||
.api-input-wide-card:hover {
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 32px 64px -12px rgba(0, 0, 0, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
@@ -135,7 +125,7 @@ const { queryStats } = apiStatsStore
|
||||
.wide-card-input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(96, 165, 250, 0.2),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
@@ -162,14 +152,14 @@ const { queryStats } = apiStatsStore
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
@@ -209,8 +199,12 @@ const { queryStats } = apiStatsStore
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@@ -218,33 +212,33 @@ const { queryStats } = apiStatsStore
|
||||
.api-input-wide-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-title {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.api-input-grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-input {
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.btn-query {
|
||||
padding: 12px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.security-notice {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.8rem;
|
||||
@@ -255,23 +249,23 @@ const { queryStats } = apiStatsStore
|
||||
.api-input-wide-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-title h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-title p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
|
||||
.wide-card-input {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.btn-query {
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,89 +2,101 @@
|
||||
<div>
|
||||
<!-- 限制配置 -->
|
||||
<div class="card p-4 md:p-6">
|
||||
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-shield-alt mr-2 md:mr-3 text-red-500 text-sm md:text-base" />
|
||||
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl">
|
||||
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
|
||||
限制配置
|
||||
</h3>
|
||||
<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">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600 md:text-base">每日费用限制</span>
|
||||
<span class="text-xs text-gray-500 md:text-sm">
|
||||
<span v-if="statsData.limits.dailyCostLimit > 0">
|
||||
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{ statsData.limits.dailyCostLimit.toFixed(2) }}
|
||||
${{ 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>
|
||||
</div>
|
||||
<div v-if="statsData.limits.dailyCostLimit > 0" class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
:class="getDailyCostProgressColor()"
|
||||
<div
|
||||
v-if="statsData.limits.dailyCostLimit > 0"
|
||||
class="h-2 w-full rounded-full bg-gray-200"
|
||||
>
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="getDailyCostProgressColor()"
|
||||
: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 v-else class="h-2 w-full rounded-full bg-gray-200">
|
||||
<div class="h-2 rounded-full bg-green-500" style="width: 0%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 时间窗口限制 -->
|
||||
<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">
|
||||
<div
|
||||
v-if="
|
||||
statsData.limits.rateLimitWindow > 0 &&
|
||||
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
|
||||
"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600 md:text-base">
|
||||
时间窗口限制 ({{ statsData.limits.rateLimitWindow }}分钟)
|
||||
</span>
|
||||
</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">
|
||||
<div v-if="statsData.limits.rateLimitRequests > 0" class="mb-3 space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs md:text-sm">
|
||||
<span class="text-gray-500">请求次数</span>
|
||||
<span class="text-gray-700">
|
||||
{{ formatNumber(statsData.limits.currentWindowRequests) }} / {{ formatNumber(statsData.limits.rateLimitRequests) }}
|
||||
{{ 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()"
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all duration-300"
|
||||
:class="getWindowRequestProgressColor()"
|
||||
: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">
|
||||
<div class="flex items-center justify-between 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) }}
|
||||
{{ 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()"
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all duration-300"
|
||||
:class="getWindowTokenProgressColor()"
|
||||
: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">
|
||||
<div class="space-y-2 border-t border-gray-100 pt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">并发限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span v-if="statsData.limits.concurrencyLimit > 0">
|
||||
{{ statsData.limits.concurrencyLimit }}
|
||||
</span>
|
||||
@@ -93,39 +105,39 @@
|
||||
</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">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">模型限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span
|
||||
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">客户端限制</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||
<span
|
||||
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
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"
|
||||
>
|
||||
<span v-else class="text-green-600">
|
||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
||||
允许所有客户端
|
||||
</span>
|
||||
@@ -137,61 +149,71 @@
|
||||
|
||||
<!-- 详细限制信息 -->
|
||||
<div
|
||||
v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
|
||||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
|
||||
class="card p-4 md:p-6 mt-4 md:mt-6"
|
||||
v-if="
|
||||
(statsData.restrictions.enableModelRestriction &&
|
||||
statsData.restrictions.restrictedModels.length > 0) ||
|
||||
(statsData.restrictions.enableClientRestriction &&
|
||||
statsData.restrictions.allowedClients.length > 0)
|
||||
"
|
||||
class="card mt-4 p-4 md:mt-6 md:p-6"
|
||||
>
|
||||
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-list-alt mr-2 md:mr-3 text-amber-500 text-sm md:text-base" />
|
||||
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl">
|
||||
<i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
|
||||
详细限制信息
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2">
|
||||
<!-- 模型限制详情 -->
|
||||
<div
|
||||
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
|
||||
class="bg-amber-50 border border-amber-200 rounded-lg p-3 md:p-4"
|
||||
v-if="
|
||||
statsData.restrictions.enableModelRestriction &&
|
||||
statsData.restrictions.restrictedModels.length > 0
|
||||
"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 md:p-4"
|
||||
>
|
||||
<h4 class="font-bold text-amber-800 mb-2 md:mb-3 flex items-center text-sm md:text-base">
|
||||
<i class="fas fa-robot mr-1 md:mr-2 text-xs md:text-sm" />
|
||||
<h4 class="mb-2 flex items-center text-sm font-bold text-amber-800 md:mb-3 md:text-base">
|
||||
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
|
||||
受限模型列表
|
||||
</h4>
|
||||
<div class="space-y-1 md:space-y-2">
|
||||
<div
|
||||
v-for="model in statsData.restrictions.restrictedModels"
|
||||
:key="model"
|
||||
class="bg-white rounded px-2 md:px-3 py-1 md:py-2 text-xs md:text-sm border border-amber-200"
|
||||
v-for="model in statsData.restrictions.restrictedModels"
|
||||
:key="model"
|
||||
class="rounded border border-amber-200 bg-white px-2 py-1 text-xs md:px-3 md:py-2 md:text-sm"
|
||||
>
|
||||
<i class="fas fa-ban mr-1 md:mr-2 text-red-500 text-xs" />
|
||||
<span class="text-gray-800 break-all">{{ model }}</span>
|
||||
<i class="fas fa-ban mr-1 text-xs text-red-500 md:mr-2" />
|
||||
<span class="break-all text-gray-800">{{ model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-amber-700 mt-2 md:mt-3">
|
||||
<p class="mt-2 text-xs text-amber-700 md:mt-3">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
此 API Key 不能访问以上列出的模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 客户端限制详情 -->
|
||||
<div
|
||||
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
|
||||
class="bg-blue-50 border border-blue-200 rounded-lg p-3 md:p-4"
|
||||
v-if="
|
||||
statsData.restrictions.enableClientRestriction &&
|
||||
statsData.restrictions.allowedClients.length > 0
|
||||
"
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-3 md:p-4"
|
||||
>
|
||||
<h4 class="font-bold text-blue-800 mb-2 md:mb-3 flex items-center text-sm md:text-base">
|
||||
<i class="fas fa-desktop mr-1 md:mr-2 text-xs md:text-sm" />
|
||||
<h4 class="mb-2 flex items-center text-sm font-bold text-blue-800 md:mb-3 md:text-base">
|
||||
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
|
||||
允许的客户端
|
||||
</h4>
|
||||
<div class="space-y-1 md:space-y-2">
|
||||
<div
|
||||
v-for="client in statsData.restrictions.allowedClients"
|
||||
:key="client"
|
||||
class="bg-white rounded px-2 md:px-3 py-1 md:py-2 text-xs md:text-sm border border-blue-200"
|
||||
v-for="client in statsData.restrictions.allowedClients"
|
||||
:key="client"
|
||||
class="rounded border border-blue-200 bg-white px-2 py-1 text-xs md:px-3 md:py-2 md:text-sm"
|
||||
>
|
||||
<i class="fas fa-check mr-1 md:mr-2 text-green-500 text-xs" />
|
||||
<span class="text-gray-800 break-all">{{ client }}</span>
|
||||
<i class="fas fa-check mr-1 text-xs text-green-500 md:mr-2" />
|
||||
<span class="break-all text-gray-800">{{ client }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-blue-700 mt-2 md:mt-3">
|
||||
<p class="mt-2 text-xs text-blue-700 md:mt-3">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
此 API Key 只能被以上列出的客户端使用
|
||||
</p>
|
||||
@@ -213,9 +235,9 @@ const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
@@ -228,8 +250,10 @@ const formatNumber = (num) => {
|
||||
|
||||
// 获取每日费用进度
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -243,8 +267,10 @@ const getDailyCostProgressColor = () => {
|
||||
|
||||
// 获取窗口请求进度
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -259,7 +285,8 @@ const getWindowRequestProgressColor = () => {
|
||||
// 获取窗口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
|
||||
const percentage =
|
||||
(statsData.value.limits.currentWindowTokens / statsData.value.limits.tokenLimit) * 100
|
||||
return Math.min(percentage, 100)
|
||||
}
|
||||
|
||||
@@ -278,7 +305,7 @@ const getWindowTokenProgressColor = () => {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
@@ -298,8 +325,8 @@ const getWindowTokenProgressColor = () => {
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,84 +1,64 @@
|
||||
<template>
|
||||
<div class="card p-4 md:p-6">
|
||||
<div class="mb-4 md:mb-6">
|
||||
<h3 class="text-lg md:text-xl font-bold flex flex-col sm:flex-row sm:items-center text-gray-900">
|
||||
<h3
|
||||
class="flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-robot mr-2 md:mr-3 text-indigo-500 text-sm md:text-base" />
|
||||
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
|
||||
模型使用统计
|
||||
</span>
|
||||
<span class="text-xs md:text-sm font-normal text-gray-600 sm:ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计加载状态 -->
|
||||
<div
|
||||
v-if="modelStatsLoading"
|
||||
class="text-center py-6 md:py-8"
|
||||
>
|
||||
<i class="fas fa-spinner loading-spinner text-xl md:text-2xl mb-2 text-gray-600" />
|
||||
<p class="text-gray-600 text-sm md:text-base">
|
||||
加载模型统计数据中...
|
||||
</p>
|
||||
<div v-if="modelStatsLoading" class="py-6 text-center md:py-8">
|
||||
<i class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 md:text-2xl" />
|
||||
<p class="text-sm text-gray-600 md:text-base">加载模型统计数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型统计数据 -->
|
||||
<div
|
||||
v-else-if="modelStats.length > 0"
|
||||
class="space-y-3 md:space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="(model, index) in modelStats"
|
||||
:key="index"
|
||||
class="model-usage-item"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2 md:mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-bold text-base md:text-lg text-gray-900 break-all">
|
||||
<div v-else-if="modelStats.length > 0" class="space-y-3 md:space-y-4">
|
||||
<div v-for="(model, index) in modelStats" :key="index" class="model-usage-item">
|
||||
<div class="mb-2 flex items-start justify-between md:mb-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="break-all text-base font-bold text-gray-900 md:text-lg">
|
||||
{{ model.model }}
|
||||
</h4>
|
||||
<p class="text-gray-600 text-xs md:text-sm">
|
||||
{{ model.requests }} 次请求
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 md:text-sm">{{ model.requests }} 次请求</p>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0 ml-3">
|
||||
<div class="text-base md:text-lg font-bold text-green-600">
|
||||
<div class="ml-3 flex-shrink-0 text-right">
|
||||
<div class="text-base font-bold text-green-600 md:text-lg">
|
||||
{{ model.formatted?.total || '$0.000000' }}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-600">
|
||||
总费用
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 md:text-sm">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 md:gap-3 text-xs md:text-sm">
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">
|
||||
输入 Token
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm">
|
||||
<div class="rounded bg-gray-50 p-2">
|
||||
<div class="text-gray-600">输入 Token</div>
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ formatNumber(model.inputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">
|
||||
输出 Token
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2">
|
||||
<div class="text-gray-600">输出 Token</div>
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ formatNumber(model.outputTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">
|
||||
缓存创建
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2">
|
||||
<div class="text-gray-600">缓存创建</div>
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ formatNumber(model.cacheCreateTokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-2">
|
||||
<div class="text-gray-600">
|
||||
缓存读取
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 p-2">
|
||||
<div class="text-gray-600">缓存读取</div>
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ formatNumber(model.cacheReadTokens) }}
|
||||
</div>
|
||||
@@ -88,11 +68,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 无模型数据 -->
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-6 md:py-8 text-gray-500"
|
||||
>
|
||||
<i class="fas fa-chart-pie text-2xl md:text-3xl mb-3" />
|
||||
<div v-else class="py-6 text-center text-gray-500 md:py-8">
|
||||
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
|
||||
<p class="text-sm md:text-base">
|
||||
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
|
||||
</p>
|
||||
@@ -112,9 +89,9 @@ const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
@@ -132,7 +109,7 @@ const formatNumber = (num) => {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
@@ -152,7 +129,7 @@ const formatNumber = (num) => {
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -186,7 +163,7 @@ const formatNumber = (num) => {
|
||||
|
||||
.model-usage-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
@@ -199,8 +176,12 @@ const formatNumber = (num) => {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@@ -214,9 +195,9 @@ const formatNumber = (num) => {
|
||||
.model-usage-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
.model-usage-item .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,65 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 mb-6 md:mb-8">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
||||
<!-- API Key 基本信息 -->
|
||||
<div class="card p-4 md:p-6">
|
||||
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex items-center text-gray-900">
|
||||
<i class="fas fa-info-circle mr-2 md:mr-3 text-blue-500 text-sm md:text-base" />
|
||||
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl">
|
||||
<i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" />
|
||||
API Key 信息
|
||||
</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">名称</span>
|
||||
<span class="font-medium text-gray-900 text-sm md:text-base break-all">{{ statsData.name }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">名称</span>
|
||||
<span class="break-all text-sm font-medium text-gray-900 md:text-base">{{
|
||||
statsData.name
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm md:text-base">状态</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">状态</span>
|
||||
<span
|
||||
class="text-sm font-medium md:text-base"
|
||||
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
|
||||
class="font-medium text-sm md:text-base"
|
||||
>
|
||||
<i
|
||||
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
|
||||
class="mr-1 text-xs md:text-sm"
|
||||
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
|
||||
/>
|
||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||
</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">{{ formatPermissions(statsData.permissions) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">权限</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">{{
|
||||
formatPermissions(statsData.permissions)
|
||||
}}</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-xs md:text-base break-all">{{ formatDate(statsData.createdAt) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 md:text-base">创建时间</span>
|
||||
<span class="break-all text-xs font-medium text-gray-900 md:text-base">{{
|
||||
formatDate(statsData.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-gray-600 text-sm md:text-base flex-shrink-0 mt-1">过期时间</span>
|
||||
<div
|
||||
v-if="statsData.expiresAt"
|
||||
class="text-right"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 md:text-base">过期时间</span>
|
||||
<div v-if="statsData.expiresAt" class="text-right">
|
||||
<div
|
||||
v-if="isApiKeyExpired(statsData.expiresAt)"
|
||||
class="text-red-600 font-medium text-sm md:text-base"
|
||||
class="text-sm font-medium text-red-600 md:text-base"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
|
||||
已过期
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
|
||||
class="text-orange-600 font-medium text-xs md:text-base break-all"
|
||||
class="break-all text-xs font-medium text-orange-600 md:text-base"
|
||||
>
|
||||
<i class="fas fa-clock mr-1 text-xs md:text-sm" />
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-gray-900 font-medium text-xs md:text-base break-all"
|
||||
>
|
||||
<div v-else class="break-all text-xs font-medium text-gray-900 md:text-base">
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-gray-400 font-medium text-sm md:text-base"
|
||||
>
|
||||
<div v-else class="text-sm font-medium text-gray-400 md:text-base">
|
||||
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
||||
永不过期
|
||||
</div>
|
||||
@@ -72,43 +69,47 @@
|
||||
|
||||
<!-- 使用统计概览 -->
|
||||
<div class="card p-4 md:p-6">
|
||||
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex flex-col sm:flex-row sm:items-center text-gray-900">
|
||||
<h3
|
||||
class="mb-3 flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:mb-4 md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chart-bar mr-2 md:mr-3 text-green-500 text-sm md:text-base" />
|
||||
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
|
||||
使用统计概览
|
||||
</span>
|
||||
<span class="text-xs md:text-sm font-normal text-gray-600 sm:ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3 md:gap-4">
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-lg md:text-3xl font-bold text-green-600">
|
||||
<div class="text-lg font-bold text-green-600 md:text-3xl">
|
||||
{{ formatNumber(currentPeriodData.requests) }}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-600">
|
||||
<div class="text-xs text-gray-600 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-lg md:text-3xl font-bold text-blue-600">
|
||||
<div class="text-lg font-bold text-blue-600 md:text-3xl">
|
||||
{{ formatNumber(currentPeriodData.allTokens) }}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-600">
|
||||
<div class="text-xs text-gray-600 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-lg md:text-3xl font-bold text-purple-600">
|
||||
<div class="text-lg font-bold text-purple-600 md:text-3xl">
|
||||
{{ currentPeriodData.formattedCost || '$0.000000' }}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-600">
|
||||
<div class="text-xs text-gray-600 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card text-center">
|
||||
<div class="text-lg md:text-3xl font-bold text-yellow-600">
|
||||
<div class="text-lg font-bold text-yellow-600 md:text-3xl">
|
||||
{{ formatNumber(currentPeriodData.inputTokens) }}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-600">
|
||||
<div class="text-xs text-gray-600 md:text-sm">
|
||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +129,7 @@ const { statsData, statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '无'
|
||||
|
||||
|
||||
try {
|
||||
const date = dayjs(dateString)
|
||||
return date.format('YYYY年MM月DD日 HH:mm')
|
||||
@@ -170,9 +171,9 @@ const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
@@ -186,11 +187,11 @@ const formatNumber = (num) => {
|
||||
// 格式化权限
|
||||
const formatPermissions = (permissions) => {
|
||||
const permissionMap = {
|
||||
'claude': 'Claude',
|
||||
'gemini': 'Gemini',
|
||||
'all': '全部模型'
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
all: '全部模型'
|
||||
}
|
||||
|
||||
|
||||
return permissionMap[permissions] || permissions || '未知'
|
||||
}
|
||||
</script>
|
||||
@@ -201,7 +202,7 @@ const formatPermissions = (permissions) => {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
@@ -221,7 +222,7 @@ const formatPermissions = (permissions) => {
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -258,7 +259,7 @@ const formatPermissions = (permissions) => {
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
@@ -279,4 +280,4 @@ const formatPermissions = (permissions) => {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,45 +1,59 @@
|
||||
<template>
|
||||
<div class="card p-4 md:p-6">
|
||||
<h3 class="text-lg md:text-xl font-bold mb-3 md:mb-4 flex flex-col sm:flex-row sm:items-center text-gray-900">
|
||||
<h3
|
||||
class="mb-3 flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:mb-4 md:text-xl"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-coins mr-2 md:mr-3 text-yellow-500 text-sm md:text-base" />
|
||||
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
|
||||
Token 使用分布
|
||||
</span>
|
||||
<span class="text-xs md:text-sm font-normal text-gray-600 sm:ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
|
||||
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm"
|
||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
||||
>
|
||||
</h3>
|
||||
<div class="space-y-2 md:space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center text-sm md:text-base">
|
||||
<i class="fas fa-arrow-right mr-1 md:mr-2 text-green-500 text-xs md:text-sm" />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 md:text-base">
|
||||
<i class="fas fa-arrow-right mr-1 text-xs text-green-500 md:mr-2 md:text-sm" />
|
||||
输入 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">{{
|
||||
formatNumber(currentPeriodData.inputTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center text-sm md:text-base">
|
||||
<i class="fas fa-arrow-left mr-1 md:mr-2 text-blue-500 text-xs md:text-sm" />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 md:text-base">
|
||||
<i class="fas fa-arrow-left mr-1 text-xs text-blue-500 md:mr-2 md:text-sm" />
|
||||
输出 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">{{
|
||||
formatNumber(currentPeriodData.outputTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center text-sm md:text-base">
|
||||
<i class="fas fa-save mr-1 md:mr-2 text-purple-500 text-xs md:text-sm" />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 md:text-base">
|
||||
<i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" />
|
||||
缓存创建 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">{{
|
||||
formatNumber(currentPeriodData.cacheCreateTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 flex items-center text-sm md:text-base">
|
||||
<i class="fas fa-download mr-1 md:mr-2 text-orange-500 text-xs md:text-sm" />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center text-sm text-gray-600 md:text-base">
|
||||
<i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" />
|
||||
缓存读取 Token
|
||||
</span>
|
||||
<span class="font-medium text-gray-900 text-sm md:text-base">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>
|
||||
<span class="text-sm font-medium text-gray-900 md:text-base">{{
|
||||
formatNumber(currentPeriodData.cacheReadTokens)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 md:mt-4 pt-3 md:pt-4 border-t border-gray-200">
|
||||
<div class="flex justify-between items-center font-bold text-gray-900">
|
||||
<span class="text-sm md:text-base">{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span>
|
||||
<div class="mt-3 border-t border-gray-200 pt-3 md:mt-4 md:pt-4">
|
||||
<div class="flex items-center justify-between font-bold text-gray-900">
|
||||
<span class="text-sm md:text-base"
|
||||
>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
|
||||
>
|
||||
<span class="text-lg md:text-xl">{{ formatNumber(currentPeriodData.allTokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,9 +72,9 @@ const formatNumber = (num) => {
|
||||
if (typeof num !== 'number') {
|
||||
num = parseInt(num) || 0
|
||||
}
|
||||
|
||||
|
||||
if (num === 0) return '0'
|
||||
|
||||
|
||||
// 大数字使用简化格式
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
@@ -78,7 +92,7 @@ const formatNumber = (num) => {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
@@ -98,8 +112,8 @@ const formatNumber = (num) => {
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
<div ref="triggerRef" class="relative">
|
||||
<!-- 选择器主体 -->
|
||||
<div
|
||||
class="form-input w-full cursor-pointer flex items-center justify-between"
|
||||
class="form-input flex w-full cursor-pointer items-center justify-between"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
@click="!disabled && toggleDropdown()"
|
||||
>
|
||||
<span :class="modelValue ? 'text-gray-900' : 'text-gray-500'">{{ selectedLabel }}</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': showDropdown }" />
|
||||
<i
|
||||
class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': showDropdown }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
@@ -23,26 +26,28 @@
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
ref="dropdownRef"
|
||||
class="absolute z-50 bg-white rounded-lg shadow-lg border border-gray-200 flex flex-col"
|
||||
class="absolute z-50 flex flex-col rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<div class="p-3 border-b border-gray-200 flex-shrink-0">
|
||||
<div class="flex-shrink-0 border-b border-gray-200 p-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索账号名称..."
|
||||
class="form-input w-full text-sm"
|
||||
style="padding-left: 40px; padding-right: 36px;"
|
||||
placeholder="搜索账号名称..."
|
||||
style="padding-left: 40px; padding-right: 36px"
|
||||
type="text"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" />
|
||||
/>
|
||||
<i
|
||||
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-400"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="fas fa-times text-sm" />
|
||||
@@ -51,10 +56,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div class="custom-scrollbar flex-1 overflow-y-auto">
|
||||
<!-- 默认选项 -->
|
||||
<div
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
|
||||
:class="{ 'bg-blue-50': !modelValue }"
|
||||
@click="selectAccount(null)"
|
||||
>
|
||||
@@ -63,13 +68,11 @@
|
||||
|
||||
<!-- 分组选项 -->
|
||||
<div v-if="filteredGroups.length > 0">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||
调度分组
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">调度分组</div>
|
||||
<div
|
||||
v-for="group in filteredGroups"
|
||||
:key="`group:${group.id}`"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
|
||||
:class="{ 'bg-blue-50': modelValue === `group:${group.id}` }"
|
||||
@click="selectAccount(`group:${group.id}`)"
|
||||
>
|
||||
@@ -82,13 +85,13 @@
|
||||
|
||||
<!-- OAuth 账号 -->
|
||||
<div v-if="filteredOAuthAccounts.length > 0">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">
|
||||
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOAuthAccounts"
|
||||
:key="account.id"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
|
||||
:class="{ 'bg-blue-50': modelValue === account.id }"
|
||||
@click="selectAccount(account.id)"
|
||||
>
|
||||
@@ -96,8 +99,12 @@
|
||||
<div>
|
||||
<span class="text-gray-700">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-xs px-2 py-0.5 rounded-full"
|
||||
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
"
|
||||
>
|
||||
{{ account.status === 'active' ? '正常' : '异常' }}
|
||||
</span>
|
||||
@@ -111,13 +118,13 @@
|
||||
|
||||
<!-- Console 账号(仅 Claude) -->
|
||||
<div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0">
|
||||
<div class="px-4 py-2 text-xs font-semibold text-gray-500 bg-gray-50">
|
||||
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">
|
||||
Claude Console 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredConsoleAccounts"
|
||||
:key="account.id"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
|
||||
:class="{ 'bg-blue-50': modelValue === `console:${account.id}` }"
|
||||
@click="selectAccount(`console:${account.id}`)"
|
||||
>
|
||||
@@ -125,8 +132,12 @@
|
||||
<div>
|
||||
<span class="text-gray-700">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-xs px-2 py-0.5 rounded-full"
|
||||
:class="account.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
"
|
||||
>
|
||||
{{ account.status === 'active' ? '正常' : '异常' }}
|
||||
</span>
|
||||
@@ -140,7 +151,7 @@
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div v-if="searchQuery && !hasResults" class="px-4 py-8 text-center text-gray-500">
|
||||
<i class="fas fa-search text-2xl mb-2" />
|
||||
<i class="fas fa-search mb-2 text-2xl" />
|
||||
<p class="text-sm">没有找到匹配的账号</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,23 +210,25 @@ const lastDirection = ref('') // 记住上次的显示方向
|
||||
const selectedLabel = computed(() => {
|
||||
// 如果没有选中值,显示默认选项文本
|
||||
if (!props.modelValue) return props.defaultOptionText
|
||||
|
||||
|
||||
// 分组
|
||||
if (props.modelValue.startsWith('group:')) {
|
||||
const groupId = props.modelValue.substring(6)
|
||||
const group = props.groups.find(g => g.id === groupId)
|
||||
const group = props.groups.find((g) => g.id === groupId)
|
||||
return group ? `${group.name} (${group.memberCount || 0} 个成员)` : ''
|
||||
}
|
||||
|
||||
|
||||
// Console 账号
|
||||
if (props.modelValue.startsWith('console:')) {
|
||||
const accountId = props.modelValue.substring(8)
|
||||
const account = props.accounts.find(a => a.id === accountId && a.platform === 'claude-console')
|
||||
const account = props.accounts.find(
|
||||
(a) => a.id === accountId && a.platform === 'claude-console'
|
||||
)
|
||||
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
|
||||
}
|
||||
|
||||
|
||||
// OAuth 账号
|
||||
const account = props.accounts.find(a => a.id === props.modelValue)
|
||||
const account = props.accounts.find((a) => a.id === props.modelValue)
|
||||
return account ? `${account.name} (${account.status === 'active' ? '正常' : '异常'})` : ''
|
||||
})
|
||||
|
||||
@@ -232,51 +245,50 @@ const sortedAccounts = computed(() => {
|
||||
const filteredGroups = computed(() => {
|
||||
if (!searchQuery.value) return props.groups
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.groups.filter(group =>
|
||||
group.name.toLowerCase().includes(query)
|
||||
)
|
||||
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
// 过滤的 OAuth 账号
|
||||
const filteredOAuthAccounts = computed(() => {
|
||||
let accounts = sortedAccounts.value.filter(a =>
|
||||
a.accountType === 'dedicated' &&
|
||||
(props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console')
|
||||
let accounts = sortedAccounts.value.filter(
|
||||
(a) =>
|
||||
a.accountType === 'dedicated' &&
|
||||
(props.platform === 'claude'
|
||||
? a.platform === 'claude-oauth'
|
||||
: a.platform !== 'claude-console')
|
||||
)
|
||||
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
accounts = accounts.filter(account =>
|
||||
account.name.toLowerCase().includes(query)
|
||||
)
|
||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 过滤的 Console 账号
|
||||
const filteredConsoleAccounts = computed(() => {
|
||||
if (props.platform !== 'claude') return []
|
||||
|
||||
let accounts = sortedAccounts.value.filter(a =>
|
||||
a.accountType === 'dedicated' && a.platform === 'claude-console'
|
||||
|
||||
let accounts = sortedAccounts.value.filter(
|
||||
(a) => a.accountType === 'dedicated' && a.platform === 'claude-console'
|
||||
)
|
||||
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
accounts = accounts.filter(account =>
|
||||
account.name.toLowerCase().includes(query)
|
||||
)
|
||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 是否有搜索结果
|
||||
const hasResults = computed(() => {
|
||||
return filteredGroups.value.length > 0 ||
|
||||
filteredOAuthAccounts.value.length > 0 ||
|
||||
filteredConsoleAccounts.value.length > 0
|
||||
return (
|
||||
filteredGroups.value.length > 0 ||
|
||||
filteredOAuthAccounts.value.length > 0 ||
|
||||
filteredConsoleAccounts.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
@@ -285,12 +297,13 @@ const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffInHours = (now - date) / (1000 * 60 * 60)
|
||||
|
||||
|
||||
if (diffInHours < 24) {
|
||||
return '今天创建'
|
||||
} else if (diffInHours < 48) {
|
||||
return '昨天创建'
|
||||
} else if (diffInHours < 168) { // 7天内
|
||||
} else if (diffInHours < 168) {
|
||||
// 7天内
|
||||
return `${Math.floor(diffInHours / 24)} 天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
@@ -300,28 +313,28 @@ const formatDate = (dateString) => {
|
||||
// 更新下拉菜单位置
|
||||
const updateDropdownPosition = () => {
|
||||
if (!showDropdown.value || !dropdownRef.value || !triggerRef.value) return
|
||||
|
||||
|
||||
const trigger = triggerRef.value
|
||||
if (!trigger) return
|
||||
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const windowHeight = window.innerHeight
|
||||
const windowWidth = window.innerWidth
|
||||
const spaceBelow = windowHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
const margin = 8 // 边距
|
||||
|
||||
|
||||
// 获取下拉框的高度
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight
|
||||
|
||||
// const dropdownHeight = dropdownRef.value.offsetHeight
|
||||
|
||||
// 计算最大可用高度
|
||||
const maxHeightBelow = spaceBelow - margin
|
||||
const maxHeightAbove = spaceAbove - margin
|
||||
|
||||
|
||||
// 决定显示方向和最大高度
|
||||
let showAbove = false
|
||||
let maxHeight = maxHeightBelow
|
||||
|
||||
|
||||
// 优先使用上次的方向,除非空间不足
|
||||
if (lastDirection.value === 'above' && maxHeightAbove >= 150) {
|
||||
showAbove = true
|
||||
@@ -336,10 +349,10 @@ const updateDropdownPosition = () => {
|
||||
maxHeight = maxHeightAbove
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 记住这次的方向
|
||||
lastDirection.value = showAbove ? 'above' : 'below'
|
||||
|
||||
|
||||
// 确保下拉框不超出视窗左右边界
|
||||
let left = rect.left
|
||||
const dropdownWidth = rect.width
|
||||
@@ -349,16 +362,13 @@ const updateDropdownPosition = () => {
|
||||
if (left < margin) {
|
||||
left = margin
|
||||
}
|
||||
|
||||
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
width: `${rect.width}px`,
|
||||
maxHeight: `${Math.min(maxHeight, 400)}px`, // 限制最大高度为400px
|
||||
...(showAbove
|
||||
? { bottom: `${windowHeight - rect.top}px` }
|
||||
: { top: `${rect.bottom}px` }
|
||||
)
|
||||
...(showAbove ? { bottom: `${windowHeight - rect.top}px` } : { top: `${rect.bottom}px` })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +380,7 @@ const toggleDropdown = () => {
|
||||
const windowHeight = window.innerHeight
|
||||
const spaceBelow = windowHeight - rect.bottom
|
||||
const margin = 8
|
||||
|
||||
|
||||
// 预先设置一个合理的初始位置
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
@@ -465,4 +475,4 @@ watch(showDropdown, (newVal) => {
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,43 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
name="modal"
|
||||
appear
|
||||
>
|
||||
<div
|
||||
<Transition appear name="modal">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="fixed inset-0 modal z-[100] flex items-center justify-center p-4"
|
||||
class="modal fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="modal-content w-full max-w-md p-6 mx-auto">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-white text-lg" />
|
||||
<div class="modal-content mx-auto w-full max-w-md p-6">
|
||||
<div class="mb-6 flex items-start gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-amber-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle text-lg text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="text-gray-600 leading-relaxed whitespace-pre-line">
|
||||
<div class="whitespace-pre-line leading-relaxed text-gray-600">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||
<button
|
||||
class="btn bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200"
|
||||
:disabled="isProcessing"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="btn btn-warning px-6 py-3"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isProcessing }"
|
||||
:class="{ 'cursor-not-allowed opacity-50': isProcessing }"
|
||||
:disabled="isProcessing"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<div
|
||||
v-if="isProcessing"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<div v-if="isProcessing" class="loading-spinner mr-2" />
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -64,7 +60,12 @@ const cancelText = ref('取消')
|
||||
let resolvePromise = null
|
||||
|
||||
// 显示确认对话框
|
||||
const showConfirm = (titleText, messageText, confirmTextParam = '确认', cancelTextParam = '取消') => {
|
||||
const showConfirm = (
|
||||
titleText,
|
||||
messageText,
|
||||
confirmTextParam = '确认',
|
||||
cancelTextParam = '取消'
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
title.value = titleText
|
||||
message.value = messageText
|
||||
@@ -79,9 +80,9 @@ const showConfirm = (titleText, messageText, confirmTextParam = '确认', cancel
|
||||
// 处理确认
|
||||
const handleConfirm = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
|
||||
isProcessing.value = true
|
||||
|
||||
|
||||
// 延迟一点时间以显示loading状态
|
||||
setTimeout(() => {
|
||||
isVisible.value = false
|
||||
@@ -96,7 +97,7 @@ const handleConfirm = () => {
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
|
||||
isVisible.value = false
|
||||
if (resolvePromise) {
|
||||
resolvePromise(false)
|
||||
@@ -107,7 +108,7 @@ const handleCancel = () => {
|
||||
// 键盘事件处理
|
||||
const handleKeydown = (event) => {
|
||||
if (!isVisible.value) return
|
||||
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handleCancel()
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
|
||||
@@ -150,7 +151,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@@ -162,7 +163,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply w-4 h-4 border-2 border-gray-300 border-t-white rounded-full animate-spin;
|
||||
@apply h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-white;
|
||||
}
|
||||
|
||||
/* Modal transitions */
|
||||
@@ -204,4 +205,4 @@ defineExpose({
|
||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-md p-6 mx-auto">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-exclamation text-white text-xl" />
|
||||
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-content mx-auto w-full max-w-md p-6">
|
||||
<div class="mb-6 flex items-start gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-yellow-400 to-yellow-500"
|
||||
>
|
||||
<i class="fas fa-exclamation text-xl text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">
|
||||
<h3 class="mb-2 text-lg font-bold text-gray-900">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">
|
||||
<p class="whitespace-pre-line text-sm leading-relaxed text-gray-600">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-yellow-600 hover:to-orange-600"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
@@ -63,4 +62,4 @@ defineProps({
|
||||
})
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,45 +1,35 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Logo区域 -->
|
||||
<div 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">
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-gray-300/30 bg-gradient-to-br from-blue-500/20 to-purple-500/20 backdrop-blur-sm"
|
||||
>
|
||||
<template v-if="!loading">
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
v-if="logoSrc"
|
||||
alt="Logo"
|
||||
class="w-8 h-8 object-contain"
|
||||
class="h-8 w-8 object-contain"
|
||||
:src="logoSrc"
|
||||
@error="handleLogoError"
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-cloud text-xl text-gray-700"
|
||||
/>
|
||||
<i v-else class="fas fa-cloud text-xl text-gray-700" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="w-8 h-8 bg-gray-300/50 rounded animate-pulse"
|
||||
/>
|
||||
<div v-else class="h-8 w-8 animate-pulse rounded bg-gray-300/50" />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex flex-col justify-center min-h-[48px]">
|
||||
<div class="flex min-h-[48px] flex-col justify-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="!loading && title">
|
||||
<h1 :class="['text-2xl font-bold header-title leading-tight', titleClass]">
|
||||
<h1 :class="['header-title text-2xl font-bold leading-tight', titleClass]">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="h-8 w-64 bg-gray-300/50 rounded animate-pulse"
|
||||
/>
|
||||
<div v-else-if="loading" class="h-8 w-64 animate-pulse rounded bg-gray-300/50" />
|
||||
<!-- 插槽用于版本信息等额外内容 -->
|
||||
<slot name="after-title" />
|
||||
</div>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="text-gray-600 text-sm leading-tight mt-0.5"
|
||||
>
|
||||
<p v-if="subtitle" class="mt-0.5 text-sm leading-tight text-gray-600">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,4 +88,4 @@ const handleLogoError = (e) => {
|
||||
.header-title {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
<div class="stat-card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-xs sm:text-sm font-medium text-gray-600 mb-1">
|
||||
<p class="mb-1 text-xs font-medium text-gray-600 sm:text-sm">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-2xl sm:text-3xl font-bold text-gray-800">
|
||||
<p class="text-2xl font-bold text-gray-800 sm:text-3xl">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="text-xs sm:text-sm text-gray-500 mt-1.5 sm:mt-2"
|
||||
>
|
||||
<p v-if="subtitle" class="mt-1.5 text-xs text-gray-500 sm:mt-2 sm:text-sm">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,4 +59,4 @@ const iconBgClass = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .stat-card 和 .stat-icon 类 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'toast',
|
||||
`toast-${toast.type}`,
|
||||
toast.isVisible ? 'toast-show' : 'toast-hide'
|
||||
]"
|
||||
:class="['toast', `toast-${toast.type}`, toast.isVisible ? 'toast-show' : 'toast-hide']"
|
||||
@click="removeToast(toast.id)"
|
||||
>
|
||||
<div class="toast-content">
|
||||
@@ -16,24 +12,18 @@
|
||||
<i :class="getIconClass(toast.type)" />
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div
|
||||
v-if="toast.title"
|
||||
class="toast-title"
|
||||
>
|
||||
<div v-if="toast.title" class="toast-title">
|
||||
{{ toast.title }}
|
||||
</div>
|
||||
<div class="toast-message">
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="toast-close"
|
||||
@click.stop="removeToast(toast.id)"
|
||||
>
|
||||
<button class="toast-close" @click.stop="removeToast(toast.id)">
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
v-if="toast.duration > 0"
|
||||
class="toast-progress"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
@@ -72,34 +62,34 @@ const addToast = (message, type = 'info', title = null, duration = 5000) => {
|
||||
duration,
|
||||
isVisible: false
|
||||
}
|
||||
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
|
||||
// 下一帧显示动画
|
||||
setTimeout(() => {
|
||||
toast.isVisible = true
|
||||
}, 10)
|
||||
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// 移除Toast
|
||||
const removeToast = (id) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
const index = toasts.value.findIndex((toast) => toast.id === id)
|
||||
if (index > -1) {
|
||||
const toast = toasts.value[index]
|
||||
toast.isVisible = false
|
||||
|
||||
|
||||
// 等待动画完成后移除
|
||||
setTimeout(() => {
|
||||
const currentIndex = toasts.value.findIndex(t => t.id === id)
|
||||
const currentIndex = toasts.value.findIndex((t) => t.id === id)
|
||||
if (currentIndex > -1) {
|
||||
toasts.value.splice(currentIndex, 1)
|
||||
}
|
||||
@@ -109,10 +99,10 @@ const removeToast = (id) => {
|
||||
|
||||
// 清除所有Toast
|
||||
const clearAllToasts = () => {
|
||||
toasts.value.forEach(toast => {
|
||||
toasts.value.forEach((toast) => {
|
||||
toast.isVisible = false
|
||||
})
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
toasts.value.length = 0
|
||||
}, 300)
|
||||
@@ -351,7 +341,7 @@ defineExpose({
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
@@ -377,4 +367,4 @@ defineExpose({
|
||||
.toast-list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,45 @@
|
||||
<template>
|
||||
<div class="glass-strong rounded-3xl p-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-robot mr-2 text-purple-500" />
|
||||
模型使用分布
|
||||
</h2>
|
||||
|
||||
<el-radio-group
|
||||
v-model="modelPeriod"
|
||||
size="small"
|
||||
@change="handlePeriodChange"
|
||||
>
|
||||
<el-radio-button label="daily">
|
||||
今日
|
||||
</el-radio-button>
|
||||
<el-radio-button label="total">
|
||||
累计
|
||||
</el-radio-button>
|
||||
|
||||
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
|
||||
<el-radio-button label="daily"> 今日 </el-radio-button>
|
||||
<el-radio-button label="total"> 累计 </el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-if="dashboardStore.dashboardModelStats.length === 0"
|
||||
class="text-center py-12 text-gray-500"
|
||||
class="py-12 text-center text-gray-500"
|
||||
>
|
||||
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30" />
|
||||
<i class="fas fa-chart-pie mb-3 text-4xl opacity-30" />
|
||||
<p>暂无模型使用数据</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- 饼图 -->
|
||||
<div
|
||||
class="relative"
|
||||
style="height: 300px;"
|
||||
>
|
||||
<div class="relative" style="height: 300px">
|
||||
<canvas ref="chartCanvas" />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(stat, index) in sortedStats"
|
||||
:key="stat.model"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
:key="stat.model"
|
||||
class="flex items-center justify-between rounded-lg bg-gray-50 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-4 h-4 rounded"
|
||||
:style="`background-color: ${getColor(index)}`"
|
||||
/>
|
||||
<div class="h-4 w-4 rounded" :style="`background-color: ${getColor(index)}`" />
|
||||
<span class="font-medium text-gray-700">{{ stat.model }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-800">
|
||||
{{ formatNumber(stat.requests) }} 请求
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ formatNumber(stat.totalTokens) }} tokens
|
||||
</p>
|
||||
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
|
||||
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,23 +72,25 @@ const getColor = (index) => {
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.dashboardModelStats.length) return
|
||||
|
||||
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
|
||||
const { colorSchemes } = useChartConfig()
|
||||
const colors = colorSchemes.primary
|
||||
|
||||
|
||||
chart = new Chart(chartCanvas.value, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sortedStats.value.map(stat => stat.model),
|
||||
datasets: [{
|
||||
data: sortedStats.value.map(stat => stat.requests),
|
||||
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
|
||||
borderWidth: 0
|
||||
}]
|
||||
labels: sortedStats.value.map((stat) => stat.model),
|
||||
datasets: [
|
||||
{
|
||||
data: sortedStats.value.map((stat) => stat.requests),
|
||||
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
@@ -120,9 +101,13 @@ const createChart = () => {
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
label: function (context) {
|
||||
const stat = sortedStats.value[context.dataIndex]
|
||||
const percentage = ((stat.requests / dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) * 100).toFixed(1)
|
||||
const percentage = (
|
||||
(stat.requests /
|
||||
dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) *
|
||||
100
|
||||
).toFixed(1)
|
||||
return [
|
||||
`${stat.model}: ${percentage}%`,
|
||||
`请求: ${formatNumber(stat.requests)}`,
|
||||
@@ -141,9 +126,13 @@ const handlePeriodChange = async () => {
|
||||
createChart()
|
||||
}
|
||||
|
||||
watch(() => dashboardStore.dashboardModelStats, () => {
|
||||
createChart()
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => dashboardStore.dashboardModelStats,
|
||||
() => {
|
||||
createChart()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
@@ -158,4 +147,4 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
<template>
|
||||
<div class="glass-strong rounded-3xl p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<div class="glass-strong mb-8 rounded-3xl p-6">
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="flex items-center text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-chart-area mr-2 text-blue-500" />
|
||||
使用趋势
|
||||
</h2>
|
||||
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<el-radio-group
|
||||
v-model="granularity"
|
||||
size="small"
|
||||
@change="handleGranularityChange"
|
||||
>
|
||||
<el-radio-button label="day">
|
||||
按天
|
||||
</el-radio-button>
|
||||
<el-radio-button label="hour">
|
||||
按小时
|
||||
</el-radio-button>
|
||||
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
|
||||
<el-radio-button label="day"> 按天 </el-radio-button>
|
||||
<el-radio-button label="hour"> 按小时 </el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
|
||||
<el-select
|
||||
v-model="trendPeriod"
|
||||
size="small"
|
||||
@@ -35,11 +27,8 @@
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
style="height: 300px;"
|
||||
>
|
||||
|
||||
<div class="relative" style="height: 300px">
|
||||
<canvas ref="chartCanvas" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,15 +55,15 @@ const periodOptions = [
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value || !dashboardStore.trendData.length) return
|
||||
|
||||
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
}
|
||||
|
||||
|
||||
const { getGradient } = useChartConfig()
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
|
||||
const labels = dashboardStore.trendData.map(item => {
|
||||
|
||||
const labels = dashboardStore.trendData.map((item) => {
|
||||
if (granularity.value === 'hour') {
|
||||
// 小时粒度使用hour字段
|
||||
const date = new Date(item.hour)
|
||||
@@ -85,7 +74,7 @@ const createChart = () => {
|
||||
}
|
||||
return item.date
|
||||
})
|
||||
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -93,7 +82,7 @@ const createChart = () => {
|
||||
datasets: [
|
||||
{
|
||||
label: '请求次数',
|
||||
data: dashboardStore.trendData.map(item => item.requests),
|
||||
data: dashboardStore.trendData.map((item) => item.requests),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
||||
yAxisID: 'y',
|
||||
@@ -101,7 +90,7 @@ const createChart = () => {
|
||||
},
|
||||
{
|
||||
label: 'Token使用量',
|
||||
data: dashboardStore.trendData.map(item => item.tokens),
|
||||
data: dashboardStore.trendData.map((item) => item.tokens),
|
||||
borderColor: '#f093fb',
|
||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
||||
yAxisID: 'y1',
|
||||
@@ -172,9 +161,13 @@ const handleGranularityChange = async () => {
|
||||
createChart()
|
||||
}
|
||||
|
||||
watch(() => dashboardStore.trendData, () => {
|
||||
createChart()
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => dashboardStore.trendData,
|
||||
() => {
|
||||
createChart()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
@@ -189,4 +182,4 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<template>
|
||||
<!-- 顶部导航 -->
|
||||
<div
|
||||
class="glass-strong rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-6 mb-4 sm:mb-6 md:mb-8 shadow-xl"
|
||||
style="z-index: 10; position: relative;"
|
||||
class="glass-strong mb-4 rounded-xl p-3 shadow-xl sm:mb-6 sm:rounded-2xl sm:p-4 md:mb-8 md:rounded-3xl md:p-6"
|
||||
style="z-index: 10; position: relative"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-3 sm:gap-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3 md:gap-4 w-full sm:w-auto justify-center sm:justify-start">
|
||||
<LogoTitle
|
||||
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row sm:gap-4">
|
||||
<div
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto sm:justify-start sm:gap-3 md:gap-4"
|
||||
>
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:title="oemSettings.siteName"
|
||||
subtitle="管理后台"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
subtitle="管理后台"
|
||||
:title="oemSettings.siteName"
|
||||
title-class="text-white"
|
||||
>
|
||||
<template #after-title>
|
||||
<!-- 版本信息 -->
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<span class="text-xs sm:text-sm text-gray-400 font-mono">v{{ versionInfo.current || '...' }}</span>
|
||||
<span class="font-mono text-xs text-gray-400 sm:text-sm"
|
||||
>v{{ versionInfo.current || '...' }}</span
|
||||
>
|
||||
<!-- 更新提示 -->
|
||||
<a
|
||||
v-if="versionInfo.hasUpdate"
|
||||
<a
|
||||
v-if="versionInfo.hasUpdate"
|
||||
class="inline-flex animate-pulse items-center gap-1 rounded-full border border-green-600 bg-green-500 px-2 py-0.5 text-xs text-white transition-colors hover:bg-green-600"
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500 border border-green-600 rounded-full text-xs text-white hover:bg-green-600 transition-colors animate-pulse"
|
||||
title="有新版本可用"
|
||||
>
|
||||
<i class="fas fa-arrow-up text-[10px]" />
|
||||
@@ -33,9 +37,9 @@
|
||||
</LogoTitle>
|
||||
</div>
|
||||
<!-- 用户菜单 -->
|
||||
<div class="relative user-menu-container">
|
||||
<button
|
||||
class="btn btn-primary px-3 sm:px-4 py-2 sm:py-3 flex items-center gap-1 sm:gap-2 relative text-sm sm:text-base"
|
||||
<div class="user-menu-container relative">
|
||||
<button
|
||||
class="btn btn-primary relative flex items-center gap-1 px-3 py-2 text-sm sm:gap-2 sm:px-4 sm:py-3 sm:text-base"
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
>
|
||||
<i class="fas fa-user-circle" />
|
||||
@@ -45,34 +49,31 @@
|
||||
:class="{ 'rotate-180': userMenuOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- 悬浮菜单 -->
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-48 sm:w-56 bg-white rounded-xl shadow-xl border border-gray-200 py-2 user-menu-dropdown"
|
||||
style="z-index: 999999;"
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="user-menu-dropdown absolute right-0 top-full mt-2 w-48 rounded-xl border border-gray-200 bg-white py-2 shadow-xl sm:w-56"
|
||||
style="z-index: 999999"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 版本信息 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100">
|
||||
<div class="border-b border-gray-100 px-4 py-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">当前版本</span>
|
||||
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="versionInfo.hasUpdate"
|
||||
class="mt-2"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-green-600 font-medium">
|
||||
<div v-if="versionInfo.hasUpdate" class="mt-2">
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-green-600">
|
||||
<i class="fas fa-arrow-up mr-1" />有新版本
|
||||
</span>
|
||||
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
|
||||
</div>
|
||||
<a
|
||||
<a
|
||||
class="block w-full rounded-lg bg-green-500 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-green-600"
|
||||
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
|
||||
target="_blank"
|
||||
class="block w-full text-center px-3 py-1.5 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-external-link-alt mr-1" />查看更新
|
||||
</a>
|
||||
@@ -83,28 +84,22 @@
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mt-2 text-center"
|
||||
>
|
||||
<div v-else class="mt-2 text-center">
|
||||
<!-- 已是最新版提醒 -->
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<transition mode="out-in" name="fade">
|
||||
<div
|
||||
v-if="versionInfo.noUpdateMessage"
|
||||
key="message"
|
||||
class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block"
|
||||
class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5"
|
||||
>
|
||||
<p class="text-xs text-green-700 font-medium">
|
||||
<p class="text-xs font-medium text-green-700">
|
||||
<i class="fas fa-check-circle mr-1" />当前已是最新版本
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
v-else
|
||||
key="button"
|
||||
class="text-xs text-blue-500 hover:text-blue-700 transition-colors"
|
||||
class="text-xs text-blue-500 transition-colors hover:text-blue-700"
|
||||
@click="checkForUpdates()"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />检查更新
|
||||
@@ -112,19 +107,19 @@
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
|
||||
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50"
|
||||
@click="openChangePasswordModal"
|
||||
>
|
||||
<i class="fas fa-key text-blue-500" />
|
||||
<span>修改账户信息</span>
|
||||
</button>
|
||||
|
||||
<hr class="my-2 border-gray-200">
|
||||
|
||||
<button
|
||||
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
|
||||
|
||||
<hr class="my-2 border-gray-200" />
|
||||
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50"
|
||||
@click="logout"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt text-red-500" />
|
||||
@@ -134,117 +129,105 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 修改账户信息模态框 -->
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
|
||||
class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-md p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="modal-content mx-auto flex max-h-[90vh] w-full max-w-md flex-col p-4 sm:p-6 md:p-8">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
>
|
||||
<i class="fas fa-key text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
修改账户信息
|
||||
</h3>
|
||||
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
<button
|
||||
class="text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="closeChangePasswordModal"
|
||||
>
|
||||
<i class="fas fa-times text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<form
|
||||
class="space-y-6 modal-scroll-content custom-scrollbar flex-1"
|
||||
class="modal-scroll-content custom-scrollbar flex-1 space-y-6"
|
||||
@submit.prevent="changePassword"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
|
||||
<input
|
||||
:value="currentUser.username || 'Admin'"
|
||||
type="text"
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">当前用户名</label>
|
||||
<input
|
||||
class="form-input w-full cursor-not-allowed bg-gray-100"
|
||||
disabled
|
||||
class="form-input w-full bg-gray-100 cursor-not-allowed"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
当前用户名,输入新用户名以修改
|
||||
</p>
|
||||
type="text"
|
||||
:value="currentUser.username || 'Admin'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">当前用户名,输入新用户名以修改</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新用户名</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newUsername"
|
||||
type="text"
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">新用户名</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newUsername"
|
||||
class="form-input w-full"
|
||||
placeholder="输入新用户名(留空保持不变)"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
留空表示不修改用户名
|
||||
</p>
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">留空表示不修改用户名</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">当前密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">当前密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
class="form-input w-full"
|
||||
placeholder="请输入当前密码"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
class="form-input w-full"
|
||||
placeholder="请输入新密码"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
密码长度至少8位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">确认新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">密码长度至少8位</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">确认新密码</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
class="form-input w-full"
|
||||
placeholder="请再次输入新密码"
|
||||
>
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="changePasswordLoading"
|
||||
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
|
||||
type="submit"
|
||||
>
|
||||
<div
|
||||
v-if="changePasswordLoading"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-save mr-2"
|
||||
/>
|
||||
<div v-if="changePasswordLoading" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -300,30 +283,33 @@ const checkForUpdates = async () => {
|
||||
if (versionInfo.value.checkingUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
versionInfo.value.checkingUpdate = true
|
||||
|
||||
|
||||
try {
|
||||
const result = await apiClient.get('/admin/check-updates')
|
||||
|
||||
|
||||
if (result.success) {
|
||||
const data = result.data
|
||||
|
||||
|
||||
versionInfo.value.current = data.current
|
||||
versionInfo.value.latest = data.latest
|
||||
versionInfo.value.hasUpdate = data.hasUpdate
|
||||
versionInfo.value.releaseInfo = data.releaseInfo
|
||||
versionInfo.value.lastChecked = new Date()
|
||||
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('versionInfo', JSON.stringify({
|
||||
current: data.current,
|
||||
latest: data.latest,
|
||||
lastChecked: versionInfo.value.lastChecked,
|
||||
hasUpdate: data.hasUpdate,
|
||||
releaseInfo: data.releaseInfo
|
||||
}))
|
||||
|
||||
localStorage.setItem(
|
||||
'versionInfo',
|
||||
JSON.stringify({
|
||||
current: data.current,
|
||||
latest: data.latest,
|
||||
lastChecked: versionInfo.value.lastChecked,
|
||||
hasUpdate: data.hasUpdate,
|
||||
releaseInfo: data.releaseInfo
|
||||
})
|
||||
)
|
||||
|
||||
// 如果没有更新,显示提醒
|
||||
if (!data.hasUpdate) {
|
||||
versionInfo.value.noUpdateMessage = true
|
||||
@@ -335,7 +321,7 @@ const checkForUpdates = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
|
||||
|
||||
// 尝试从localStorage读取缓存的版本信息
|
||||
const cached = localStorage.getItem('versionInfo')
|
||||
if (cached) {
|
||||
@@ -372,26 +358,28 @@ const changePassword = async () => {
|
||||
showToast('两次输入的密码不一致', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (changePasswordForm.newPassword.length < 8) {
|
||||
showToast('新密码长度至少8位', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
changePasswordLoading.value = true
|
||||
|
||||
|
||||
try {
|
||||
const data = await apiClient.post('/web/auth/change-password', {
|
||||
currentPassword: changePasswordForm.currentPassword,
|
||||
newPassword: changePasswordForm.newPassword,
|
||||
newUsername: changePasswordForm.newUsername || undefined
|
||||
})
|
||||
|
||||
|
||||
if (data.success) {
|
||||
const message = changePasswordForm.newUsername ? '账户信息修改成功,请重新登录' : '密码修改成功,请重新登录'
|
||||
const message = changePasswordForm.newUsername
|
||||
? '账户信息修改成功,请重新登录'
|
||||
: '密码修改成功,请重新登录'
|
||||
showToast(message, 'success')
|
||||
closeChangePasswordModal()
|
||||
|
||||
|
||||
// 延迟后退出登录
|
||||
setTimeout(() => {
|
||||
authStore.logout()
|
||||
@@ -427,12 +415,12 @@ const handleClickOutside = (event) => {
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates()
|
||||
|
||||
|
||||
// 设置自动检查更新(每小时检查一次)
|
||||
setInterval(() => {
|
||||
checkForUpdates()
|
||||
}, 3600000) // 1小时
|
||||
|
||||
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
@@ -448,10 +436,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* fade过渡动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,25 +2,19 @@
|
||||
<div class="min-h-screen p-3 sm:p-4 md:p-6">
|
||||
<!-- 顶部导航 -->
|
||||
<AppHeader />
|
||||
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div
|
||||
class="glass-strong rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-6 shadow-xl"
|
||||
style="z-index: 1; min-height: calc(100vh - 120px);"
|
||||
class="glass-strong rounded-xl p-3 shadow-xl sm:rounded-2xl sm:p-4 md:rounded-3xl md:p-6"
|
||||
style="z-index: 1; min-height: calc(100vh - 120px)"
|
||||
>
|
||||
<!-- 标签栏 -->
|
||||
<TabBar
|
||||
:active-tab="activeTab"
|
||||
@tab-change="handleTabChange"
|
||||
/>
|
||||
|
||||
<TabBar :active-tab="activeTab" @tab-change="handleTabChange" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="tab-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition
|
||||
name="slide-up"
|
||||
mode="out-in"
|
||||
>
|
||||
<transition mode="out-in" name="slide-up">
|
||||
<keep-alive :include="['DashboardView', 'ApiKeysView']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
@@ -52,14 +46,16 @@ const tabRouteMap = {
|
||||
}
|
||||
|
||||
// 监听路由变化,更新激活的标签
|
||||
watch(() => route.path, (newPath) => {
|
||||
const tabKey = Object.keys(tabRouteMap).find(
|
||||
key => tabRouteMap[key] === newPath
|
||||
)
|
||||
if (tabKey) {
|
||||
activeTab.value = tabKey
|
||||
}
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
|
||||
if (tabKey) {
|
||||
activeTab.value = tabKey
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabChange = (tabKey) => {
|
||||
@@ -72,4 +68,4 @@ const handleTabChange = (tabKey) => {
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局定义的过渡样式 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
<template>
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<!-- 移动端下拉选择器 -->
|
||||
<div class="block sm:hidden bg-white/10 rounded-xl p-2 backdrop-blur-sm">
|
||||
<select
|
||||
<div class="block rounded-xl bg-white/10 p-2 backdrop-blur-sm sm:hidden">
|
||||
<select
|
||||
class="focus:ring-primary-color w-full rounded-lg bg-white/90 px-4 py-3 font-semibold text-gray-700 focus:outline-none focus:ring-2"
|
||||
:value="activeTab"
|
||||
class="w-full px-4 py-3 bg-white/90 rounded-lg text-gray-700 font-semibold focus:outline-none focus:ring-2 focus:ring-primary-color"
|
||||
@change="$emit('tab-change', $event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:value="tab.key"
|
||||
>
|
||||
<option v-for="tab in tabs" :key="tab.key" :value="tab.key">
|
||||
{{ tab.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 桌面端标签栏 -->
|
||||
<div class="hidden sm:flex flex-wrap gap-2 bg-white/10 rounded-2xl p-2 backdrop-blur-sm">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
<div class="hidden flex-wrap gap-2 rounded-2xl bg-white/10 p-2 backdrop-blur-sm sm:flex">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="[
|
||||
'tab-btn flex-1 py-2 sm:py-3 px-3 sm:px-4 md:px-6 text-xs sm:text-sm font-semibold transition-all duration-300',
|
||||
'tab-btn flex-1 px-3 py-2 text-xs font-semibold transition-all duration-300 sm:px-4 sm:py-3 sm:text-sm md:px-6',
|
||||
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900'
|
||||
]"
|
||||
@click="$emit('tab-change', tab.key)"
|
||||
@@ -57,4 +53,4 @@ const tabs = [
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .tab-btn 类 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,22 +2,28 @@ import { Chart } from 'chart.js/auto'
|
||||
|
||||
export function useChartConfig() {
|
||||
// 设置Chart.js默认配置
|
||||
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
||||
Chart.defaults.font.family =
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
||||
Chart.defaults.color = '#6b7280'
|
||||
Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(0, 0, 0, 0.8)'
|
||||
Chart.defaults.plugins.tooltip.padding = 12
|
||||
Chart.defaults.plugins.tooltip.cornerRadius = 8
|
||||
Chart.defaults.plugins.tooltip.titleFont.size = 14
|
||||
Chart.defaults.plugins.tooltip.bodyFont.size = 12
|
||||
|
||||
|
||||
// 创建渐变色
|
||||
const getGradient = (ctx, color, opacity = 0.2) => {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||
gradient.addColorStop(0, `${color}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`)
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`${color}${Math.round(opacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}`
|
||||
)
|
||||
gradient.addColorStop(1, `${color}00`)
|
||||
return gradient
|
||||
}
|
||||
|
||||
|
||||
// 通用图表选项
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
@@ -39,7 +45,7 @@ export function useChartConfig() {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) {
|
||||
label += ': '
|
||||
@@ -72,7 +78,7 @@ export function useChartConfig() {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
callback: function(value) {
|
||||
callback: function (value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
@@ -84,7 +90,7 @@ export function useChartConfig() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 颜色方案
|
||||
const colorSchemes = {
|
||||
primary: ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'],
|
||||
@@ -92,10 +98,10 @@ export function useChartConfig() {
|
||||
warning: ['#f59e0b', '#d97706', '#fbbf24', '#fcd34d', '#fde68a'],
|
||||
danger: ['#ef4444', '#dc2626', '#f87171', '#fca5a5', '#fecaca']
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
getGradient,
|
||||
commonOptions,
|
||||
colorSchemes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,4 @@ export function useConfirm() {
|
||||
handleConfirm,
|
||||
handleCancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// API 配置
|
||||
import { APP_CONFIG, getLoginUrl } from './app'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
// 开发环境使用 /webapi 前缀,生产环境不使用前缀
|
||||
export const API_PREFIX = APP_CONFIG.apiPrefix
|
||||
|
||||
@@ -22,11 +20,11 @@ export function getRequestConfig(token) {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -68,7 +66,7 @@ class ApiClient {
|
||||
// 如果当前已经在登录页面,不要再次跳转
|
||||
const currentPath = window.location.pathname + window.location.hash
|
||||
const isLoginPage = currentPath.includes('/login') || currentPath.endsWith('/')
|
||||
|
||||
|
||||
if (!isLoginPage) {
|
||||
localStorage.removeItem('authToken')
|
||||
// 使用统一的登录URL
|
||||
@@ -81,12 +79,12 @@ class ApiClient {
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
|
||||
|
||||
// 如果响应不成功,抛出错误
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -170,4 +168,4 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const apiClient = new ApiClient()
|
||||
export const apiClient = new ApiClient()
|
||||
|
||||
@@ -14,7 +14,7 @@ class ApiStatsClient {
|
||||
if (this.isDev && url.startsWith('/admin')) {
|
||||
url = '/webapi' + url
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(`${this.baseURL}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -78,4 +78,4 @@ class ApiStatsClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const apiStatsClient = new ApiStatsClient()
|
||||
export const apiStatsClient = new ApiStatsClient()
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
export const APP_CONFIG = {
|
||||
// 应用基础路径
|
||||
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
|
||||
|
||||
|
||||
// 应用标题
|
||||
title: import.meta.env.VITE_APP_TITLE || 'Claude Relay Service - 管理后台',
|
||||
|
||||
|
||||
// 是否为开发环境
|
||||
isDev: import.meta.env.DEV,
|
||||
|
||||
|
||||
// API 前缀
|
||||
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
|
||||
}
|
||||
@@ -25,4 +25,4 @@ export function getAppUrl(path = '') {
|
||||
// 获取登录页面URL
|
||||
export function getLoginUrl() {
|
||||
return getAppUrl('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ app.use(router)
|
||||
|
||||
// 使用Element Plus
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
locale: zhCn
|
||||
})
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
|
||||
@@ -19,12 +19,12 @@ const routes = [
|
||||
// 智能重定向:避免循环
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = APP_CONFIG.basePath.replace(/\/$/, '') // 移除末尾斜杠
|
||||
|
||||
|
||||
// 如果当前路径已经是 basePath 或 basePath/,重定向到 api-stats
|
||||
if (currentPath === basePath || currentPath === basePath + '/') {
|
||||
return '/api-stats'
|
||||
}
|
||||
|
||||
|
||||
// 否则保持默认重定向
|
||||
return '/api-stats'
|
||||
}
|
||||
@@ -116,7 +116,7 @@ const router = createRouter({
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
|
||||
console.log('路由导航:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
@@ -124,12 +124,12 @@ router.beforeEach((to, from, next) => {
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: authStore.isAuthenticated
|
||||
})
|
||||
|
||||
|
||||
// 防止重定向循环:如果已经在目标路径,直接放行
|
||||
if (to.path === from.path && to.fullPath === from.fullPath) {
|
||||
return next()
|
||||
}
|
||||
|
||||
|
||||
// API Stats 页面不需要认证,直接放行
|
||||
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
|
||||
next()
|
||||
@@ -142,4 +142,4 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
@@ -12,9 +12,9 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
const error = ref(null)
|
||||
const sortBy = ref('')
|
||||
const sortOrder = ref('asc')
|
||||
|
||||
|
||||
// Actions
|
||||
|
||||
|
||||
// 获取Claude账户列表
|
||||
const fetchClaudeAccounts = async () => {
|
||||
loading.value = true
|
||||
@@ -33,7 +33,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取Claude Console账户列表
|
||||
const fetchClaudeConsoleAccounts = async () => {
|
||||
loading.value = true
|
||||
@@ -52,7 +52,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取Bedrock账户列表
|
||||
const fetchBedrockAccounts = async () => {
|
||||
loading.value = true
|
||||
@@ -71,7 +71,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取Gemini账户列表
|
||||
const fetchGeminiAccounts = async () => {
|
||||
loading.value = true
|
||||
@@ -90,7 +90,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取所有账户
|
||||
const fetchAllAccounts = async () => {
|
||||
loading.value = true
|
||||
@@ -109,7 +109,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建Claude账户
|
||||
const createClaudeAccount = async (data) => {
|
||||
loading.value = true
|
||||
@@ -129,7 +129,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建Claude Console账户
|
||||
const createClaudeConsoleAccount = async (data) => {
|
||||
loading.value = true
|
||||
@@ -149,7 +149,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建Bedrock账户
|
||||
const createBedrockAccount = async (data) => {
|
||||
loading.value = true
|
||||
@@ -169,7 +169,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建Gemini账户
|
||||
const createGeminiAccount = async (data) => {
|
||||
loading.value = true
|
||||
@@ -189,7 +189,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新Claude账户
|
||||
const updateClaudeAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -209,7 +209,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新Claude Console账户
|
||||
const updateClaudeConsoleAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -229,7 +229,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新Bedrock账户
|
||||
const updateBedrockAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -249,7 +249,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新Gemini账户
|
||||
const updateGeminiAccount = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -269,7 +269,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 切换账户状态
|
||||
const toggleAccount = async (platform, id) => {
|
||||
loading.value = true
|
||||
@@ -285,7 +285,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
} else {
|
||||
endpoint = `/admin/gemini-accounts/${id}/toggle`
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.put(endpoint)
|
||||
if (response.success) {
|
||||
if (platform === 'claude') {
|
||||
@@ -308,7 +308,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 删除账户
|
||||
const deleteAccount = async (platform, id) => {
|
||||
loading.value = true
|
||||
@@ -324,7 +324,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
} else {
|
||||
endpoint = `/admin/gemini-accounts/${id}`
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.delete(endpoint)
|
||||
if (response.success) {
|
||||
if (platform === 'claude') {
|
||||
@@ -347,7 +347,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 刷新Claude Token
|
||||
const refreshClaudeToken = async (id) => {
|
||||
loading.value = true
|
||||
@@ -367,7 +367,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 生成Claude OAuth URL
|
||||
const generateClaudeAuthUrl = async (proxyConfig) => {
|
||||
try {
|
||||
@@ -382,7 +382,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 交换Claude OAuth Code
|
||||
const exchangeClaudeCode = async (data) => {
|
||||
try {
|
||||
@@ -397,7 +397,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 生成Gemini OAuth URL
|
||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||
try {
|
||||
@@ -412,7 +412,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 交换Gemini OAuth Code
|
||||
const exchangeGeminiCode = async (data) => {
|
||||
try {
|
||||
@@ -427,8 +427,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 排序账户
|
||||
const sortAccounts = (field) => {
|
||||
if (sortBy.value === field) {
|
||||
@@ -438,7 +437,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 重置store
|
||||
const reset = () => {
|
||||
claudeAccounts.value = []
|
||||
@@ -450,7 +449,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
sortBy.value = ''
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
// State
|
||||
claudeAccounts,
|
||||
@@ -461,7 +460,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
error,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
|
||||
|
||||
// Actions
|
||||
fetchClaudeAccounts,
|
||||
fetchClaudeConsoleAccounts,
|
||||
@@ -486,4 +485,4 @@ export const useAccountsStore = defineStore('accounts', () => {
|
||||
sortAccounts,
|
||||
reset
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,9 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
const statsTimeRange = ref('all')
|
||||
const sortBy = ref('')
|
||||
const sortOrder = ref('asc')
|
||||
|
||||
|
||||
// Actions
|
||||
|
||||
|
||||
// 获取API Keys列表
|
||||
const fetchApiKeys = async () => {
|
||||
loading.value = true
|
||||
@@ -31,7 +31,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建API Key
|
||||
const createApiKey = async (data) => {
|
||||
loading.value = true
|
||||
@@ -51,7 +51,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新API Key
|
||||
const updateApiKey = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -71,7 +71,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 切换API Key状态
|
||||
const toggleApiKey = async (id) => {
|
||||
loading.value = true
|
||||
@@ -91,7 +91,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 续期API Key
|
||||
const renewApiKey = async (id, data) => {
|
||||
loading.value = true
|
||||
@@ -111,7 +111,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 删除API Key
|
||||
const deleteApiKey = async (id) => {
|
||||
loading.value = true
|
||||
@@ -131,7 +131,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取API Key统计
|
||||
const fetchApiKeyStats = async (id, timeRange = 'all') => {
|
||||
try {
|
||||
@@ -148,7 +148,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 排序API Keys
|
||||
const sortApiKeys = (field) => {
|
||||
if (sortBy.value === field) {
|
||||
@@ -158,7 +158,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取已存在的标签
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
@@ -173,7 +173,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 重置store
|
||||
const reset = () => {
|
||||
apiKeys.value = []
|
||||
@@ -183,7 +183,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
sortBy.value = ''
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
// State
|
||||
apiKeys,
|
||||
@@ -192,7 +192,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
statsTimeRange,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
|
||||
|
||||
// Actions
|
||||
fetchApiKeys,
|
||||
createApiKey,
|
||||
@@ -205,4 +205,4 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
sortApiKeys,
|
||||
reset
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,14 +54,19 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
const limits = statsData.value.limits
|
||||
|
||||
return {
|
||||
tokenUsage: limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0,
|
||||
costUsage: limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0,
|
||||
requestUsage: limits.rateLimitRequests > 0 ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) : 0
|
||||
tokenUsage:
|
||||
limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0,
|
||||
costUsage:
|
||||
limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0,
|
||||
requestUsage:
|
||||
limits.rateLimitRequests > 0
|
||||
? Math.min((current.requests / limits.rateLimitRequests) * 100, 100)
|
||||
: 0
|
||||
}
|
||||
})
|
||||
|
||||
// Actions
|
||||
|
||||
|
||||
// 查询统计数据
|
||||
async function queryStats() {
|
||||
if (!apiKey.value.trim()) {
|
||||
@@ -78,22 +83,22 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
try {
|
||||
// 获取 API Key ID
|
||||
const idResult = await apiStatsClient.getKeyId(apiKey.value)
|
||||
|
||||
|
||||
if (idResult.success) {
|
||||
apiId.value = idResult.data.id
|
||||
|
||||
|
||||
// 使用 apiId 查询统计数据
|
||||
const statsResult = await apiStatsClient.getUserStats(apiId.value)
|
||||
|
||||
|
||||
if (statsResult.success) {
|
||||
statsData.value = statsResult.data
|
||||
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await loadAllPeriodStats()
|
||||
|
||||
|
||||
// 清除错误信息
|
||||
error.value = ''
|
||||
|
||||
|
||||
// 更新 URL
|
||||
updateURL()
|
||||
} else {
|
||||
@@ -118,10 +123,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
if (!apiId.value) return
|
||||
|
||||
// 并行加载今日和本月的数据
|
||||
await Promise.all([
|
||||
loadPeriodStats('daily'),
|
||||
loadPeriodStats('monthly')
|
||||
])
|
||||
await Promise.all([loadPeriodStats('daily'), loadPeriodStats('monthly')])
|
||||
|
||||
// 加载当前选择时间段的模型统计
|
||||
await loadModelStats(statsPeriod.value)
|
||||
@@ -131,7 +133,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
async function loadPeriodStats(period) {
|
||||
try {
|
||||
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
// 计算汇总数据
|
||||
const modelData = result.data || []
|
||||
@@ -145,8 +147,8 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
cost: 0,
|
||||
formattedCost: '$0.000000'
|
||||
}
|
||||
|
||||
modelData.forEach(model => {
|
||||
|
||||
modelData.forEach((model) => {
|
||||
summary.requests += model.requests || 0
|
||||
summary.inputTokens += model.inputTokens || 0
|
||||
summary.outputTokens += model.outputTokens || 0
|
||||
@@ -155,9 +157,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
summary.allTokens += model.allTokens || 0
|
||||
summary.cost += model.costs?.total || 0
|
||||
})
|
||||
|
||||
|
||||
summary.formattedCost = formatCost(summary.cost)
|
||||
|
||||
|
||||
// 存储到对应的时间段数据
|
||||
if (period === 'daily') {
|
||||
dailyStats.value = summary
|
||||
@@ -180,7 +182,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
try {
|
||||
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
} else {
|
||||
@@ -203,8 +205,10 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
statsPeriod.value = period
|
||||
|
||||
// 如果对应时间段的数据还没有加载,则加载它
|
||||
if ((period === 'daily' && !dailyStats.value) ||
|
||||
(period === 'monthly' && !monthlyStats.value)) {
|
||||
if (
|
||||
(period === 'daily' && !dailyStats.value) ||
|
||||
(period === 'monthly' && !monthlyStats.value)
|
||||
) {
|
||||
await loadPeriodStats(period)
|
||||
}
|
||||
|
||||
@@ -223,13 +227,13 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
try {
|
||||
const result = await apiStatsClient.getUserStats(apiId.value)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
statsData.value = result.data
|
||||
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await loadAllPeriodStats()
|
||||
|
||||
|
||||
// 清除错误信息
|
||||
error.value = ''
|
||||
} else {
|
||||
@@ -267,7 +271,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
|
||||
|
||||
// 格式化费用
|
||||
function formatCost(cost) {
|
||||
if (typeof cost !== 'number' || cost === 0) {
|
||||
@@ -324,11 +328,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
dailyStats,
|
||||
monthlyStats,
|
||||
oemSettings,
|
||||
|
||||
|
||||
// Computed
|
||||
currentPeriodData,
|
||||
usagePercentages,
|
||||
|
||||
|
||||
// Actions
|
||||
queryStats,
|
||||
loadAllPeriodStats,
|
||||
@@ -340,4 +344,4 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
clearData,
|
||||
reset
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,16 +27,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
async function login(credentials) {
|
||||
loginLoading.value = true
|
||||
loginError.value = ''
|
||||
|
||||
|
||||
try {
|
||||
const result = await apiClient.post('/web/auth/login', credentials)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
authToken.value = result.token
|
||||
username.value = result.username || credentials.username
|
||||
isLoggedIn.value = true
|
||||
localStorage.setItem('authToken', result.token)
|
||||
|
||||
|
||||
await router.push('/dashboard')
|
||||
} else {
|
||||
loginError.value = result.message || '登录失败'
|
||||
@@ -71,7 +71,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
if (userResult.success && userResult.user) {
|
||||
username.value = userResult.user.username
|
||||
}
|
||||
|
||||
|
||||
// 使用 dashboard 端点来验证 token
|
||||
// 如果 token 无效,会抛出错误
|
||||
const result = await apiClient.get('/admin/dashboard')
|
||||
@@ -90,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const result = await apiClient.get('/admin/oem-settings')
|
||||
if (result.success && result.data) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
|
||||
// 设置favicon
|
||||
if (result.data.siteIconData || result.data.siteIcon) {
|
||||
const link = document.querySelector("link[rel*='icon']") || document.createElement('link')
|
||||
@@ -99,7 +99,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
link.href = result.data.siteIconData || result.data.siteIcon
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}
|
||||
|
||||
|
||||
// 设置页面标题
|
||||
if (result.data.siteName) {
|
||||
document.title = `${result.data.siteName} - 管理后台`
|
||||
@@ -121,16 +121,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
loginLoading,
|
||||
oemSettings,
|
||||
oemLoading,
|
||||
|
||||
|
||||
// 计算属性
|
||||
isAuthenticated,
|
||||
token,
|
||||
user,
|
||||
|
||||
|
||||
// 方法
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
loadOemSettings
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,27 +7,27 @@ export const useClientsStore = defineStore('clients', {
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
|
||||
actions: {
|
||||
async loadSupportedClients() {
|
||||
if (this.supportedClients.length > 0) {
|
||||
// 如果已经加载过,不重复加载
|
||||
return this.supportedClients
|
||||
}
|
||||
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/admin/supported-clients')
|
||||
|
||||
|
||||
if (response.success) {
|
||||
this.supportedClients = response.data || []
|
||||
} else {
|
||||
this.error = response.message || '加载支持的客户端失败'
|
||||
console.error('Failed to load supported clients:', this.error)
|
||||
}
|
||||
|
||||
|
||||
return this.supportedClients
|
||||
} catch (error) {
|
||||
this.error = error.message || '加载支持的客户端失败'
|
||||
@@ -38,4 +38,4 @@ export const useClientsStore = defineStore('clients', {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,12 +34,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
uptime: 0,
|
||||
systemTimezone: 8 // 默认 UTC+8
|
||||
})
|
||||
|
||||
|
||||
const costsData = ref({
|
||||
todayCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } },
|
||||
totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }
|
||||
})
|
||||
|
||||
|
||||
const modelStats = ref([])
|
||||
const trendData = ref([])
|
||||
const dashboardModelStats = ref([])
|
||||
@@ -48,7 +48,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
topApiKeys: [],
|
||||
totalApiKeys: 0
|
||||
})
|
||||
|
||||
|
||||
// 日期筛选
|
||||
const dateFilter = ref({
|
||||
type: 'preset', // preset 或 custom
|
||||
@@ -62,21 +62,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
// 趋势图粒度
|
||||
const trendGranularity = ref('day') // 'day' 或 'hour'
|
||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||
|
||||
|
||||
// 默认时间
|
||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||
|
||||
|
||||
// 计算属性
|
||||
const formattedUptime = computed(() => {
|
||||
const seconds = dashboardData.value.uptime
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`
|
||||
} else if (hours > 0) {
|
||||
@@ -85,27 +85,27 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 辅助函数:基于系统时区计算时间
|
||||
function getDateInSystemTimezone(date = new Date()) {
|
||||
const offset = dashboardData.value.systemTimezone || 8
|
||||
// 将本地时间转换为UTC时间,然后加上系统时区偏移
|
||||
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000)
|
||||
return new Date(utcTime + (offset * 3600000))
|
||||
}
|
||||
|
||||
// function getDateInSystemTimezone(date = new Date()) {
|
||||
// const offset = dashboardData.value.systemTimezone || 8
|
||||
// // 将本地时间转换为UTC时间,然后加上系统时区偏移
|
||||
// const utcTime = date.getTime() + date.getTimezoneOffset() * 60000
|
||||
// return new Date(utcTime + offset * 3600000)
|
||||
// }
|
||||
|
||||
// 辅助函数:获取系统时区某一天的起止UTC时间
|
||||
// 输入:一个本地时间的日期对象(如用户选择的日期)
|
||||
// 输出:该日期在系统时区的0点/23:59对应的UTC时间
|
||||
function getSystemTimezoneDay(localDate, startOfDay = true) {
|
||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
||||
const systemTz = 8
|
||||
|
||||
// const systemTz = 8
|
||||
|
||||
// 获取本地日期的年月日(这是用户想要查看的日期)
|
||||
const year = localDate.getFullYear()
|
||||
const month = localDate.getMonth()
|
||||
const day = localDate.getDate()
|
||||
|
||||
|
||||
if (startOfDay) {
|
||||
// 系统时区(UTC+8)的 YYYY-MM-DD 00:00:00
|
||||
// 对应的UTC时间是前一天的16:00
|
||||
@@ -118,37 +118,37 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
return new Date(Date.UTC(year, month, day, 15, 59, 59, 999))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 方法
|
||||
async function loadDashboardData(timeRange = null) {
|
||||
loading.value = true
|
||||
try {
|
||||
// 根据timeRange动态设置costs查询参数
|
||||
let costsParams = { today: 'today', all: 'all' }
|
||||
|
||||
|
||||
if (timeRange) {
|
||||
const periodMapping = {
|
||||
'today': { today: 'today', all: 'today' },
|
||||
'7days': { today: '7days', all: '7days' },
|
||||
'monthly': { today: 'monthly', all: 'monthly' },
|
||||
'all': { today: 'today', all: 'all' }
|
||||
today: { today: 'today', all: 'today' },
|
||||
'7days': { today: '7days', all: '7days' },
|
||||
monthly: { today: 'monthly', all: 'monthly' },
|
||||
all: { today: 'today', all: 'all' }
|
||||
}
|
||||
costsParams = periodMapping[timeRange] || costsParams
|
||||
}
|
||||
|
||||
|
||||
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
|
||||
apiClient.get('/admin/dashboard'),
|
||||
apiClient.get(`/admin/usage-costs?period=${costsParams.today}`),
|
||||
apiClient.get(`/admin/usage-costs?period=${costsParams.all}`)
|
||||
])
|
||||
|
||||
|
||||
if (dashboardResponse.success) {
|
||||
const overview = dashboardResponse.data.overview || {}
|
||||
const recentActivity = dashboardResponse.data.recentActivity || {}
|
||||
const systemAverages = dashboardResponse.data.systemAverages || {}
|
||||
const realtimeMetrics = dashboardResponse.data.realtimeMetrics || {}
|
||||
const systemHealth = dashboardResponse.data.systemHealth || {}
|
||||
|
||||
|
||||
dashboardData.value = {
|
||||
totalApiKeys: overview.totalApiKeys || 0,
|
||||
activeApiKeys: overview.activeApiKeys || 0,
|
||||
@@ -178,12 +178,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
systemTimezone: dashboardResponse.data.systemTimezone || 8
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新费用数据
|
||||
if (todayCostsResponse.success && totalCostsResponse.success) {
|
||||
costsData.value = {
|
||||
todayCosts: todayCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } },
|
||||
totalCosts: totalCostsResponse.data.totalCosts || { totalCost: 0, formatted: { totalCost: '$0.000000' } }
|
||||
todayCosts: todayCostsResponse.data.totalCosts || {
|
||||
totalCost: 0,
|
||||
formatted: { totalCost: '$0.000000' }
|
||||
},
|
||||
totalCosts: totalCostsResponse.data.totalCosts || {
|
||||
totalCost: 0,
|
||||
formatted: { totalCost: '$0.000000' }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -192,15 +198,15 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadUsageTrend(days = 7, granularity = 'day') {
|
||||
try {
|
||||
let url = '/admin/usage-trend?'
|
||||
|
||||
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度,计算时间范围
|
||||
url += `granularity=hour`
|
||||
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
@@ -210,53 +216,58 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
|
||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
||||
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds))
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与loadApiKeysTrend保持一致
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'last24h':
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'yesterday':
|
||||
// 昨天:基于系统时区的昨天
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
case 'dayBefore':
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:基于系统时区的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
// 默认近24小时
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 默认使用days参数计算
|
||||
startTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
}
|
||||
@@ -264,7 +275,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// 天粒度,传递天数
|
||||
url += `granularity=day&days=${days}`
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.get(url)
|
||||
if (response.success) {
|
||||
trendData.value = response.data
|
||||
@@ -273,11 +284,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
console.error('加载使用趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadModelStats(period = 'daily') {
|
||||
try {
|
||||
let url = `/admin/model-stats?period=${period}`
|
||||
|
||||
|
||||
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
|
||||
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
@@ -287,40 +298,46 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds))
|
||||
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
} else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
|
||||
// 小时粒度的预设时间范围
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'last24h':
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'yesterday':
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
case 'dayBefore':
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
}
|
||||
@@ -328,8 +345,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
// 天粒度的预设时间范围,需要传递startDate和endDate参数
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
|
||||
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
|
||||
|
||||
const option = dateFilter.value.presetOptions.find(
|
||||
(opt) => opt.value === dateFilter.value.preset
|
||||
)
|
||||
if (option) {
|
||||
if (dateFilter.value.preset === 'today') {
|
||||
// 今日:从系统时区的今天0点到23:59
|
||||
@@ -342,12 +361,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
startDate = getSystemTimezoneDay(daysAgo, true)
|
||||
endDate = getSystemTimezoneDay(now, false)
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startDate.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endDate.toISOString())}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.get(url)
|
||||
if (response.success) {
|
||||
dashboardModelStats.value = response.data
|
||||
@@ -356,16 +375,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
console.error('加载模型统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadApiKeysTrend(metric = 'requests') {
|
||||
try {
|
||||
let url = '/admin/api-keys-usage-trend?'
|
||||
let days = 7
|
||||
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度,计算时间范围
|
||||
url += `granularity=hour`
|
||||
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
|
||||
const convertToUTC = (systemTzTimeStr) => {
|
||||
@@ -375,66 +394,77 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const [datePart, timePart] = systemTzTimeStr.split(' ')
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
|
||||
|
||||
// 创建UTC时间,使其在系统时区显示为用户选择的时间
|
||||
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
|
||||
// 对应的UTC时间是 2025-07-24 16:00:00
|
||||
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds))
|
||||
const utcDate = new Date(
|
||||
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
|
||||
)
|
||||
return utcDate.toISOString()
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
|
||||
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与setDateFilterPreset保持一致
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'last24h':
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'yesterday':
|
||||
}
|
||||
case 'yesterday': {
|
||||
// 昨天:基于系统时区的昨天
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
case 'dayBefore':
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:基于系统时区的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
// 默认近24小时
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 默认近24小时
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
days = dateFilter.value.type === 'preset'
|
||||
? (dateFilter.value.preset === 'today' ? 1 : dateFilter.value.preset === '7days' ? 7 : 30)
|
||||
: calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
|
||||
days =
|
||||
dateFilter.value.type === 'preset'
|
||||
? dateFilter.value.preset === 'today'
|
||||
? 1
|
||||
: dateFilter.value.preset === '7days'
|
||||
? 7
|
||||
: 30
|
||||
: calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd)
|
||||
url += `granularity=day&days=${days}`
|
||||
}
|
||||
|
||||
|
||||
url += `&metric=${metric}`
|
||||
|
||||
|
||||
const response = await apiClient.get(url)
|
||||
if (response.success) {
|
||||
apiKeysTrendData.value = {
|
||||
@@ -447,27 +477,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
console.error('加载API Keys趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 日期筛选相关方法
|
||||
function setDateFilterPreset(preset) {
|
||||
dateFilter.value.type = 'preset'
|
||||
dateFilter.value.preset = preset
|
||||
|
||||
|
||||
// 根据预设计算并设置具体的日期范围
|
||||
const option = dateFilter.value.presetOptions.find(opt => opt.value === preset)
|
||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset)
|
||||
if (option) {
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度的预设
|
||||
switch (preset) {
|
||||
case 'last24h':
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'yesterday':
|
||||
}
|
||||
case 'yesterday': {
|
||||
// 昨天:获取本地时间的昨天
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
@@ -475,7 +506,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
startDate = getSystemTimezoneDay(yesterday, true)
|
||||
endDate = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
case 'dayBefore':
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:获取本地时间的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
@@ -483,12 +515,13 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 天粒度的预设
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
|
||||
|
||||
if (preset === 'today') {
|
||||
// 今日:从凌晨开始
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
@@ -500,10 +533,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
|
||||
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
|
||||
|
||||
|
||||
// 设置 customRange 为 Element Plus 需要的格式
|
||||
// 对于小时粒度的昨天/前天,需要特殊处理显示
|
||||
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
|
||||
@@ -514,12 +547,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
} else {
|
||||
targetDate.setDate(targetDate.getDate() - 2)
|
||||
}
|
||||
|
||||
|
||||
// 显示系统时区的完整一天
|
||||
const year = targetDate.getFullYear()
|
||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(targetDate.getDate()).padStart(2, '0')
|
||||
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
`${year}-${month}-${day} 00:00:00`,
|
||||
`${year}-${month}-${day} 23:59:59`
|
||||
@@ -531,7 +564,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const systemTz = 8
|
||||
const tzOffset = systemTz * 60 * 60 * 1000
|
||||
const localTime = new Date(date.getTime() + tzOffset)
|
||||
|
||||
|
||||
const year = localTime.getUTCFullYear()
|
||||
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(localTime.getUTCDate()).padStart(2, '0')
|
||||
@@ -540,18 +573,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
|
||||
dateFilter.value.customRange = [
|
||||
formatDateForDisplay(startDate),
|
||||
formatDateForDisplay(endDate)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
}
|
||||
|
||||
|
||||
function onCustomDateRangeChange(value) {
|
||||
if (value && value.length === 2) {
|
||||
dateFilter.value.type = 'custom'
|
||||
@@ -559,10 +592,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
dateFilter.value.customRange = value
|
||||
dateFilter.value.customStart = value[0].split(' ')[0]
|
||||
dateFilter.value.customEnd = value[1].split(' ')[0]
|
||||
|
||||
|
||||
// 检查日期范围限制 - value中的时间已经是系统时区时间
|
||||
const systemTz = dashboardData.value.systemTimezone || 8
|
||||
|
||||
// const systemTz = dashboardData.value.systemTimezone || 8
|
||||
|
||||
// 解析系统时区时间
|
||||
const parseSystemTime = (timeStr) => {
|
||||
const [datePart, timePart] = timeStr.split(' ')
|
||||
@@ -570,10 +603,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number)
|
||||
return new Date(year, month - 1, day, hours, minutes, seconds)
|
||||
}
|
||||
|
||||
|
||||
const start = parseSystemTime(value[0])
|
||||
const end = parseSystemTime(value[1])
|
||||
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度:限制 24 小时
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
@@ -589,7 +622,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
} else if (value === null) {
|
||||
@@ -597,10 +630,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setTrendGranularity(granularity) {
|
||||
trendGranularity.value = granularity
|
||||
|
||||
|
||||
// 根据粒度更新预设选项
|
||||
if (granularity === 'hour') {
|
||||
dateFilter.value.presetOptions = [
|
||||
@@ -608,9 +641,13 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
{ value: 'yesterday', label: '昨天', hours: 24 },
|
||||
{ value: 'dayBefore', label: '前天', hours: 24 }
|
||||
]
|
||||
|
||||
|
||||
// 检查当前自定义日期范围是否超过24小时
|
||||
if (dateFilter.value.type === 'custom' && dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
if (
|
||||
dateFilter.value.type === 'custom' &&
|
||||
dateFilter.value.customRange &&
|
||||
dateFilter.value.customRange.length === 2
|
||||
) {
|
||||
const start = new Date(dateFilter.value.customRange[0])
|
||||
const end = new Date(dateFilter.value.customRange[1])
|
||||
const hoursDiff = (end - start) / (1000 * 60 * 60)
|
||||
@@ -620,7 +657,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
|
||||
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('last24h')
|
||||
@@ -633,26 +670,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
{ value: '7days', label: '7天', days: 7 },
|
||||
{ value: '30days', label: '30天', days: 30 }
|
||||
]
|
||||
|
||||
|
||||
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
|
||||
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
|
||||
setDateFilterPreset('7days')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 触发数据刷新
|
||||
refreshChartsData()
|
||||
}
|
||||
|
||||
|
||||
async function refreshChartsData() {
|
||||
// 根据当前筛选条件刷新数据
|
||||
let days
|
||||
let modelPeriod = 'monthly'
|
||||
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
const option = dateFilter.value.presetOptions.find(opt => opt.value === dateFilter.value.preset)
|
||||
|
||||
const option = dateFilter.value.presetOptions.find(
|
||||
(opt) => opt.value === dateFilter.value.preset
|
||||
)
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
// 小时粒度
|
||||
days = 1 // 小时粒度默认查看1天的数据
|
||||
@@ -680,14 +719,14 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
modelPeriod = 'daily' // 自定义范围使用日统计
|
||||
}
|
||||
|
||||
|
||||
await Promise.all([
|
||||
loadUsageTrend(days, trendGranularity.value),
|
||||
loadModelStats(modelPeriod),
|
||||
loadApiKeysTrend(apiKeysTrendMetric.value)
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
function calculateDaysBetween(start, end) {
|
||||
if (!start || !end) return 7
|
||||
const startDate = new Date(start)
|
||||
@@ -696,11 +735,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays || 7
|
||||
}
|
||||
|
||||
|
||||
function disabledDate(date) {
|
||||
return date > new Date()
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
@@ -714,10 +753,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
trendGranularity,
|
||||
apiKeysTrendMetric,
|
||||
defaultTime,
|
||||
|
||||
|
||||
// 计算属性
|
||||
formattedUptime,
|
||||
|
||||
|
||||
// 方法
|
||||
loadDashboardData,
|
||||
loadUsageTrend,
|
||||
@@ -729,4 +768,4 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
refreshChartsData,
|
||||
disabledDate
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
siteIconData: '',
|
||||
updatedAt: null
|
||||
})
|
||||
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
@@ -21,14 +21,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await apiClient.get('/admin/oem-settings')
|
||||
|
||||
|
||||
if (result && result.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
|
||||
// 应用设置到页面
|
||||
applyOemSettings()
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to load OEM settings:', error)
|
||||
@@ -45,7 +45,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
if (result && result.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
|
||||
// 应用设置到页面
|
||||
applyOemSettings()
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
siteIconData: '',
|
||||
updatedAt: null
|
||||
}
|
||||
|
||||
|
||||
oemSettings.value = { ...defaultSettings }
|
||||
return await saveOemSettings(defaultSettings)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
if (oemSettings.value.siteName) {
|
||||
document.title = `${oemSettings.value.siteName} - 管理后台`
|
||||
}
|
||||
|
||||
|
||||
// 更新favicon
|
||||
if (oemSettings.value.siteIconData || oemSettings.value.siteIcon) {
|
||||
const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link')
|
||||
@@ -105,18 +105,18 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
// 验证文件上传
|
||||
const validateIconFile = (file) => {
|
||||
const errors = []
|
||||
|
||||
|
||||
// 检查文件大小 (350KB)
|
||||
if (file.size > 350 * 1024) {
|
||||
errors.push('图标文件大小不能超过 350KB')
|
||||
}
|
||||
|
||||
|
||||
// 检查文件类型
|
||||
const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件')
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
@@ -138,7 +138,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
oemSettings,
|
||||
loading,
|
||||
saving,
|
||||
|
||||
|
||||
// Actions
|
||||
loadOemSettings,
|
||||
saveOemSettings,
|
||||
@@ -148,4 +148,4 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
validateIconFile,
|
||||
fileToBase64
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 数字格式化函数
|
||||
export function formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0'
|
||||
|
||||
|
||||
const absNum = Math.abs(num)
|
||||
|
||||
|
||||
if (absNum >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B'
|
||||
} else if (absNum >= 1e6) {
|
||||
@@ -11,23 +11,23 @@ export function formatNumber(num) {
|
||||
} else if (absNum >= 1e3) {
|
||||
return (num / 1e3).toFixed(1) + 'K'
|
||||
}
|
||||
|
||||
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 日期格式化函数
|
||||
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
if (!date) return ''
|
||||
|
||||
|
||||
const d = new Date(date)
|
||||
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', month)
|
||||
@@ -40,7 +40,7 @@ export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
||||
// 相对时间格式化
|
||||
export function formatRelativeTime(date) {
|
||||
if (!date) return ''
|
||||
|
||||
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const diffMs = now - past
|
||||
@@ -48,7 +48,7 @@ export function formatRelativeTime(date) {
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}天前`
|
||||
} else if (diffHours > 0) {
|
||||
@@ -63,12 +63,12 @@ export function formatRelativeTime(date) {
|
||||
// 字节格式化
|
||||
export function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;'
|
||||
document.body.appendChild(toastContainer)
|
||||
}
|
||||
|
||||
|
||||
// 创建 toast
|
||||
const id = ++toastId
|
||||
const toast = document.createElement('div')
|
||||
@@ -23,14 +23,14 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
`
|
||||
|
||||
|
||||
const iconMap = {
|
||||
success: 'fas fa-check-circle',
|
||||
error: 'fas fa-times-circle',
|
||||
warning: 'fas fa-exclamation-triangle',
|
||||
info: 'fas fa-info-circle'
|
||||
}
|
||||
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
@@ -46,14 +46,14 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
|
||||
toastContainer.appendChild(toast)
|
||||
|
||||
|
||||
// 触发动画
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)'
|
||||
}, 10)
|
||||
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
@@ -63,6 +63,6 @@ export function showToast(message, type = 'info', title = '', duration = 3000) {
|
||||
}, 300)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="min-h-screen gradient-bg p-4 md:p-6">
|
||||
<div class="gradient-bg min-h-screen p-4 md:p-6">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="glass-strong rounded-3xl p-4 md:p-6 mb-6 md:mb-8 shadow-xl">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<LogoTitle
|
||||
<div class="glass-strong mb-6 rounded-3xl p-4 shadow-xl md:mb-8 md:p-6">
|
||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:title="oemSettings.siteName"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:title="oemSettings.siteName"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link
|
||||
class="admin-button flex items-center gap-2 rounded-xl px-3 py-2 text-white transition-all duration-300 md:px-4 md:py-2"
|
||||
to="/dashboard"
|
||||
class="admin-button rounded-xl px-3 py-2 md:px-4 md:py-2 text-white transition-all duration-300 flex items-center gap-2"
|
||||
>
|
||||
<i class="fas fa-cog text-sm" />
|
||||
<span class="text-xs md:text-sm font-medium">管理后台</span>
|
||||
<span class="text-xs font-medium md:text-sm">管理后台</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,22 +24,18 @@
|
||||
<!-- Tab 切换 -->
|
||||
<div class="mb-6 md:mb-8">
|
||||
<div class="flex justify-center">
|
||||
<div class="inline-flex bg-white/10 backdrop-blur-xl rounded-full p-1 shadow-lg border border-white/20 w-full max-w-md md:w-auto">
|
||||
<div
|
||||
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'tab-pill-button',
|
||||
currentTab === 'stats' ? 'active' : ''
|
||||
]"
|
||||
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
|
||||
@click="currentTab = 'stats'"
|
||||
>
|
||||
<i class="fas fa-chart-line mr-1 md:mr-2" />
|
||||
<span class="text-sm md:text-base">统计查询</span>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'tab-pill-button',
|
||||
currentTab === 'tutorial' ? 'active' : ''
|
||||
]"
|
||||
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
|
||||
@click="currentTab = 'tutorial'"
|
||||
>
|
||||
<i class="fas fa-graduation-cap mr-1 md:mr-2" />
|
||||
@@ -50,50 +46,45 @@
|
||||
</div>
|
||||
|
||||
<!-- 统计内容 -->
|
||||
<div
|
||||
v-if="currentTab === 'stats'"
|
||||
class="tab-content"
|
||||
>
|
||||
<div v-if="currentTab === 'stats'" class="tab-content">
|
||||
<!-- API Key 输入区域 -->
|
||||
<ApiKeyInput />
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-6 md:mb-8"
|
||||
>
|
||||
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-3 md:p-4 text-red-800 backdrop-blur-sm text-sm md:text-base">
|
||||
<div v-if="error" class="mb-6 md:mb-8">
|
||||
<div
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-sm text-red-800 backdrop-blur-sm md:p-4 md:text-base"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-2" />
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据展示区域 -->
|
||||
<div
|
||||
v-if="statsData"
|
||||
class="fade-in"
|
||||
>
|
||||
<div class="glass-strong rounded-3xl p-4 md:p-6 shadow-xl">
|
||||
<div v-if="statsData" class="fade-in">
|
||||
<div class="glass-strong rounded-3xl p-4 shadow-xl md:p-6">
|
||||
<!-- 时间范围选择器 -->
|
||||
<div class="mb-4 md:mb-6 pb-4 md:pb-6 border-b border-gray-200">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-4">
|
||||
<div class="mb-4 border-b border-gray-200 pb-4 md:mb-6 md:pb-6">
|
||||
<div
|
||||
class="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 md:gap-3">
|
||||
<i class="fas fa-clock text-blue-500 text-base md:text-lg" />
|
||||
<span class="text-base md:text-lg font-medium text-gray-700">统计时间范围</span>
|
||||
<i class="fas fa-clock text-base text-blue-500 md:text-lg" />
|
||||
<span class="text-base font-medium text-gray-700 md:text-lg">统计时间范围</span>
|
||||
</div>
|
||||
<div class="flex gap-2 w-full md:w-auto">
|
||||
<button
|
||||
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
|
||||
class="px-4 md:px-6 py-2 text-xs md:text-sm font-medium flex items-center gap-1 md:gap-2 flex-1 md:flex-none justify-center"
|
||||
<div class="flex w-full gap-2 md:w-auto">
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
||||
:class="['period-btn', { active: statsPeriod === 'daily' }]"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
@click="switchPeriod('daily')"
|
||||
>
|
||||
<i class="fas fa-calendar-day text-xs md:text-sm" />
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
|
||||
class="px-4 md:px-6 py-2 text-xs md:text-sm font-medium flex items-center gap-1 md:gap-2 flex-1 md:flex-none justify-center"
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
||||
:class="['period-btn', { active: statsPeriod === 'monthly' }]"
|
||||
:disabled="loading || modelStatsLoading"
|
||||
@click="switchPeriod('monthly')"
|
||||
>
|
||||
@@ -108,7 +99,7 @@
|
||||
<StatsOverview />
|
||||
|
||||
<!-- Token 分布和限制配置 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 mb-6 md:mb-8">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
||||
<TokenDistribution />
|
||||
<LimitConfig />
|
||||
</div>
|
||||
@@ -120,10 +111,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 教程内容 -->
|
||||
<div
|
||||
v-if="currentTab === 'tutorial'"
|
||||
class="tab-content"
|
||||
>
|
||||
<div v-if="currentTab === 'tutorial'" class="tab-content">
|
||||
<div class="glass-strong rounded-3xl shadow-xl">
|
||||
<TutorialView />
|
||||
</div>
|
||||
@@ -162,13 +150,7 @@ const {
|
||||
oemSettings
|
||||
} = storeToRefs(apiStatsStore)
|
||||
|
||||
const {
|
||||
queryStats,
|
||||
switchPeriod,
|
||||
loadStatsWithApiId,
|
||||
loadOemSettings,
|
||||
reset
|
||||
} = apiStatsStore
|
||||
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
||||
|
||||
// 处理键盘快捷键
|
||||
const handleKeyDown = (event) => {
|
||||
@@ -179,7 +161,7 @@ const handleKeyDown = (event) => {
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
|
||||
// ESC 清除数据
|
||||
if (event.key === 'Escape') {
|
||||
reset()
|
||||
@@ -189,15 +171,18 @@ const handleKeyDown = (event) => {
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
console.log('API Stats Page loaded')
|
||||
|
||||
|
||||
// 加载 OEM 设置
|
||||
loadOemSettings()
|
||||
|
||||
|
||||
// 检查 URL 参数
|
||||
const urlApiId = route.query.apiId
|
||||
const urlApiKey = route.query.apiKey
|
||||
|
||||
if (urlApiId && urlApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
|
||||
|
||||
if (
|
||||
urlApiId &&
|
||||
urlApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||
) {
|
||||
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
||||
apiId.value = urlApiId
|
||||
loadStatsWithApiId()
|
||||
@@ -205,7 +190,7 @@ onMounted(() => {
|
||||
// 向后兼容,支持 apiKey 参数
|
||||
apiKey.value = urlApiKey
|
||||
}
|
||||
|
||||
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
@@ -239,7 +224,7 @@ watch(apiKey, (newValue) => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||
@@ -252,7 +237,7 @@ watch(apiKey, (newValue) => {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
@@ -275,7 +260,9 @@ watch(apiKey, (newValue) => {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.3), 0 2px 4px -1px rgba(102, 126, 234, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(102, 126, 234, 0.3),
|
||||
0 2px 4px -1px rgba(102, 126, 234, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -293,7 +280,9 @@ watch(apiKey, (newValue) => {
|
||||
|
||||
.admin-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(102, 126, 234, 0.4), 0 4px 6px -2px rgba(102, 126, 234, 0.15);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.4),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.admin-button:hover::before {
|
||||
@@ -315,7 +304,7 @@ watch(apiKey, (newValue) => {
|
||||
.period-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||
transform: translateY(-1px);
|
||||
@@ -365,7 +354,7 @@ watch(apiKey, (newValue) => {
|
||||
.tab-pill-button.active {
|
||||
background: white;
|
||||
color: #764ba2;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
@@ -396,13 +385,13 @@ watch(apiKey, (newValue) => {
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,87 +1,74 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-4 sm:p-6">
|
||||
<div 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">
|
||||
<div class="text-center mb-6 sm:mb-8">
|
||||
<div class="flex min-h-screen items-center justify-center p-4 sm:p-6">
|
||||
<div
|
||||
class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10"
|
||||
>
|
||||
<div class="mb-6 text-center sm:mb-8">
|
||||
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
|
||||
<div 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">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center overflow-hidden rounded-xl border border-gray-300/30 bg-gradient-to-br from-blue-500/20 to-purple-500/20 backdrop-blur-sm sm:mb-6 sm:h-20 sm:w-20 sm:rounded-2xl"
|
||||
>
|
||||
<template v-if="!oemLoading">
|
||||
<img
|
||||
v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
alt="Logo"
|
||||
class="w-10 h-10 sm:w-12 sm:h-12 object-contain"
|
||||
@error="(e) => e.target.style.display = 'none'"
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-cloud text-2xl sm:text-3xl text-gray-700"
|
||||
class="h-10 w-10 object-contain sm:h-12 sm:w-12"
|
||||
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
|
||||
@error="(e) => (e.target.style.display = 'none')"
|
||||
/>
|
||||
<i v-else class="fas fa-cloud text-2xl text-gray-700 sm:text-3xl" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="w-10 h-10 sm:w-12 sm:h-12 bg-gray-300/50 rounded animate-pulse"
|
||||
/>
|
||||
<div v-else class="h-10 w-10 animate-pulse rounded bg-gray-300/50 sm:h-12 sm:w-12" />
|
||||
</div>
|
||||
<template v-if="!oemLoading && authStore.oemSettings.siteName">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-white mb-2 header-title">
|
||||
<h1 class="header-title mb-2 text-2xl font-bold text-white sm:text-3xl">
|
||||
{{ authStore.oemSettings.siteName }}
|
||||
</h1>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="oemLoading"
|
||||
class="h-8 sm:h-9 w-48 sm:w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"
|
||||
class="mx-auto mb-2 h-8 w-48 animate-pulse rounded bg-gray-300/50 sm:h-9 sm:w-64"
|
||||
/>
|
||||
<p class="text-gray-600 text-base sm:text-lg">
|
||||
管理后台
|
||||
</p>
|
||||
<p class="text-base text-gray-600 sm:text-lg">管理后台</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="space-y-4 sm:space-y-6"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
|
||||
<form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 mb-2 sm:mb-3">用户名</label>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
required
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-900 sm:mb-3">用户名</label>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
class="form-input w-full"
|
||||
placeholder="请输入用户名"
|
||||
>
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 mb-2 sm:mb-3">密码</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
required
|
||||
<label class="mb-2 block text-sm font-semibold text-gray-900 sm:mb-3">密码</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
class="form-input w-full"
|
||||
placeholder="请输入密码"
|
||||
>
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
<button
|
||||
class="btn btn-primary w-full px-4 py-3 text-base font-semibold sm:px-6 sm:py-4 sm:text-lg"
|
||||
:disabled="authStore.loginLoading"
|
||||
class="btn btn-primary w-full py-3 sm:py-4 px-4 sm:px-6 text-base sm:text-lg font-semibold"
|
||||
type="submit"
|
||||
>
|
||||
<i
|
||||
v-if="!authStore.loginLoading"
|
||||
class="fas fa-sign-in-alt mr-2"
|
||||
/>
|
||||
<div
|
||||
v-if="authStore.loginLoading"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2" />
|
||||
<div v-if="authStore.loginLoading" class="loading-spinner mr-2" />
|
||||
{{ authStore.loginLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
<div
|
||||
v-if="authStore.loginError"
|
||||
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"
|
||||
class="mt-4 rounded-lg border border-red-500/30 bg-red-500/20 p-3 text-center text-xs text-red-800 backdrop-blur-sm sm:mt-6 sm:rounded-xl sm:p-4 sm:text-sm"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-2" />{{ authStore.loginError }}
|
||||
</div>
|
||||
@@ -92,7 +79,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const oemLoading = computed(() => authStore.oemLoading)
|
||||
@@ -114,4 +100,4 @@ const handleLogin = async () => {
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式已经在全局样式中定义 */
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,76 +2,58 @@
|
||||
<div class="settings-container">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<h3 class="text-lg sm:text-xl font-bold text-gray-900 mb-1 sm:mb-2">
|
||||
其他设置
|
||||
</h3>
|
||||
<p class="text-sm sm:text-base text-gray-600">
|
||||
自定义网站名称和图标
|
||||
</p>
|
||||
<h3 class="mb-1 text-lg font-bold text-gray-900 sm:mb-2 sm:text-xl">其他设置</h3>
|
||||
<p class="text-sm text-gray-600 sm:text-base">自定义网站名称和图标</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-center py-12"
|
||||
>
|
||||
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<div class="loading-spinner mx-auto mb-4" />
|
||||
<p class="text-gray-500">
|
||||
正在加载设置...
|
||||
</p>
|
||||
<p class="text-gray-500">正在加载设置...</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 桌面端表格视图 -->
|
||||
<div
|
||||
v-else
|
||||
class="hidden sm:block table-container"
|
||||
>
|
||||
<div v-else class="table-container hidden sm:block">
|
||||
<table class="min-w-full">
|
||||
<tbody class="divide-y divide-gray-200/50">
|
||||
<!-- 网站名称 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||
<td class="w-48 whitespace-nowrap px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<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">
|
||||
<i class="fas fa-font text-white text-xs" />
|
||||
<div
|
||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
>
|
||||
<i class="fas fa-font text-xs text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">
|
||||
网站名称
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
品牌标识
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-900">网站名称</div>
|
||||
<div class="text-xs text-gray-500">品牌标识</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<input
|
||||
<input
|
||||
v-model="oemSettings.siteName"
|
||||
type="text"
|
||||
class="form-input w-full max-w-md"
|
||||
placeholder="Claude Relay Service"
|
||||
maxlength="100"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
将显示在浏览器标题和页面头部
|
||||
</p>
|
||||
placeholder="Claude Relay Service"
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">将显示在浏览器标题和页面头部</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<!-- 网站图标 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap w-48">
|
||||
<td class="w-48 whitespace-nowrap px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<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">
|
||||
<i class="fas fa-image text-white text-xs" />
|
||||
<div
|
||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600"
|
||||
>
|
||||
<i class="fas fa-image text-xs text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">
|
||||
网站图标
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Favicon
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-gray-900">网站图标</div>
|
||||
<div class="text-xs text-gray-500">Favicon</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -80,72 +62,62 @@
|
||||
<!-- 图标预览 -->
|
||||
<div
|
||||
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
|
||||
class="inline-flex items-center gap-3 rounded-lg bg-gray-50 p-3"
|
||||
>
|
||||
<img
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
alt="图标预览"
|
||||
class="w-8 h-8"
|
||||
<img
|
||||
alt="图标预览"
|
||||
class="h-8 w-8"
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
@error="handleIconError"
|
||||
>
|
||||
/>
|
||||
<span class="text-sm text-gray-600">当前图标</span>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
<button
|
||||
class="rounded-lg px-3 py-1 font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900"
|
||||
@click="removeIcon"
|
||||
>
|
||||
<i class="fas fa-trash mr-1" />删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div>
|
||||
<input
|
||||
ref="iconFileInput"
|
||||
type="file"
|
||||
<input
|
||||
ref="iconFileInput"
|
||||
accept=".ico,.png,.jpg,.jpeg,.svg"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
<button
|
||||
class="btn btn-success px-4 py-2"
|
||||
@click="$refs.iconFileInput.click()"
|
||||
>
|
||||
/>
|
||||
<button class="btn btn-success px-4 py-2" @click="$refs.iconFileInput.click()">
|
||||
<i class="fas fa-upload mr-2" />
|
||||
上传图标
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式,最大 350KB</span>
|
||||
<span class="ml-3 text-xs text-gray-500"
|
||||
>支持 .ico, .png, .jpg, .svg 格式,最大 350KB</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<tr>
|
||||
<td
|
||||
class="px-6 py-6"
|
||||
colspan="2"
|
||||
>
|
||||
<td class="px-6 py-6" colspan="2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
:disabled="saving"
|
||||
<button
|
||||
class="btn btn-primary px-6 py-3"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': saving }"
|
||||
:class="{ 'cursor-not-allowed opacity-50': saving }"
|
||||
:disabled="saving"
|
||||
@click="saveOemSettings"
|
||||
>
|
||||
<div
|
||||
v-if="saving"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-save mr-2"
|
||||
/>
|
||||
<div v-if="saving" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
|
||||
|
||||
<button
|
||||
class="btn bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200"
|
||||
:disabled="saving"
|
||||
@click="resetOemSettings"
|
||||
>
|
||||
@@ -153,11 +125,8 @@
|
||||
重置为默认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="oemSettings.updatedAt"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
|
||||
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
|
||||
</div>
|
||||
@@ -167,140 +136,118 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 移动端卡片视图 -->
|
||||
<div
|
||||
v-if="!loading"
|
||||
class="sm:hidden space-y-4"
|
||||
>
|
||||
<div v-if="!loading" class="space-y-4 sm:hidden">
|
||||
<!-- 网站名称设置卡片 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-font text-white text-sm" />
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
|
||||
>
|
||||
<i class="fas fa-font text-sm text-white" />
|
||||
</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">
|
||||
<input
|
||||
<input
|
||||
v-model="oemSettings.siteName"
|
||||
type="text"
|
||||
class="form-input w-full text-sm"
|
||||
placeholder="Claude Relay Service"
|
||||
maxlength="100"
|
||||
>
|
||||
<p class="text-xs text-gray-500">
|
||||
将显示在浏览器标题和页面头部
|
||||
</p>
|
||||
placeholder="Claude Relay Service"
|
||||
type="text"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">将显示在浏览器标题和页面头部</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 网站图标设置卡片 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-image text-white text-sm" />
|
||||
<div class="rounded-lg bg-gray-50 p-4">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600"
|
||||
>
|
||||
<i class="fas fa-image text-sm text-white" />
|
||||
</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">
|
||||
<!-- 图标预览 -->
|
||||
<div
|
||||
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
class="inline-flex items-center gap-3 p-3 bg-white rounded-lg border border-gray-200 w-full"
|
||||
class="inline-flex w-full items-center gap-3 rounded-lg border border-gray-200 bg-white p-3"
|
||||
>
|
||||
<img
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
alt="图标预览"
|
||||
class="w-8 h-8"
|
||||
<img
|
||||
alt="图标预览"
|
||||
class="h-8 w-8"
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
@error="handleIconError"
|
||||
>
|
||||
<span class="text-sm text-gray-600 flex-1">当前图标</span>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-900 text-sm font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-gray-600">当前图标</span>
|
||||
<button
|
||||
class="rounded-lg px-3 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900"
|
||||
@click="removeIcon"
|
||||
>
|
||||
<i class="fas fa-trash mr-1" />删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<input
|
||||
<input
|
||||
ref="iconFileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
type="file"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm text-gray-700 hover:bg-gray-50 transition-colors flex items-center justify-center gap-2"
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50"
|
||||
@click="$refs.iconFileInput.click()"
|
||||
>
|
||||
<i class="fas fa-upload" />
|
||||
上传图标
|
||||
</button>
|
||||
<div class="text-xs text-gray-500">
|
||||
或者输入图标URL:
|
||||
</div>
|
||||
<input
|
||||
<div class="text-xs text-gray-500">或者输入图标URL:</div>
|
||||
<input
|
||||
v-model="oemSettings.siteIcon"
|
||||
type="url"
|
||||
class="form-input w-full text-sm"
|
||||
placeholder="https://example.com/icon.png"
|
||||
>
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
支持 PNG、JPEG、GIF 格式,建议使用正方形图片
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">支持 PNG、JPEG、GIF 格式,建议使用正方形图片</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="space-y-3 pt-2">
|
||||
<button
|
||||
<button
|
||||
class="btn btn-primary w-full py-3 text-sm font-semibold"
|
||||
:disabled="saving"
|
||||
@click="saveOemSettings"
|
||||
>
|
||||
<div
|
||||
v-if="saving"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-save mr-2"
|
||||
/>
|
||||
<div v-if="saving" class="loading-spinner mr-2" />
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 w-full py-3 text-sm"
|
||||
|
||||
<button
|
||||
class="btn w-full bg-gray-100 py-3 text-sm text-gray-700 hover:bg-gray-200"
|
||||
:disabled="saving"
|
||||
@click="resetOemSettings"
|
||||
>
|
||||
<i class="fas fa-undo mr-2" />
|
||||
重置为默认
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="oemSettings.updatedAt"
|
||||
class="text-center text-xs text-gray-500"
|
||||
>
|
||||
|
||||
<div v-if="oemSettings.updatedAt" class="text-center text-xs text-gray-500">
|
||||
<i class="fas fa-clock mr-1" />
|
||||
最后更新:{{ formatDateTime(oemSettings.updatedAt) }}
|
||||
</div>
|
||||
@@ -354,7 +301,7 @@ const saveOemSettings = async () => {
|
||||
// 重置OEM设置
|
||||
const resetOemSettings = async () => {
|
||||
if (!confirm('确定要重置为默认设置吗?\n\n这将清除所有自定义的网站名称和图标设置。')) return
|
||||
|
||||
|
||||
try {
|
||||
const result = await settingsStore.resetOemSettings()
|
||||
if (result && result.success) {
|
||||
@@ -371,14 +318,14 @@ const resetOemSettings = async () => {
|
||||
const handleIconUpload = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
|
||||
// 验证文件
|
||||
const validation = settingsStore.validateIconFile(file)
|
||||
if (!validation.isValid) {
|
||||
validation.errors.forEach(error => showToast(error, 'error'))
|
||||
validation.errors.forEach((error) => showToast(error, 'error'))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 转换为Base64
|
||||
const base64Data = await settingsStore.fileToBase64(file)
|
||||
@@ -386,7 +333,7 @@ const handleIconUpload = async (event) => {
|
||||
} catch (error) {
|
||||
showToast('文件读取失败', 'error')
|
||||
}
|
||||
|
||||
|
||||
// 清除input的值,允许重复选择同一文件
|
||||
event.target.value = ''
|
||||
}
|
||||
@@ -433,11 +380,11 @@ const formatDateTime = settingsStore.formatDateTime
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200;
|
||||
@apply w-full rounded-lg border border-gray-300 px-4 py-2 transition-all duration-200 focus:border-transparent focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-semibold rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -449,6 +396,6 @@ const formatDateTime = settingsStore.formatDateTime
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply w-5 h-5 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin;
|
||||
@apply h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user