This commit is contained in:
XOF
2025-11-20 12:24:05 +08:00
commit f28bdc751f
164 changed files with 64248 additions and 0 deletions

1167
web/static/css/dashboard.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

2348
web/static/css/input_old.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,315 @@
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(./font-awesome/webfonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

5195
web/static/css/output.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
/* Filename: web/static/css/status-grid.css (V8.0 - 布局简化版) */
#poolGridContainer {
position: relative; /* 仅用于定位内部的Canvas */
}
#poolGridContainer canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

160
web/static/css/style.css Normal file
View File

@@ -0,0 +1,160 @@
/* Filename: web/static/css/style.css */
/* --- 基础与字体 --- */
body {
font-family: 'Inter', sans-serif;
}
/* --- 核心卡片与布局 --- */
.glass-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* --- 导航按钮 --- */
.nav-buttons-container {
display: flex;
justify-content: center;
margin-bottom: 2rem;
overflow-x: auto;
gap: 0.5rem;
}
.nav-link {
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
font-weight: 500;
border-radius: 0.5rem;
color: #374151; /* gray-700 */
background-color: rgba(229, 231, 235, 0.8); /* gray-200 with opacity */
transition: all 0.2s;
}
.nav-link:hover {
color: #111827; /* gray-900 */
transform: translateY(-2px);
}
.nav-link.active {
background-color: #4f46e5; /* primary-600 */
color: #ffffff;
box-shadow: 0 4px 14px 0 rgba(79, 70, 229, 0.39);
}
/* --- 现代统计面板 --- */
.stats-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.stats-card {
background-color: #ffffff;
border-radius: 1rem;
border: 1px solid #e5e7eb; /* gray-200 */
overflow: hidden;
display: flex;
flex-direction: column;
}
.stats-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e5e7eb;
}
.stats-card-title {
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
color: #1f2937; /* gray-800 */
}
/* 统计项网格 */
.stats-grid {
display: grid;
gap: 1rem;
padding: 1rem;
}
.stat-item {
position: relative;
padding: 1rem;
border-radius: 0.75rem;
overflow: hidden;
color: #ffffff;
}
.stat-value {
font-size: 1.875rem; /* text-3xl */
font-weight: 700;
}
.stat-label {
font-size: 0.875rem; /* text-sm */
opacity: 0.8;
}
.stat-icon {
position: absolute;
right: 1rem;
top: 1rem;
font-size: 2rem;
opacity: 0.2;
}
/* 统计项颜色变体 */
.stat-primary { background-color: #6366f1; }
.stat-success { background-color: #10b981; }
.stat-danger { background-color: #ef4444; }
.stat-warning { background-color: #f59e0b; }
.stat-info { background-color: #3b82f6; }
/* --- 通用组件 --- */
/* 下拉菜单 */
.dropdown-toggle .dropdown-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
z-index: 10;
min-width: 160px;
border: 1px solid #f3f4f6;
}
.dropdown-toggle .dropdown-menu.show {
display: block;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151; /* gray-700 */
cursor: pointer;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: #f3f4f6; /* gray-100 */
}
/* 自动刷新开关 */
.toggle-checkbox:checked {
right: 0px;
border-color: #4f46e5;
}
.toggle-checkbox:checked + .toggle-label {
background-color: #4f46e5;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/static/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
web/static/icons/logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

65
web/static/js/api.js Normal file
View File

@@ -0,0 +1,65 @@
// 全局Promise缓存现在被封装在模块作用域内不再污染全局
const apiPromiseCache = new Map();
/**
* 具备缓存、认证处理和自动JSON解析的apiFetch函数
*/
export async function apiFetch(url, options = {}) {
// [修正] 不再使用 window.apiPromiseCache
if (apiPromiseCache.has(url) && !options.noCache) {
return apiPromiseCache.get(url);
}
const token = localStorage.getItem('bearerToken');
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const requestPromise = fetch(url, {
...options,
headers,
}).then(response => {
if (response.status === 401) {
apiPromiseCache.delete(url);
localStorage.removeItem('bearerToken');
if (window.location.pathname !== '/login') {
window.location.href = '/login?error=会话已过期,请重新登录。';
}
throw new Error('Unauthorized');
}
if (!response.ok) {
throw new Error(`API请求失败: ${response.status}`);
}
return response;
});
apiPromiseCache.set(url, requestPromise);
return requestPromise;
}
/**
* 更安全的apiFetch包装器直接返回解析后的JSON数据
*/
export async function apiFetchJson(url, options = {}) {
const response = await apiFetch(url, options);
return response.clone().json();
}
export async function fetchVersionInfo() {
console.log("Placeholder for fetchVersionInfo function.");
// 示例: 如果您有一个/version的API端点
/*
fetch('/version')
.then(res => res.json())
.then(data => {
const versionElement = document.getElementById('system-version');
if (versionElement) {
versionElement.textContent = data.version || 'N/A';
}
});
*/
}

4163
web/static/js/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,817 @@
// frontend/js/components/customSelect.js
var CustomSelect = class _CustomSelect {
constructor(container) {
this.container = container;
this.trigger = this.container.querySelector(".custom-select-trigger");
this.panel = this.container.querySelector(".custom-select-panel");
if (!this.trigger || !this.panel) {
console.warn("CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.", this.container);
return;
}
this.nativeSelect = this.container.querySelector("select");
this.triggerText = this.trigger.querySelector("span");
this.template = this.panel.querySelector(".custom-select-option-template");
if (typeof _CustomSelect.openInstance === "undefined") {
_CustomSelect.openInstance = null;
_CustomSelect.initGlobalListener();
}
if (this.nativeSelect) {
this.generateOptions();
this.updateTriggerText();
}
this.bindEvents();
}
static initGlobalListener() {
document.addEventListener("click", (event) => {
if (_CustomSelect.openInstance && !_CustomSelect.openInstance.container.contains(event.target)) {
_CustomSelect.openInstance.close();
}
});
}
generateOptions() {
this.panel.querySelectorAll(":scope > *:not(.custom-select-option-template)").forEach((child) => child.remove());
Array.from(this.nativeSelect.options).forEach((option) => {
let item;
if (this.template) {
item = this.template.cloneNode(true);
item.classList.remove("custom-select-option-template");
item.removeAttribute("hidden");
} else {
item = document.createElement("a");
item.href = "#";
item.className = "block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600";
}
item.classList.add("custom-select-option");
item.textContent = option.textContent;
item.dataset.value = option.value;
if (option.selected) {
item.classList.add("is-selected");
}
this.panel.appendChild(item);
});
}
bindEvents() {
this.trigger.addEventListener("click", (event) => {
if (this.trigger.classList.contains("is-disabled")) {
return;
}
event.stopPropagation();
if (_CustomSelect.openInstance && _CustomSelect.openInstance !== this) {
_CustomSelect.openInstance.close();
}
this.toggle();
});
if (this.nativeSelect) {
this.panel.addEventListener("click", (event) => {
event.preventDefault();
const option = event.target.closest(".custom-select-option");
if (option) {
this.selectOption(option);
}
});
}
}
selectOption(optionEl) {
const selectedValue = optionEl.dataset.value;
if (this.nativeSelect.value !== selectedValue) {
this.nativeSelect.value = selectedValue;
this.nativeSelect.dispatchEvent(new Event("change", { bubbles: true }));
}
this.updateTriggerText();
this.panel.querySelectorAll(".custom-select-option").forEach((el) => el.classList.remove("is-selected"));
optionEl.classList.add("is-selected");
this.close();
}
updateTriggerText() {
if (!this.nativeSelect || !this.triggerText) return;
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
if (selectedOption) {
this.triggerText.textContent = selectedOption.textContent;
}
}
toggle() {
this.panel.classList.toggle("hidden");
if (this.panel.classList.contains("hidden")) {
if (_CustomSelect.openInstance === this) {
_CustomSelect.openInstance = null;
}
} else {
_CustomSelect.openInstance = this;
}
}
open() {
this.panel.classList.remove("hidden");
_CustomSelect.openInstance = this;
}
close() {
this.panel.classList.add("hidden");
if (_CustomSelect.openInstance === this) {
_CustomSelect.openInstance = null;
}
}
};
// frontend/js/components/ui.js
var ModalManager = class {
/**
* Shows a generic modal by its ID.
* @param {string} modalId The ID of the modal element to show.
*/
show(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("hidden");
} else {
console.error(`Modal with ID "${modalId}" not found.`);
}
}
/**
* Hides a generic modal by its ID.
* @param {string} modalId The ID of the modal element to hide.
*/
hide(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("hidden");
} else {
console.error(`Modal with ID "${modalId}" not found.`);
}
}
/**
* Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts.
* It dynamically sets the title, message, and confirm action for a generic confirmation modal.
* @param {object} options - The options for the confirmation modal.
* @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal').
* @param {string} options.title - The title to display in the modal header.
* @param {string} options.message - The message to display in the modal body. Can contain HTML.
* @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked.
* @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled.
*/
showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) {
const modalElement = document.getElementById(modalId);
if (!modalElement) {
console.error(`Confirmation modal with ID "${modalId}" not found.`);
return;
}
const titleElement = modalElement.querySelector('[id$="ModalTitle"]');
const messageElement = modalElement.querySelector('[id$="ModalMessage"]');
const confirmButton = modalElement.querySelector('[id^="confirm"]');
if (!titleElement || !messageElement || !confirmButton) {
console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`);
return;
}
titleElement.textContent = title;
messageElement.innerHTML = message;
confirmButton.disabled = disableConfirm;
const newConfirmButton = confirmButton.cloneNode(true);
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
newConfirmButton.onclick = () => onConfirm();
this.show(modalId);
}
/**
* Shows a result modal to indicate the outcome of an operation (success or failure).
* @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators.
* @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content.
* @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed.
*/
showResult(success, message, autoReload = false) {
const modalElement = document.getElementById("resultModal");
if (!modalElement) {
console.error("Result modal with ID 'resultModal' not found.");
return;
}
const titleElement = document.getElementById("resultModalTitle");
const messageElement = document.getElementById("resultModalMessage");
const iconElement = document.getElementById("resultIcon");
const confirmButton = document.getElementById("resultModalConfirmBtn");
if (!titleElement || !messageElement || !iconElement || !confirmButton) {
console.error("Result modal is missing required child elements.");
return;
}
titleElement.textContent = success ? "\u64CD\u4F5C\u6210\u529F" : "\u64CD\u4F5C\u5931\u8D25";
if (success) {
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
iconElement.className = "text-6xl mb-3 text-success-500";
} else {
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
iconElement.className = "text-6xl mb-3 text-danger-500";
}
messageElement.innerHTML = "";
if (typeof message === "string") {
const messageDiv = document.createElement("div");
messageDiv.innerText = message;
messageElement.appendChild(messageDiv);
} else if (message instanceof Node) {
messageElement.appendChild(message);
} else {
const messageDiv = document.createElement("div");
messageDiv.innerText = String(message);
messageElement.appendChild(messageDiv);
}
confirmButton.onclick = () => this.closeResult(autoReload);
this.show("resultModal");
}
/**
* Closes the result modal.
* @param {boolean} [reload=false] - If true, reloads the page after closing the modal.
*/
closeResult(reload = false) {
this.hide("resultModal");
if (reload) {
location.reload();
}
}
/**
* Shows and initializes the progress modal for long-running operations.
* @param {string} title - The title to display for the progress modal.
*/
showProgress(title) {
const modal = document.getElementById("progressModal");
if (!modal) {
console.error("Progress modal with ID 'progressModal' not found.");
return;
}
const titleElement = document.getElementById("progressModalTitle");
const statusText = document.getElementById("progressStatusText");
const progressBar = document.getElementById("progressBar");
const progressPercentage = document.getElementById("progressPercentage");
const progressLog = document.getElementById("progressLog");
const closeButton = document.getElementById("progressModalCloseBtn");
const closeIcon = document.getElementById("closeProgressModalBtn");
if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) {
console.error("Progress modal is missing required child elements.");
return;
}
titleElement.textContent = title;
statusText.textContent = "\u51C6\u5907\u5F00\u59CB...";
progressBar.style.width = "0%";
progressPercentage.textContent = "0%";
progressLog.innerHTML = "";
closeButton.disabled = true;
closeIcon.disabled = true;
this.show("progressModal");
}
/**
* Updates the progress bar and status text within the progress modal.
* @param {number} processed - The number of items that have been processed.
* @param {number} total - The total number of items to process.
* @param {string} status - The current status message to display.
*/
updateProgress(processed, total, status) {
const modal = document.getElementById("progressModal");
if (!modal || modal.classList.contains("hidden")) return;
const progressBar = document.getElementById("progressBar");
const progressPercentage = document.getElementById("progressPercentage");
const statusText = document.getElementById("progressStatusText");
const closeButton = document.getElementById("progressModalCloseBtn");
const closeIcon = document.getElementById("closeProgressModalBtn");
const percentage = total > 0 ? Math.round(processed / total * 100) : 0;
progressBar.style.width = `${percentage}%`;
progressPercentage.textContent = `${percentage}%`;
statusText.textContent = status;
if (processed === total) {
closeButton.disabled = false;
closeIcon.disabled = false;
}
}
/**
* Adds a log entry to the progress modal's log area.
* @param {string} message - The log message to append.
* @param {boolean} [isError=false] - If true, styles the log entry as an error.
*/
addProgressLog(message, isError = false) {
const progressLog = document.getElementById("progressLog");
if (!progressLog) return;
const logEntry = document.createElement("div");
logEntry.textContent = message;
logEntry.className = isError ? "text-danger-600" : "text-gray-700";
progressLog.appendChild(logEntry);
progressLog.scrollTop = progressLog.scrollHeight;
}
/**
* Closes the progress modal.
* @param {boolean} [reload=false] - If true, reloads the page after closing.
*/
closeProgress(reload = false) {
this.hide("progressModal");
if (reload) {
location.reload();
}
}
};
var UIPatterns = class {
/**
* Animates numerical values in elements from 0 to their target number.
* The target number is read from the element's text content.
* @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value').
* @param {number} [duration=1500] - The duration of the animation in milliseconds.
*/
animateCounters(selector = ".stat-value", duration = 1500) {
const statValues = document.querySelectorAll(selector);
statValues.forEach((valueElement) => {
const finalValue = parseInt(valueElement.textContent, 10);
if (isNaN(finalValue)) return;
if (!valueElement.dataset.originalValue) {
valueElement.dataset.originalValue = valueElement.textContent;
}
let startValue = 0;
const startTime = performance.now();
const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime;
if (elapsedTime < duration) {
const progress = elapsedTime / duration;
const easeOutValue = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.floor(easeOutValue * finalValue);
valueElement.textContent = currentValue;
requestAnimationFrame(updateCounter);
} else {
valueElement.textContent = valueElement.dataset.originalValue;
}
};
requestAnimationFrame(updateCounter);
});
}
/**
* Toggles the visibility of a content section with a smooth height animation.
* It expects a specific HTML structure where the header and content are within a common parent (e.g., a card).
* The content element should have a `collapsed` class when hidden.
* @param {HTMLElement} header - The header element that was clicked to trigger the toggle.
*/
toggleSection(header) {
const card = header.closest(".stats-card");
if (!card) return;
const content = card.querySelector(".key-content");
const toggleIcon = header.querySelector(".toggle-icon");
if (!content || !toggleIcon) {
console.error("Toggle section failed: Content or icon element not found.", { header });
return;
}
const isCollapsed = content.classList.contains("collapsed");
toggleIcon.classList.toggle("collapsed", !isCollapsed);
if (isCollapsed) {
content.classList.remove("collapsed");
content.style.maxHeight = null;
content.style.opacity = null;
content.style.paddingTop = null;
content.style.paddingBottom = null;
content.style.overflow = "hidden";
requestAnimationFrame(() => {
const targetHeight = content.scrollHeight;
content.style.maxHeight = `${targetHeight}px`;
content.style.opacity = "1";
content.style.paddingTop = "1rem";
content.style.paddingBottom = "1rem";
content.addEventListener("transitionend", function onExpansionEnd() {
content.removeEventListener("transitionend", onExpansionEnd);
if (!content.classList.contains("collapsed")) {
content.style.maxHeight = "";
content.style.overflow = "visible";
}
}, { once: true });
});
} else {
const currentHeight = content.scrollHeight;
content.style.maxHeight = `${currentHeight}px`;
content.style.overflow = "hidden";
requestAnimationFrame(() => {
content.style.maxHeight = "0px";
content.style.opacity = "0";
content.style.paddingTop = "0";
content.style.paddingBottom = "0";
content.classList.add("collapsed");
});
}
}
};
var modalManager = new ModalManager();
var uiPatterns = new UIPatterns();
// frontend/js/components/taskCenter.js
var TaskCenterManager = class {
constructor() {
this.tasks = [];
this.activePolls = /* @__PURE__ */ new Map();
this.heartbeatInterval = null;
this.MINIMUM_TASK_DISPLAY_TIME_MS = 800;
this.hasUnreadCompletedTasks = false;
this.isAnimating = false;
this.countdownTimer = null;
this.trigger = document.getElementById("task-hub-trigger");
this.panel = document.getElementById("task-hub-panel");
this.countdownBar = document.getElementById("task-hub-countdown-bar");
this.countdownRing = document.getElementById("task-hub-countdown-ring");
this.indicator = document.getElementById("task-hub-indicator");
this.clearBtn = document.getElementById("task-hub-clear-btn");
this.taskListContainer = document.getElementById("task-list-container");
this.emptyState = document.getElementById("task-list-empty");
}
// [THE FINAL, DEFINITIVE VERSION]
init() {
if (!this.trigger || !this.panel) {
console.warn("Task Center UI core elements not found. Initialization skipped.");
return;
}
this.trigger.addEventListener("click", (event) => {
event.stopPropagation();
if (this.isAnimating) return;
if (this.panel.classList.contains("hidden")) {
this._handleUserInteraction();
this.openPanel();
} else {
this.closePanel();
}
});
document.addEventListener("click", (event) => {
if (!this.panel.classList.contains("hidden") && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) {
this.closePanel();
}
});
this.trigger.addEventListener("mouseenter", this._stopCountdown.bind(this));
this.panel.addEventListener("mouseenter", this._handleUserInteraction.bind(this));
const handleMouseLeave = () => {
if (!this.panel.classList.contains("hidden")) {
this._startCountdown();
}
};
this.panel.addEventListener("mouseleave", handleMouseLeave);
this.trigger.addEventListener("mouseleave", handleMouseLeave);
this.clearBtn?.addEventListener("click", this.clearCompletedTasks.bind(this));
this.taskListContainer.addEventListener("click", (event) => {
const toggleHeader = event.target.closest("[data-task-toggle]");
if (!toggleHeader) return;
this._handleUserInteraction();
const taskItem = toggleHeader.closest(".task-list-item");
const content = taskItem.querySelector("[data-task-content]");
if (!content) return;
const isCollapsed = content.classList.contains("collapsed");
toggleHeader.classList.toggle("expanded", isCollapsed);
if (isCollapsed) {
content.classList.remove("collapsed");
content.style.maxHeight = `${content.scrollHeight}px`;
content.style.opacity = "1";
content.addEventListener("transitionend", () => {
if (!content.classList.contains("collapsed")) content.style.maxHeight = "none";
}, { once: true });
} else {
content.style.maxHeight = `${content.scrollHeight}px`;
requestAnimationFrame(() => {
content.style.maxHeight = "0px";
content.style.opacity = "0";
content.classList.add("collapsed");
});
}
});
this._render();
this._startHeartbeat();
console.log("Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].");
}
async startTask(taskDefinition) {
try {
const initialTaskData = await taskDefinition.start();
if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object.");
const newTask = {
id: initialTaskData.id,
definition: taskDefinition,
data: initialTaskData,
timestamp: /* @__PURE__ */ new Date(),
startTime: Date.now()
};
if (!initialTaskData.is_running) {
console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`);
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
this.tasks.unshift(newTask);
this._render();
this._handleTaskCompletion(newTask);
return;
}
this.tasks.unshift(newTask);
this.activePolls.set(newTask.id, newTask);
this._render();
this.openPanel();
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
this._updateIndicatorState();
} catch (error) {
console.error("Failed to start task:", error);
toastManager.show(`\u4EFB\u52A1\u542F\u52A8\u5931\u8D25: ${error.message}`, "error");
}
}
_startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(this._tick.bind(this), 1500);
}
_stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
async _tick() {
if (this.activePolls.size === 0) {
return;
}
for (const taskId of [...this.activePolls.keys()]) {
const task = this.activePolls.get(taskId);
if (!task) continue;
try {
const response = await task.definition.poll(taskId);
if (!response.success || !response.data) throw new Error(response.message || "Polling failed");
const oldData = { ...task.data };
task.data = response.data;
this._updateTaskItemInHistory(task.id, task.data);
task.definition.renderToastNarrative(task.data, oldData, toastManager);
if (!task.data.is_running) {
this._handleTaskCompletion(task);
}
} catch (error) {
console.error(`Polling for task ${taskId} failed:`, error);
task.data.error = error.message;
this._updateTaskItemInHistory(task.id, task.data);
this._handleTaskCompletion(task);
}
}
}
_handleTaskCompletion(task) {
this.activePolls.delete(task.id);
this._updateIndicatorState();
const toastId = `task-${task.id}`;
const finalize = async () => {
await toastManager.dismiss(toastId, !task.data.error);
this._updateTaskItemInDom(task);
this.hasUnreadCompletedTasks = true;
this._updateIndicatorState();
if (task.data.error) {
if (task.definition.onError) task.definition.onError(task.data);
} else {
if (task.definition.onSuccess) task.definition.onSuccess(task.data);
}
};
const elapsedTime = Date.now() - task.startTime;
const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime;
if (remainingTime > 0) {
setTimeout(finalize, remainingTime);
} else {
finalize();
}
}
// [REFACTORED for robustness]
_updateIndicatorState() {
const hasRunningTasks = this.activePolls.size > 0;
const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks;
this.indicator.classList.toggle("hidden", !shouldBeVisible);
}
// [REFACTORED for robustness]
clearCompletedTasks() {
this.tasks = this.tasks.filter((task) => this.activePolls.has(task.id));
this.hasUnreadCompletedTasks = false;
this._render();
}
// [NEW SAFETY METHOD]
_updateTaskItemInHistory(taskId, newData) {
const taskInHistory = this.tasks.find((t) => t.id === taskId);
if (taskInHistory) {
taskInHistory.data = newData;
}
}
// --- 渲染与DOM操作 ---
_render() {
this.taskListContainer.innerHTML = this.tasks.map((task) => this._createTaskItemHtml(task)).join("");
const hasTasks = this.tasks.length > 0;
this.taskListContainer.classList.toggle("hidden", !hasTasks);
this.emptyState.classList.toggle("hidden", hasTasks);
this._updateIndicatorState();
}
_createTaskItemHtml(task) {
const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
return `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
}
_updateTaskItemInDom(task) {
const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`);
if (item) {
item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
}
}
// --- 核心面板开关逻辑 ---
openPanel() {
if (this.isAnimating || !this.panel.classList.contains("hidden")) return;
this.isAnimating = true;
this.panel.classList.remove("hidden");
this.panel.classList.add("animate-panel-in");
setTimeout(() => {
this.panel.classList.remove("animate-panel-in");
this.isAnimating = false;
this._startCountdown();
}, 150);
}
closePanel() {
if (this.isAnimating || this.panel.classList.contains("hidden")) return;
this._stopCountdown();
this.isAnimating = true;
this.panel.classList.add("animate-panel-out");
setTimeout(() => {
this.panel.classList.remove("animate-panel-out");
this.panel.classList.add("hidden");
this.isAnimating = false;
}, 50);
}
// --- [新增] 倒计时管理方法 ---
/**
* 启动或重启倒计时和进度条动画
* @private
*/
_startCountdown() {
this._stopCountdown();
this.countdownBar.classList.add("w-full", "duration-[4950ms]");
this.countdownRing.style.transition = "none";
this.countdownRing.style.strokeDashoffset = "72.26";
void this.countdownRing.offsetHeight;
this.countdownRing.style.transition = "stroke-dashoffset 4.95s linear";
this.countdownRing.style.strokeDashoffset = "0";
this.countdownTimer = setTimeout(() => {
this.closePanel();
}, 4950);
}
/**
* 停止倒计时并重置进度条
* @private
*/
_stopCountdown() {
if (this.countdownTimer) {
clearTimeout(this.countdownTimer);
this.countdownTimer = null;
}
this.countdownBar.classList.remove("w-full");
this.countdownRing.style.transition = "none";
this.countdownRing.style.strokeDashoffset = "72.26";
}
// [NEW] A central handler for any action that confirms the user has seen the panel.
_handleUserInteraction() {
this._stopCountdown();
if (this.hasUnreadCompletedTasks) {
this.hasUnreadCompletedTasks = false;
this._updateIndicatorState();
}
}
_formatTimeAgo(date) {
if (!date) return "";
const seconds = Math.floor((/* @__PURE__ */ new Date() - new Date(date)) / 1e3);
if (seconds < 2) return "\u521A\u521A";
if (seconds < 60) return `${seconds}\u79D2\u524D`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}\u5206\u949F\u524D`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}\u5C0F\u65F6\u524D`;
const days = Math.floor(hours / 24);
return `${days}\u5929\u524D`;
}
};
var ToastManager = class {
constructor() {
this.container = document.getElementById("toast-container");
if (!this.container) {
this.container = document.createElement("div");
this.container.id = "toast-container";
this.container.className = "fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3";
document.body.appendChild(this.container);
}
this.activeToasts = /* @__PURE__ */ new Map();
}
/**
* 显示一个 Toast 通知
* @param {string} message - The message to display.
* @param {string} [type='info'] - 'info', 'success', or 'error'.
* @param {number} [duration=4000] - Duration in milliseconds.
*/
show(message, type = "info", duration = 4e3) {
const toastElement = this._createToastHtml(message, type);
this.container.appendChild(toastElement);
requestAnimationFrame(() => {
toastElement.classList.remove("opacity-0", "translate-y-2");
toastElement.classList.add("opacity-100", "translate-y-0");
});
setTimeout(() => {
toastElement.classList.remove("opacity-100", "translate-y-0");
toastElement.classList.add("opacity-0", "translate-y-2");
toastElement.addEventListener("transitionend", () => toastElement.remove(), { once: true });
}, duration);
}
// [NEW] 创建或更新一个带进度条的Toast
showProgressToast(toastId, title, message, progress) {
if (this.activeToasts.has(toastId)) {
const toastElement = this.activeToasts.get(toastId);
const messageEl = toastElement.querySelector(".toast-message");
const progressBar = toastElement.querySelector(".toast-progress-bar");
messageEl.textContent = `${message} - ${Math.round(progress)}%`;
anime({
targets: progressBar,
width: `${progress}%`,
duration: 400,
easing: "easeOutQuad"
});
} else {
const toastElement = this._createProgressToastHtml(toastId, title, message, progress);
this.container.appendChild(toastElement);
this.activeToasts.set(toastId, toastElement);
requestAnimationFrame(() => {
toastElement.classList.remove("opacity-0", "translate-x-full");
toastElement.classList.add("opacity-100", "translate-x-0");
});
}
}
// [NEW] 移除一个进度Toast
dismiss(toastId, success = null) {
return new Promise((resolve) => {
if (!this.activeToasts.has(toastId)) {
resolve();
return;
}
const toastElement = this.activeToasts.get(toastId);
const performFadeOut = () => {
toastElement.classList.remove("opacity-100", "translate-x-0");
toastElement.classList.add("opacity-0", "translate-x-full");
toastElement.addEventListener("transitionend", () => {
toastElement.remove();
this.activeToasts.delete(toastId);
resolve();
}, { once: true });
};
if (success === null) {
performFadeOut();
} else {
const iconContainer = toastElement.querySelector(".toast-icon");
const messageEl = toastElement.querySelector(".toast-message");
if (success) {
const progressBar = toastElement.querySelector(".toast-progress-bar");
messageEl.textContent = "\u5DF2\u5B8C\u6210";
anime({
targets: progressBar,
width: "100%",
duration: 300,
easing: "easeOutQuad",
complete: () => {
iconContainer.innerHTML = `<i class="fas fa-check-circle text-white"></i>`;
iconContainer.className = `toast-icon bg-green-500`;
setTimeout(performFadeOut, 900);
}
});
} else {
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
iconContainer.className = `toast-icon bg-red-500`;
messageEl.textContent = "\u5931\u8D25";
setTimeout(performFadeOut, 1200);
}
}
});
}
_createToastHtml(message, type) {
const icons = {
info: { class: "bg-blue-500", icon: "fa-info-circle" },
success: { class: "bg-green-500", icon: "fa-check-circle" },
error: { class: "bg-red-500", icon: "fa-exclamation-triangle" }
};
const typeInfo = icons[type] || icons.info;
const toast = document.createElement("div");
toast.className = "toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out";
toast.innerHTML = `
<div class="toast-icon ${typeInfo.class}">
<i class="fas ${typeInfo.icon}"></i>
</div>
<div class="toast-content">
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
<p class="toast-message">${message}</p>
</div>
`;
return toast;
}
// [NEW] 创建带进度条Toast的HTML结构
_createProgressToastHtml(toastId, title, message, progress) {
const toast = document.createElement("div");
toast.className = "toast-item opacity-0 translate-x-full transition-all duration-300 ease-out";
toast.dataset.toastId = toastId;
toast.innerHTML = `
<div class="toast-icon bg-blue-500">
<i class="fas fa-spinner animate-spin"></i>
</div>
<div class="toast-content">
<p class="toast-title">${title}</p>
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
</div>
</div>
`;
return toast;
}
_capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
};
var taskCenterManager = new TaskCenterManager();
var toastManager = new ToastManager();
export {
CustomSelect,
modalManager,
uiPatterns,
taskCenterManager,
toastManager
};

View File

@@ -0,0 +1,83 @@
// frontend/js/services/api.js
var APIClientError = class extends Error {
constructor(message, status, code, rawMessageFromServer) {
super(message);
this.name = "APIClientError";
this.status = status;
this.code = code;
this.rawMessageFromServer = rawMessageFromServer;
}
};
var apiPromiseCache = /* @__PURE__ */ new Map();
async function apiFetch(url, options = {}) {
const isGetRequest = !options.method || options.method.toUpperCase() === "GET";
const cacheKey = isGetRequest && !options.noCache ? url : null;
if (cacheKey && apiPromiseCache.has(cacheKey)) {
return apiPromiseCache.get(cacheKey);
}
const token = localStorage.getItem("bearerToken");
const headers = {
"Content-Type": "application/json",
...options.headers
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const requestPromise = (async () => {
try {
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
if (cacheKey) apiPromiseCache.delete(cacheKey);
localStorage.removeItem("bearerToken");
if (window.location.pathname !== "/login") {
window.location.href = "/login?error=\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002";
}
throw new APIClientError("Unauthorized", 401, "UNAUTHORIZED", "Session expired or token is invalid.");
}
if (!response.ok) {
let errorData = null;
let rawMessage = "";
try {
rawMessage = await response.text();
if (rawMessage) {
errorData = JSON.parse(rawMessage);
}
} catch (e) {
errorData = { error: { code: "UNKNOWN_FORMAT", message: rawMessage || response.statusText } };
}
const code = errorData?.error?.code || "UNKNOWN_ERROR";
const messageFromServer = errorData?.error?.message || rawMessage || "No message provided by server.";
const error = new APIClientError(
`API request failed: ${response.status}`,
response.status,
code,
messageFromServer
);
throw error;
}
return response;
} catch (error) {
if (cacheKey) apiPromiseCache.delete(cacheKey);
throw error;
}
})();
if (cacheKey) {
apiPromiseCache.set(cacheKey, requestPromise);
}
return requestPromise;
}
async function apiFetchJson(url, options = {}) {
try {
const response = await apiFetch(url, options);
const clonedResponse = response.clone();
const jsonData = await clonedResponse.json();
return jsonData;
} catch (error) {
throw error;
}
}
export {
apiFetch,
apiFetchJson
};

View File

@@ -0,0 +1,7 @@
// frontend/js/pages/dashboard.js
function init() {
console.log("[Modern Frontend] Dashboard module loaded. Future logic will execute here.");
}
export {
init as default
};

View File

@@ -0,0 +1,91 @@
// Filename: public/static/js/dashboard-chart.js (V2.0 - 兼容全局授权版)
// export default function initializeDashboardChart() {
// ========================================================================= //
// Dashboard Chart Module //
// ========================================================================= //
/** @type {import('chart.js').Chart | null} 专属的图表实例 */
let historicalChartInstance = null;
// 使用 DOMContentLoaded 确保页面结构加载完毕后再执行
document.addEventListener('DOMContentLoaded', main);
function main() {
setupChartEventListeners();
fetchChartData();
// [新增] 监听来自主程序的刷新命令
window.addEventListener('refresh-chart', () => fetchChartData(document.getElementById('chartGroupFilter')?.value));
}
function setupChartEventListeners() {
const chartGroupFilter = document.getElementById('chartGroupFilter');
if (chartGroupFilter) {
chartGroupFilter.addEventListener('change', (e) => fetchChartData(e.target.value));
}
}
function handleFilterChange(groupId) {
fetchChartData(groupId);
}
async function fetchChartData(groupId = '') {
const url = groupId ? `/admin/dashboard/chart?group_id=${groupId}` : '/admin/dashboard/chart';
try {
const response = await apiFetch(url, { noCache: true }); // 图表数据总是获取最新的
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const chartData = await response.json();
renderHistoricalChart(chartData);
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to fetch chart data:', error);
renderHistoricalChart(null);
}
}
}
function renderHistoricalChart(chartData) {
const canvas = document.getElementById('historicalChart');
if (!canvas) return;
// [关键] 我们不再需要 setTimeout 检查!
// 因为HTML的加载顺序保证了当这个脚本执行时`Chart` 对象必然存在。
const ctx = canvas.getContext('2d');
if (historicalChartInstance) {
historicalChartInstance.destroy();
}
if (!chartData || !chartData.labels || chartData.labels.length === 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "16px Inter, sans-serif";
ctx.fillStyle = "#9ca3af";
ctx.textAlign = "center";
ctx.fillText("暂无图表数据", canvas.width / 2, canvas.height / 2);
return;
}
// 创建新图表
historicalChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: chartData.datasets.map(dataset => ({
label: dataset.label, data: dataset.data, borderColor: dataset.color,
backgroundColor: `${dataset.color}33`, pointBackgroundColor: dataset.color,
pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: dataset.color, fill: true, tension: 0.4
}))
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.05)' } },
x: { grid: { display: false } }
},
plugins: {
legend: { position: 'top', align: 'end', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } }
},
interaction: { intersect: false, mode: 'index' }
}
});
}

439
web/static/js/dashboard.js Normal file
View File

@@ -0,0 +1,439 @@
// ========================================================================= //
// 全局变量 //
// ========================================================================= //
/** @type {number | null} 全局自动刷新定时器ID */
let autoRefreshInterval = null;
/** @type {StatusGrid | null} API状态分布网格的实例 */
let apiCanvasInstance = null;
/** @type {import('chart.js').Chart | null} 全局历史趋势图表实例 */
let historicalChartInstance = null;
// 预热关键数据
document.addEventListener('DOMContentLoaded', function() {
apiFetch('/admin/dashboard/overview');
apiFetch('/admin/keygroups');
// ... dashboard页面的其他初始化代码 ...
});
// ========================================================================= //
// 主程序入口 //
// ========================================================================= //
// 脚本位于<body>末尾无需等待DOMContentLoaded立即执行主程序。
main();
/**
* 主执行函数负责编排核心UI渲染和非核心模块的异步加载。
* [语法修正] 此函数必须是 async以允许在其中使用 await。
*/
async function main() {
// 阶段一立即初始化并渲染所有核心UI
initializeStaticFeatures();
await hydrateCoreUIFromPrefetchedData(); // 等待核心UI所需数据加载并渲染完成
// (图表模块),并与之分离
// 这个函数调用本身是同步的,但它内部会启动一个不会阻塞主流程的异步加载过程。
loadChartModulesAndRender();
}
// ========================================================================= //
// 核心UI功能 //
// ========================================================================= //
/**
* 负责初始化所有静态的、非数据驱动的UI功能。
*/
function initializeStaticFeatures() {
setupCoreEventListeners(); // [名称修正] 只监听核心UI相关的事件
initializeDropdownMenu();
initializeAutoRefreshControls();
initStatItemAnimations();
}
/**
* 核心数据“水合”函数。
* 它的使命是使用由 base.html 中的“信使”脚本预取的数据尽快填充核心UI。
*/
async function hydrateCoreUIFromPrefetchedData() {
// 初始化Canvas网格实例
if (document.getElementById('poolGridCanvas')) {
apiCanvasInstance = new StatusGrid('poolGridCanvas');
apiCanvasInstance.init();
}
// 从缓存中等待并获取“概览”数据,然后更新统计卡片
try {
const overviewResponse = await apiFetch('/admin/dashboard/overview');
const overviewData = await overviewResponse.json();
updateStatCards(overviewData);
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to hydrate overview data:', error);
showNotification('渲染总览数据失败。', 'error');
}
}
// 从缓存中等待并获取“分组”数据,然后填充下拉菜单
try {
const keygroupsResponse = await apiFetch('/admin/keygroups');
const keygroupsData = await keygroupsResponse.json();
populateSelectWithOptions(keygroupsData);
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to hydrate keygroups data:', error);
}
}
}
// ========================================================================= //
// 二级火箭:图表模块功能 //
// ========================================================================= //
/**
* 动态加载Chart.js引擎并在加载成功后启动图表的渲染流程。
* 这个过程是完全异步的,不会阻塞页面其它部分的交互。
*/
function loadChartModulesAndRender() {
// 辅助函数:通过动态创建<script>标签来注入外部JS
const injectScript = (src) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
};
// 1. 启动Chart.js引擎的后台下载
injectScript('https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js')
.then(() => {
// 2. 当且仅当引擎加载成功后,才执行图表相关的初始化
console.log("Chart.js engine loaded successfully. Fetching chart data...");
setupChartEventListeners(); // 绑定图表专用的事件监听
fetchAndRenderChart(); // 获取数据并渲染图表
})
.catch(error => {
console.error("Failed to load Chart.js engine:", error);
showNotification('图表引擎加载失败。', 'error');
});
}
/**
* 负责图表模块的数据获取与渲染。
* @param {string} [groupId=''] - 可选的组ID用于筛选数据。
*/
async function fetchAndRenderChart(groupId = '') {
const url = groupId ? `/admin/dashboard/chart?group_id=${groupId}` : '/admin/dashboard/chart';
try {
// 图表数据总是获取最新的,不使用缓存
const response = await apiFetch(url, { noCache: true });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const chartData = await response.json();
// --- 渲染逻辑 ---
const canvas = document.getElementById('historicalChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (historicalChartInstance) {
historicalChartInstance.destroy();
}
const noData = !chartData || !chartData.datasets || chartData.datasets.length === 0;
if (noData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = "16px Inter, sans-serif";
ctx.fillStyle = "#9ca3af";
ctx.textAlign = "center";
ctx.fillText("暂无图表数据", canvas.width / 2, canvas.height / 2);
return;
}
historicalChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: chartData.datasets.map(dataset => ({
label: dataset.label, data: dataset.data, borderColor: dataset.color,
backgroundColor: `${dataset.color}33`, pointBackgroundColor: dataset.color,
pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: dataset.color, fill: true, tension: 0.4
}))
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.05)' } },
x: { grid: { display: false } }
},
plugins: {
legend: { position: 'top', align: 'end', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } }
},
interaction: { intersect: false, mode: 'index' }
}
});
} catch (error) {
if (error.message !== 'Unauthorized') {
console.error('Failed to fetch chart data:', error);
// 可以在此处调用渲染函数并传入null来显示错误信息
showNotification('渲染图表失败,请检查控制台。', 'error');
}
}
}
// ========================================================================= //
// 辅助函数与事件监听 //
// ========================================================================= //
/**
* [名称修正] 绑定所有与核心UI非图表相关的事件。
*/
function setupCoreEventListeners() {
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
if (autoRefreshToggle) {
autoRefreshToggle.addEventListener('change', handleAutoRefreshToggle);
}
const refreshButton = document.querySelector('button[title="手动刷新"]');
if (refreshButton) {
refreshButton.addEventListener('click', () => refreshPage(refreshButton));
}
}
/**
* [新增] 绑定图表专用的事件监听。此函数在Chart.js加载后才被调用。
*/
function setupChartEventListeners() {
const chartGroupFilter = document.getElementById('chartGroupFilter');
if (chartGroupFilter) {
chartGroupFilter.addEventListener('change', (e) => fetchAndRenderChart(e.target.value));
}
}
/**
* [重构] 一个纯粹的UI填充函数它只负责根据传入的数据渲染下拉菜单。
* @param {object} result - 从 /admin/keygroups API 返回的完整JSON对象。
*/
function populateSelectWithOptions(result) {
const groups = result?.data?.items || [];
const selectElements = ['chartGroupFilter', 'poolGroupFilter'];
selectElements.forEach(selectId => {
const selectElement = document.getElementById(selectId);
if (!selectElement) return;
const currentVal = selectElement.value;
selectElement.innerHTML = '<option value="">所有分组</option>';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name.length > 20 ? group.name.substring(0, 20) + '...' : group.name;
selectElement.appendChild(option);
});
selectElement.value = currentVal;
});
}
/**
* 更新所有统计卡片的数字。
* @param {object} data - 从 /admin/dashboard/overview API 返回的数据对象。
*/
function updateStatCards(data) {
const useAnimation = true; // 动画默认开启
if (!data) return;
const updateFn = useAnimation ? animateValue : (id, val) => {
const elem = document.getElementById(id);
if (elem) elem.textContent = (val || 0).toLocaleString();
};
if (data.key_count) {
updateFn('stat-total-keys', data.key_count.value || 0);
updateFn('stat-invalid-keys', data.key_count.sub_value || 0);
}
if (data.key_status_count) {
updateFn('stat-valid-keys', data.key_status_count.ACTIVE || 0);
updateFn('stat-cooldown-keys', data.key_status_count.COOLDOWN || 0);
if (apiCanvasInstance) {
apiCanvasInstance.updateData(data.key_status_count);
}
updateLegendCounts(data.key_status_count);
}
if (data.request_counts) {
updateFn('stat-calls-1m', data.request_counts["1m"] || 0);
updateFn('stat-calls-1h', data.request_counts["1h"] || 0);
updateFn('stat-calls-1d', data.request_counts["1d"] || 0);
updateFn('stat-calls-30d', data.request_counts["30d"] || 0);
}
}
/**
* 更新API状态图例下方的各项计数。
* @param {object} counts - 包含各状态计数的对象。
*/
function updateLegendCounts(counts) {
if (!counts) return;
['active', 'pending', 'cooldown', 'disabled', 'banned'].forEach(field => {
const elem = document.getElementById(`legend-${field}`);
if(elem) elem.textContent = (counts[field.toUpperCase()] || 0).toLocaleString();
});
}
/**
* 统一的刷新函数,供手动和自动刷新调用。
*/
async function refreshAllData() {
// 强制刷新核心UI数据
try {
const overviewResponse = await apiFetch('/admin/dashboard/overview', { noCache: true });
const overviewData = await overviewResponse.json();
updateStatCards(overviewData); // 使用无动画的方式更新,追求速度
} catch(e) { console.error("Failed to refresh overview", e); }
// 强制刷新图表数据
if (typeof Chart !== 'undefined' && historicalChartInstance) { // 仅当图表已加载时才刷新
fetchAndRenderChart(document.getElementById('chartGroupFilter')?.value);
}
}
/**
* 处理手动刷新按钮的点击事件。
* @param {HTMLElement} button - 被点击的刷新按钮。
*/
function refreshPage(button) {
if (!button || button.disabled) return;
button.disabled = true;
const icon = button.querySelector('i');
if (icon) icon.classList.add('fa-spin');
showNotification('正在刷新...', 'info', 1500);
refreshAllData().finally(() => {
setTimeout(() => {
button.disabled = false;
if (icon) icon.classList.remove('fa-spin');
}, 500);
});
}
/**
* 处理自动刷新开关的事件。
* @param {Event} event - change事件对象。
*/
function handleAutoRefreshToggle(event) {
const isEnabled = event.target.checked;
localStorage.setItem("autoRefreshEnabled", isEnabled);
if (isEnabled) {
showNotification("自动刷新已开启 (30秒)", "info", 2000);
refreshAllData(); // 立即执行一次
autoRefreshInterval = setInterval(refreshAllData, 30000);
} else {
if (autoRefreshInterval) {
showNotification("自动刷新已停止", "info", 2000);
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
}
/**
* 初始化自动刷新控件的状态从localStorage读取
*/
function initializeAutoRefreshControls() {
const toggle = document.getElementById("autoRefreshToggle");
if (!toggle) return;
const isEnabled = localStorage.getItem("autoRefreshEnabled") === "true";
if (isEnabled) {
toggle.checked = true;
handleAutoRefreshToggle({ target: toggle });
}
}
/**
* 初始化右上角下拉菜单的交互逻辑。
*/
function initializeDropdownMenu() {
const dropdownButton = document.getElementById('dropdownMenuButton');
const dropdownMenu = document.getElementById('dropdownMenu');
const dropdownToggle = dropdownButton ? dropdownButton.closest('.dropdown-toggle') : null;
if (!dropdownButton || !dropdownMenu || !dropdownToggle) return;
dropdownButton.addEventListener('click', (event) => {
event.stopPropagation();
dropdownMenu.classList.toggle('show');
});
document.addEventListener('click', (event) => {
if (dropdownMenu.classList.contains('show') && !dropdownToggle.contains(event.target)) {
dropdownMenu.classList.remove('show');
}
});
}
/**
* 显示一个全局浮动通知。
* @param {string} message - 通知内容。
* @param {'info'|'success'|'error'} [type='info'] - 通知类型。
* @param {number} [duration=3000] - 显示时长(毫秒)。
*/
function showNotification(message, type = 'info', duration = 3000) {
const container = document.body;
const styles = {
info: { icon: 'fa-info-circle', color: 'blue-500' },
success: { icon: 'fa-check-circle', color: 'green-500' },
error: { icon: 'fa-times-circle', color: 'red-500' }
};
const style = styles[type] || styles.info;
const notification = document.createElement('div');
notification.className = `fixed top-5 right-5 flex items-center bg-white text-gray-800 p-4 rounded-lg shadow-lg border-l-4 border-${style.color} z-[9999] animate-fade-in`;
notification.innerHTML = `<i class="fas ${style.icon} text-${style.color} text-xl mr-3"></i><span class="font-semibold">${message}</span>`;
container.appendChild(notification);
setTimeout(() => {
notification.classList.remove('animate-fade-in');
notification.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
setTimeout(() => notification.remove(), 500);
}, duration);
}
/**
* 为统计卡片的数字添加动画效果。
* @param {string} elementId - 目标元素ID。
* @param {number} endValue - 最终要显示的数字。
*/
function animateValue(elementId, endValue) {
const element = document.getElementById(elementId);
if (!element) return;
const startValue = parseInt(element.textContent.replace(/,/g, '') || '0');
if (startValue === endValue) return;
let startTime = null;
const duration = 1200;
const step = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
const easeOutValue = 1 - Math.pow(1 - progress, 3);
const currentValue = Math.floor(easeOutValue * (endValue - startValue) + startValue);
element.textContent = currentValue.toLocaleString();
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}
/**
* 初始化统计项的悬浮动画(放大效果)。
*/
function initStatItemAnimations() {
document.querySelectorAll(".stat-item").forEach(item => {
item.addEventListener("mouseenter", () => item.style.transform = "scale(1.05)");
item.addEventListener("mouseleave", () => item.style.transform = "");
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2245
web/static/js/keys_status.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
import {
apiFetchJson
} from "./chunk-PLQL6WIO.js";
// frontend/js/pages/logs/logList.js
var LogList = class {
constructor(container) {
this.container = container;
if (!this.container) {
console.error("LogList: container element (tbody) not found.");
}
}
renderLoading() {
if (!this.container) return;
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> \u52A0\u8F7D\u65E5\u5FD7\u4E2D...</td></tr>`;
}
render(logs) {
if (!this.container) return;
if (!logs || logs.length === 0) {
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">\u6CA1\u6709\u627E\u5230\u76F8\u5173\u7684\u65E5\u5FD7\u8BB0\u5F55\u3002</td></tr>`;
return;
}
const logsHtml = logs.map((log) => this.createLogRowHtml(log)).join("");
this.container.innerHTML = logsHtml;
}
createLogRowHtml(log) {
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : "N/A");
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : "N/A");
const errorTag = log.IsSuccess ? `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-600">\u6210\u529F</span>` : `<span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">${log.ErrorCode || "\u5931\u8D25"}</span>`;
const requestTime = new Date(log.RequestTime).toLocaleString();
return `
<tr class="border-b border-b-border transition-colors hover:bg-muted/80" data-log-id="${log.ID}">
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
<td class="p-4 align-middle font-mono text-muted-foreground">#${log.ID}</td>
<td class="p-4 align-middle font-medium font-mono">${apiKeyName}</td>
<td class="p-4 align-middle">${groupName}</td>
<td class="p-4 align-middle text-foreground">${log.ErrorMessage || (log.IsSuccess ? "" : "\u672A\u77E5\u9519\u8BEF")}</td>
<td class="p-4 align-middle">${errorTag}</td>
<td class="p-4 align-middle font-mono">${log.ModelName}</td>
<td class="p-4 align-middle text-muted-foreground text-xs">${requestTime}</td>
<td class="p-4 align-middle">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="\u67E5\u770B\u8BE6\u60C5">
<i class="fas fa-ellipsis-h h-4 w-4"></i>
</button>
</td>
</tr>
`;
}
};
var logList_default = LogList;
// frontend/js/pages/logs/index.js
var LogsPage = class {
constructor() {
this.state = {
logs: [],
// [修正] 暂时将分页状态设为默认值,直到后端添加分页支持
pagination: { page: 1, pages: 1, total: 0 },
isLoading: true,
filters: { page: 1, page_size: 20 }
};
this.elements = {
tableBody: document.getElementById("logs-table-body")
};
this.initialized = !!this.elements.tableBody;
if (this.initialized) {
this.logList = new logList_default(this.elements.tableBody);
}
}
async init() {
if (!this.initialized) {
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
return;
}
this.initEventListeners();
await this.loadAndRenderLogs();
}
initEventListeners() {
}
async loadAndRenderLogs() {
this.state.isLoading = true;
this.logList.renderLoading();
try {
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
const responseData = await apiFetchJson(url);
if (responseData && responseData.success && Array.isArray(responseData.data)) {
this.state.logs = responseData.data;
this.logList.render(this.state.logs);
} else {
console.error("API response for logs is incorrect:", responseData);
this.logList.render([]);
}
} catch (error) {
console.error("Failed to load logs:", error);
} finally {
this.state.isLoading = false;
}
}
};
function logs_default() {
const page = new LogsPage();
page.init();
}
export {
logs_default as default
};

213
web/static/js/main.js Normal file
View File

@@ -0,0 +1,213 @@
import {
CustomSelect,
modalManager,
taskCenterManager,
toastManager,
uiPatterns
} from "./chunk-EZAP7GR4.js";
import {
apiFetch,
apiFetchJson
} from "./chunk-PLQL6WIO.js";
// frontend/js/components/slidingTabs.js
var SlidingTabs = class {
/**
* @param {HTMLElement} containerElement - The main container element with the `data-sliding-tabs-container` attribute.
*/
constructor(containerElement) {
this.container = containerElement;
this.indicator = this.container.querySelector("[data-tab-indicator]");
this.tabs = this.container.querySelectorAll("[data-tab-item]");
this.activeTab = this.container.querySelector(".tab-active");
if (!this.indicator || this.tabs.length === 0) {
console.error("SlidingTabs component is missing required elements (indicator or items).", this.container);
return;
}
this.init();
}
init() {
if (this.activeTab) {
setTimeout(() => this.updateIndicator(this.activeTab), 50);
}
this.bindEvents();
}
updateIndicator(targetTab) {
if (!targetTab) return;
const containerRect = this.container.getBoundingClientRect();
const targetRect = targetTab.getBoundingClientRect();
const left = targetRect.left - containerRect.left;
const width = targetRect.width;
this.indicator.style.left = `${left}px`;
this.indicator.style.width = `${width}px`;
}
bindEvents() {
this.tabs.forEach((tab) => {
tab.addEventListener("click", (e) => {
if (this.activeTab) {
this.activeTab.classList.remove("tab-active");
}
tab.classList.add("tab-active");
this.activeTab = tab;
this.updateIndicator(this.activeTab);
});
tab.addEventListener("mouseenter", () => {
this.updateIndicator(tab);
});
});
this.container.addEventListener("mouseleave", () => {
this.updateIndicator(this.activeTab);
});
}
};
document.addEventListener("DOMContentLoaded", () => {
const allTabContainers = document.querySelectorAll("[data-sliding-tabs-container]");
allTabContainers.forEach((container) => {
new SlidingTabs(container);
});
});
// frontend/js/components/themeManager.js
var themeManager = {
// 用于存储图标的 SVG HTML
icons: {},
init: function() {
this.html = document.documentElement;
this.buttons = document.querySelectorAll(".theme-btn");
this.cyclerBtn = document.getElementById("theme-cycler-btn");
this.cyclerIconContainer = document.getElementById("theme-cycler-icon");
if (!this.html || this.buttons.length === 0 || !this.cyclerBtn || !this.cyclerIconContainer) {
console.warn("ThemeManager init failed: one or more required elements not found.");
return;
}
this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.storeIcons();
this.buttons.forEach((btn) => {
btn.addEventListener("click", () => this.setTheme(btn.dataset.theme));
});
this.cyclerBtn.addEventListener("click", () => this.cycleTheme());
this.mediaQuery.addEventListener("change", () => this.applyTheme());
this.applyTheme();
},
// 从现有按钮中提取并存储 SVG 图标
storeIcons: function() {
this.buttons.forEach((btn) => {
const theme = btn.dataset.theme;
const svg = btn.querySelector("svg");
if (theme && svg) {
this.icons[theme] = svg.outerHTML;
}
});
},
// 循环切换主题的核心逻辑
cycleTheme: function() {
const themes = ["system", "light", "dark"];
const currentTheme = this.getTheme();
const currentIndex = themes.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % themes.length;
this.setTheme(themes[nextIndex]);
},
applyTheme: function() {
let theme = this.getTheme();
if (theme === "system") {
theme = this.mediaQuery.matches ? "dark" : "light";
}
if (theme === "dark") {
this.html.classList.add("dark");
} else {
this.html.classList.remove("dark");
}
this.updateButtons();
this.updateCyclerIcon();
},
setTheme: function(theme) {
localStorage.setItem("theme", theme);
this.applyTheme();
},
getTheme: function() {
return localStorage.getItem("theme") || "system";
},
updateButtons: function() {
const currentTheme = this.getTheme();
this.buttons.forEach((btn) => {
if (btn.dataset.theme === currentTheme) {
btn.classList.add("bg-white", "dark:bg-zinc-700");
} else {
btn.classList.remove("bg-white", "dark:bg-zinc-700");
}
});
},
// 更新移动端循环按钮的图标
updateCyclerIcon: function() {
if (this.cyclerIconContainer) {
const currentTheme = this.getTheme();
if (this.icons[currentTheme]) {
this.cyclerIconContainer.innerHTML = this.icons[currentTheme];
}
}
}
};
// frontend/js/layout/base.js
function initActiveNav() {
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll(".nav-link");
navLinks.forEach((link) => {
const linkPath = link.getAttribute("href");
if (linkPath && linkPath !== "/" && currentPath.startsWith(linkPath)) {
const wrapper = link.closest(".nav-item-wrapper");
if (wrapper) {
wrapper.dataset.active = "true";
}
}
});
}
function bridgeApiToGlobal() {
window.apiFetch = apiFetch;
window.apiFetchJson = apiFetchJson;
console.log("[Bridge] apiFetch and apiFetchJson are now globally available.");
}
function initLayout() {
console.log("[Init] Executing global layout JavaScript...");
initActiveNav();
themeManager.init();
bridgeApiToGlobal();
}
var base_default = initLayout;
// frontend/js/main.js
var pageModules = {
// 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise
// esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件
"dashboard": () => import("./dashboard-CJJWKYPR.js"),
"keys": () => import("./keys-A2UAJYOX.js"),
"logs": () => import("./logs-FGZ2SMPN.js")
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
// 未来新增的页面只需在这里添加一行映射esbuild会自动处理
};
document.addEventListener("DOMContentLoaded", async () => {
base_default();
const allTabContainers = document.querySelectorAll("[data-sliding-tabs-container]");
allTabContainers.forEach((container) => new SlidingTabs(container));
const allSelectContainers = document.querySelectorAll("[data-custom-select-container]");
allSelectContainers.forEach((container) => new CustomSelect(container));
taskCenterManager.init();
const pageContainer = document.querySelector("[data-page-id]");
if (pageContainer) {
const pageId = pageContainer.dataset.pageId;
if (pageId && pageModules[pageId]) {
try {
const pageModule = await pageModules[pageId]();
if (pageModule.default && typeof pageModule.default === "function") {
pageModule.default();
}
} catch (error) {
console.error(`Failed to load module for page: ${pageId}`, error);
}
}
}
});
window.modalManager = modalManager;
window.taskCenterManager = taskCenterManager;
window.toastManager = toastManager;
window.uiPatterns = uiPatterns;

3055
web/static/js/settings.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
// Filename: web/static/js/status-grid.js (V6.0 - 健壮初始化最终版)
// export default function initializeStatusGrid() {
class StatusGrid {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
if (!this.canvas) { return; }
this.ctx = this.canvas.getContext('2d');
this.config = {
cols: 100, rows: 4, gap: 2, cornerRadius: 2,
colors: {
'ACTIVE': '#22C55E', 'ACTIVE_BLINK': '#A7F3D0',
'PENDING': '#9CA3AF', 'COOLDOWN': '#EAB308',
'DISABLED': '#F97316', 'BANNED': '#EF4444',
'EMPTY': '#F3F4F6',
},
blinkInterval: 100, blinksPerInterval: 2, blinkDuration: 200,
statusOrder: ['ACTIVE', 'COOLDOWN', 'DISABLED', 'BANNED', 'PENDING']
};
this.state = {
squares: [], squareSize: 0,
devicePixelRatio: window.devicePixelRatio || 1,
animationFrameId: null, blinkIntervalId: null,
blinkingSquares: new Set()
};
this.debouncedResize = this.debounce(this.resize.bind(this), 250);
}
init() {
this.setupCanvas();
// 初始化时,绘制一个完全为空的网格
this.state.squares = Array(this.config.rows * this.config.cols).fill({ status: 'EMPTY' });
this.drawFullGrid();
window.addEventListener('resize', this.debouncedResize);
}
/**
* [灵魂重塑] 这是被彻底重写的核心函数
* @param {object} keyStatusCounts - 例如 { "BANNED": 1, "DISABLED": 4, ... }
*/
updateData(keyStatusCounts) {
if (!keyStatusCounts) return;
this.destroyAnimation();
this.state.squares = [];
this._activeIndices = null;
this.state.blinkingSquares.clear();
const totalKeys = Object.values(keyStatusCounts).reduce((s, c) => s + c, 0);
const totalSquares = this.config.rows * this.config.cols;
// 如果没有密钥,则显示为空白的网格
if (totalKeys === 0) {
this.init();
return;
}
let statusMap = [];
let calculatedSquares = 0;
// 1. 严格按照比例,计算每个状态应该占据多少个“像素”
for (const status of this.config.statusOrder) {
const count = keyStatusCounts[status] || 0;
if (count > 0) {
const proportion = count / totalKeys;
const squaresForStatus = Math.floor(proportion * totalSquares);
for (let i = 0; i < squaresForStatus; i++) {
statusMap.push(status);
}
calculatedSquares += squaresForStatus;
}
}
// 2. [关键] 修正四舍五入的误差,将剩余的方块填满
const remainingSquares = totalSquares - calculatedSquares;
if (remainingSquares > 0) {
// 将剩余方块,全部分配给数量最多的那个状态,以使其最不失真
let largestStatus = this.config.statusOrder[0]; // 默认给第一个
let maxCount = -1;
for (const status in keyStatusCounts) {
if (keyStatusCounts[status] > maxCount) {
maxCount = keyStatusCounts[status];
largestStatus = status;
}
}
for (let i = 0; i < remainingSquares; i++) {
statusMap.push(largestStatus);
}
}
// 3. 将最终的、按比例填充的地图,转化为内部的 square 对象
this.state.squares = statusMap.map(status => ({
status: status,
isBlinking: false,
blinkUntil: 0
}));
// 4. [渲染修正] 直接、完整地重绘整个网格,然后启动动画
this.drawFullGrid();
this.startAnimationSystem();
}
// [渲染修正] 一个简单、直接、一次性绘制所有方块的函数
drawFullGrid() {
const rect = this.canvas.getBoundingClientRect();
this.ctx.clearRect(0, 0, rect.width, rect.height);
// offsetX 和 offsetY 是在 setupCanvas 中计算的
const offsetX = this.state.offsetX;
const offsetY = this.state.offsetY;
this.state.squares.forEach((square, i) => {
const c = i % this.config.cols;
const r = Math.floor(i / this.config.cols);
const x = offsetX + c * (this.state.squareSize + this.config.gap);
const y = offsetY + r * (this.state.squareSize + this.config.gap);
const color = this.config.colors[square.status];
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
});
}
startAnimationSystem() {
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
this.state.blinkIntervalId = setInterval(() => {
const activeSquareIndices = this.getActiveSquareIndices();
if (activeSquareIndices.length === 0) return;
for(let i = 0; i < this.config.blinksPerInterval; i++) {
const randomIndex = activeSquareIndices[Math.floor(Math.random() * activeSquareIndices.length)];
const square = this.state.squares[randomIndex];
if (square && !square.isBlinking) {
square.isBlinking = true;
square.blinkUntil = performance.now() + this.config.blinkDuration;
this.state.blinkingSquares.add(randomIndex);
}
}
}, this.config.blinkInterval);
const animationLoop = (timestamp) => {
if (this.state.blinkingSquares.size > 0) {
this.state.blinkingSquares.forEach(index => {
const square = this.state.squares[index];
if(!square) { // 防御性检查
this.state.blinkingSquares.delete(index);
return;
}
const c = index % this.config.cols;
const r = Math.floor(index / this.config.cols);
this.drawSquare(square, c, r);
if (timestamp > square.blinkUntil) {
square.isBlinking = false;
this.state.blinkingSquares.delete(index);
this.drawSquare(square, c, r);
}
});
}
this.state.animationFrameId = requestAnimationFrame(animationLoop);
};
animationLoop();
}
setupCanvas() {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * this.state.devicePixelRatio;
this.canvas.height = rect.height * this.state.devicePixelRatio;
this.ctx.scale(this.state.devicePixelRatio, this.state.devicePixelRatio);
const calculatedWidth = (rect.width - (this.config.cols - 1) * this.config.gap) / this.config.cols;
const calculatedHeight = (rect.height - (this.config.rows - 1) * this.config.gap) / this.config.rows;
this.state.squareSize = Math.max(1, Math.floor(Math.min(calculatedWidth, calculatedHeight)));
const totalGridWidth = this.config.cols * this.state.squareSize + (this.config.cols - 1) * this.config.gap;
const totalGridHeight = this.config.rows * this.state.squareSize + (this.config.rows - 1) * this.config.gap;
this.state.offsetX = Math.floor((rect.width - totalGridWidth) / 2);
this.state.offsetY = Math.floor((rect.height - totalGridHeight) / 2);
}
getActiveSquareIndices() {
if (!this._activeIndices) {
this._activeIndices = [];
this.state.squares.forEach((s, i) => {
if (s.status === 'ACTIVE') this._activeIndices.push(i);
});
}
return this._activeIndices;
}
drawRoundedRect(x, y, size, radius, color) {
this.ctx.fillStyle = color;
this.ctx.beginPath();
this.ctx.moveTo(x + radius, y);
this.ctx.arcTo(x + size, y, x + size, y + size, radius);
this.ctx.arcTo(x + size, y + size, x, y + size, radius);
this.ctx.arcTo(x, y + size, x, y, radius);
this.ctx.arcTo(x, y, x + size, y, radius);
this.ctx.closePath();
this.ctx.fill();
}
drawSquare(square, c, r) {
const x = this.state.offsetX + c * (this.state.squareSize + this.config.gap);
const y = this.state.offsetY + r * (this.state.squareSize + this.config.gap);
const color = square.isBlinking ? this.config.colors.ACTIVE_BLINK : this.config.colors[square.status];
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
}
destroyAnimation() {
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
this.state.animationFrameId = null;
this.state.blinkIntervalId = null;
}
resize() {
this.destroyAnimation();
this.init(); // 重新绘制空网格
// 这里依赖dashboard.js在resize后重新获取并传入数据
}
debounce(func, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), delay);
};
}
}

17
web/static/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "Gemini Balance",
"short_name": "GBalance",
"description": "Gemini API密钥管理工具",
"start_url": "/",
"display": "standalone",
"background_color": "#667eea",
"theme_color": "#764ba2",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,57 @@
const CACHE_NAME = 'gbalance-cache-v1';
const urlsToCache = [
'/',
'/static/manifest.json',
'/static/icons/icon-192x192.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
// 1. 尝试从缓存获取
return cache.match(event.request).then(responseFromCache => {
// 2. 同时从网络获取 (后台进行)
const fetchPromise = fetch(event.request).then(responseFromNetwork => {
// 3. 网络请求成功,更新缓存
cache.put(event.request, responseFromNetwork.clone());
return responseFromNetwork;
}).catch(err => {
// 网络请求失败时,可以选择记录错误或不执行任何操作
console.error('Network fetch failed:', err);
// 确保即使网络失败,如果缓存存在,我们仍然返回缓存
// 如果缓存也不存在,则此 Promise 会 reject
throw err;
});
// 4. 如果缓存存在,立即返回缓存;否则等待网络响应
// 后台的网络请求仍在进行,用于更新缓存
return responseFromCache || fetchPromise;
});
})
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

245
web/templates/auth.html Normal file
View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="zh-CN" class="">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 块:标题 (继承自旧模板的 block title) -->
<title>登录 - GEMINI BALANCER</title>
<!-- 引入项目主CSS文件这是独立页面所必需的 -->
<link rel="stylesheet" href="/static/css/output.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- 块:额外的头部内容 (继承自旧模板的 block head_extra) -->
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out forwards;
}
</style>
</head>
<body class="bg-zinc-100 dark:bg-zinc-900">
<!-- 块:主内容区 (继承自旧模板的 block app_container) -->
<div class="relative w-full h-screen flex flex-col justify-center items-center p-8 overflow-hidden">
<div class="absolute top-6 right-6 flex items-center space-x-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<!-- 注意这个按钮现在只在这个页面生效其JS逻辑也包含在下方 -->
<div id="theme-switcher-container">
<!-- 我们将您的三态切换器HTML暂时放在这里虽然JS还没准备好 -->
<div id="theme-switcher" class="relative z-10 inline-grid grid-cols-3 gap-1 rounded-full bg-gray-950/5 p-1 text-gray-950 dark:bg-white/10 dark:text-white">
<button type="button" data-theme="system" class="theme-btn rounded-full p-1.5 focus:outline-none">
<svg class="size-6" viewBox="0 0 28 28" fill="none"><path d="M7.5 8.5C7.5 7.94772 7.94772 7.5 8.5 7.5H19.5C20.0523 7.5 20.5 7.94772 20.5 8.5V16.5C20.5 17.0523 20.0523 17.5 19.5 17.5H8.5C7.94772 17.5 7.5 17.0523 7.5 16.5V8.5Z" stroke="currentColor"></path><path d="M7.5 8.5C7.5 7.94772 7.94772 7.5 8.5 7.5H19.5C20.0523 7.5 20.5 7.94772 20.5 8.5V14.5C20.5 15.0523 20.0523 15.5 19.5 15.5H8.5C7.94772 15.5 7.5 15.0523 7.5 14.5V8.5Z" stroke="currentColor"></path><path d="M16.5 20.5V17.5H11.5V20.5M16.5 20.5H11.5M16.5 20.5H17.5M11.5 20.5H10.5" stroke="currentColor" stroke-linecap="round"></path></svg>
</button>
<button type="button" data-theme="light" class="theme-btn rounded-full p-1.5 focus:outline-none">
<svg class="size-6" viewBox="0 0 28 28" fill="none"><circle cx="14" cy="14" r="3.5" stroke="currentColor"></circle><path d="M14 8.5V6.5" stroke="currentColor" stroke-linecap="round"></path><path d="M17.889 10.1115L19.3032 8.69727" stroke="currentColor" stroke-linecap="round"></path><path d="M19.5 14L21.5 14" stroke="currentColor" stroke-linecap="round"></path><path d="M17.889 17.8885L19.3032 19.3027" stroke="currentColor" stroke-linecap="round"></path><path d="M14 21.5V19.5" stroke="currentColor" stroke-linecap="round"></path><path d="M8.69663 19.3029L10.1108 17.8887" stroke="currentColor" stroke-linecap="round"></path><path d="M6.5 14L8.5 14" stroke="currentColor" stroke-linecap="round"></path><path d="M8.69663 8.69711L10.1108 10.1113" stroke="currentColor" stroke-linecap="round"></path></svg>
</button>
<button type="button" data-theme="dark" class="theme-btn rounded-full p-1.5 focus:outline-none">
<svg class="size-6" viewBox="0 0 28 28" fill="none"><path d="M10.5 9.99914C10.5 14.1413 13.8579 17.4991 18 17.4991C19.0332 17.4991 20.0176 17.2902 20.9132 16.9123C19.7761 19.6075 17.109 21.4991 14 21.4991C9.85786 21.4991 6.5 18.1413 6.5 13.9991C6.5 10.8902 8.39167 8.22304 11.0868 7.08594C10.7089 7.98159 10.5 8.96597 10.5 9.99914Z" stroke="currentColor" stroke-linejoin="round"></path><path d="M16.3561 6.50754L16.5 5.5L16.6439 6.50754C16.7068 6.94752 17.0525 7.29321 17.4925 7.35607L18.5 7.5L17.4925 7.64393C17.0525 7.70679 16.7068 8.05248 16.6439 8.49246L16.5 9.5L16.3561 8.49246C16.2932 8.05248 15.9475 7.70679 15.5075 7.64393L14.5 7.5L15.5075 7.35607C15.9475 7.29321 16.2932 6.94752 16.3561 6.50754Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20.3561 11.5075L20.5 10.5L20.6439 11.5075C20.7068 11.9475 21.0525 12.2932 21.4925 12.3561L22.5 12.5L21.4925 12.6439C21.0525 12.7068 20.7068 13.0525 20.6439 13.4925L20.5 14.5L20.3561 13.4925C20.2932 13.0525 19.9475 12.7068 19.5075 12.6439L18.5 12.5L19.5075 12.3561C19.9475 12.2932 20.2932 11.9475 20.3561 11.5075Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
</div>
</div>
<div class="w-full max-w-lg mx-auto text-center animate-fade-in-up">
<div class="mb-12">
<img src="/static/icons/logo.png" id="logo-image" alt="GEMINI BALANCER Logo" class="h-20 w-20 mx-auto mb-6 transition-transform duration-300 ease-in-out">
<h1 class="text-4xl font-bold text-zinc-800 dark:text-zinc-100">
GEMINI BALANCER
</h1>
<p class="mt-4 text-lg text-zinc-500 dark:text-zinc-400">
一个强大且优雅的管理后台
</p>
</div>
<p class="mb-8 max-w-md mx-auto text-zinc-600 dark:text-zinc-400">
请输入授权令牌以访问系统。
</p>
<form id="auth-form" class="max-w-md mx-auto" novalidate>
<div class="relative flex items-center group transition-all duration-300 rounded-full bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700
hover:-translate-y-1 hover:border-blue-500 dark:hover:border-blue-400
focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-opacity-50 focus-within:border-transparent">
<!-- [修正#2] 移除了冲突的 focus:border-* 和 focus:ring-* 类 -->
<input
type="password"
id="auth-token"
name="auth_token"
required
placeholder="在此处粘贴您的令牌..."
class="w-full h-14 pl-6 pr-20 rounded-full text-lg bg-white dark:bg-zinc-800 border-2 border-transparent focus:ring-blue-500/20 transition-all duration-300 outline-none text-zinc-800 dark:text-zinc-200"
>
<!-- [修正#1] 按钮恢复为原始的 span/i 结构,解决了椭圆问题 -->
<button type="submit" id="login-button" class="absolute right-2 h-10 w-10 flex items-center justify-center rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-all duration-300 transform hover:scale-110">
<span id="button-icon" class="transition-opacity duration-200">
<i class="fas fa-arrow-right"></i>
</span>
<i id="button-spinner" class="fas fa-spinner fa-spin absolute transition-opacity duration-200 opacity-0 pointer-events-none"></i>
</button>
</div>
</form>
<div id="error-container" class="mt-6 max-w-md mx-auto min-h-[56px]"></div>
</div>
<footer class="absolute bottom-6 text-zinc-500 dark:text-zinc-600 text-sm animate-fade-in-up" style="animation-delay: 0.2s;">
<p>&copy; 2024 GEMINI BALANCER. All Rights Reserved.</p>
</footer>
</div>
<!-- 块:页面脚本 (继承自旧模板的 block body_scripts) -->
<script>
const ThemeManager = {
// 初始化,绑定所有事件
init: function() {
this.html = document.documentElement;
this.buttons = document.querySelectorAll('.theme-btn');
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.buttons.forEach(btn => {
btn.addEventListener('click', () => this.setTheme(btn.dataset.theme));
});
this.mediaQuery.addEventListener('change', () => this.applyTheme());
this.applyTheme();
},
// 核心函数:应用当前主题
applyTheme: function() {
let theme = this.getTheme();
// 解析 'system' 模式
if (theme === 'system') {
theme = this.mediaQuery.matches ? 'dark' : 'light';
}
// 应用 'dark' 或 'light' 类到 <html>
if (theme === 'dark') {
this.html.classList.add('dark');
} else {
this.html.classList.remove('dark');
}
// 更新按钮的选中状态
this.updateButtons();
},
// 设置新主题并存入 localStorage
setTheme: function(theme) {
localStorage.setItem('theme', theme);
this.applyTheme();
},
// 从 localStorage 获取主题,若无则默认为 'system'
getTheme: function() {
return localStorage.getItem('theme') || 'system';
},
// 更新按钮UI
updateButtons: function() {
const currentTheme = this.getTheme();
this.buttons.forEach(btn => {
if (btn.dataset.theme === currentTheme) {
// 这是选中状态
btn.classList.add('bg-white', 'dark:bg-zinc-700');
} else {
// 这是非选中状态
btn.classList.remove('bg-white', 'dark:bg-zinc-700');
}
});
}
};
document.addEventListener('DOMContentLoaded', function() {
// 初始化主题管理器
ThemeManager.init();
// 原有的登录页逻辑
localStorage.removeItem('bearerToken');
const logo = document.getElementById('logo-image');
if (logo) {
let rotationAngle = 0;
setInterval(() => {
rotationAngle += 90;
logo.style.transform = `rotate(${rotationAngle}deg)`;
}, 2000);
}
// --- 登录表单逻辑 (优化版) ---
const form = document.getElementById('auth-form');
const tokenInput = document.getElementById('auth-token');
const loginButton = document.getElementById('login-button');
const buttonIcon = document.getElementById('button-icon');
const buttonSpinner = document.getElementById('button-spinner');
const errorContainer = document.getElementById('error-container');
// [新增] UI状态管理函数确保图标互斥
function setButtonLoading(isLoading) {
// 获取最新的元素引用
const buttonIcon = document.getElementById('button-icon');
const buttonSpinner = document.getElementById('button-spinner');
const loginButton = document.getElementById('login-button');
const tokenInput = document.getElementById('auth-token');
if (isLoading) {
// 隐藏箭头显示Spinner
buttonIcon.classList.add('opacity-0', 'pointer-events-none');
buttonSpinner.classList.remove('opacity-0', 'pointer-events-none');
loginButton.disabled = true;
tokenInput.disabled = true;
} else {
// 显示箭头隐藏Spinner
buttonIcon.classList.remove('opacity-0', 'pointer-events-none');
buttonSpinner.classList.add('opacity-0', 'pointer-events-none');
loginButton.disabled = false;
tokenInput.disabled = false;
}
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
errorContainer.innerHTML = '';
const token = tokenInput.value.trim();
if (!token) {
displayError('请输入验证令牌');
return;
}
// 进入加载状态
setButtonLoading(true);
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '无效的令牌或服务器错误');
}
localStorage.setItem('bearerToken', data.token);
displaySuccess('验证成功!正在进入...');
// 成功后,不需要重置按钮状态,因为页面即将跳转
setTimeout(() => {
window.location.href = '/dashboard';
}, 800);
} catch (error) {
displayError(error.message);
// 发生错误,恢复按钮为可交互状态
setButtonLoading(false);
}
});
function displayError(message) {
errorContainer.innerHTML = `<p class="text-red-500 font-medium p-3 bg-red-100 dark:bg-red-900/40 rounded-full">${message}</p>`;
}
function displaySuccess(message) {
errorContainer.innerHTML = `<p class="text-green-500 font-medium p-3 bg-green-100 dark:bg-green-900/40 rounded-full">${message}</p>`;
}
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
if (error) {
displayError(error);
}
});
</script>
</body>
</html>

220
web/templates/base.html Normal file
View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="zh-CN" class=""> <!-- [核心] <html> 标签是 dark class 的应用目标 -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/static/icons/favicon.png" sizes="any">
<!--<script src="https://cdn.tailwindcss.com"></script>-->
<link href="/static/css/output.css" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.cn/css2?family=Pixelify+Sans:wght@400..700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<title>{% block title %}GEMINI BALANCER{% endblock %}</title>
{% block head_extra %}{% endblock %}
</head>
<body data-page-id="{{ PageID }}" class="bg-zinc-800 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-200 overflow-hidden h-screen">
<div id="app-container" class="relative h-full w-full max-w-[1660px] mx-auto">
<aside id="sidebar" class="absolute top-0 left-0 h-full z-10 flex flex-col w-16 lg:w-64 bg-zinc-900 border-r border-zinc-700/50 dark:bg-zinc-950 transition-all duration-300 ease-in-out">
<!-- Logo 部分也做响应式处理 -->
<div class="flex items-center justify-center h-16 border-b border-zinc-700/50 lg:pl-4 lg:pr-8 shrink-0">
<img src="/static/icons/logo.png" alt="Logo" class="h-6 w-6 mr-3"/><span class="font-bold text-md text-zinc-100 hidden lg:inline">GEMINI BALANCER</span>
</div>
<nav class="flex-1 flex flex-col py-2 space-y-1">
{% block sidebar_nav %}
<!-- 监控面板 -->
<div class="nav-item-wrapper group"> <!-- .group 类现在被封装在 .nav-item-wrapper 内部了 -->
<span class="nav-indicator"></span>
<a href="/dashboard" class="nav-link">
<!-- 关键: 响应式 transform 逻辑保留在 HTML 中,因为它更清晰 -->
<i class="nav-icon fas fa-bar-chart -translate-x-1 lg:translate-x-0"></i>
<span class="ml-3 whitespace-nowrap hidden lg:inline">监控面板</span>
<span class="pixel-decoration hidden lg:inline">SYS.STATUS</span>
</a>
</div>
<!-- 全局设置 -->
<div class="nav-item-wrapper group">
<span class="nav-indicator"></span>
<a href="/settings" class="nav-link">
<i class="nav-icon fas fa-cog -translate-x-1 lg:translate-x-0"></i>
<span class="ml-3 whitespace-nowrap hidden lg:inline">全局设置</span>
<span class="pixel-decoration hidden lg:inline">GL.SETTINGS</span>
</a>
</div>
<!-- API 管理 -->
<div class="nav-item-wrapper group">
<span class="nav-indicator"></span>
<a href="/keys" class="nav-link">
<i class="nav-icon fas fa-key -translate-x-1 lg:translate-x-0"></i>
<span class="ml-3 whitespace-nowrap hidden lg:inline">API 管理</span>
<span class="pixel-decoration hidden lg:inline">KEY.MANAGER</span>
</a>
</div>
<!-- 请求日志 -->
<div class="nav-item-wrapper group">
<span class="nav-indicator"></span>
<a href="/logs" class="nav-link">
<i class="nav-icon fas fa-clipboard-list -translate-x-1 lg:translate-x-0"></i>
<span class="ml-3 whitespace-nowrap hidden lg:inline">请求日志</span>
<span class="pixel-decoration hidden lg:inline">LOG.VIEW</span>
</a>
</div>
<!-- 计划任务 -->
<div class="nav-item-wrapper group">
<span class="nav-indicator"></span>
<a href="/tasks" class="nav-link">
<i class="nav-icon fas fa-clock -translate-x-1 lg:translate-x-0"></i>
<span class="ml-3 whitespace-nowrap hidden lg:inline">计划任务</span>
<span class="pixel-decoration hidden lg:inline">TASK.SCHEDULER</span>
</a>
</div>
<!-- WebChat -->
<div class="nav-item-wrapper group">
<span class="nav-indicator"></span>
<a href="/chat" class="nav-link">
<i class="nav-icon fas fa-comments -translate-x-1 lg:translate-x-0"></i>
<span class="ml-3 whitespace-nowrap hidden lg:inline">WebChat</span>
<span class="pixel-decoration hidden lg:inline">LIVE.CHAT</span>
</a>
</div>
{% endblock %}
</nav>
<div class="py-4 border-t border-zinc-700/50 shrink-0 flex justify-center lg:justify-start left-5 lg:pl-8 lg:pr-8">
<div class="flex items-center space-x-4 -translate-x-2 lg:transform-none">
<!-- [精简版] 三态主题切换器 -->
<div id="theme-switcher" class="hidden lg:inline-grid relative z-10 grid-cols-3 gap-1 rounded-full bg-gray-950/50 p-1 text-gray-500 dark:bg-white/10 dark:text-white">
<!-- 1. 系统模式按钮 -->
<button type="button" data-theme="system" class="theme-btn rounded-full p-1 focus:outline-none">
<svg class="h-6 w-6" viewBox="0 0 28 28" fill="none"><path d="M7.5 8.5C7.5 7.94772 7.94772 7.5 8.5 7.5H19.5C20.0523 7.5 20.5 7.94772 20.5 8.5V16.5C20.5 17.0523 20.0523 17.5 19.5 17.5H8.5C7.94772 17.5 7.5 17.0523 7.5 16.5V8.5Z" stroke="currentColor"></path><path d="M7.5 8.5C7.5 7.94772 7.94772 7.5 8.5 7.5H19.5C20.0523 7.5 20.5 7.94772 20.5 8.5V14.5C20.5 15.0523 20.0523 15.5 19.5 15.5H8.5C7.94772 15.5 7.5 15.0523 7.5 14.5V8.5Z" stroke="currentColor"></path><path d="M16.5 20.5V17.5H11.5V20.5M16.5 20.5H11.5M16.5 20.5H17.5M11.5 20.5H10.5" stroke="currentColor" stroke-linecap="round"></path></svg>
</button>
<!-- 2. 亮色模式按钮 -->
<button type="button" data-theme="light" class="theme-btn rounded-full p-1 focus:outline-none">
<svg class="h-6 w-6" viewBox="0 0 28 28" fill="none"><circle cx="14" cy="14" r="3.5" stroke="currentColor"></circle><path d="M14 8.5V6.5" stroke="currentColor" stroke-linecap="round"></path><path d="M17.889 10.1115L19.3032 8.69727" stroke="currentColor" stroke-linecap="round"></path><path d="M19.5 14L21.5 14" stroke="currentColor" stroke-linecap="round"></path><path d="M17.889 17.8885L19.3032 19.3027" stroke="currentColor" stroke-linecap="round"></path><path d="M14 21.5V19.5" stroke="currentColor" stroke-linecap="round"></path><path d="M8.69663 19.3029L10.1108 17.8887" stroke="currentColor" stroke-linecap="round"></path><path d="M6.5 14L8.5 14" stroke="currentColor" stroke-linecap="round"></path><path d="M8.69663 8.69711L10.1108 10.1113" stroke="currentColor" stroke-linecap="round"></path></svg>
</button>
<!-- 3. 暗色模式按钮 -->
<button type="button" data-theme="dark" class="theme-btn rounded-full p-1 focus:outline-none">
<svg class="h-6 w-6" viewBox="0 0 28 28" fill="none"><path d="M10.5 9.99914C10.5 14.1413 13.8579 17.4991 18 17.4991C19.0332 17.4991 20.0176 17.2902 20.9132 16.9123C19.7761 19.6075 17.109 21.4991 14 21.4991C9.85786 21.4991 6.5 18.1413 6.5 13.9991C6.5 10.8902 8.39167 8.22304 11.0868 7.08594C10.7089 7.98159 10.5 8.96597 10.5 9.99914Z" stroke="currentColor" stroke-linejoin="round"></path><path d="M16.3561 6.50754L16.5 5.5L16.6439 6.50754C16.7068 6.94752 17.0525 7.29321 17.4925 7.35607L18.5 7.5L17.4925 7.64393C17.0525 7.70679 16.7068 8.05248 16.6439 8.49246L16.5 9.5L16.3561 8.49246C16.2932 8.05248 15.9475 7.70679 15.5075 7.64393L14.5 7.5L15.5075 7.35607C15.9475 7.29321 16.2932 6.94752 16.3561 6.50754Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M20.3561 11.5075L20.5 10.5L20.6439 11.5075C20.7068 11.9475 21.0525 12.2932 21.4925 12.3561L22.5 12.5L21.4925 12.6439C21.0525 12.7068 20.7068 13.0525 20.6439 13.4925L20.5 14.5L20.3561 13.4925C20.2932 13.0525 19.9475 12.7068 19.5075 12.6439L18.5 12.5L19.5075 12.3561C19.9475 12.2932 20.2932 11.9475 20.3561 11.5075Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
<!-- 2. 移动端形态 -->
<button id="theme-cycler-btn" type="button" class="flex lg:hidden items-center justify-center rounded-full ml-4 bg-zinc-800 p-1 text-gray-200 dark:bg-white/10 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<span id="theme-cycler-icon" class="h-6 w-6"></span>
</button>
<div id="app-version" class="text-xs text-zinc-400 hidden lg:inline">v1.0.0</div>
</div>
</div>
</aside>
<main id="main-content-wrapper" class="absolute top-4 right-4 bottom-4 left-12 lg:left-60 z-20 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-zinc-800 shadow-xs ring-1 ring-black/15 dark:ring-white/15 shadow-main shadow-inner transition-all duration-300 ease-in-out pt-4 pb-4 pl-4 pr-1 lg:pt-16 lg:pb-16 lg:pl-16 lg:pr-6">
<div class="flex-1 w-full h-full overflow-y-auto main-content-scroll lg:pr-6 translate-x-1 lg:translate-x-0 lg:transform-none">
{% block content %}{% endblock %}
</div>
</div>
</main>
</div>
{% block core_scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="/static/js/main.js" type="module" defer></script>
{% endblock core_scripts %}
<!-- [核心] Block 2: 留给子页面的脚本扩展插槽 -->
{% block page_scripts %}{% endblock page_scripts %}
{% block modals %}{% endblock modals %}
<!-- =================================================================== -->
<!-- 全局异步任务中心 (Global Async Task Center) -->
<!-- =================================================================== -->
<!-- 组件 A: 全局实时反馈信使 (The Toast/Snackbar Messenger) -->
<div id="global-toast-container"
class="fixed bottom-4 right-4 z-[9999] w-full max-w-sm space-y-3 pointer-events-none">
<!-- Toast 通知将由JS动态插入到这里 -->
<!-- 示例Toast结构 (供JS参考):
<div class="toast-item">
<div class="toast-icon toast-icon-loading">
<i class="fas fa-spinner fa-spin"></i>
</div>
<div class="toast-content">
<p class="toast-title">任务已开始</p>
<p class="toast-message">正在批量添加API Keys...</p>
</div>
<button class="toast-close-btn">&times;</button>
</div>
-->
</div>
<!-- 组件 B: 全局持久化任务中心 (The Task Hub) -->
<div id="global-task-hub" class="fixed top-6 right-6 z-[9998]">
<!-- 1. 触发器: 小铃铛图标 -->
<button id="task-hub-trigger"
class="relative h-10 w-10 flex items-center justify-center rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-sm shadow-lg ring-1 ring-black/10 dark:ring-white/10 text-zinc-500 dark:text-zinc-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200">
<!-- SVG动画层 -->
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle id="task-hub-countdown-ring"
cx="12" cy="12" r="11.5" stroke="#3b82f6" stroke-width="1"
class="origin-center -rotate-90"
stroke-linecap="round"
style="stroke-dasharray: 72.26; stroke-dashoffset: 72.26;"
/>
</svg>
<i class="fas fa-bell text-lg"></i>
<!-- 任务活动指示器 (默认隐藏) -->
<span id="task-hub-indicator"
class="absolute top-1 right-1 h-2.5 w-2.5 rounded-full bg-blue-500 border-2 border-white dark:border-zinc-800 hidden animate-pulse">
</span>
</button>
<!-- 2. 浮动面板 (默认隐藏) -->
<div id="task-hub-panel"
class="absolute top-full right-0 mt-2 w-80 rounded-xl bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg shadow-2xl ring-1 ring-black/5 dark:ring-white/10 hidden origin-top-right transition-all"
role="dialog" aria-modal="true" aria-labelledby="task-hub-title">
<!-- [新增] 面板头部容器,用于定位倒计时条 -->
<div class="relative">
<!-- 面板头部 -->
<div class="flex items-center justify-between p-3 border-b border-black/10 dark:border-white/10">
<h3 id="task-hub-title" class="font-semibold text-sm text-zinc-800 dark:text-zinc-200">任务中心</h3>
<button id="task-hub-clear-btn" class="text-xs text-zinc-500 hover:text-blue-500">清空已完成</button>
</div>
<!-- [新增] 倒计时进度条 (默认宽度为0) -->
<div id="task-hub-countdown-bar"
class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-blue-500/30 to-blue-500 w-0 transition-all duration-[5000ms] ease-linear">
</div>
</div>
<!-- 任务列表容器 (可滚动) -->
<div id="task-list-container" class="p-2 max-h-96 overflow-y-auto space-y-2">
<!-- 任务项将由JS动态插入到这里 -->
<!-- 示例任务项 (供JS参考):
<div class="task-list-item">
<div class="task-item-icon task-item-icon-success">
<i class="fas fa-check"></i>
</div>
<div class="task-item-content">
<p class="task-item-title">批量添加 Key</p>
<p class="task-item-status">成功</p>
</div>
<div class="task-item-timestamp">1分钟前</div>
</div>
-->
</div>
<!-- 空状态 (默认隐藏) -->
<div id="task-list-empty" class="hidden text-center text-xs text-zinc-400 py-10 px-4">
<i class="fas fa-inbox fa-2x mb-2"></i>
<p>当前没有正在运行或最近完成的任务</p>
</div>
</div>
</div>
</body>
</html>

117
web/templates/chat.html Normal file
View File

@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}Web Chat - GEMINI BALANCER{% endblock %}
{% block content %}
<!-- [核心] 聊天界面的主容器,使用 flex 布局撑满整个可用空间 -->
<div class="w-full h-full flex overflow-hidden rounded-lg border border-border" data-page-id="chat">
<!-- =================================================================== -->
<!-- 1. 左侧栏: 会话列表 -->
<!-- =================================================================== -->
<aside class="w-[280px] h-full flex flex-col border-r border-border bg-muted/50 shrink-0">
<!-- 侧边栏头部 -->
<div class="p-4 border-b border-border shrink-0">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold tracking-tight">会话</h2>
<button class="btn btn-ghost btn-icon btn-sm" aria-label="新建会话">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="relative mt-4">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"></i>
<input class="input h-9 pl-10" placeholder="搜索会话...">
</div>
</div>
<!-- 会话列表 (可滚动) -->
<div class="flex-grow overflow-y-auto main-content-scroll p-2">
<!-- 示例会话 1: 激活状态 -->
<a href="#" class="flex flex-col items-start gap-2 rounded-lg p-3 text-left text-sm transition-all hover:bg-accent bg-accent">
<div class="flex w-full items-center justify-between">
<div class="font-semibold">关于Python异步编程</div>
<div class="ml-auto text-xs text-muted-foreground">3:15 PM</div>
</div>
<div class="text-xs text-muted-foreground line-clamp-2">好的,我明白了。所以 aiohttp 客户端的 session 应该在...</div>
</a>
<!-- 示例会话 2: 非激活状态 -->
<a href="#" class="flex flex-col items-start gap-2 rounded-lg p-3 text-left text-sm transition-all hover:bg-accent">
<div class="flex w-full items-center justify-between">
<div class="font-semibold">Tailwind v4 迁移指南</div>
<div class="ml-auto text-xs text-muted-foreground">1:45 PM</div>
</div>
<div class="text-xs text-muted-foreground line-clamp-2">我们已经成功解决了 JIT 编译器的缓存和 HSL 函数的问题...</div>
</a>
<!-- 示例会话 3: 非激活状态 -->
<a href="#" class="flex flex-col items-start gap-2 rounded-lg p-3 text-left text-sm transition-all hover:bg-accent">
<div class="flex w-full items-center justify-between">
<div class="font-semibold">数据库性能优化</div>
<div class="ml-auto text-xs text-muted-foreground">昨天</div>
</div>
<div class="text-xs text-muted-foreground line-clamp-2">索引的创建确实是关键,特别是在有大量 JOIN 操作的查询中。</div>
</a>
</div>
</aside>
<!-- =================================================================== -->
<!-- 2. 右侧主区域: 聊天窗口 -->
<!-- =================================================================== -->
<main class="flex-1 flex flex-col h-full">
<!-- 聊天窗口头部 -->
<div class="flex items-center p-4 border-b border-border shrink-0">
<h3 class="text-lg font-semibold">关于Python异步编程</h3>
<div class="ml-auto flex items-center gap-2">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="清除会话">
<i class="fas fa-eraser"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm" aria-label="更多选项">
<i class="fas fa-ellipsis-v"></i>
</button>
</div>
</div>
<!-- 消息区域 (可滚动) -->
<div class="flex-grow p-6 overflow-y-auto main-content-scroll">
<div class="space-y-6">
<!-- 示例消息 1: 用户 -->
<div class="flex items-start gap-4">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<i class="fas fa-user"></i>
</span>
<div class="flex-1 space-y-2">
<div class="rounded-lg bg-muted p-3">
<p class="text-sm text-foreground">你能给我解释一下 aiohttp 客户端的 session 和 connector 管理吗?我总是搞不清楚什么时候应该创建,什么时候应该关闭。</p>
</div>
</div>
</div>
<!-- 示例消息 2: 助手 (Gemini) -->
<div class="flex items-start gap-4">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<i class="fas fa-robot"></i>
</span>
<div class="flex-1 space-y-2">
<div class="rounded-lg bg-primary/10 border border-primary/20 p-3">
<p class="text-sm text-foreground">当然可以。这是一个非常经典的问题。简单来说,`ClientSession` 应该在你的应用程序的生命周期内尽可能地保持单例存在...</p>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="p-4 border-t border-border shrink-0 bg-background">
<div class="relative">
<textarea placeholder="输入消息..." class="input pr-20 resize-none" rows="1"></textarea>
<div class="absolute top-1/2 right-3 -translate-y-1/2 flex items-center gap-2">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="附加文件">
<i class="fas fa-paperclip"></i>
</button>
<button class="btn btn-primary btn-sm">发送</button>
</div>
</div>
</div>
</main>
</div>
{% endblock %}
{% block page_scripts %}
{% endblock page_scripts %}

View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}监控面板 - Gemini Balancer{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/status-grid.css">
{% endblock %}
{% block content %}
<!-- [核心] 页面顶栏:标题与全局控制器 -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-3xl font-bold tracking-tight">数据总览</h2>
<div class="flex items-center space-x-3">
<!-- [最终形态] 完全自定义的下拉选择器组件 -->
<div class="relative w-full" data-custom-select-container>
<!-- 1. 隐藏的真实 <select>,用于表单提交 -->
<select name="group-filter" id="globalGroupFilter" class="hidden">
<option value="">所有分组</option>
<!-- JS将会动态填充更多选项 -->
</select>
<!-- 2. "诱饵" - 我们看到的、可以完全自定义的按钮 -->
<button type="button" class="custom-select-trigger h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 py-1 pl-4 pr-8 text-left text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset dark:border-zinc-700 dark:bg-zinc-900">
<span class="block truncate">所有分组</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg class="h-4 w-4 text-zinc-700 dark:text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m6 8 4 4 4-4"/>
</svg>
</span>
</button>
<!-- 3. "真正的菜单" - 可以被我们完全样式化的下拉面板 -->
<div class="custom-select-panel absolute z-10 mt-1 w-full rounded-lg bg-zinc-50 dark:bg-zinc-900 shadow-xl p-1 hidden">
<!-- 选项将会由 JS 动态生成并插入此处 -->
<!-- 示例选项 -->
<div class="custom-select-option" data-value="">所有分组</div>
<div class="custom-select-option" data-value="group1">分组一</div>
<div class="custom-select-option" data-value="group2">分组二</div>
</div>
</div>
<!-- [核心] 快速处置按钮 (触发模态框) -->
<button id="quick-action-btn" class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors h-9 px-4 py-2 bg-blue-500 text-white shadow hover:bg-blue-500/90">
<i class="fas fa-bolt"></i>
<span class="hidden lg:inline">快速处置</span>
</button>
</div>
</div>
<!-- [组件蓝图] 任何页面都可以复制此结构来创建一个新的滑动标签实例 -->
<div class="py-2">
<div class="w-full overflow-x-auto scrollbar-hide">
<!-- 1. 容器: 必须有 `data-sliding-tabs-container` 属性 -->
<div role="tablist" class="relative inline-flex h-10 items-center justify-center inset-shadow-sm/25 rounded-lg bg-zinc-800/50 dark:bg-zinc-950 p-1" data-sliding-tabs-container>
<!-- 2. 指示器: 必须有 `data-tab-indicator` 属性 -->
<div class="absolute left-0 h-[calc(100%-0.5rem)] rounded-md bg-white dark:bg-zinc-700 shadow-sm" data-tab-indicator style="transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);"></div>
<!-- 3. 标签项: 必须有 `data-tab-item` 属性。激活项应有 `tab-active` class -->
<a href="#" role="tab" class="tab-item tab-active" data-tab-item>数据总览</a>
<a href="#" role="tab" class="tab-item" data-tab-item>密钥管理</a>
<a href="#" role="tab" class="tab-item" data-tab-item>性能分析</a>
<a href="#" role="tab" class="tab-item" data-tab-item>请求日志</a>
</div>
</div>
</div>
<!-- [核心] 标签页内容区域 -->
<div class="mt-8">
<!-- 标签A: 数据总览 (默认显示) -->
<div id="tab-content-overview" class="space-y-6">
<!-- 第一行: 四核心指标卡片 -->
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- 卡片1: 密钥统计 -->
<div class="ds-stats-card bg-card text-card-foreground">
<div class="flex flex-row items-center justify-between space-y-0 pb-2">
<div class="text-sm font-medium">密钥统计</div>
<i class="h-4 w-4 text-zinc-500 fas fa-key"></i>
</div>
<div>
<div class="text-2xl font-bold">
<span id="stat-valid-keys">0</span> / <span id="stat-total-keys">0</span>
</div>
<p class="text-xs text-zinc-500 dark:text-zinc-400">有效密钥 / 总密钥数</p>
</div>
</div>
<!-- 卡片2: 请求总览 -->
<div class="ds-stats-card bg-card text-card-foreground">
<div class="flex flex-row items-center justify-between space-y-0 pb-2">
<div class="text-sm font-medium">请求总览</div>
<i class="h-4 w-4 text-zinc-500 fas fa-exchange-alt"></i>
</div>
<div>
<!-- [ID保留] 沿用旧ID: stat-calls-24h -->
<div class="text-2xl font-bold" id="stat-calls-24h">0</div>
<p class="text-xs text-zinc-500 dark:text-zinc-400">24小时请求数</p>
</div>
</div>
<!-- 卡片3: Token消耗 -->
<div class="ds-stats-card bg-card text-card-foreground">
<div class="flex flex-row items-center justify-between space-y-0 pb-2">
<div class="text-sm font-medium">Token 消耗</div>
<i class="h-4 w-4 text-zinc-500 fas fa-coins"></i>
</div>
<div>
<!-- [ID新增] 新增ID: stat-tokens-24h -->
<div class="text-2xl font-bold" id="stat-tokens-24h">0</div>
<p class="text-xs text-zinc-500 dark:text-zinc-400">24小时消耗</p>
</div>
</div>
<!-- 卡片4: 请求成功率 -->
<div class="ds-stats-card bg-card text-card-foreground">
<div class="flex flex-row items-center justify-between space-y-0 pb-2">
<div class="text-sm font-medium">请求成功率</div>
<i class="h-4 w-4 text-zinc-500 fas fa-check-circle"></i>
</div>
<div>
<!-- [ID新增] 新增ID: stat-success-rate-24h -->
<div class="text-2xl font-bold" id="stat-success-rate-24h">0%</div>
<p class="text-xs text-zinc-500 dark:text-zinc-400">24小时成功率</p>
</div>
</div>
</div>
<!-- 第二行: API 状态分布 (通栏) -->
<div class="ds-stats-card bg-card text-card-foreground">
<h3 class="font-semibold leading-none tracking-tight mb-4">API 状态分布</h3>
<!-- [容器保留] 沿用旧ID, JS可无缝对接 -->
<div id="poolGridContainer" class="relative p-2 canvas-placeholder" style="height: 60px;">
<canvas id="poolGridCanvas" class="absolute top-0 left-0 w-full h-full"></canvas>
</div>
<!-- [图例保留] 沿用旧ID -->
<div id="poolStatusLegend" class="flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs mt-4">
<span class="legend-item" data-status-hover="ACTIVE"><i class="fas fa-square text-green-500"></i> ACTIVE (<span id="legend-active">0</span>)</span>
<span class="legend-item" data-status-hover="PENDING"><i class="fas fa-square text-gray-400"></i> PENDING (<span id="legend-pending">0</span>)</span>
<span class="legend-item" data-status-hover="COOLDOWN"><i class="fas fa-square text-yellow-500"></i> COOLDOWN (<span id="legend-cooldown">0</span>)</span>
<span class="legend-item" data-status-hover="DISABLED"><i class="fas fa-square text-orange-500"></i> DISABLED (<span id="legend-disabled">0</span>)</span>
<span class="legend-item" data-status-hover="BANNED"><i class="fas fa-square text-red-500"></i> BANNED (<span id="legend-banned">0</span>)</span>
</div>
</div>
<!-- 第三行: 左侧图表 + 右侧排行 -->
<div class="grid gap-6 lg:grid-cols-5">
<!-- 左侧大卡片: 请求趋势图 (占位) -->
<div class="ds-stats-card bg-card text-card-foreground lg:col-span-3">
<h3 class="font-semibold leading-none tracking-tight">请求趋势</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">成功与失败请求数</p>
<!-- [容器新增] 新增一个清晰的ID给新图表 -->
<div class="h-[350px] flex items-center justify-center text-zinc-400">
<canvas id="successFailChart"></canvas>
<span>图表加载中...</span>
</div>
</div>
<!-- 右侧小卡片: 模型排行 (占位) -->
<div class="ds-stats-card bg-card text-card-foreground lg:col-span-2">
<h3 class="font-semibold leading-none tracking-tight">模型排行</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">24小时内调用次数</p>
<!-- [容器新增] 新增一个列表容器ID -->
<div id="model-ranking-list" class="space-y-4 h-[350px] overflow-y-auto pr-2">
<!-- JS将在此处动态填充排行列表 -->
<div class="flex items-center text-sm text-zinc-500">排行数据加载中...</div>
</div>
</div>
</div>
</div>
<!-- 其他标签页的内容区域 (暂时留空) -->
<div id="tab-content-keys" class="hidden">密钥管理内容</div>
<div id="tab-content-performance" class="hidden">性能分析内容</div>
<div id="tab-content-requests" class="hidden">请求日志内容</div>
</div>
<!-- [占位符] 快速处置模态框 -->
<div id="quick-action-modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
<div class="bg-white dark:bg-zinc-800 rounded-lg shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-medium">快速处置</h3>
<p class="text-sm text-zinc-500 mt-2">模态框内容占位符,未来将在此处实现具体功能。</p>
<div class="mt-4 flex justify-end">
<button id="close-modal-btn" class="px-4 py-2 text-sm rounded-md bg-zinc-200 dark:bg-zinc-700">关闭</button>
</div>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script>
document.addEventListener('DOMContentLoaded', () => {
});
</script>
<!-- [保留] 核心UI依赖 -->
<script src="/static/js/status-grid.js" defer></script>
<!-- [保留] 核心UI主程序 (需要进行适配) -->
<script src="/static/js/dashboard.js" defer></script>
{% endblock %}

29
web/templates/error.html Normal file
View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}发生错误 - Gemini Balance{% endblock %}
{% block content %}
<div class="container mx-auto mt-10 px-4">
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-8 rounded-lg shadow-lg" role="alert">
<div class="flex">
<div class="py-1">
<svg class="fill-current h-8 w-8 text-red-500 mr-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M10 0C4.486 0 0 4.486 0 10s4.486 10 10 10 10-4.486 10-10S15.514 0 10 0zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm-1-5h2v2h-2v-2zm0-8h2v6h-2V5z"/>
</svg>
</div>
<div>
<p class="text-2xl font-bold mb-2">服务出现内部错误</p>
<p class="text-base">很抱歉,在处理您的请求时遇到了一个问题。</p>
{% if error %}
<div class="mt-4 bg-red-200 p-3 rounded">
<p class="font-mono text-sm">
<strong>错误详情:</strong> {{ error }}
</p>
</div>
{% endif %}
<a href="/" class="mt-6 inline-block bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
返回首页
</a>
</div>
</div>
</div>
</div>
{% endblock %}

943
web/templates/keys.html Normal file
View File

@@ -0,0 +1,943 @@
{% extends "base.html" %}
{% block title %}API 分组管理 - GEMINI BALANCER{% endblock %}
{% block content %}
<div class="w-full h-full flex flex-col pl-0 pr-3 lg:px-0">
<!-- [核心] 页面顶栏:标题与全局控制器 -->
<div class="flex items-center justify-between mb-6 shrink-0">
<h2 class="text-3xl font-bold tracking-tight">API 管理</h2>
<button class="lg:hidden text-zinc-500 dark:text-zinc-400">
<i class="fas fa-search text-lg"></i>
</button>
</div>
<div class="flex flex-col lg:flex-row flex-grow gap-x-4 overflow-hidden min-h-0">
<!-- 左侧分栏: Group 列表 -->
<aside class="w-full lg:w-1/4 flex flex-col p-0 relative mb-3 lg:mb-0 lg:min-h-0 shrink-0">
<!-- 桌面端搜索框 -->
<div class="relative mb-3 mr-4 shrink-0 hidden lg:block">
<input type="text" class="pl-8 h-9 bg-transparent border border-gray-300 dark:border-gray-700 dark:text-white w-full rounded-md text-sm transition-colors duration-200 ease-in-out focus:outline-none focus:border-blue-500 dark:focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:ring-inset" placeholder="Search Groups...">
<i class="fas fa-search w-4 absolute text-gray-400 top-1/2 transform -translate-y-1/2 left-3"></i>
</div>
<!-- 移动端首屏控件 -->
<div class="lg:hidden flex items-center gap-x-2">
<div class="mobile-group-selector">
<div>
<h3 class="font-semibold text-sm">Loading...</h3>
<p class="card-sub-text">当前选择</p>
</div>
<button id="group-menu-toggle" class="text-zinc-500 dark:text-zinc-400">
<i class="fas fa-bars text-lg"></i>
</button>
</div>
<div id="add-group-btn-container-mobile" class="shrink-0">
<button class="add-group-btn add-group-btn-mobile flex items-center justify-center rounded-lg border-2 border-dashed transition-all duration-200">
<i class="fas fa-plus text-lg"></i>
</button>
</div>
</div>
<div id="group-list-collapsible" class="hidden lg:flex flex-col flex-grow overflow-y-auto pr-1">
<div id="desktop-group-cards-list" class="hidden lg:flex flex-col">
<div class="card-list-content space-y-2"><!-- JS通过 desktopGroupContainer 选择器填充这里 --></div>
<div id="add-group-btn-container" class="sticky pr-1 bottom-0 mt-2 mr-2 shrink-0 pt-2 bg-white dark:bg-zinc-800">
<button class="add-group-btn add-group-btn-desktop flex items-center justify-center rounded-lg border-2 border-dashed transition-all duration-200">
<i class="fas fa-plus text-lg"></i>
</button>
</div>
</div>
<!-- [移动端] 列表 -->
<div id="mobile-group-cards-list" class="block lg:hidden space-y-2"></div>
</div>
</aside>
{# 右侧主内容区: Group 详情与 API 管理 #}
<main class="w-full lg:w-3/4 flex flex-grow flex-col gap-y-4 overflow-y-auto lg:min-h-0">
<!-- Group Dashboard -->
<div id="group-dashboard" class="bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-700/60 rounded-lg p-4 shrink-0">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">默认分组 (Default)</h2>
<div class="flex items-center gap-x-3 text-zinc-400">
<button data-action="clone-group" class="hover:text-blue-500 transition-colors duration-200" title="克隆分组"><i class="fas fa-clone"></i></button>
<button data-action="edit-group" class="hover:text-blue-500 transition-colors duration-200" title="编辑分组"><i class="fas fa-cog"></i></button>
<button data-action="open-settings" class="hover:text-blue-500 transition-colors duration-200" title="高级请求设置"><i class="fas fa-sliders-h"></i></button>
<button data-action="delete-group" class="hover:text-red-500 transition-colors duration-200" title="删除分组"><i class="fas fa-trash"></i></button>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-center">
<!-- 密钥统计 -->
<div>
<p class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">7 / 10</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">有效密钥 / 总密钥数</p>
<div class="mt-2 h-4"></div> <!-- 健康度图标占位 -->
</div>
<!-- 请求总览 -->
<div>
<p class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">0</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">24小时请求数</p>
<div class="mt-2 h-4"></div> <!-- 健康度图标占位 -->
</div>
<!-- Token 消耗 -->
<div>
<p class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">0</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">24小时消耗</p>
<div class="mt-2 h-4"></div> <!-- 健康度图标占位 -->
</div>
<!-- 请求成功率 -->
<div>
<p class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">0%</p>
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">24小时成功率</p>
<div class="mt-2 h-4"></div> <!-- 健康度图标占位 -->
</div>
</div>
</div>
<!-- API Management Area -->
<div class="bg-zinc-100 border border-zinc-200 dark:border-zinc-700/60 dark:bg-zinc-900/50 rounded-lg p-4 flex-grow flex flex-col lg:min-h-0">
<!-- Controls Header (已进行响应式重构 - V3 Final) -->
<div class="flex flex-col gap-y-3 mb-4">
<!--
============================================================
移动端第一行 / 桌面端完整操作行
============================================================
- 使用 justify-between 将左右两组推开。
-->
<div class="flex items-center justify-between">
<!-- 左侧主要操作 (始终显示) -->
<div class="flex items-center gap-x-2">
<button id="add-api-btn" class="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
<i class="fas fa-plus mr-1"></i> KEY
</button>
<button id="delete-api-btn" class="px-3 py-1.5 text-sm bg-red-600/80 text-white rounded-md hover:bg-red-700 transition-colors">
<i class="fas fa-minus mr-1"></i> KEY
</button>
</div>
<!-- 右侧操作组 -->
<div class="flex items-center gap-x-2">
<!-- 移动端: 快速处置 -->
<div class="lg:hidden items-center gap-x-2 relative inline-block custom-select" id="mobile-quick-actions-dropdown">
<button class="px-3 py-1.5 text-sm bg-zinc-200 dark:bg-zinc-700 rounded-md hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors custom-select-trigger">
<i class="fas fa-bolt"></i>
</button>
<div class="dropdown-panel z-30 w-48 custom-select-panel hidden" id="mobile-quick-actions-panel">
<!-- Dropdown content will be injected by JavaScript -->
</div>
</div>
<!-- 桌面端操作组 (lg及以上屏幕显示) -->
<div class="hidden lg:flex items-center gap-x-2">
<div class="relative inline-block batch-action-dropdown custom-select">
<button class="px-3 py-1.5 text-sm bg-zinc-200 dark:bg-zinc-700 rounded-md hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-x-2 batch-action-btn custom-select-trigger">
<span>批量操作</span>
<i class="fas fa-chevron-down text-xs text-zinc-400"></i>
</button>
<div class="dropdown-panel batch-action-panel custom-select-panel hidden">
<div class="py-1">
<button data-batch-action="copy-to-clipboard" class="menu-item">
<i class="fas fa-copy menu-item-icon menu-item-icon-neutral"></i>
<span>批量复制</span>
</button>
<button data-batch-action="set-status-active" class="menu-item">
<i class="fas fa-check-circle menu-item-icon text-green-500"></i>
<span>批量启用</span>
</button>
<button data-batch-action="set-status-disabled" class="menu-item">
<i class="fas fa-ban menu-item-icon text-yellow-500"></i>
<span>批量禁用</span>
</button>
<button data-batch-action="revalidate" class="menu-item">
<i class="fas fa-rocket menu-item-icon text-blue-500"></i>
<span>批量验证</span>
</button>
<!-- Use the new .menu-divider class -->
<div class="menu-divider"></div>
<!-- Use the base class and the danger modifier -->
<button data-batch-action="delete" class="menu-item menu-item-danger">
<i class="fas fa-trash-alt menu-item-icon"></i> <!-- Color is inherited from .menu-item-danger -->
<span>批量移除</span>
</button>
</div>
</div>
</div>
<!-- 桌面端: 快速处置 -->
<div class="relative inline-block custom-select" id="desktop-quick-actions-dropdown">
<button class="px-3 py-1.5 text-sm bg-zinc-200 dark:bg-zinc-700 rounded-md hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-x-2 custom-select-trigger">
<i class="fas fa-bolt"></i>
<span>快速处置</span>
</button>
<div class="dropdown-panel z-30 w-48 custom-select-panel hidden" id="desktop-quick-actions-panel">
<!-- Dropdown content will be injected by JavaScript -->
</div>
</div>
<div class="relative inline-block text-left">
<!-- 1. The Trigger Button -->
<button data-toggle="custom-select"
data-target="#desktop-multifunction-panel"
class="px-3 py-1.5 text-sm bg-zinc-200 dark:bg-zinc-700 rounded-md hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors">
<i class="fas fa-ellipsis-h"></i>
</button>
<!-- 2. The Dropdown Panel (initially hidden) -->
<div id="desktop-multifunction-panel"
class="custom-select-panel absolute right-0 z-30 mt-2 min-w-max origin-top-right rounded-md bg-white dark:bg-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none hidden">
<!-- JS will inject menu items here -->
</div>
</div>
</div>
</div>
</div>
<!--
============================================================
移动端第二行 / 桌面端完整过滤行
============================================================
- display: lg:hidden 表示此行在桌面端将被隐藏,仅用于移动端布局。
-->
<div class="flex items-center justify-between lg:hidden">
<!-- 左侧:状态选择器 -->
<div class="custom-select relative status-filter-select">
<select class="hidden">
<option value="all">所有状态</option>
<option value="active">有效</option>
<option value="cooldown">冷却</option>
<option value="pending">待验证</option>
<option value="disabled">禁用</option>
<option value="banned">无效</option>
</select>
<div class="custom-select-trigger flex items-center justify-between w-32 cursor-pointer rounded-md border border-zinc-300 dark:border-zinc-600 bg-transparent px-3 py-1.5">
<span>所有状态</span>
<i class="fas fa-chevron-down text-xs text-zinc-400"></i>
</div>
<div class="custom-select-panel hidden absolute z-10 mt-1 w-full rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-700"></div>
</div>
<!-- 右侧:批量操作 和 多功能按钮 -->
<div class="flex items-center gap-x-2">
<div class="relative inline-block batch-action-dropdown custom-select">
<button class="px-3 py-1.5 text-sm bg-zinc-200 dark:bg-zinc-700 rounded-md hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-x-2 batch-action-btn custom-select-trigger">
<span>批量操作</span>
<i class="fas fa-chevron-down text-xs text-zinc-400"></i>
</button>
<div class="dropdown-panel batch-action-panel custom-select-panel hidden">
<div class="py-1">
<button data-batch-action="copy-to-clipboard" class="menu-item">
<i class="fas fa-copy menu-item-icon menu-item-icon-neutral"></i>
<span>批量复制</span>
</button>
<button data-batch-action="set-status-active" class="menu-item">
<i class="fas fa-check-circle menu-item-icon text-green-500"></i>
<span>批量启用</span>
</button>
<button data-batch-action="set-status-disabled" class="menu-item">
<i class="fas fa-ban menu-item-icon text-yellow-500"></i>
<span>批量禁用</span>
</button>
<button data-batch-action="revalidate" class="menu-item">
<i class="fas fa-rocket menu-item-icon text-blue-500"></i>
<span>批量验证</span>
</button>
<!-- Use the new .menu-divider class -->
<div class="menu-divider"></div>
<!-- Use the base class and the danger modifier -->
<button data-batch-action="delete" class="menu-item menu-item-danger">
<i class="fas fa-trash-alt menu-item-icon"></i> <!-- Color is inherited from .menu-item-danger -->
<span>批量移除</span>
</button>
</div>
</div>
</div>
<div class="relative inline-block text-left">
<!-- 1. The Trigger Button -->
<button data-toggle="custom-select"
data-target="#desktop-multifunction-panel"
class="px-3 py-1.5 text-sm bg-zinc-200 dark:bg-zinc-700 rounded-md hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors">
<i class="fas fa-ellipsis-h"></i>
</button>
<!-- 2. The Dropdown Panel (initially hidden) -->
<div id="mobile-multifunction-panel"
class="custom-select-panel absolute right-0 z-30 mt-2 min-w-max origin-top-right rounded-md bg-white dark:bg-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none hidden">
<!-- JS will inject menu items here -->
</div>
</div>
</div>
</div>
<!--
============================================================
移动端第三行 / 合并入桌面端的第二行
============================================================
- 这个容器在两个断点都存在,但其内部元素的可见性不同。
-->
<div class="flex items-center justify-between text-sm">
<!-- 桌面端左侧: 状态选择器 -->
<div class="custom-select relative hidden lg:block status-filter-select">
<select class="hidden">
<option value="all">所有状态</option>
<option value="active">有效</option>
<option value="cooldown">冷却</option>
<option value="pending">待验证</option>
<option value="disabled">禁用</option>
<option value="banned">无效</option>
</select>
<div class="custom-select-trigger flex items-center justify-between w-32 cursor-pointer rounded-md border border-zinc-300 dark:border-zinc-600 bg-transparent px-3 py-1.5">
<span>所有状态</span>
<i class="fas fa-chevron-down text-xs text-zinc-400"></i>
</div>
<div class="custom-select-panel hidden absolute z-10 mt-1 w-full rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-700"></div>
</div>
<!-- 移动端左侧: 搜索图标 -->
<button id="mobile-search-btn" class="px-2 py-1.5 text-sm bg-transparent rounded-md hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors lg:hidden">
<i class="fas fa-search text-zinc-500"></i>
</button>
<!-- 右侧组 (通用) -->
<div class="flex items-center gap-x-3">
<!-- 桌面端搜索框 -->
<div id="desktop-search-container" class="relative hidden lg:block w-full sm:w-48">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-300"></i>
<input type="text" id="desktop-search-input" placeholder="模糊查找..." class="w-full pl-9 pr-3 py-1.5 text-sm text-zinc-500 dark:text-zinc-400 border border-zinc-300 dark:border-zinc-600 rounded-md bg-transparent focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 通用分页与选择 -->
<div class="custom-select relative items-per-page-select"> <!-- [ADD CLASS] -->
<select class="hidden">
<option value="20" selected>20</option> <!-- [ADD VALUE & SELECTED] -->
<option value="50">50</option>
<option value="100">100</option>
</select>
<div class="custom-select-trigger flex items-center justify-between w-28 cursor-pointer rounded-md border border-zinc-300 dark:border-zinc-600 bg-transparent px-3 py-1.5">
<span>20 / 页</span>
<i class="fas fa-chevron-down text-xs text-zinc-400"></i>
</div>
<div class="custom-select-panel hidden absolute z-30 mt-1 w-full rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-700"></div>
</div>
<div class="flex items-center gap-x-2">
<input type="checkbox" id="select-all" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500">
<label for="select-all" class="text-zinc-600 dark:text-zinc-300">全选</label>
</div>
</div>
</div>
</div>
<!-- API List Container -->
<div id="api-list-container" class="flex-grow overflow-y-auto pr-2 -mr-2 main-content-scroll">
<!-- Content will be rendered here by JS -->
</div>
<!-- Pagination Controls -->
<div class="pagination-controls flex justify-center items-center mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-2 lg:shrink-0"> <!-- [ADD CLASS] -->
<!-- Content will be rendered here by JS -->
</div>
</div>
</main>
</div>
</div>
{% endblock %}
{% block modals %}
<!-- Add/Edit Group Modal -->
<div id="keygroup-modal" class="modal-overlay hidden">
<div class="modal-panel max-w-3xl max-h-[90vh]">
<!-- Header -->
<div class="modal-header shrink-0">
<h2 id="modal-title" class="modal-title">创建新的 Key Group</h2>
<button data-modal-close="keygroup-modal" id="modal-close-btn" class="modal-close-btn">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Form Body -->
<div class="modal-body flex-grow overflow-y-auto pr-4 -mr-4">
<div class="grid grid-cols-2 gap-x-6 gap-y-4">
<!-- Name -->
<div>
<label for="group-name" class="flex items-center modal-label">
<span>分组名称<span class="text-red-500">*</span></span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="分组的唯一程序内标识符,例如 'default-group'。创建后不可修改。"></i>
</label>
<input type="text" id="group-name" class="modal-input">
</div>
<!-- Display Name -->
<div>
<label for="group-display-name" class="flex items-center modal-label">
<span>显示名称<span class="text-red-500">*</span></span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="显示在UI上的名称方便识别例如'默认分组'。"></i>
</label>
<input type="text" id="group-display-name" class="modal-input">
</div>
<!-- Description -->
<div class="col-span-2">
<label for="group-description" class="flex items-center modal-label">
<span>描述</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="关于此分组用途的简短说明。"></i>
</label>
<textarea id="group-description" rows="2" class="modal-input"></textarea>
</div>
<!-- Allowed Models -->
<div class="col-span-2">
<label class="flex items-center modal-label">
<span>允许模型</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="允许该分组使用的模型ID列表。留空表示允许所有模型。"></i>
</label>
<div id="allowed-models-container" class="tag-input-container">
<span class="tag-item">gemini-pro<button class="tag-delete">&times;</button></span>
<span class="tag-item">gemini-1.5-pro-latest<button class="tag-delete">&times;</button></span>
<input type="text" placeholder="添加模型..." class="tag-input-new">
</div>
</div>
<!-- Allowed Tokens -->
<div class="col-span-2">
<label class="flex items-center modal-label">
<span>专属密钥</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="只有列表中的认证令牌 (Auth Tokens) 才能使用此分组。留空则所有密钥都可用。"></i>
</label>
<div id="allowed-tokens-container" class="tag-input-container">
<input type="text" placeholder="添加专属密钥..." class="tag-input-new">
</div>
</div>
<!-- Polling Strategy -->
<div>
<label for="group-strategy" class="flex items-center modal-label">
<span>轮询模式</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="从此分组中选择可用API Key的策略。"></i>
</label>
<select id="group-strategy" class="modal-input">
<option value="random">随机</option>
<option value="sequential">顺序</option>
<option value="weighted">加权</option>
</select>
</div>
<!-- Channel Type -->
<div>
<label for="group-channel-type" class="flex items-center modal-label">
<span>渠道类型</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="此分组关联的上游渠道类型,由系统确定。"></i>
</label>
<input type="text" id="group-channel-type" class="modal-input bg-zinc-100 dark:bg-zinc-700/50" value="Gemini" disabled>
</div>
<!-- Max Retries (新增) -->
<div>
<label for="group-max-retries" class="flex items-center modal-label">
<span>重试次数</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="单个请求使用不同Key的最大重试次数。"></i>
</label>
<input type="number" id="group-max-retries" class="modal-input" placeholder="默认: 3">
</div>
<!-- Failure Threshold-->
<div>
<label for="group-key-blacklist-threshold" class="flex items-center modal-label">
<span>失败阈值</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="连续失败多少次后将密钥列入冷却状态。"></i>
</label>
<input type="number" id="group-key-blacklist-threshold" class="modal-input" placeholder="默认: 3">
</div>
<!-- Enable Proxy -->
<div class="flex items-center">
<label for="group-enable-proxy" class="modal-label flex-grow flex items-center">
<span>启用代理</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="是否为此分组的请求启用系统配置的代理。"></i>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="group-enable-proxy" id="group-enable-proxy" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="group-enable-proxy" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- Enable Smart Gateway -->
<div class="flex items-center">
<label for="group-enable-smart-gateway" class="modal-label flex-grow flex items-center">
<span>启用智能网关</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="是否启用智能网关功能,自动处理模型映射和重试。"></i>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="group-enable-smart-gateway" id="group-enable-smart-gateway" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="group-enable-smart-gateway" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- Allowed Upstreams -->
<div class="col-span-2">
<label class="flex items-center modal-label">
<span>上游地址</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="指定可用的上游API地址。如果留空将使用系统默认地址。"></i>
</label>
<div id="allowed-upstreams-container" class="tag-input-container">
<input type="text" placeholder="添加上游地址..." class="tag-input-new">
</div>
</div>
<!-- Max Retries (Moved to Advanced Request Settings) -->
<!-- API Key Auto Validation -->
<div class="col-span-2 mt-2">
<div class="flex items-center justify-between">
<label for="group-enable-key-check" class="modal-label flex-grow flex items-center font-semibold">
<span>开启自动验证</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="启用后,系统将定期自动验证该分组下所有密钥的有效性。"></i>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="group-enable-key-check" id="group-enable-key-check" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer" checked>
<label for="group-enable-key-check" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- Collapsible settings for key check -->
<div id="key-check-settings" class="grid grid-cols-2 gap-x-6 gap-y-4 mt-4 ">
<!-- Detection Model (从全宽收缩为半宽,并移到最前) -->
<div>
<label for="group-key-check-model" class="flex items-center modal-label">
<span>检测模型</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="用于验证此分组下API Key有效性的模型ID。"></i>
</label>
<input type="text" id="group-key-check-model" class="modal-input" placeholder="默认: gemini-1.5-flash">
</div>
<!-- Detection Interval -->
<div>
<label for="group-key-check-interval-minutes" class="flex items-center modal-label">
<span>检测周期 (分钟)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="每隔多少分钟对分组内的密钥进行一次健康检查。"></i>
</label>
<input type="number" id="group-key-check-interval-minutes" class="modal-input" placeholder="默认: 60">
</div>
<!-- Detection Concurrency -->
<div>
<label for="group-key-check-concurrency" class="flex items-center modal-label">
<span>检测并发数</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="同时执行密钥验证的最大并发数。"></i>
</label>
<input type="number" id="group-key-check-concurrency" class="modal-input" placeholder="默认: 5">
</div>
<!-- Cooldown Duration -->
<div>
<label for="group-key-cooldown-minutes" class="flex items-center modal-label">
<span>冷却时长 (分钟)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="密钥进入冷却状态后,需要等待多长时间才能被再次检查。"></i>
</label>
<input type="number" id="group-key-cooldown-minutes" class="modal-input" placeholder="默认: 10">
</div>
<!-- Detection Endpoint (保持全宽) -->
<div class="col-span-2">
<label for="group-key-check-endpoint" class="flex items-center modal-label">
<span>检测端点</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="用于密钥验证的上游API地址。如果留空将使用系统默认地址。"></i>
</label>
<input type="text" id="group-key-check-endpoint" class="modal-input" placeholder="留空以使用全局默认">
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer shrink-0">
<button data-modal-close="keygroup-modal" id="modal-cancel-btn" class="modal-btn modal-btn-secondary">取消</button>
<button id="modal-save-btn" class="modal-btn modal-btn-primary">保存</button>
</div>
</div>
</div>
<!-- Add API Modal -->
<div id="add-api-modal" class="modal-overlay hidden">
<div class="modal-panel">
<!-- Header -->
<div class="modal-header">
<h2 id="add-api-modal-title" class="modal-title">批量添加 API Keys</h2>
<button data-modal-close="add-api-modal" class="modal-close-btn">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Body 现在包含两个可切换的视图 -->
<div class="modal-body">
<!-- 视图 1: 输入视图 (默认显示) -->
<div id="add-api-input-view">
<label for="api-add-textarea" class="modal-label">API Keys (每行一个)</label>
<textarea id="api-add-textarea" rows="15" class="modal-input mt-1 font-mono text-xs"
placeholder="sk-abc...&#10;AIza...&#10;gsk_..."></textarea>
</div>
<!-- 视图 2: 结果/进度视图 (默认隐藏) -->
<div id="add-api-result-view" class="hidden">
<!-- 这里的内容将由JS动态填充 -->
</div>
</div>
<!-- Footer -->
<div id="add-api-modal-footer" class="modal-footer justify-between">
<!-- 左側:新增的驗證開關 -->
<div class="flex items-center space-x-4">
<input type="checkbox" id="validate-on-import-checkbox" class="form-checkbox h-5 w-5 ml-2 text-blue-600 focus:ring-blue-500 border-gray-300" checked>
<label for="validate-on-import-checkbox" class="text-sm font-medium text-zinc-700 dark:text-zinc-300 select-none"> 同步验证</label>
<i class="fas fa-question-circle text-gray-400 tooltip-icon"
data-tooltip-text="推荐开启。密钥导入后同步调用测试模型对API进行有效性验证无效Key将被隔离。"></i>
</div>
<!-- 右側:現有的按鈕組 -->
<div class="flex items-center space-x-2">
<button data-modal-close="add-api-modal" class="modal-btn modal-btn-secondary">取消</button>
<button id="add-api-import-btn" class="modal-btn modal-btn-primary">導入</button>
</div>
</div>
</div>
</div>
<!-- Delete API Modal -->
<div id="delete-api-modal" class="modal-overlay hidden">
<div class="modal-panel">
<!-- Header -->
<div class="modal-header">
<h2 class="modal-title">批量删除 API Keys</h2>
<button data-modal-close="delete-api-modal" class="modal-close-btn">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<label for="api-delete-textarea" class="text-sm font-medium text-zinc-700 dark:text-zinc-300">API Keys (每行一个)</label>
<textarea id="api-delete-textarea" rows="15" class="modal-input mt-1 font-mono text-xs" placeholder="sk-abc...&#10;AIza...&#10;gsk_..."></textarea>
</div>
<!-- Footer -->
<div class="modal-footer">
<button data-modal-close="delete-api-modal" class="modal-btn modal-btn-secondary">取消</button>
<button class="modal-btn modal-btn-danger">删除</button>
</div>
</div>
</div>
<!-- Delete Group Confirmation Modal -->
<div id="delete-group-modal" class="modal-overlay hidden">
<div class="modal-panel">
<!-- Header -->
<div class="modal-header">
<h2 id="delete-group-modal-title" class="modal-title">确认删除分组</h2>
<button data-modal-close="delete-group-modal" class="modal-close-btn">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Body -->
<div class="modal-body space-y-4">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
这是一个危险且不可逆的操作。请仔细阅读以下警告:
</p>
<ul class="list-disc list-inside space-y-2 text-sm bg-yellow-100 dark:bg-yellow-900/30 p-3 rounded-lg border border-yellow-300 dark:border-yellow-700">
<li>此操作将解除所有 API Key 与该分组的关联。</li>
<li class="font-semibold">未与其他分组关联的 Key 将被 <strong class="text-red-500">彻底从数据库中删除</strong></li>
<li>该操作无法撤销。</li>
</ul>
<div>
<label for="delete-group-confirm-input" class="text-sm font-medium text-zinc-700 dark:text-zinc-300">
请输入 <code class="text-red-500 bg-zinc-200 dark:bg-zinc-700 px-1 py-0.5 rounded">删除</code> 来确认操作:
</label>
<input type="text" id="delete-group-confirm-input" class="modal-input mt-1 font-mono" autocomplete="off">
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<button data-modal-close="delete-group-modal" class="modal-btn modal-btn-secondary">取消</button>
<button id="delete-group-confirm-btn" class="modal-btn modal-btn-danger" disabled>确认删除</button>
</div>
</div>
</div>
<!-- Clone Group Confirmation Modal -->
<div id="clone-group-modal" class="modal-overlay hidden">
<div class="modal-panel">
<!-- Header -->
<div class="modal-header">
<h2 id="clone-group-modal-title" class="modal-title">确认克隆分组</h2>
<button data-modal-close="clone-group-modal" class="modal-close-btn">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Body -->
<div class="modal-body space-y-4">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
此操作将创建一个当前分组的完整副本,包括:
</p>
<ul class="list-disc list-inside space-y-2 text-sm bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg border border-blue-300 dark:border-blue-700">
<li>所有相同的 API Key 关联关系。</li>
<li>所有允许的模型和上游设置。</li>
<li>所有请求配置和运营设置。</li>
</ul>
<p class="text-sm text-zinc-600 dark:text-zinc-400">
新分组的名称将被自动命名为 <code class="bg-zinc-200 dark:bg-zinc-700 px-1 py-0.5 rounded">[原名称]-clone-[时间戳]</code>,您可以在之后对其进行编辑。
</p>
</div>
<!-- Footer -->
<div class="modal-footer">
<button data-modal-close="clone-group-modal" class="modal-btn modal-btn-secondary">取消</button>
<button id="clone-group-confirm-btn" class="modal-btn modal-btn-primary">确认克隆</button>
</div>
</div>
</div>
<!-- ========================================================================== -->
<!-- NEW: Advanced Request Settings Modal -->
<!-- ========================================================================== -->
<div id="request-settings-modal" class="modal-overlay hidden">
<div class="modal-panel max-w-3xl max-h-[90vh]">
<!-- Header -->
<div class="modal-header shrink-0">
<h2 class="modal-title">高级请求设置</h2>
<button data-modal-close="request-settings-modal" class="modal-close-btn">
<i class="fas fa-times text-lg"></i>
</button>
</div>
<!-- Form Body -->
<div class="modal-body flex-grow overflow-y-auto pr-4 -mr-4 space-y-8"> <!-- 增加了 space-y-8 来拉开分区距离 -->
<!-- 1. Custom Headers -->
<div>
<label class="flex items-center modal-label text-lg font-semibold">
<i class="fas fa-file-alt w-6 text-center text-zinc-400 mr-2"></i> <!-- ICON -->
<span>自定义请求头 (Custom Headers)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="在这里添加的键值对将被添加到所有出站API请求的Header中。"></i>
</label>
<div id="CUSTOM_HEADERS_container" class="mt-2 space-y-2">
<!-- JS will populate this -->
</div>
<button id="addCustomHeaderBtn" type="button" class="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
<i class="fas fa-plus mr-1"></i> 添加 Header
</button>
</div>
<!-- 2. Streaming Optimization (折叠优化) -->
<div class="border-t dark:border-zinc-700 pt-6"> <!-- 分隔线 -->
<div class="flex items-center justify-between">
<label class="flex items-center modal-label text-lg font-semibold">
<i class="fas fa-stream w-6 text-center text-zinc-400 mr-2"></i> <!-- ICON -->
<span>流式输出优化</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="启用后,可对流式输出进行延迟、分块等优化,改善用户体验。"></i>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="STREAM_OPTIMIZER_ENABLED" id="STREAM_OPTIMIZER_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="STREAM_OPTIMIZER_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- [折叠容器] 这个容器将由JS控制显示/隐藏 -->
<div id="streaming-settings-panel" class="hidden mt-4">
<div class="grid grid-cols-2 gap-x-6 gap-y-4">
<div>
<label for="STREAM_MIN_DELAY" class="flex items-center modal-label"><span>最小延迟 (ms)</span></label>
<input type="number" id="STREAM_MIN_DELAY" class="modal-input" placeholder="例如: 16">
</div>
<div>
<label for="STREAM_MAX_DELAY" class="flex items-center modal-label"><span>最大延迟 (ms)</span></label>
<input type="number" id="STREAM_MAX_DELAY" class="modal-input" placeholder="例如: 24">
</div>
<div>
<label for="STREAM_SHORT_TEXT_THRESHOLD" class="flex items-center modal-label"><span>短文本阈值</span></label>
<input type="number" id="STREAM_SHORT_TEXT_THRESHOLD" class="modal-input" placeholder="例如: 10">
</div>
<div>
<label for="STREAM_LONG_TEXT_THRESHOLD" class="flex items-center modal-label"><span>长文本阈值</span></label>
<input type="number" id="STREAM_LONG_TEXT_THRESHOLD" class="modal-input" placeholder="例如: 50">
</div>
<div class="col-span-2">
<label for="STREAM_CHUNK_SIZE" class="flex items-center modal-label"><span>分块大小</span></label>
<input type="number" id="STREAM_CHUNK_SIZE" class="modal-input" placeholder="例如: 5">
</div>
</div>
<!-- [假流式输出同行] -->
<div class="border-t dark:border-zinc-700 pt-4 mt-4 flex items-center justify-between gap-x-6">
<div class="flex items-center flex-grow translate-y-3">
<label for="FAKE_STREAM_ENABLED" class="modal-label flex items-center">
<span>启用假流式输出</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="当启用时,将调用非流式接口,并在等待响应期间发送空数据以维持连接。"></i>
</label>
<div class="relative inline-block w-10 ml-auto mr-2 align-middle select-none">
<input type="checkbox" name="FAKE_STREAM_ENABLED" id="FAKE_STREAM_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="FAKE_STREAM_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<div class="flex-grow">
<label for="FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS" class="flex items-center modal-label"><span>发送间隔 (s)</span></label>
<input type="number" id="FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS" class="modal-input" placeholder="例如: 5">
</div>
</div>
</div>
</div>
<!-- 3. Safety Settings -->
<div class="border-t dark:border-zinc-700 pt-6"> <!-- 分隔线 -->
<label class="flex items-center modal-label text-lg font-semibold">
<i class="fas fa-shield-alt w-6 text-center text-zinc-400 mr-2"></i> <!-- ICON -->
<span>安全设置 (Safety Settings)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="配置模型的安全过滤级别,例如 HARM_CATEGORY_HARASSMENT: BLOCK_NONE。"></i>
</label>
<div id="SAFETY_SETTINGS_container" class="mt-2 space-y-2">
<!-- JS will populate this -->
</div>
<button id="addSafetySettingBtn" type="button" class="mt-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
<i class="fas fa-plus mr-1"></i> 添加安全设置
</button>
</div>
<!-- 4. Advanced Model Settings (保持折叠) -->
<div class="border-t dark:border-zinc-700 pt-6"> <!-- 分隔线 -->
<details>
<summary class="cursor-pointer text-lg font-semibold text-zinc-700 dark:text-zinc-300 flex items-center">
<i class="fas fa-cogs w-6 text-center text-zinc-400 mr-2"></i> <!-- ICON -->
<span>高级模型设置</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="配置模型的高级设置,自定义图像模型/搜索模型/思考设置等,非熟知不建议修改。"></i>
<i class="fas fa-chevron-down text-sm ml-auto transition-transform"></i>
</summary>
<div class="mt-4 space-y-6">
<!-- Image Models -->
<div>
<label class="flex items-center modal-label">
<span>图像模型 (Image Models)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="支持图像处理的模型列表。"></i>
</label>
<div id="IMAGE_MODELS_container" class="tag-input-container">
<input type="text" placeholder="添加模型..." class="tag-input-new">
</div>
</div>
<!-- Search Models -->
<div>
<label class="flex items-center modal-label">
<span>搜索模型 (Search Models)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="支持搜索功能的模型列表。"></i>
</label>
<div id="SEARCH_MODELS_container" class="tag-input-container">
<input type="text" placeholder="添加模型..." class="tag-input-new">
</div>
</div>
<!-- Filtered Models -->
<div>
<label class="flex items-center modal-label">
<span>过滤模型 (Filtered Models)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="需要从模型列表中排除的模型。"></i>
</label>
<div id="FILTERED_MODELS_container" class="tag-input-container">
<input type="text" placeholder="添加模型..." class="tag-input-new">
</div>
</div>
<!-- Enable Code Execution -->
<div class="flex items-center">
<label for="TOOLS_CODE_EXECUTION_ENABLED" class="modal-label flex-grow flex items-center">
<span>启用代码执行工具</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="是否为模型启用代码执行工具。"></i>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="TOOLS_CODE_EXECUTION_ENABLED" id="TOOLS_CODE_EXECUTION_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="TOOLS_CODE_EXECUTION_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- Enable URL Context -->
<div class="flex items-center">
<label for="URL_CONTEXT_ENABLED" class="modal-label flex-grow flex items-center">
<span>启用网址上下文</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="是否启用网址上下文功能。"></i>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="URL_CONTEXT_ENABLED" id="URL_CONTEXT_ENABLED" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="URL_CONTEXT_ENABLED" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- URL Context Models -->
<div>
<label class="flex items-center modal-label">
<span>网址上下文模型</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="支持网址上下文功能的模型列表。"></i>
</label>
<div id="URL_CONTEXT_MODELS_container" class="tag-input-container">
<input type="text" placeholder="添加模型..." class="tag-input-new">
</div>
</div>
<!-- Show Search Link -->
<div class="flex items-center">
<label for="SHOW_SEARCH_LINK" class="modal-label flex-grow flex items-center">
<span>显示搜索链接</span>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="SHOW_SEARCH_LINK" id="SHOW_SEARCH_LINK" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="SHOW_SEARCH_LINK" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- Show Thinking Process -->
<div class="flex items-center">
<label for="SHOW_THINKING_PROCESS" class="modal-label flex-grow flex items-center">
<span>显示思考过程</span>
</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="SHOW_THINKING_PROCESS" id="SHOW_THINKING_PROCESS" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
<label for="SHOW_THINKING_PROCESS" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- Thinking Models -->
<div>
<label class="flex items-center modal-label">
<span>思考模型 (Thinking Models)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="用于“思考过程”的模型列表。"></i>
</label>
<div id="THINKING_MODELS_container" class="tag-input-container">
<input type="text" placeholder="添加模型..." class="tag-input-new">
</div>
</div>
<!-- Thinking Budget Map -->
<div>
<label class="flex items-center modal-label">
<span>思考模型预算映射</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="为每个思考模型设置预算(-1为auto此项与上方模型列表自动关联。"></i>
</label>
<div id="THINKING_BUDGET_MAP_container" class="mt-2 space-y-2">
<!-- JS will populate this -->
</div>
</div>
</div>
</details>
</div>
<!-- 5. Config Overrides (折叠优化) -->
<div class="border-t dark:border-zinc-700 pt-6"> <!-- 分隔线 -->
<details>
<summary class="cursor-pointer text-lg font-semibold text-zinc-700 dark:text-zinc-300 flex items-center">
<i class="fas fa-code w-6 text-center text-zinc-400 mr-2"></i> <!-- ICON -->
<span>覆盖参数 (JSON)</span>
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="使用JSON格式覆盖默认的请求参数。"></i>
<i class="fas fa-chevron-down text-sm ml-auto transition-transform"></i>
</summary>
<!-- [修复] 将 placeholder 内容放在一行,避免被截断 -->
<textarea id="group-config-overrides" rows="6" class="mt-2 modal-input font-mono text-xs" placeholder='{
"temperature": 0.8
}'></textarea>
</details>
</div>
</div>
<!-- Footer -->
<div class="modal-footer shrink-0">
<button data-modal-close="request-settings-modal" class="modal-btn modal-btn-secondary">取消</button>
<button id="request-settings-save-btn" class="modal-btn modal-btn-primary">保存</button>
</div>
</div>
</div>
{% endblock modals %}
{% block page_scripts %}
{% endblock page_scripts %}

184
web/templates/logs.html Normal file
View File

@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block title %}日志管理 - GEMINI BALANCER{% endblock %}
{% block content %}
<div class="w-full h-full flex flex-col pl-0 pr-3 lg:px-0" data-page-id="logs">
<!-- =================================================================== -->
<!-- 1. 页面顶栏:标题与全局控制器 -->
<!-- =================================================================== -->
<div class="flex items-center justify-between mb-4 shrink-0">
<div>
<h2 class="text-3xl font-bold tracking-tight">日志管理</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">查看、筛选和分析所有通过系统的请求记录。</p>
</div>
<div class="flex items-center space-x-2">
<!-- [新] 日志系统参数管理图标 -->
<button class="btn btn-icon" aria-label="日志设置">
<i class="fas fa-cog text-lg"></i>
</button>
</div>
</div>
<!-- =================================================================== -->
<!-- 2. 导航标签 -->
<!-- =================================================================== -->
<div class="py-2 shrink-0">
<div class="w-full overflow-x-auto scrollbar-hide">
<div role="tablist" class="relative inline-flex h-10 items-center justify-center inset-shadow-sm/25 rounded-lg bg-zinc-800/50 dark:bg-zinc-950 p-1" data-sliding-tabs-container>
<div class="absolute left-0 h-[calc(100%-0.5rem)] rounded-md bg-white dark:bg-zinc-700 shadow-sm" data-tab-indicator style="transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);"></div>
<a href="#" role="tab" class="tab-item tab-active" data-tab-item>错误日志</a>
<a href="#" role="tab" class="tab-item" data-tab-item>系统日志</a>
<a href="#" role="tab" class="tab-item" data-tab-item>保留标签</a>
<a href="#" role="tab" class="tab-item" data-tab-item>保留标签</a>
</div>
</div>
</div>
<!-- =================================================================== -->
<!-- 3. 主内容区:过滤器 + 表格 (精确复刻版) -->
<!-- =================================================================== -->
<div class="flex items-center justify-between shrink-0 py-4">
<div class="flex flex-1 items-center space-x-2">
<input class="input h-8 w-[150px] lg:w-[250px]" placeholder="筛选密钥..." value="">
<button class="btn btn-outline border-dashed h-8 px-3 text-xs">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 h-4 w-4">
<circle cx="12" cy="12" r="10"></circle><path d="M8 12h8"></path><path d="M12 8v8"></path>
</svg>
错误类型
</button>
<button class="btn btn-outline border-dashed h-8 px-3 text-xs">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 h-4 w-4">
<circle cx="12" cy="12" r="10"></circle><path d="M8 12h8"></path><path d="M12 8v8"></path>
</svg>
错误码
</button>
<button class="btn btn-outline border-dashed h-8 px-3 text-xs">
<i class="fas fa-calendar-alt mr-2 h-4 w-4"></i>
时间范围
</button>
</div>
<!-- [应用] .btn .btn-outline, 并覆盖高度/字体 -->
<button class="btn btn-outline hidden lg:flex h-8 px-3 text-xs">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2 h-4 w-4">
<path d="M20 7h-9"></path><path d="M14 17H5"></path><circle cx="17" cy="17" r="3"></circle><circle cx="7" cy="7" r="3"></circle>
</svg>
批量操作
</button>
</div>
<!-- 3.2 数据表格容器 (带边框和圆角) -->
<div class="rounded-lg border border-zinc-200 dark:border-zinc-700 flex-grow overflow-hidden flex flex-col">
<!-- 容器需要 overflow-auto 以便表格内容超出时滚动 -->
<div class="relative w-full overflow-auto flex-grow main-content-scroll">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b border-b-zinc-200 dark:border-b-zinc-800 sticky top-0 bg-zinc-50 dark:bg-zinc-900 z-10">
<tr class="transition-colors hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50">
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400 w-4">
<input type="checkbox" class="checkbox">
</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">ID</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">Gemini 密钥</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">错误类型</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">错误码</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">模型名称</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">请求时间</th>
<th class="h-12 px-4 text-left align-middle font-medium text-zinc-500 dark:text-zinc-400">操作</th>
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0" id="logs-table-body">
<tr class="border-b border-b-zinc-200 dark:border-b-zinc-700 transition-colors hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50">
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
<td class="p-4 align-middle font-mono text-zinc-600 dark:text-zinc-300">#12346</td>
<td class="p-4 align-middle font-medium font-mono">AIza...s7f1</td>
<td class="p-4 align-middle text-zinc-600 dark:text-zinc-300">API Key Invalid</td>
<td class="p-4 align-middle"><span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">429</span></td>
<td class="p-4 align-middle font-mono">gemini-1.5-pro-latest</td>
<td class="p-4 align-middle text-zinc-500 dark:text-zinc-400">2024-05-21 10:31:15</td>
<td class="p-4 align-middle">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="更多操作"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button>
</td>
</tr>
<!-- [示例] 您可以复制粘贴下面这行来增加更多数据行进行测试 -->
<tr class="border-b border-b-zinc-200 dark:border-b-zinc-700 transition-colors hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50">
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
<td class="p-4 align-middle font-mono text-zinc-600 dark:text-zinc-300">#12347</td>
<td class="p-4 align-middle font-medium font-mono">AIza...s7f2</td>
<td class="p-4 align-middle text-zinc-600 dark:text-zinc-300">Quota Exceeded</td>
<td class="p-4 align-middle"><span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">429</span></td>
<td class="p-4 align-middle font-mono">gemini-1.0-pro</td>
<td class="p-4 align-middle text-zinc-500 dark:text-zinc-400">2024-05-21 10:32:15</td>
<td class="p-4 align-middle">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="更多操作"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button>
</td>
</tr>
<tr class="border-b border-b-zinc-200 dark:border-b-zinc-700 transition-colors hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50">
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
<td class="p-4 align-middle font-mono text-zinc-600 dark:text-zinc-300">#12348</td>
<td class="p-4 align-middle font-medium font-mono">AIza...s7f3</td>
<td class="p-4 align-middle text-zinc-600 dark:text-zinc-300">Server Error</td>
<td class="p-4 align-middle"><span class="inline-flex items-center rounded-md bg-yellow-500/20 px-2 py-1 text-xs font-medium text-yellow-700 dark:text-yellow-400">500</span></td>
<td class="p-4 align-middle font-mono">gemini-1.5-pro-latest</td>
<td class="p-4 align-middle text-zinc-500 dark:text-zinc-400">2024-05-21 10:33:15</td>
<td class="p-4 align-middle">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="更多操作"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 3.3 分页控制器 -->
<div class="flex items-center justify-between p-2 shrink-0 border-t border-zinc-200 dark:border-zinc-700">
<div class="flex-1 text-sm text-zinc-500 dark:text-zinc-400">
已选择 <span class="font-semibold text-zinc-900 dark:text-white">0</span> / <span class="font-semibold text-zinc-900 dark:text-white">100</span>
</div>
<div class="flex items-center space-x-6 lg:space-x-8">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">每页行数</p>
<button type="button" class="btn btn-secondary h-8 w-[70px] flex justify-between items-center px-2">
<span>10</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 opacity-50"><path d="m6 9 6 6 6-6"></path></svg>
</button>
</div>
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
第 1 / 10 页
</div>
<div class="flex items-center space-x-2">
<button class="btn btn-secondary h-8 w-8 p-0 hidden lg:flex" disabled>
<span class="sr-only">Go to first page</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m11 17-5-5 5-5"></path><path d="m18 17-5-5 5-5"></path></svg>
</button>
<button class="btn btn-secondary h-8 w-8 p-0" disabled>
<span class="sr-only">Go to previous page</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m15 18-6-6 6-6"></path></svg>
</button>
<button class="btn btn-secondary h-8 w-8 p-0">
<span class="sr-only">Go to next page</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m9 18 6-6-6-6"></path></svg>
</button>
<button class="btn btn-secondary h-8 w-8 p-0 hidden lg:flex">
<span class="sr-only">Go to last page</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="m6 17 5-5-5-5"></path><path d="m13 17 5-5-5-5"></path></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block modals %}
<!-- 日志详情模态框将在此处定义 -->
{% endblock modals %}
{% block page_scripts %}
{% endblock page_scripts %}

2048
web/templates/settings.html Normal file

File diff suppressed because it is too large Load Diff

102
web/templates/tasks.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}计划任务 - GEMINI BALANCER{% endblock %}
{% block content %}
<div class="w-full h-full flex flex-col pl-0 pr-3 lg:px-0" data-page-id="tasks">
<!-- =================================================================== -->
<!-- 1. 页面顶栏:标题与全局控制器 -->
<!-- =================================================================== -->
<div class="flex items-center justify-between mb-6 shrink-0">
<div>
<h2 class="text-3xl font-bold tracking-tight">计划任务</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">管理和监控所有周期性系统任务。</p>
</div>
<div class="flex items-center space-x-2">
<button class="btn btn-primary">
<i class="fas fa-plus mr-2 h-4 w-4"></i>
新建任务
</button>
</div>
</div>
<!-- =================================================================== -->
<!-- 2. 主内容区:任务列表 -->
<!-- =================================================================== -->
<div class="rounded-lg border border-border flex-grow overflow-hidden flex flex-col">
<!-- 容器需要 overflow-auto 以便表格内容超出时滚动 -->
<div class="relative w-full overflow-auto flex-grow main-content-scroll">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b border-b-border sticky top-0 bg-muted/50 dark:bg-muted/50 z-10">
<tr class="transition-colors hover:bg-muted/80 dark:hover:bg-muted/80">
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-24">状态</th>
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground">任务名称</th>
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground">CRON 表达式</th>
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground">上次运行</th>
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground">下次运行</th>
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-32">操作</th>
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
<!-- 示例任务 1: 运行中 -->
<tr class="border-b border-b-border transition-colors hover:bg-muted/80 dark:hover:bg-muted/80">
<td class="p-4 align-middle">
<div class="inline-flex items-center gap-2">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span class="text-foreground">运行中</span>
</div>
</td>
<td class="p-4 align-middle font-medium">密钥可用性检查</td>
<td class="p-4 align-middle font-mono text-muted-foreground">0 */1 * * *</td>
<td class="p-4 align-middle text-muted-foreground">2024-05-21 10:30:00</td>
<td class="p-4 align-middle text-muted-foreground">2024-05-21 11:30:00</td>
<td class="p-4 align-middle space-x-2">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="立即运行"><i class="fas fa-play"></i></button>
<button class="btn btn-ghost btn-icon btn-sm" aria-label="编辑任务"><i class="fas fa-pencil-alt"></i></button>
</td>
</tr>
<!-- 示例任务 2: 已暂停 -->
<tr class="border-b border-b-border transition-colors hover:bg-muted/80 dark:hover:bg-muted/80">
<td class="p-4 align-middle">
<div class="inline-flex items-center gap-2">
<span class="relative flex h-3 w-3"><span class="relative inline-flex rounded-full h-3 w-3 bg-yellow-500"></span></span>
<span class="text-foreground">已暂停</span>
</div>
</td>
<td class="p-4 align-middle font-medium">清理过期日志</td>
<td class="p-4 align-middle font-mono text-muted-foreground">0 1 * * *</td>
<td class="p-4 align-middle text-muted-foreground">2024-05-20 01:00:00</td>
<td class="p-4 align-middle text-muted-foreground">N/A</td>
<td class="p-4 align-middle space-x-2">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="立即运行"><i class="fas fa-play"></i></button>
<button class="btn btn-ghost btn-icon btn-sm" aria-label="编辑任务"><i class="fas fa-pencil-alt"></i></button>
</td>
</tr>
<!-- 示例任务 3: 失败 -->
<tr class="border-b border-b-border transition-colors hover:bg-muted/80 dark:hover:bg-muted/80">
<td class="p-4 align-middle">
<div class="inline-flex items-center gap-2">
<span class="relative flex h-3 w-3"><span class="relative inline-flex rounded-full h-3 w-3 bg-destructive"></span></span>
<span class="text-destructive">失败</span>
</div>
</td>
<td class="p-4 align-middle font-medium">同步外部数据源</td>
<td class="p-4 align-middle font-mono text-muted-foreground">*/30 * * * *</td>
<td class="p-4 align-middle text-muted-foreground">2024-05-21 10:00:00 (错误)</td>
<td class="p-4 align-middle text-muted-foreground">2024-05-21 10:30:00</td>
<td class="p-4 align-middle space-x-2">
<button class="btn btn-ghost btn-icon btn-sm" aria-label="查看日志"><i class="fas fa-file-alt"></i></button>
<button class="btn btn-ghost btn-icon btn-sm" aria-label="编辑任务"><i class="fas fa-pencil-alt"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block page_scripts %}
{% endblock page_scripts %}

10
web/templates/test.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Pongo2 Test</title>
</head>
<body>
<h1>Hello from Test Template!</h1>
<p>My name is {{ name }}.</p>
</body>
</html>