KiroProxy User
chore: repo cleanup and maintenance
0edbd7b
"""Web UI - 组件化单文件结构"""
# ==================== CSS 样式 ====================
CSS_BASE = '''
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0a;
--card: #1a1a1a;
--border: #333;
--text: #fafafa;
--muted: #a3a3a3;
--accent: #3b82f6;
--success: #22c55e;
--error: #ef4444;
--warn: #f59e0b;
--info: #3b82f6;
--primary: #6366f1;
--secondary: #8b5cf6;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
'''
CSS_LAYOUT = '''
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--card);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
h1 {
font-size: 1.75rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h1 img {
width: 32px;
height: 32px;
border-radius: 8px;
}
.status {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.875rem;
color: var(--muted);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.status-dot.ok {
background: var(--success);
color: var(--success);
}
.status-dot.err {
background: var(--error);
color: var(--error);
}
/* Navigation Tabs */
.tabs {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 2rem;
padding: 0.5rem;
background: var(--card);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.tab {
padding: 0.75rem 1.5rem;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.3s ease;
border-radius: 12px;
position: relative;
}
.tab:hover {
color: var(--text);
background: rgba(255,255,255,0.05);
}
.tab.active {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
box-shadow: 0 4px 12px rgba(59,130,246,0.3);
}
/* Panels */
.panel {
display: none;
flex: 1;
}
.panel.active {
display: block;
}
/* Footer */
.footer {
text-align: center;
color: var(--muted);
font-size: 0.75rem;
margin-top: 2rem;
padding: 1rem;
border-top: 1px solid var(--border);
}
'''
CSS_COMPONENTS = '''
/* Cards */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
transform: translateY(-2px);
}
.card h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text);
}
/* Stats Grid - OXO Style */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-item {
text-align: center;
padding: 1.5rem;
background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1));
border-radius: 16px;
border: 1px solid rgba(59,130,246,0.2);
transition: all 0.3s ease;
}
.stat-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(59,130,246,0.2);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--muted);
font-weight: 500;
}
/* Compact Stats Grid for Monitor Panel */
.stats-grid-compact {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.5rem;
}
.stats-grid-compact .stat-item {
text-align: center;
padding: 0.5rem;
background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08));
border-radius: 8px;
border: 1px solid rgba(59,130,246,0.15);
transition: all 0.2s ease;
}
.stats-grid-compact .stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
}
.stats-grid-compact .stat-value {
font-size: 1.2rem;
font-weight: 600;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.25rem;
line-height: 1.2;
}
.stats-grid-compact .stat-label {
font-size: 0.7rem;
color: var(--muted);
font-weight: 500;
line-height: 1.2;
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge.success {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: white;
box-shadow: 0 2px 8px rgba(34,197,94,0.3);
}
.badge.error {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 2px 8px rgba(239,68,68,0.3);
}
.badge.warn {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
box-shadow: 0 2px 8px rgba(245,158,11,0.3);
}
.badge.info {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 2px 8px rgba(59,130,246,0.3);
}
/* Circular Progress */
.progress-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: conic-gradient(var(--primary) 0deg, var(--secondary) 180deg, var(--border) 180deg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.progress-circle::before {
content: '';
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--card);
position: absolute;
}
.progress-text {
position: relative;
z-index: 1;
font-weight: 700;
font-size: 0.875rem;
}
'''
CSS_FORMS = '''
/* Buttons - OXO Style */
button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(59,130,246,0.3);
text-transform: uppercase;
letter-spacing: 0.025em;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59,130,246,0.4);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
button.secondary {
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
button.secondary:hover {
background: rgba(255,255,255,0.05);
border-color: var(--primary);
}
button.small {
padding: 0.5rem 1rem;
font-size: 0.75rem;
border-radius: 8px;
}
button.circle {
width: 48px;
height: 48px;
border-radius: 50%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
button.large {
padding: 1rem 2rem;
font-size: 1rem;
border-radius: 16px;
}
/* Inputs */
input[type="text"],
input[type="number"],
input[type="search"],
input[type="password"],
textarea {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
color: var(--text);
font-size: 0.875rem;
transition: all 0.3s ease;
width: 100%;
}
input:hover, textarea:hover {
border-color: var(--primary);
}
input:focus, textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
input::placeholder, textarea::placeholder {
color: var(--muted);
}
/* Select */
select {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23a3a3a3' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
padding-right: 3rem;
transition: all 0.3s ease;
}
select:hover {
border-color: var(--primary);
}
select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
background: var(--card);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
/* Compact table styles for monitor panel */
#monitor table th,
#monitor table td {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
line-height: 1.3;
}
#monitor table tbody tr {
height: 32px;
}
#monitor table tbody tr:hover {
background: rgba(59,130,246,0.05);
}
th {
font-weight: 600;
color: var(--muted);
background: rgba(59,130,246,0.05);
}
tr:hover {
background: rgba(255,255,255,0.02);
}
/* Code blocks */
pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
overflow-x: auto;
font-size: 0.8rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
code {
background: rgba(59,130,246,0.1);
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.875em;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
'''
CSS_ACCOUNTS = '''
.account-card { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; background: var(--card); }
.account-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.account-name { font-weight: 500; display: flex; align-items: center; gap: 0.5rem; }
.account-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; font-size: 0.8rem; color: var(--muted); }
.account-meta-item { display: flex; justify-content: space-between; padding: 0.25rem 0; }
.account-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
'''
CSS_API = '''
.endpoint { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.method { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.method.get { background: #dcfce7; color: #166534; }
.method.post { background: #fef3c7; color: #92400e; }
@media (prefers-color-scheme: dark) {
.method.get { background: #14532d; color: #86efac; }
.method.post { background: #78350f; color: #fde68a; }
}
.copy-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--card); border: 1px solid var(--border); color: var(--text); }
'''
CSS_DOCS = '''
.docs-container { display: flex; gap: 1.5rem; min-height: 500px; }
.docs-nav { width: 200px; flex-shrink: 0; }
.docs-nav-item { display: block; padding: 0.5rem 0.75rem; margin-bottom: 0.25rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; color: var(--text); text-decoration: none; transition: background 0.2s; }
.docs-nav-item:hover { background: var(--bg); }
.docs-nav-item.active { background: var(--accent); color: var(--bg); }
.docs-content { flex: 1; min-width: 0; }
.docs-content h1 { font-size: 1.5rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
.docs-content h2 { font-size: 1.25rem; margin: 1.5rem 0 0.75rem; color: var(--text); }
.docs-content h3 { font-size: 1rem; margin: 1rem 0 0.5rem; color: var(--text); }
.docs-content h4 { font-size: 0.9rem; margin: 0.75rem 0 0.5rem; color: var(--muted); }
.docs-content p { margin: 0.5rem 0; }
.docs-content ul, .docs-content ol { margin: 0.5rem 0; padding-left: 1.5rem; }
.docs-content li { margin: 0.25rem 0; }
.docs-content code { background: var(--bg); padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
.docs-content pre { margin: 0.75rem 0; }
.docs-content pre code { background: none; padding: 0; }
.docs-content table { margin: 0.75rem 0; }
.docs-content blockquote { margin: 0.75rem 0; padding: 0.5rem 1rem; border-left: 3px solid var(--border); color: var(--muted); background: var(--bg); border-radius: 0 6px 6px 0; }
.docs-content hr { margin: 1.5rem 0; border: none; border-top: 1px solid var(--border); }
.docs-content a { color: var(--info); text-decoration: none; }
.docs-content a:hover { text-decoration: underline; }
@media (max-width: 768px) {
.docs-container { flex-direction: column; }
.docs-nav { width: 100%; display: flex; flex-wrap: wrap; gap: 0.5rem; }
.docs-nav-item { margin-bottom: 0; }
}
'''
# ==================== UI 组件库样式 ====================
CSS_UI_COMPONENTS = '''
/* Modal 模态框 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; visibility: hidden; transition: all 0.2s; }
.modal-overlay.active { opacity: 1; visibility: visible; }
.modal { background: var(--card); border-radius: 12px; max-width: 500px; width: 90%; max-height: 90vh; overflow: hidden; transform: scale(0.9); transition: transform 0.2s; }
.modal-overlay.active .modal { transform: scale(1); }
.modal-header { padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.modal-header h3 { font-size: 1.1rem; font-weight: 600; }
.modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--muted); padding: 0; line-height: 1; }
.modal-body { padding: 1.5rem; overflow-y: auto; max-height: 60vh; }
.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal.danger .modal-header { background: #fee2e2; }
.modal.warning .modal-header { background: #fef3c7; }
@media (prefers-color-scheme: dark) {
.modal.danger .modal-header { background: #7f1d1d; }
.modal.warning .modal-header { background: #78350f; }
}
/* Toast 通知 */
.toast-container { position: fixed; top: 1rem; right: 1rem; z-index: 1100; display: flex; flex-direction: column; gap: 0.5rem; }
.toast { padding: 0.75rem 1rem; border-radius: 8px; background: var(--card); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 0.5rem; animation: slideIn 0.3s ease; min-width: 250px; }
.toast.success { border-left: 4px solid var(--success); }
.toast.error { border-left: 4px solid var(--error); }
.toast.warning { border-left: 4px solid var(--warn); }
.toast.info { border-left: 4px solid var(--info); }
.toast-close { margin-left: auto; background: none; border: none; cursor: pointer; color: var(--muted); font-size: 1.2rem; padding: 0; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* Select 下拉选择 */
.custom-select { position: relative; }
.custom-select-trigger { padding: 0.75rem 1rem; border: 1px solid var(--border); border-radius: 6px; background: var(--card); cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.custom-select-trigger::after { content: "▼"; font-size: 0.7rem; color: var(--muted); }
.custom-select-options { position: absolute; top: 100%; left: 0; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; margin-top: 4px; max-height: 200px; overflow-y: auto; z-index: 100; display: none; }
.custom-select.open .custom-select-options { display: block; }
.custom-select-option { padding: 0.5rem 1rem; cursor: pointer; }
.custom-select-option:hover { background: var(--bg); }
.custom-select-option.selected { background: var(--accent); color: var(--bg); }
/* ProgressBar 进度条 */
.progress-bar { height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden; }
.progress-bar.large { height: 12px; }
.progress-bar.small { height: 4px; }
.progress-fill { height: 100%; background: var(--info); transition: width 0.3s; }
.progress-fill.success { background: var(--success); }
.progress-fill.warning { background: var(--warn); }
.progress-fill.error { background: var(--error); }
.progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--muted); margin-top: 0.25rem; }
/* Dropdown 下拉菜单 */
.dropdown { position: relative; display: inline-block; }
.dropdown-menu { position: absolute; top: 100%; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 8px; min-width: 120px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 100; display: none; margin-top: 4px; overflow: hidden; }
.dropdown.open .dropdown-menu { display: block; }
.dropdown-item { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; white-space: nowrap; }
.dropdown-item:hover { background: var(--bg); }
.dropdown-item.danger { color: var(--error); }
.dropdown-divider { height: 1px; background: var(--border); margin: 0.25rem 0; }
/* 账号卡片增强 */
.account-card-enhanced { border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; background: var(--card); }
.account-card-enhanced.priority { border-color: var(--info); border-width: 2px; }
.account-card-enhanced.active { box-shadow: 0 0 0 2px var(--success); }
.account-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
.account-card-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.account-card-badges { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.account-quota-section { margin: 1rem 0; }
.quota-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.875rem; }
.quota-detail { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--muted); margin-top: 0.5rem; flex-wrap: wrap; }
.quota-reset-info { display: flex; gap: 1rem; flex-wrap: wrap; }
.quota-reset-info span { display: inline-flex; align-items: center; gap: 0.25rem; }
.account-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; margin: 1rem 0; }
.account-stat { text-align: center; padding: 0.5rem; background: var(--bg); border-radius: 6px; }
.account-stat-value { font-weight: 600; font-size: 0.9rem; }
.account-stat-label { font-size: 0.7rem; color: var(--muted); }
/* 账号网格布局 - 动态自适应 */
.accounts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
.account-card-compact { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 0.875rem; transition: all 0.2s; }
.account-card-compact:hover { border-color: var(--accent); }
.account-card-compact.priority { border-color: var(--info); border-width: 2px; }
.account-card-compact.low-balance { border-color: var(--warn); }
.account-card-compact.exhausted { border-color: var(--error); border-width: 2px; }
.account-card-compact.suspended { border-color: var(--error); border-width: 2px; background: rgba(239, 68, 68, 0.1); }
.account-card-compact.unavailable { opacity: 0.6; }
.account-card-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; }
.account-card-info { flex: 1; min-width: 0; }
.account-card-name { font-weight: 600; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.25rem; }
.account-card-email { font-size: 0.75rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.account-card-status { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.account-card-quota { margin: 0.75rem 0; }
.account-card-quota-bar { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; }
.account-card-quota-fill { height: 100%; transition: width 0.3s; }
.account-card-quota-text { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.25rem; }
.account-card-stats { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.75rem; }
.account-card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.account-card-actions button { flex: 1; min-width: 60px; }
/* 紧凑汇总面板 */
.summary-compact { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; padding: 0.75rem; background: var(--bg); border-radius: 8px; }
.summary-compact-item { display: flex; align-items: center; gap: 0.5rem; }
.summary-compact-value { font-weight: 600; font-size: 1.1rem; }
.summary-compact-label { font-size: 0.75rem; color: var(--muted); }
.summary-compact-divider { width: 1px; height: 24px; background: var(--border); }
.summary-quota-bar { flex: 1; min-width: 200px; }
/* 全局进度条 - 批量刷新操作进度显示 */
.global-progress-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 1200; background: var(--card); border-bottom: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.1); transform: translateY(-100%); transition: transform 0.3s ease; }
.global-progress-bar.active { transform: translateY(0); }
.global-progress-bar-inner { max-width: 1400px; margin: 0 auto; padding: 0.75rem 1rem; }
.global-progress-bar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.global-progress-bar-title { font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; }
.global-progress-bar-title .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
.global-progress-bar-stats { display: flex; gap: 1rem; font-size: 0.8rem; color: var(--muted); }
.global-progress-bar-stats span { display: flex; align-items: center; gap: 0.25rem; }
.global-progress-bar-stats .success { color: var(--success); }
.global-progress-bar-stats .error { color: var(--error); }
.global-progress-bar-track { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; }
.global-progress-bar-fill { height: 100%; background: var(--info); transition: width 0.3s ease; border-radius: 3px; }
.global-progress-bar-fill.complete { background: var(--success); }
.global-progress-bar-current { font-size: 0.75rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.global-progress-bar-close { background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--muted); padding: 0; margin-left: 0.5rem; }
.global-progress-bar-close:hover { color: var(--text); }
/* 汇总面板 */
.summary-panel { background: linear-gradient(135deg, var(--card) 0%, var(--bg) 100%); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.summary-item { text-align: center; }
.summary-value { font-size: 1.75rem; font-weight: 700; }
.summary-label { font-size: 0.75rem; color: var(--muted); }
.summary-item.success .summary-value { color: var(--success); }
.summary-item.warning .summary-value { color: var(--warn); }
.summary-item.error .summary-value { color: var(--error); }
.summary-quota { margin: 1rem 0; }
.summary-info { display: flex; gap: 2rem; flex-wrap: wrap; font-size: 0.875rem; color: var(--muted); }
.summary-actions { margin-top: 1rem; display: flex; gap: 0.5rem; }
'''
CSS_AUTH = '''
/* 登录模态框 */
.auth-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.auth-modal {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
max-width: 400px;
width: 90%;
text-align: center;
}
.auth-modal h2 {
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.auth-modal input {
margin: 1rem 0;
}
.auth-modal .auth-error {
color: var(--error);
font-size: 0.875rem;
margin-top: 0.5rem;
}
'''
CSS_STYLES = CSS_BASE + CSS_LAYOUT + CSS_COMPONENTS + CSS_FORMS + CSS_ACCOUNTS + CSS_API + CSS_DOCS + CSS_UI_COMPONENTS + CSS_AUTH
# ==================== HTML 模板 ====================
# 登录模态框
HTML_AUTH_MODAL = '''
<div class="auth-overlay" id="authOverlay" style="display:none">
<div class="auth-modal">
<h2>🔐 管理员登录</h2>
<p style="color:var(--muted);margin-bottom:1rem">请输入管理员密码访问管理面板</p>
<input type="password" id="authPassword" placeholder="输入密码" onkeydown="if(event.key==='Enter')adminLogin()">
<button onclick="adminLogin()" style="width:100%">登录</button>
<div class="auth-error" id="authError"></div>
</div>
</div>
'''
# 全局进度条容器 - 显示在页面顶部
HTML_GLOBAL_PROGRESS = '''
<!-- 全局进度条 - 批量刷新操作进度显示 -->
<div class="global-progress-bar" id="globalProgressBar">
<div class="global-progress-bar-inner">
<div class="global-progress-bar-header">
<div class="global-progress-bar-title">
<span class="spinner"></span>
<span id="globalProgressTitle">正在刷新额度...</span>
</div>
<div class="global-progress-bar-stats">
<span>完成: <strong id="globalProgressCompleted">0</strong>/<strong id="globalProgressTotal">0</strong></span>
<span class="success">成功: <strong id="globalProgressSuccess">0</strong></span>
<span class="error">失败: <strong id="globalProgressFailed">0</strong></span>
<button class="global-progress-bar-close" id="globalProgressClose" onclick="GlobalProgressBar.hide()" style="display:none">&times;</button>
</div>
</div>
<div class="global-progress-bar-track">
<div class="global-progress-bar-fill" id="globalProgressFill" style="width:0%"></div>
</div>
<div class="global-progress-bar-current" id="globalProgressCurrent">准备中...</div>
</div>
</div>
'''
HTML_HEADER = '''
<header>
<h1><img src="/assets/icon.svg" alt="Kiro">Kiro API Proxy</h1>
<div class="status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">检查中...</span>
<span id="uptime"></span>
</div>
</header>
<div class="tabs">
<div class="tab" data-tab="help">📚 帮助</div>
<div class="tab" data-tab="monitor">📊 监控</div>
<div class="tab active" data-tab="accounts">👥 账号</div>
<div class="tab" data-tab="api">🔌 API</div>
<div class="tab" data-tab="settings">⚙️ 设置</div>
</div>
'''
HTML_HELP = '''
<div class="panel" id="help">
<div class="card" style="padding:1rem">
<div class="docs-container">
<nav class="docs-nav" id="docsNav"></nav>
<div class="docs-content" id="docsContent">
<p style="color:var(--muted)">加载中...</p>
</div>
</div>
</div>
</div>
'''
HTML_FLOWS = '''
<div class="panel" id="flows">
<div class="card">
<h3>Flow 统计 <button class="secondary small" onclick="loadFlowStats()">刷新</button></h3>
<div class="stats-grid" id="flowStatsGrid"></div>
</div>
<div class="card">
<h3>流量监控</h3>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap">
<select id="flowProtocol" onchange="loadFlows()">
<option value="">全部协议</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="gemini">Gemini</option>
</select>
<select id="flowState" onchange="loadFlows()">
<option value="">全部状态</option>
<option value="completed">完成</option>
<option value="error">错误</option>
<option value="streaming">流式中</option>
<option value="pending">等待中</option>
</select>
<input type="text" id="flowSearch" placeholder="搜索内容..." style="flex:1;min-width:150px" onkeydown="if(event.key==='Enter')loadFlows()">
<button class="secondary" onclick="loadFlows()">搜索</button>
<button class="secondary" onclick="exportFlows()">导出</button>
</div>
<div id="flowList"></div>
</div>
<div class="card" id="flowDetail" style="display:none">
<h3>Flow 详情 <button class="secondary small" onclick="$('#flowDetail').style.display='none'">关闭</button></h3>
<div id="flowDetailContent"></div>
</div>
</div>
'''
HTML_MONITOR = '''
<div class="panel" id="monitor">
<!-- 紧凑的顶部统计面板 -->
<div class="card" style="padding: 0.75rem; margin-bottom: 0.75rem;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
<!-- 服务状态 -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<h4 style="font-size: 0.9rem; margin: 0;">🚀 服务状态</h4>
<button class="secondary" style="padding: 0.25rem 0.5rem; font-size: 0.7rem;" onclick="loadStats()">刷新</button>
</div>
<div class="stats-grid-compact" id="statsGrid"></div>
</div>
<!-- 流量统计 -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<h4 style="font-size: 0.9rem; margin: 0;">📈 流量统计</h4>
<button class="secondary" style="padding: 0.25rem 0.5rem; font-size: 0.7rem;" onclick="loadFlowStats()">刷新</button>
</div>
<div class="stats-grid-compact" id="flowStatsGrid"></div>
</div>
<!-- 配额状态 -->
<div>
<h4 style="font-size: 0.9rem; margin: 0 0 0.5rem 0;">⚡ 配额状态</h4>
<div id="quotaStatus" style="font-size: 0.8rem;"></div>
</div>
</div>
<!-- 速度测试 - 移到底部一行 -->
<div style="display: flex; align-items: center; justify-content: space-between; padding-top: 0.5rem; border-top: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<span style="font-size: 0.9rem; font-weight: 500;">🎯 速度测试</span>
<button class="circle" onclick="runSpeedtest()" id="speedtestBtn" style="width: 28px; height: 28px; font-size: 0.8rem;">▶</button>
<span id="speedtestResult" style="color: var(--muted); font-size: 0.8rem;">点击开始测试</span>
</div>
</div>
</div>
<!-- 请求监控 - 紧凑布局 -->
<div class="card" style="padding: 0.75rem; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;">
<h4 style="font-size: 0.9rem; margin: 0;">📋 请求监控</h4>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="flowProtocol" onchange="loadFlows()" style="padding: 0.25rem; font-size: 0.8rem;">
<option value="">全部协议</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="gemini">Gemini</option>
</select>
<select id="flowState" onchange="loadFlows()" style="padding: 0.25rem; font-size: 0.8rem;">
<option value="">全部状态</option>
<option value="completed">完成</option>
<option value="error">错误</option>
<option value="streaming">流式中</option>
<option value="pending">等待中</option>
</select>
<input type="text" id="flowSearch" placeholder="搜索..." style="width: 120px; padding: 0.25rem; font-size: 0.8rem;" onkeydown="if(event.key==='Enter')loadFlows()">
<button class="secondary" style="padding: 0.25rem 0.5rem; font-size: 0.7rem;" onclick="loadFlows()">搜索</button>
<button class="secondary" style="padding: 0.25rem 0.5rem; font-size: 0.7rem;" onclick="exportFlows()">导出</button>
</div>
</div>
<div id="flowList" style="max-height: 200px; overflow-y: auto;"></div>
</div>
<!-- 请求日志 - 紧凑表格 -->
<div class="card" style="padding: 0.75rem; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;">
<h4 style="font-size: 0.9rem; margin: 0;">📝 请求日志</h4>
<button class="secondary" style="padding: 0.25rem 0.5rem; font-size: 0.7rem;" onclick="loadLogs()">刷新</button>
</div>
<div style="overflow-x: auto; max-height: 300px; overflow-y: auto;">
<table style="font-size: 0.75rem;">
<thead>
<tr style="height: 32px;">
<th style="padding: 0.25rem 0.5rem;">时间</th>
<th style="padding: 0.25rem 0.5rem;">路径</th>
<th style="padding: 0.25rem 0.5rem;">模型</th>
<th style="padding: 0.25rem 0.5rem;">账号</th>
<th style="padding: 0.25rem 0.5rem;">状态</th>
<th style="padding: 0.25rem 0.5rem;">耗时</th>
</tr>
</thead>
<tbody id="logTable"></tbody>
</table>
</div>
</div>
<!-- Flow 详情弹窗 -->
<div class="card" id="flowDetail" style="display: none; padding: 0.75rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;">
<h4 style="font-size: 0.9rem; margin: 0;">🔍 Flow 详情</h4>
<button class="secondary" style="padding: 0.25rem 0.5rem; font-size: 0.7rem;" onclick="$('#flowDetail').style.display='none'">关闭</button>
</div>
<div id="flowDetailContent" style="font-size: 0.8rem;"></div>
</div>
</div>
'''
HTML_ACCOUNTS = '''
<div class="panel active" id="accounts">
<!-- 紧凑的工具栏 + 汇总面板 -->
<div class="card" style="padding:1rem">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem">
<h3 style="margin:0;font-size:1.1rem">账号管理</h3>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="small" onclick="showLoginOptions()">+ 添加</button>
<button class="secondary small" onclick="scanTokens()">扫描</button>
<button class="secondary small" onclick="showImportExportMenu(this)">导入/导出 ▼</button>
<button class="secondary small" onclick="refreshAllQuotas()">刷新额度</button>
</div>
</div>
<!-- 内嵌汇总统计 -->
<div id="accountsSummaryCompact"></div>
</div>
<!-- 隐藏的弹出面板 - 移到账号列表上方 -->
<div class="card" id="loginOptions" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h3 style="margin:0">添加账号</h3>
<button class="secondary small" onclick="$('#loginOptions').style.display='none'">✕</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem">
<!-- 在线登录 -->
<div style="border:1px solid var(--border);border-radius:8px;padding:1rem">
<h4 style="margin-bottom:0.75rem;font-size:0.9rem">🌐 在线登录</h4>
<div style="display:flex;flex-direction:column;gap:0.5rem">
<button class="secondary small" onclick="startSocialLogin('google')" style="justify-content:flex-start;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google
</button>
<button class="secondary small" onclick="startSocialLogin('github')" style="justify-content:flex-start;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</button>
<button class="secondary small" onclick="startAwsLogin()" style="justify-content:flex-start;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#FF9900"><path d="M21.698 16.207c-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.27-.351 3.384 1.963 7.559 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.439-.2.814.287.385.607z"/></svg>
AWS Builder ID
</button>
</div>
</div>
<!-- 其他方式 -->
<div style="border:1px solid var(--border);border-radius:8px;padding:1rem">
<h4 style="margin-bottom:0.75rem;font-size:0.9rem">📋 其他方式</h4>
<div style="display:flex;flex-direction:column;gap:0.5rem">
<button class="secondary small" onclick="showManualAdd()">✏️ 手动添加 Token</button>
</div>
</div>
</div>
</div>
<div class="card" id="loginPanel" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h3 style="margin:0">在线登录</h3>
<button class="secondary small" onclick="cancelKiroLogin()">✕</button>
</div>
<div id="loginContent"></div>
</div>
<div class="card" id="manualAddPanel" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h3 style="margin:0">手动添加 Token</h3>
<button class="secondary small" onclick="$('#manualAddPanel').style.display='none'">✕</button>
</div>
<div style="display:grid;gap:0.75rem">
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">账号名称(可选,留空自动获取邮箱)</label>
<input type="text" id="manualName" placeholder="留空自动获取邮箱作为名称" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
</div>
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">认证方式</label>
<select id="manualAuthMethod" onchange="toggleManualFields()" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
<option value="social">Social (Google/GitHub)</option>
<option value="idc">IDC (AWS Builder ID)</option>
</select>
</div>
<div id="manualProviderField">
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">登录提供商</label>
<select id="manualProvider" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
<option value="">未知</option>
<option value="Google">Google</option>
<option value="Github">GitHub</option>
</select>
</div>
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">Refresh Token <span style="color:var(--error)">*必填</span></label>
<textarea id="manualRefreshToken" placeholder="粘贴 refreshToken(必填,用于获取 Access Token 和去重)..." style="width:100%;height:60px;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-family:monospace;font-size:0.75rem"></textarea>
</div>
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">Access Token(可选,留空自动通过 Refresh Token 获取)</label>
<textarea id="manualAccessToken" placeholder="粘贴 accessToken(可选)..." style="width:100%;height:60px;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-family:monospace;font-size:0.75rem"></textarea>
</div>
<div id="manualIdcFields" style="display:none">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-bottom:0.5rem">
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">Client ID</label>
<input type="text" id="manualClientId" placeholder="IDC 认证需要" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-family:monospace;font-size:0.75rem">
</div>
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">Client Secret</label>
<input type="text" id="manualClientSecret" placeholder="IDC 认证需要" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-family:monospace;font-size:0.75rem">
</div>
</div>
<div>
<label style="display:block;font-size:0.8rem;color:var(--muted);margin-bottom:0.25rem">Region</label>
<input type="text" id="manualRegion" value="us-east-1" placeholder="默认 us-east-1" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
</div>
</div>
<button onclick="submitManualToken()">添加账号</button>
<p style="font-size:0.75rem;color:var(--muted);margin:0">提示:根据 Refresh Token 自动去重,相同 Refresh Token 的账号不会重复添加</p>
</div>
</div>
<div class="card" id="scanResults" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h3 style="margin:0">扫描结果</h3>
<button class="secondary small" onclick="$('#scanResults').style.display='none'">✕</button>
</div>
<div id="scanList"></div>
</div>
<!-- 账号网格列表 - 移到面板下方 -->
<div id="accountsGrid" class="accounts-grid"></div>
</div>
'''
HTML_LOGS = '''
<div class="panel" id="logs">
<div class="card">
<h3>请求日志 <button class="secondary small" onclick="loadLogs()">刷新</button></h3>
<table>
<thead><tr><th>时间</th><th>路径</th><th>模型</th><th>账号</th><th>状态</th><th>耗时</th></tr></thead>
<tbody id="logTable"></tbody>
</table>
</div>
</div>
'''
HTML_API = '''
<div class="panel" id="api">
<div class="card">
<h3>API 端点</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">支持 OpenAI、Anthropic、Gemini 三种协议</p>
<h4 style="color:var(--muted);margin-bottom:0.5rem">OpenAI 协议</h4>
<div class="endpoint"><span class="method post">POST</span><code>/v1/chat/completions</code></div>
<div class="endpoint"><span class="method get">GET</span><code>/v1/models</code></div>
<h4 style="color:var(--muted);margin-top:1rem;margin-bottom:0.5rem">Anthropic 协议</h4>
<div class="endpoint"><span class="method post">POST</span><code>/v1/messages</code></div>
<div class="endpoint"><span class="method post">POST</span><code>/v1/messages/count_tokens</code></div>
<h4 style="color:var(--muted);margin-top:1rem;margin-bottom:0.5rem">Gemini 协议</h4>
<div class="endpoint"><span class="method post">POST</span><code>/v1/models/{model}:generateContent</code></div>
<h4 style="margin-top:1rem;color:var(--muted)">Base URL</h4>
<pre><code id="baseUrl"></code></pre>
<button class="copy-btn" onclick="copy(location.origin)" style="margin-top:0.5rem">复制</button>
</div>
<div class="card">
<h3>配置示例</h3>
<h4 style="color:var(--muted);margin-bottom:0.5rem">Claude Code</h4>
<pre><code>Base URL: <span class="pyUrl"></span>
API Key: any
模型: claude-sonnet-4</code></pre>
<h4 style="color:var(--muted);margin-top:1rem;margin-bottom:0.5rem">Codex CLI</h4>
<pre><code>Endpoint: <span class="pyUrl"></span>/v1
API Key: any
模型: gpt-4o</code></pre>
</div>
<div class="card">
<h3>Claude Code 终端配置</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">Claude Code 终端版需要配置 <code>~/.claude/settings.json</code> 才能跳过登录使用代理</p>
<h4 style="color:var(--muted);margin-bottom:0.5rem">临时生效(当前终端)</h4>
<pre id="envTempCmd"><code>export ANTHROPIC_BASE_URL="<span class="pyUrl"></span>"
export ANTHROPIC_AUTH_TOKEN="sk-any"
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1</code></pre>
<button class="copy-btn" onclick="copyEnvTemp()" style="margin-top:0.5rem">复制命令</button>
<h4 style="color:var(--muted);margin-top:1rem;margin-bottom:0.5rem">永久生效(推荐,写入配置文件)</h4>
<pre id="envPermCmd"><code># 写入 Claude Code 配置文件
mkdir -p ~/.claude
cat > ~/.claude/settings.json << 'EOF'
{
"env": {
"ANTHROPIC_BASE_URL": "<span class="pyUrl"></span>",
"ANTHROPIC_AUTH_TOKEN": "sk-any",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
}
}
EOF</code></pre>
<button class="copy-btn" onclick="copyEnvPerm()" style="margin-top:0.5rem">复制命令</button>
<h4 style="color:var(--muted);margin-top:1rem;margin-bottom:0.5rem">清除配置</h4>
<pre id="envClearCmd"><code># 删除 Claude Code 配置
rm -f ~/.claude/settings.json
unset ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC</code></pre>
<button class="copy-btn" onclick="copyEnvClear()" style="margin-top:0.5rem">复制命令</button>
<p style="color:var(--muted);font-size:0.75rem;margin-top:1rem">
💡 使用 <code>ANTHROPIC_AUTH_TOKEN</code> + <code>CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1</code> 可跳过登录
</p>
</div>
<div class="card">
<h3>模型映射</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">支持多种模型名称,自动映射到 Kiro 模型</p>
<table>
<thead><tr><th>Kiro 模型</th><th>能力</th><th>可用名称</th></tr></thead>
<tbody>
<tr><td><code>claude-sonnet-4</code></td><td>⭐⭐⭐ 推荐</td><td>gpt-4o, gpt-4, claude-3-5-sonnet-*, sonnet</td></tr>
<tr><td><code>claude-sonnet-4.5</code></td><td>⭐⭐⭐⭐ 更强</td><td>gemini-1.5-pro, o1, o1-preview, claude-3-opus-*, opus</td></tr>
<tr><td><code>claude-haiku-4.5</code></td><td>⚡ 快速</td><td>gpt-4o-mini, gpt-3.5-turbo, haiku</td></tr>
<tr><td><code>auto</code></td><td>🤖 自动</td><td>auto</td></tr>
</tbody>
</table>
<p style="color:var(--muted);font-size:0.75rem;margin-top:0.75rem">
💡 直接使用 Kiro 模型名(如 claude-sonnet-4)或任意映射名称均可
</p>
</div>
</div>
'''
HTML_SETTINGS = '''
<div class="panel" id="settings">
<!-- 自动化管理提示 -->
<div class="card">
<h3>🤖 自动化管理</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">
以下功能已启用自动化管理,无需手动配置:
</p>
<div style="display:grid;gap:1rem">
<div style="padding:1rem;background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(59,130,246,0.1));border-radius:8px">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
<span style="font-size:1.25rem">🔄</span>
<strong>Token 与额度刷新</strong>
<span style="background:var(--success);color:white;padding:0.125rem 0.5rem;border-radius:4px;font-size:0.75rem">自动</span>
</div>
<p style="font-size:0.875rem;color:var(--muted);margin:0">
Token 过期前自动刷新,额度信息定期更新
</p>
</div>
<div style="padding:1rem;background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(59,130,246,0.1));border-radius:8px">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
<span style="font-size:1.25rem">⚡</span>
<strong>请求限速与 429 冷却</strong>
<span style="background:var(--success);color:white;padding:0.125rem 0.5rem;border-radius:4px;font-size:0.75rem">自动</span>
</div>
<p style="font-size:0.875rem;color:var(--muted);margin:0">
遇到 429 错误自动冷却 5 分钟,自动切换到其他可用账号
</p>
</div>
<div style="padding:1rem;background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(59,130,246,0.1));border-radius:8px">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
<span style="font-size:1.25rem">📝</span>
<strong>历史消息压缩</strong>
<span style="background:var(--success);color:white;padding:0.125rem 0.5rem;border-radius:4px;font-size:0.75rem">自动</span>
</div>
<p style="font-size:0.875rem;color:var(--muted);margin:0">
上下文超限时默认自动压缩并重试,也可在下方设置为超限直接报错
</p>
</div>
<div style="padding:1rem;background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(59,130,246,0.1));border-radius:8px">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
<span style="font-size:1.25rem">🎲</span>
<strong>账号负载均衡</strong>
<span style="background:var(--success);color:white;padding:0.125rem 0.5rem;border-radius:4px;font-size:0.75rem">自动</span>
</div>
<p style="font-size:0.875rem;color:var(--muted);margin:0">
支持随机、轮询、最少请求等多种账号选择策略,分散请求压力
</p>
</div>
</div>
</div>
<!-- 刷新配置面板 - 已隐藏,自动化管理 -->
<div class="card" style="display:none">
<h3>刷新配置
<button class="secondary small" onclick="loadRefreshConfig()">刷新</button>
<button class="secondary small" onclick="resetRefreshConfig()">还原默认</button>
</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">
配置 Token 刷新和额度刷新的相关参数
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1rem">
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">最大重试次数</label>
<input type="number" id="refreshMaxRetries" value="3" min="1" max="10" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="saveRefreshConfig()">
<span style="font-size:0.75rem;color:var(--muted)">刷新失败时的重试次数(1-10)</span>
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">并发数</label>
<input type="number" id="refreshConcurrency" value="3" min="1" max="10" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="saveRefreshConfig()">
<span style="font-size:0.75rem;color:var(--muted)">同时刷新的账号数量(1-10)</span>
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">自动刷新间隔(秒)</label>
<input type="number" id="refreshAutoInterval" value="60" min="30" max="600" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="saveRefreshConfig()">
<span style="font-size:0.75rem;color:var(--muted)">自动检查刷新的间隔(30-600秒)</span>
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">重试基础延迟(秒)</label>
<input type="number" id="refreshRetryDelay" value="1.0" min="0.5" max="5" step="0.5" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="saveRefreshConfig()">
<span style="font-size:0.75rem;color:var(--muted)">重试延迟基数,指数增长(0.5-5秒)</span>
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">Token 提前刷新时间(秒)</label>
<input type="number" id="refreshBeforeExpiry" value="300" min="60" max="600" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="saveRefreshConfig()">
<span style="font-size:0.75rem;color:var(--muted)">Token 过期前多久开始刷新(60-600秒)</span>
</div>
</div>
<div id="refreshConfigStatus" style="padding:0.75rem;background:var(--bg);border-radius:6px;font-size:0.875rem"></div>
</div>
<!-- 请求限速面板 - 已隐藏,自动化管理 -->
<div class="card" style="display:none">
<h3>请求限速
<button class="secondary small" onclick="loadRateLimitConfig()">刷新</button>
<button class="secondary small" onclick="resetRateLimitConfig()">还原默认</button>
</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">
启用后会限制请求频率,降低被检测为异常活动的风险
</p>
<label style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem;cursor:pointer">
<input type="checkbox" id="rateLimitEnabled" onchange="updateRateLimitConfig()">
<span><strong>启用限速</strong></span>
</label>
<div id="rateLimitOptions" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1rem">
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">最小请求间隔(秒)</label>
<input type="number" id="minRequestInterval" value="0.5" min="0" max="10" step="0.1" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="updateRateLimitConfig()">
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">每账号每分钟最大请求</label>
<input type="number" id="maxRequestsPerMinute" value="60" min="1" max="200" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="updateRateLimitConfig()">
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">全局每分钟最大请求</label>
<input type="number" id="globalMaxRequestsPerMinute" value="120" min="1" max="300" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="updateRateLimitConfig()">
</div>
</div>
<div style="padding:1rem;background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(59,130,246,0.1));border-radius:8px;margin-bottom:1rem">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
<span style="font-size:1.25rem">🔄</span>
<strong style="color:var(--success)">429 冷却自动管理</strong>
<span style="background:var(--success);color:white;padding:0.125rem 0.5rem;border-radius:4px;font-size:0.75rem">自动</span>
</div>
<p style="font-size:0.875rem;color:var(--muted);margin:0">
遇到 429 错误时自动冷却账号 5 分钟,无需手动配置。冷却期间自动切换到其他可用账号。
</p>
</div>
<div id="rateLimitStats" style="padding:0.75rem;background:var(--bg);border-radius:6px;font-size:0.875rem"></div>
</div>
<!-- 历史消息管理面板 -->
<div class="card">
<h3>历史消息管理
<button class="secondary small" onclick="loadHistoryConfig()">刷新</button>
<button class="secondary small" onclick="resetHistoryConfig()">还原默认</button>
</h3>
<p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">
控制「上下文超限」时的行为:自动压缩重试,或直接报错
</p>
<div style="padding:1rem;background:linear-gradient(135deg,rgba(34,197,94,0.1),rgba(59,130,246,0.1));border-radius:8px;margin-bottom:1rem">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
<span style="font-size:1.25rem">🤖</span>
<strong style="color:var(--success)">错误触发压缩模式</strong>
<span style="background:var(--success);color:white;padding:0.125rem 0.5rem;border-radius:4px;font-size:0.75rem">自动</span>
</div>
<p style="font-size:0.875rem;color:var(--muted);margin:0">
不再依赖阈值预检测,仅在收到上下文超限错误后自动压缩到 20K-50K 字符范围
</p>
</div>
<label style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem;cursor:pointer">
<input type="checkbox" id="historyErrorRetryEnabled" checked onchange="updateHistoryConfig()">
<span><strong>超限自动压缩并重试</strong>(关闭则直接报错)</span>
</label>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1rem">
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">最大重试次数</label>
<input type="number" id="maxRetries" value="3" min="1" max="5" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="updateHistoryConfig()">
<span style="font-size:0.75rem;color:var(--muted)">超限错误后的重试次数</span>
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">摘要缓存时间(秒)</label>
<input type="number" id="summaryCacheMaxAge" value="300" min="60" max="600" step="30" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)" onchange="updateHistoryConfig()">
<span style="font-size:0.75rem;color:var(--muted)">相同上下文复用摘要</span>
</div>
</div>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;margin-bottom:1rem">
<input type="checkbox" id="addWarningHeader" onchange="updateHistoryConfig()">
<span>压缩时在日志中显示信息</span>
</label>
<div style="padding:1rem;background:var(--bg);border-radius:6px">
<p style="font-size:0.875rem;color:var(--muted);margin:0">
<strong>工作原理:</strong><br>
1. 正常发送请求,不进行预检测<br>
2. 收到 CONTENT_LENGTH_EXCEEDS_THRESHOLD 错误时触发压缩<br>
3. 用 AI 生成早期对话摘要,保留最近 6-20 条消息<br>
4. 压缩目标: 20K-50K 字符,自动重试<br>
5. 关闭“超限自动压缩并重试”后:超限直接报错,不进行压缩/重试<br>
<br>
<span style="color:var(--success)">✓ 最大化利用上下文</span> &nbsp;
<span style="color:var(--success)">✓ 错误触发无需预估</span> &nbsp;
<span style="color:var(--success)">✓ 智能缓存避免重复调用</span>
</p>
</div>
</div>
</div>
'''
HTML_BODY = HTML_AUTH_MODAL + HTML_GLOBAL_PROGRESS + HTML_HEADER + HTML_HELP + HTML_MONITOR + HTML_ACCOUNTS + HTML_API + HTML_SETTINGS
# ==================== JavaScript ====================
JS_AUTH = '''
// ==================== 认证检查 ====================
let authToken = localStorage.getItem('admin_session');
let authRequired = false;
async function checkAuth() {
try {
const r = await fetch('/api/auth/check', {
method: 'POST',
headers: authToken ? {'Authorization': 'Bearer ' + authToken} : {}
});
const d = await r.json();
authRequired = d.auth_required;
if (d.auth_required && !d.authenticated) {
showAuthModal();
return false;
} else {
hideAuthModal();
return true;
}
} catch(e) {
console.error('认证检查失败:', e);
return true;
}
}
function showAuthModal() {
const overlay = $('#authOverlay');
if (overlay) {
overlay.style.display = 'flex';
setTimeout(() => $('#authPassword')?.focus(), 100);
}
}
function hideAuthModal() {
const overlay = $('#authOverlay');
if (overlay) {
overlay.style.display = 'none';
}
}
async function adminLogin() {
const password = $('#authPassword').value;
const errorEl = $('#authError');
if (!password) {
errorEl.textContent = '请输入密码';
return;
}
try {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password})
});
const d = await r.json();
if (d.success && d.session_id) {
authToken = d.session_id;
localStorage.setItem('admin_session', authToken);
hideAuthModal();
errorEl.textContent = '';
$('#authPassword').value = '';
Toast.success('登录成功');
initializeDefaultTab();
} else {
errorEl.textContent = d.detail || '密码错误';
}
} catch(e) {
errorEl.textContent = '登录失败: ' + e.message;
}
}
function adminLogout() {
localStorage.removeItem('admin_session');
authToken = null;
if (authRequired) {
showAuthModal();
}
Toast.info('已登出');
}
// 页面加载时检查认证
checkAuth();
'''
JS_UTILS = '''
const $=s=>document.querySelector(s);
const $$=s=>document.querySelectorAll(s);
function copy(text){
navigator.clipboard.writeText(text).then(()=>{
const toast=document.createElement('div');
toast.textContent='已复制';
toast.style.cssText='position:fixed;bottom:2rem;left:50%;transform:translateX(-50%);background:var(--accent);color:var(--bg);padding:0.5rem 1rem;border-radius:6px;font-size:0.875rem;z-index:1000';
document.body.appendChild(toast);
setTimeout(()=>toast.remove(),1500);
});
}
function copyEnvTemp(){
const url=location.origin;
copy(`export ANTHROPIC_BASE_URL="${url}"
export ANTHROPIC_AUTH_TOKEN="sk-any"
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`);
}
function copyEnvPerm(){
const url=location.origin;
copy(`# 写入 Claude Code 配置文件(推荐)
mkdir -p ~/.claude
cat > ~/.claude/settings.json << 'EOF'
{
"env": {
"ANTHROPIC_BASE_URL": "${url}",
"ANTHROPIC_AUTH_TOKEN": "sk-any",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
}
}
EOF
echo "配置完成,请重新打开终端运行 claude"`);
}
function copyEnvClear(){
copy(`# 删除 Claude Code 配置
rm -f ~/.claude/settings.json
unset ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
echo "配置已清除"`);
}
function formatUptime(s){
if(s<60)return s+'秒';
if(s<3600)return Math.floor(s/60)+'分钟';
return Math.floor(s/3600)+'小时'+Math.floor((s%3600)/60)+'分钟';
}
function escapeHtml(text){
const div=document.createElement('div');
div.textContent=text;
return div.innerHTML;
}
'''
JS_TABS = '''
// Tabs
$$('.tab').forEach(t=>t.onclick=()=>{
$$('.tab').forEach(x=>x.classList.remove('active'));
$$('.panel').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
$('#'+t.dataset.tab).classList.add('active');
// 监控面板加载所有数据
if(t.dataset.tab==='monitor'){
loadStats();
loadQuota();
loadFlowStats();
loadFlows();
loadLogs();
}
if(t.dataset.tab==='accounts'){
loadAccounts();
loadAccountsEnhanced();
}
});
'''
JS_STATUS = '''
// Status
async function checkStatus(){
try{
const r=await fetch('/api/status');
const d=await r.json();
$('#statusDot').className='status-dot '+(d.ok?'ok':'err');
$('#statusText').textContent=d.ok?'已连接':'未连接';
if(d.stats)$('#uptime').textContent='运行 '+formatUptime(d.stats.uptime_seconds);
}catch(e){
$('#statusDot').className='status-dot err';
$('#statusText').textContent='连接失败';
}
}
checkStatus();
setInterval(checkStatus,30000);
// URLs
$('#baseUrl').textContent=location.origin;
$$('.pyUrl').forEach(e=>e.textContent=location.origin);
'''
JS_DOCS = '''
// 文档浏览
let docsData = [];
let currentDoc = null;
// 简单的 Markdown 渲染
function renderMarkdown(text) {
return text
.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
.replace(/\\*(.+?)\\*/g, '<em>$1</em>')
.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>')
.replace(/^\\d+\\. (.+)$/gm, '<li>$1</li>')
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
.replace(/^---$/gm, '<hr>')
.replace(/\\|(.+)\\|/g, function(match) {
const cells = match.split('|').filter(c => c.trim());
if (cells.every(c => /^[\\s-:]+$/.test(c))) return '';
const tag = match.includes('---') ? 'th' : 'td';
return '<tr>' + cells.map(c => '<' + tag + '>' + c.trim() + '</' + tag + '>').join('') + '</tr>';
})
.replace(/(<tr>.*<\\/tr>\\n?)+/g, '<table>$&</table>')
.replace(/\\n\\n/g, '</p><p>')
.replace(/\\n/g, '<br>');
}
async function loadDocs() {
try {
const r = await fetch('/api/docs');
const d = await r.json();
docsData = d.docs || [];
// 渲染导航
$('#docsNav').innerHTML = docsData.map((doc, i) =>
'<a class="docs-nav-item' + (i === 0 ? ' active' : '') + '" data-id="' + doc.id + '" onclick="showDoc(\\'' + doc.id + '\\')">' + doc.title + '</a>'
).join('');
// 显示第一个文档
if (docsData.length > 0) {
showDoc(docsData[0].id);
}
} catch (e) {
$('#docsContent').innerHTML = '<p style="color:var(--error)">加载文档失败</p>';
}
}
async function showDoc(id) {
// 更新导航状态
$$('.docs-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.id === id);
});
// 获取文档内容
try {
const r = await fetch('/api/docs/' + id);
const d = await r.json();
currentDoc = d;
$('#docsContent').innerHTML = renderMarkdown(d.content);
} catch (e) {
$('#docsContent').innerHTML = '<p style="color:var(--error)">加载文档失败</p>';
}
}
// 页面加载时加载文档
loadDocs();
'''
JS_STATS = '''
// Stats
async function loadStats(){
try{
const r=await fetch('/api/stats');
const d=await r.json();
$('#statsGrid').innerHTML=`
<div class="stat-item"><div class="stat-value">${d.total_requests}</div><div class="stat-label">总请求</div></div>
<div class="stat-item"><div class="stat-value">${d.total_errors}</div><div class="stat-label">错误数</div></div>
<div class="stat-item"><div class="stat-value">${d.error_rate}</div><div class="stat-label">错误率</div></div>
<div class="stat-item"><div class="stat-value">${d.accounts_available}/${d.accounts_total}</div><div class="stat-label">可用账号</div></div>
<div class="stat-item"><div class="stat-value">${d.accounts_cooldown||0}</div><div class="stat-label">冷却中</div></div>
`;
}catch(e){console.error(e)}
}
// Quota
async function loadQuota(){
try{
const r=await fetch('/api/quota');
const d=await r.json();
if(d.exceeded_credentials&&d.exceeded_credentials.length>0){
$('#quotaStatus').innerHTML=d.exceeded_credentials.map(c=>`
<div style="display:flex;justify-content:space-between;align-items:center;padding:0.5rem;background:var(--bg);border-radius:4px;margin-bottom:0.5rem">
<span><span class="badge warn">冷却中</span> ${c.credential_id}</span>
<span style="color:var(--muted);font-size:0.8rem">剩余 ${c.remaining_seconds}秒</span>
<button class="secondary small" onclick="restoreAccount('${c.credential_id}')">恢复</button>
</div>
`).join('');
}else{
$('#quotaStatus').innerHTML='<p style="color:var(--muted)">无冷却中的账号</p>';
}
}catch(e){console.error(e)}
}
// Speedtest
async function runSpeedtest(){
$('#speedtestBtn').disabled=true;
$('#speedtestResult').textContent='测试中...';
try{
const r=await fetch('/api/speedtest',{method:'POST'});
const d=await r.json();
$('#speedtestResult').textContent=d.ok?`延迟: ${d.latency_ms.toFixed(0)}ms (${d.account_id})`:'测试失败: '+d.error;
}catch(e){$('#speedtestResult').textContent='测试失败'}
$('#speedtestBtn').disabled=false;
}
'''
JS_LOGS = '''
// Logs
async function loadLogs(){
try{
const r=await fetch('/api/logs?limit=50');
const d=await r.json();
$('#logTable').innerHTML=(d.logs||[]).map(l=>`
<tr>
<td>${new Date(l.timestamp*1000).toLocaleTimeString()}</td>
<td>${l.path}</td>
<td>${l.model||'-'}</td>
<td>${l.account_id||'-'}</td>
<td><span class="badge ${l.status<400?'success':l.status<500?'warn':'error'}">${l.status}</span></td>
<td>${l.duration_ms.toFixed(0)}ms</td>
</tr>
`).join('');
}catch(e){console.error(e)}
}
'''
JS_ACCOUNTS = '''
// Accounts
async function loadAccounts(){
try{
const r=await fetch('/api/accounts');
const d=await r.json();
if(!d.accounts||d.accounts.length===0){
$('#accountList').innerHTML='<p style="color:var(--muted)">暂无账号,请点击"扫描 Token"</p>';
return;
}
$('#accountList').innerHTML=d.accounts.map(a=>{
const statusBadge=a.status==='active'?'success':a.status==='cooldown'?'warn':a.status==='suspended'?'error':'error';
const statusText={active:'可用',cooldown:'冷却中',unhealthy:'不健康',disabled:'已禁用',suspended:'已封禁'}[a.status]||a.status;
const authBadge=a.auth_method==='idc'?'info':'success';
const authText=a.auth_method==='idc'?'IdC':'Social';
return `
<div class="account-card">
<div class="account-header">
<div class="account-name">
<span class="badge ${statusBadge}">${statusText}</span>
<span class="badge ${authBadge}">${authText}</span>
<span>${a.name}</span>
</div>
<span style="color:var(--muted);font-size:0.75rem">${a.id}</span>
</div>
<div class="account-meta">
<div class="account-meta-item"><span>请求数</span><span>${a.request_count}</span></div>
<div class="account-meta-item"><span>错误数</span><span>${a.error_count}</span></div>
<div class="account-meta-item"><span>Token</span><span class="badge ${a.token_expired?'error':a.token_expiring_soon?'warn':'success'}">${a.token_expired?'已过期':a.token_expiring_soon?'即将过期':'有效'}</span></div>
${a.cooldown_remaining?`<div class="account-meta-item"><span>冷却剩余</span><span>${a.cooldown_remaining}秒</span></div>`:''}
</div>
<div id="usage-${a.id}" class="account-usage" style="display:none;margin-top:0.75rem;padding:0.75rem;background:var(--bg);border-radius:6px"></div>
<div class="account-actions">
<button class="secondary small" onclick="queryUsage('${a.id}')">查询用量</button>
<button class="secondary small" onclick="refreshToken('${a.id}')">刷新 Token</button>
<button class="secondary small" onclick="viewAccountDetail('${a.id}')">详情</button>
${a.status==='cooldown'?`<button class="secondary small" onclick="restoreAccount('${a.id}')">恢复</button>`:''}
<button class="secondary small" onclick="toggleAccount('${a.id}')">${a.enabled?'禁用':'启用'}</button>
<button class="secondary small" onclick="deleteAccount('${a.id}')" style="color:var(--error)">删除</button>
</div>
</div>
`;
}).join('');
}catch(e){console.error(e)}
}
async function queryUsage(id){
const usageDiv=$('#usage-'+id);
usageDiv.style.display='block';
usageDiv.innerHTML='<span style="color:var(--muted)">查询中...</span>';
try{
const r=await fetch('/api/accounts/'+id+'/usage');
const d=await r.json();
if(d.ok){
const u=d.usage;
const pct=u.usage_limit>0?((u.current_usage/u.usage_limit)*100).toFixed(1):0;
const barColor=u.is_low_balance?'var(--error)':'var(--success)';
usageDiv.innerHTML=`
<div style="display:flex;justify-content:space-between;margin-bottom:0.5rem">
<span style="font-weight:500">${u.subscription_title}</span>
<span class="badge ${u.is_low_balance?'error':'success'}">${u.is_low_balance?'余额不足':'正常'}</span>
</div>
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:0.5rem;overflow:hidden">
<div style="background:${barColor};height:100%;width:${pct}%;transition:width 0.3s"></div>
</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:0.5rem;font-size:0.8rem">
<div><span style="color:var(--muted)">已用/总额:</span> ${u.current_usage.toFixed(2)} / ${u.usage_limit.toFixed(2)}</div>
<div><span style="color:var(--muted)">使用率:</span> ${pct}%</div>
${u.reset_date_text ? `<div><span style="color:var(--muted)">重置时间:</span> ${u.reset_date_text}</div>` : ''}
${u.trial_expiry_text ? `<div><span style="color:var(--muted)">试用过期:</span> ${u.trial_expiry_text}</div>` : ''}
</div>
`;
}else{
usageDiv.innerHTML=`<span style="color:var(--error)">查询失败: ${d.error}</span>`;
}
}catch(e){
usageDiv.innerHTML=`<span style="color:var(--error)">查询失败: ${e.message}</span>`;
}
}
async function refreshToken(id){
try{
Toast.info('正在刷新 Token...');
const r=await fetch('/api/accounts/'+id+'/refresh',{method:'POST'});
const d=await r.json();
if(d.ok) {
Toast.success('Token 刷新成功');
} else {
Toast.error('刷新失败: '+(d.message||d.error));
}
loadAccounts();
loadAccountsEnhanced();
}catch(e){Toast.error('刷新失败: '+e.message)}
}
async function refreshAllTokens(){
try{
Toast.info('正在刷新所有 Token...');
const r=await fetch('/api/accounts/refresh-all',{method:'POST'});
const d=await r.json();
Toast.success(`刷新完成: ${d.refreshed} 个账号`);
loadAccounts();
loadAccountsEnhanced();
}catch(e){Toast.error('刷新失败: '+e.message)}
}
async function restoreAccount(id){
try{
Toast.info('正在恢复账号...');
const r = await fetch('/api/accounts/'+id+'/restore',{method:'POST'});
const d = await r.json();
if(d.ok) {
Toast.success('账号已恢复');
} else {
Toast.error(d.error || '恢复失败');
}
loadAccounts();
loadAccountsEnhanced();
loadQuota();
}catch(e){Toast.error('恢复失败: '+e.message)}
}
async function viewAccountDetail(id){
try{
const r=await fetch('/api/accounts/'+id);
const d=await r.json();
Modal.info('账号详情', `
<div style="text-align:left;line-height:1.8">
<p><strong>账号名:</strong> ${d.name}</p>
<p><strong>ID:</strong> ${d.id}</p>
<p><strong>状态:</strong> ${d.status}</p>
<p><strong>请求数:</strong> ${d.request_count}</p>
<p><strong>错误数:</strong> ${d.error_count}</p>
</div>
`);
}catch(e){Toast.error('获取详情失败: '+e.message)}
}
async function toggleAccount(id){
try {
const r = await fetch('/api/accounts/'+id+'/toggle',{method:'POST'});
const d = await r.json();
if(d.ok) {
Toast.success(d.enabled ? '账号已启用' : '账号已禁用');
} else {
Toast.error(d.error || '操作失败');
}
} catch(e) {
Toast.error('操作失败: ' + e.message);
}
loadAccounts();
loadAccountsEnhanced();
}
async function deleteAccount(id){
if(confirm('确定删除此账号?')){
try {
const r = await fetch('/api/accounts/'+id,{method:'DELETE'});
const d = await r.json();
if(d.ok) {
Toast.success('账号已删除');
} else {
Toast.error(d.error || '删除失败');
}
} catch(e) {
Toast.error('删除失败: ' + e.message);
}
loadAccounts();
loadAccountsEnhanced();
}
}
function showAddAccount(){
const path=prompt('输入 Token 文件路径:');
if(path){
const name=prompt('账号名称:','账号');
fetch('/api/accounts',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name,token_path:path})
}).then(r=>r.json()).then(d=>{
if(d.ok){
Toast.success('账号添加成功');
loadAccounts();
loadAccountsEnhanced();
}
else alert(d.detail||'添加失败');
});
}
}
async function scanTokens(){
try{
const r=await fetch('/api/token/scan');
const d=await r.json();
const panel=$('#scanResults');
const list=$('#scanList');
if(d.tokens&&d.tokens.length>0){
panel.style.display='block';
list.innerHTML=d.tokens.map(t=>{
const path=encodeURIComponent(t.path||'');
const name=encodeURIComponent(t.name||'');
return `
<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem;border:1px solid var(--border);border-radius:6px;margin-bottom:0.5rem">
<div>
<div>${t.name}</div>
<div style="color:var(--muted);font-size:0.75rem">${t.path}</div>
</div>
${t.already_added?'<span class="badge info">已添加</span>':`<button class="secondary small" data-path="${path}" data-name="${name}" onclick="addFromScan(decodeURIComponent(this.dataset.path),decodeURIComponent(this.dataset.name))">添加</button>`}
</div>
`;
}).join('');
}else{
alert('未找到 Token 文件');
}
}catch(e){alert('扫描失败: '+e.message)}
}
async function addFromScan(path,name){
try{
const r=await fetch('/api/token/add-from-scan',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({path,name})
});
const d=await r.json();
if(d.ok){
loadAccounts();
scanTokens();
}else{
alert(d.detail||'添加失败');
}
}catch(e){alert('添加失败: '+e.message)}
}
async function checkTokens(){
try{
const r=await fetch('/api/token/refresh-check',{method:'POST'});
const d=await r.json();
let msg='Token 状态:\\n\\n';
(d.accounts||[]).forEach(a=>{
const status=a.valid?'✅ 有效':'❌ 无效';
msg+=`${a.name}: ${status}\\n`;
});
alert(msg);
}catch(e){alert('检查失败: '+e.message)}
}
// 手动添加 Token
function showManualAdd(){
$('#manualAddPanel').style.display='block';
$('#manualName').value='';
$('#manualAccessToken').value='';
$('#manualRefreshToken').value='';
}
async function submitManualToken(){
const name=$('#manualName').value.trim();
const accessToken=$('#manualAccessToken').value.trim();
const refreshToken=$('#manualRefreshToken').value.trim();
const authMethod=$('#manualAuthMethod').value;
const provider=$('#manualProvider')?.value || '';
const clientId=$('#manualClientId')?.value?.trim() || '';
const clientSecret=$('#manualClientSecret')?.value?.trim() || '';
const region=$('#manualRegion')?.value?.trim() || 'us-east-1';
// Refresh Token 必填
if (!refreshToken) {
Toast.error('Refresh Token 是必填项');
return;
}
// 验证 Refresh Token 格式
if (refreshToken.length < 100) {
Toast.error('Refresh Token 格式不正确(太短)');
return;
}
// IDC 认证需要 clientId 和 clientSecret
if (authMethod === 'idc' && (!clientId || !clientSecret)) {
Toast.error('IDC 认证需要填写 Client ID 和 Client Secret');
return;
}
Toast.info('正在添加账号...');
try{
const r=await fetchWithRetry('/api/accounts/manual',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
name: name || '',
access_token: accessToken,
refresh_token: refreshToken,
auth_method: authMethod,
provider: provider,
client_id: clientId,
client_secret: clientSecret,
region: region
})
});
const d=await r.json();
if(d.ok){
let msg = '添加成功';
if (d.auto_name) {
msg += '(已自动获取邮箱作为名称)';
}
Toast.success(msg);
$('#manualAddPanel').style.display='none';
// 清空表单
$('#manualName').value = '';
$('#manualAccessToken').value = '';
$('#manualRefreshToken').value = '';
if ($('#manualClientId')) $('#manualClientId').value = '';
if ($('#manualClientSecret')) $('#manualClientSecret').value = '';
loadAccounts();
loadAccountsEnhanced();
}else{
Toast.error(d.detail||'添加失败');
}
}catch(e){Toast.error('添加失败: '+e.message)}
}
// 切换手动添加表单字段显示
function toggleManualFields() {
const authMethod = $('#manualAuthMethod').value;
const idcFields = $('#manualIdcFields');
const providerField = $('#manualProviderField');
if (authMethod === 'idc') {
if (idcFields) idcFields.style.display = 'block';
if (providerField) providerField.style.display = 'none';
} else {
if (idcFields) idcFields.style.display = 'none';
if (providerField) providerField.style.display = 'block';
}
}
// 导出账号
async function exportAccounts(){
try{
const r=await fetch('/api/accounts/export');
const d=await r.json();
if(!d.ok){alert('导出失败');return;}
const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;
a.download='kiro-accounts-'+new Date().toISOString().slice(0,10)+'.json';
a.click();
}catch(e){alert('导出失败: '+e.message)}
}
// 导入账号
function importAccounts(){
const input=document.createElement('input');
input.type='file';
input.accept='.json';
input.onchange=async(e)=>{
const file=e.target.files[0];
if(!file)return;
try{
const text=await file.text();
const data=JSON.parse(text);
const r=await fetch('/api/accounts/import',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(data)
});
const d=await r.json();
if(d.ok){
alert(`导入成功: ${d.imported} 个账号`+(d.errors?.length?`\\n错误: ${d.errors.join(', ')}`:''));
loadAccounts();
}else{
alert('导入失败');
}
}catch(e){alert('导入失败: '+e.message)}
};
input.click();
}
'''
JS_LOGIN = '''
// Kiro 在线登录
let loginPollTimer=null;
function showLoginOptions(){
$('#loginOptions').style.display='block';
}
async function startSocialLogin(provider){
$('#loginOptions').style.display='none';
try{
const r=await fetch('/api/kiro/social/start',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({provider})
});
const d=await r.json();
if(!d.ok){alert('启动登录失败: '+d.error);return;}
showSocialLoginPanel(d.provider, d.login_url);
}catch(e){alert('启动登录失败: '+e.message)}
}
// 协议注册状态
let protocolRegistered = false;
let callbackPollTimer = null;
function showSocialLoginPanel(provider, loginUrl){
$('#loginPanel').style.display='block';
$('#loginContent').innerHTML=`
<div style="text-align:center;padding:1rem">
<p style="margin-bottom:1rem;font-size:1.1rem"><strong>${provider} 登录</strong></p>
<div id="socialLoginContent">
<div style="text-align:left;background:var(--bg);padding:1rem;border-radius:8px;margin:1rem 0">
<p style="margin-bottom:0.75rem"><strong>步骤 1:打开登录链接</strong></p>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<input type="text" value="${loginUrl}" readonly style="flex:1;padding:0.5rem;font-size:0.75rem;background:var(--card);border:1px solid var(--border);border-radius:4px">
<button class="small" onclick="copy('${loginUrl}')">复制</button>
</div>
<p style="margin-bottom:0.75rem"><strong>步骤 2:完成授权后粘贴回调 URL</strong></p>
<p style="font-size:0.8rem;color:var(--muted);margin-bottom:0.5rem">
授权完成后,浏览器会尝试打开 <code>kiro://</code> 链接。<br>
如果提示"无法打开",请复制地址栏中的完整 URL 粘贴到下方。
</p>
</div>
<input type="text" id="callbackUrl" placeholder="粘贴 kiro://... 回调 URL" style="width:100%;padding:0.75rem;font-size:0.875rem">
<button onclick="handleSocialCallback()" style="margin-top:0.5rem;width:100%">提交</button>
<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--border)">
<p style="font-size:0.8rem;color:var(--muted);margin-bottom:0.5rem"><strong>可选:自动回调模式</strong></p>
<button class="secondary small" onclick="registerProtocolAndWait('${provider}')" style="width:100%">🔧 注册协议处理器(自动接收回调)</button>
</div>
</div>
<button class="secondary" onclick="cancelSocialLogin()" style="margin-top:0.5rem;width:100%">取消</button>
<p style="color:var(--muted);font-size:0.75rem;margin-top:0.75rem" id="loginStatus"></p>
</div>
`;
}
async function registerProtocolAndWait(provider) {
$('#loginStatus').textContent = '正在注册协议处理器...';
$('#loginStatus').style.color = 'var(--muted)';
try {
const regResp = await fetch('/api/protocol/register', { method: 'POST' });
const regData = await regResp.json();
if (!regData.ok) {
$('#loginStatus').textContent = '协议注册失败: ' + regData.error;
$('#loginStatus').style.color = 'var(--error)';
return;
}
protocolRegistered = true;
$('#loginStatus').textContent = '✅ 协议已注册,授权完成后将自动接收回调';
$('#loginStatus').style.color = 'var(--success)';
// 开始轮询回调结果
startCallbackPolling(provider);
} catch(e) {
$('#loginStatus').textContent = '操作失败: ' + e.message;
$('#loginStatus').style.color = 'var(--error)';
}
}
function startCallbackPolling(provider) {
if (callbackPollTimer) clearInterval(callbackPollTimer);
let pollCount = 0;
const maxPolls = 300; // 5分钟超时 (300 * 1秒)
callbackPollTimer = setInterval(async () => {
pollCount++;
if (pollCount > maxPolls) {
clearInterval(callbackPollTimer);
callbackPollTimer = null;
$('#loginStatus').textContent = '等待超时,请重试';
$('#loginStatus').style.color = 'var(--error)';
return;
}
try {
const resp = await fetch('/api/protocol/callback');
const data = await resp.json();
if (data.ok && data.result) {
clearInterval(callbackPollTimer);
callbackPollTimer = null;
if (data.result.error) {
$('#loginStatus').textContent = '授权失败: ' + data.result.error;
$('#loginStatus').style.color = 'var(--error)';
} else if (data.result.code && data.result.state) {
// 自动交换 Token
$('#loginStatus').textContent = '正在交换 Token...';
await exchangeTokenWithCode(data.result.code, data.result.state);
}
}
} catch(e) {
console.error('轮询回调失败:', e);
}
}, 1000);
}
async function exchangeTokenWithCode(code, state) {
try {
const r = await fetch('/api/kiro/social/exchange', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code, state })
});
const d = await r.json();
if (d.ok && d.completed) {
$('#loginStatus').textContent = '✅ ' + d.message;
$('#loginStatus').style.color = 'var(--success)';
setTimeout(() => {
$('#loginPanel').style.display = 'none';
loadAccounts();
loadAccountsEnhanced();
}, 1500);
} else {
$('#loginStatus').textContent = '❌ ' + (d.error || '登录失败');
$('#loginStatus').style.color = 'var(--error)';
}
} catch(e) {
$('#loginStatus').textContent = '交换 Token 失败: ' + e.message;
$('#loginStatus').style.color = 'var(--error)';
}
}
function cancelSocialLogin(){
if (callbackPollTimer) {
clearInterval(callbackPollTimer);
callbackPollTimer = null;
}
fetch('/api/kiro/social/cancel',{method:'POST'});
$('#loginPanel').style.display='none';
}
async function handleSocialCallback(){
const url=$('#callbackUrl').value.trim();
if(!url){alert('请粘贴回调 URL');return;}
try{
// 支持 kiro:// 协议的 URL 解析
let code, state;
if(url.startsWith('kiro://')){
// kiro://kiro.kiroAgent/authenticate-success?code=xxx&state=xxx
const queryStart = url.indexOf('?');
if(queryStart > -1){
const params = new URLSearchParams(url.substring(queryStart + 1));
code = params.get('code');
state = params.get('state');
}
} else {
// 标准 http/https URL
const urlObj=new URL(url);
code=urlObj.searchParams.get('code');
state=urlObj.searchParams.get('state');
}
if(!code||!state){alert('无效的回调 URL,缺少 code 或 state 参数');return;}
$('#loginStatus').textContent='正在交换 Token...';
const r=await fetch('/api/kiro/social/exchange',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({code,state})
});
const d=await r.json();
if(d.ok&&d.completed){
$('#loginStatus').textContent='✅ '+d.message;
$('#loginStatus').style.color='var(--success)';
setTimeout(()=>{$('#loginPanel').style.display='none';loadAccounts();},1500);
}else{
$('#loginStatus').textContent='❌ '+(d.error||'登录失败');
$('#loginStatus').style.color='var(--error)';
}
}catch(e){alert('处理回调失败: '+e.message)}
}
async function startAwsLogin(){
$('#loginOptions').style.display='none';
try{
const r=await fetch('/api/kiro/login/start',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({})
});
const d=await r.json();
if(!d.ok){alert('启动登录失败: '+d.error);return;}
showAwsLoginPanel(d);
startLoginPoll();
}catch(e){alert('启动登录失败: '+e.message)}
}
function showAwsLoginPanel(data){
$('#loginPanel').style.display='block';
$('#loginContent').innerHTML=`
<div style="text-align:center;padding:1rem">
<p style="margin-bottom:1rem"><strong>AWS Builder ID 登录</strong></p>
<div style="font-size:2rem;font-weight:bold;letter-spacing:0.5rem;padding:1rem;background:var(--bg);border-radius:8px;margin-bottom:1rem">${data.user_code}</div>
<p style="margin-bottom:0.5rem;font-size:0.875rem">复制上方授权码,然后打开以下链接完成授权:</p>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;justify-content:center">
<input type="text" value="${data.verification_uri}" readonly style="flex:1;max-width:300px;padding:0.5rem;font-size:0.75rem;background:var(--card);border:1px solid var(--border);border-radius:4px">
<button class="small" onclick="copy('${data.verification_uri}')">复制链接</button>
</div>
<p style="color:var(--muted);font-size:0.875rem">授权码有效期: ${Math.floor(data.expires_in/60)} 分钟</p>
<button class="secondary" onclick="cancelKiroLogin()" style="margin-top:1rem;width:100%">取消</button>
<p style="color:var(--muted);font-size:0.75rem;margin-top:0.75rem" id="loginStatus">等待授权...</p>
</div>
`;
}
function startLoginPoll(){
if(loginPollTimer)clearInterval(loginPollTimer);
loginPollTimer=setInterval(pollLogin,3000);
}
async function pollLogin(){
try{
const r=await fetch('/api/kiro/login/poll');
const d=await r.json();
if(!d.ok){$('#loginStatus').textContent='错误: '+d.error;stopLoginPoll();return;}
if(d.completed){
$('#loginStatus').textContent='✅ 登录成功!';
$('#loginStatus').style.color='var(--success)';
stopLoginPoll();
setTimeout(()=>{$('#loginPanel').style.display='none';loadAccounts();},1500);
}
}catch(e){$('#loginStatus').textContent='轮询失败: '+e.message}
}
function stopLoginPoll(){
if(loginPollTimer){clearInterval(loginPollTimer);loginPollTimer=null;}
}
async function cancelKiroLogin(){
stopLoginPoll();
await fetch('/api/kiro/login/cancel',{method:'POST'});
$('#loginPanel').style.display='none';
}
'''
JS_FLOWS = '''
// Flow Monitor
async function loadFlowStats(){
try{
const r=await fetch('/api/flows/stats');
const d=await r.json();
$('#flowStatsGrid').innerHTML=`
<div class="stat-item"><div class="stat-value">${d.total_flows}</div><div class="stat-label">总请求</div></div>
<div class="stat-item"><div class="stat-value">${d.completed}</div><div class="stat-label">完成</div></div>
<div class="stat-item"><div class="stat-value">${d.errors}</div><div class="stat-label">错误</div></div>
<div class="stat-item"><div class="stat-value">${d.error_rate}</div><div class="stat-label">错误率</div></div>
<div class="stat-item"><div class="stat-value">${d.avg_duration_ms.toFixed(0)}ms</div><div class="stat-label">平均延迟</div></div>
<div class="stat-item"><div class="stat-value">${d.total_tokens_in}</div><div class="stat-label">输入Token</div></div>
<div class="stat-item"><div class="stat-value">${d.total_tokens_out}</div><div class="stat-label">输出Token</div></div>
`;
}catch(e){console.error(e)}
}
async function loadFlows(){
try{
const protocol=$('#flowProtocol').value;
const state=$('#flowState').value;
const search=$('#flowSearch').value;
let url='/api/flows?limit=50';
if(protocol)url+=`&protocol=${protocol}`;
if(state)url+=`&state=${state}`;
if(search)url+=`&search=${encodeURIComponent(search)}`;
const r=await fetch(url);
const d=await r.json();
if(!d.flows||d.flows.length===0){
$('#flowList').innerHTML='<p style="color:var(--muted)">暂无请求记录</p>';
return;
}
$('#flowList').innerHTML=d.flows.map(f=>{
const stateBadge={completed:'success',error:'error',streaming:'info',pending:'warn'}[f.state]||'info';
const stateText={completed:'完成',error:'错误',streaming:'流式中',pending:'等待中'}[f.state]||f.state;
const time=new Date(f.timing.created_at*1000).toLocaleTimeString();
const duration=f.timing.duration_ms?f.timing.duration_ms.toFixed(0)+'ms':'-';
const model=f.request?.model||'-';
const tokens=f.response?.usage?(f.response.usage.input_tokens+'/'+f.response.usage.output_tokens):'-';
return `
<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem;border:1px solid var(--border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer" onclick="viewFlow('${f.id}')">
<div style="flex:1">
<div style="display:flex;align-items:center;gap:0.5rem">
<span class="badge ${stateBadge}">${stateText}</span>
<span style="font-weight:500">${model}</span>
${f.bookmarked?'<span style="color:var(--warn)">★</span>':''}
</div>
<div style="color:var(--muted);font-size:0.75rem;margin-top:0.25rem">
${time} · ${duration} · ${tokens} tokens · ${f.protocol}
</div>
</div>
<button class="secondary small" onclick="event.stopPropagation();toggleBookmark('${f.id}',${!f.bookmarked})">${f.bookmarked?'取消':'收藏'}</button>
</div>
`;
}).join('');
}catch(e){console.error(e)}
}
async function viewFlow(id){
try{
const r=await fetch('/api/flows/'+id);
const f=await r.json();
let html=`<div style="margin-bottom:1rem"><strong>ID:</strong> ${f.id}<br><strong>协议:</strong> ${f.protocol}<br><strong>状态:</strong> ${f.state}<br><strong>时间:</strong> ${new Date(f.timing.created_at*1000).toLocaleString()}<br><strong>延迟:</strong> ${f.timing.duration_ms?f.timing.duration_ms.toFixed(0)+'ms':'N/A'}</div>`;
if(f.request){
html+=`<h4 style="margin-bottom:0.5rem">请求</h4><div style="margin-bottom:1rem"><strong>模型:</strong> ${f.request.model}<br><strong>流式:</strong> ${f.request.stream?'是':'否'}</div>`;
}
if(f.response){
html+=`<h4 style="margin-top:1rem;margin-bottom:0.5rem">响应</h4><div><strong>状态码:</strong> ${f.response.status_code}<br><strong>Token:</strong> ${f.response.usage?.input_tokens||0} in / ${f.response.usage?.output_tokens||0} out</div>`;
}
if(f.error){
html+=`<h4 style="margin-top:1rem;margin-bottom:0.5rem;color:var(--error)">错误</h4><div><strong>类型:</strong> ${f.error.type}<br><strong>消息:</strong> ${f.error.message}</div>`;
}
$('#flowDetailContent').innerHTML=html;
$('#flowDetail').style.display='block';
}catch(e){alert('获取详情失败: '+e.message)}
}
async function toggleBookmark(id,bookmarked){
await fetch('/api/flows/'+id+'/bookmark',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({bookmarked})});
loadFlows();
}
async function exportFlows(){
try{
const r=await fetch('/api/flows/export',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({format:'json'})});
const d=await r.json();
const blob=new Blob([d.content],{type:'application/json'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;
a.download='flows_'+new Date().toISOString().slice(0,10)+'.json';
a.click();
}catch(e){alert('导出失败: '+e.message)}
}
'''
JS_SETTINGS = '''
// 设置页面
// 历史消息管理
async function loadHistoryConfig(){
try{
const r=await fetch('/api/settings/history');
const d=await r.json();
const strategies=Array.isArray(d.strategies)?d.strategies:[];
$('#historyErrorRetryEnabled').checked=strategies.includes('error_retry');
$('#maxRetries').value=d.max_retries||3;
$('#summaryCacheMaxAge').value=d.summary_cache_max_age_seconds||300;
$('#addWarningHeader').checked=d.add_warning_header!==false;
}catch(e){console.error('加载配置失败:',e)}
}
async function updateHistoryConfig(){
const strategies=[];
if($('#historyErrorRetryEnabled').checked) strategies.push('error_retry');
const config={
strategies,
max_retries:parseInt($('#maxRetries').value)||3,
summary_cache_enabled:true,
summary_cache_max_age_seconds:parseInt($('#summaryCacheMaxAge').value)||300,
add_warning_header:$('#addWarningHeader').checked
};
try{
await fetch('/api/settings/history',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)});
}catch(e){console.error('保存配置失败:',e)}
}
// 刷新配置
async function loadRefreshConfig(){
try{
const r=await fetch('/api/refresh/config');
const d=await r.json();
if(d.ok && d.config){
const c=d.config;
$('#refreshMaxRetries').value=c.max_retries||3;
$('#refreshConcurrency').value=c.concurrency||3;
$('#refreshAutoInterval').value=c.auto_refresh_interval||60;
$('#refreshRetryDelay').value=c.retry_base_delay||1.0;
$('#refreshBeforeExpiry').value=c.token_refresh_before_expiry||300;
// 更新状态显示
$('#refreshConfigStatus').innerHTML=`
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:0.5rem">
<span>最大重试: <strong>${c.max_retries||3}</strong> 次</span>
<span>并发数: <strong>${c.concurrency||3}</strong></span>
<span>自动刷新间隔: <strong>${c.auto_refresh_interval||60}</strong> 秒</span>
<span>提前刷新: <strong>${c.token_refresh_before_expiry||300}</strong> 秒</span>
</div>
`;
}
}catch(e){console.error('加载刷新配置失败:',e)}
}
async function saveRefreshConfig(){
const config={
max_retries:parseInt($('#refreshMaxRetries').value)||3,
concurrency:parseInt($('#refreshConcurrency').value)||3,
auto_refresh_interval:parseInt($('#refreshAutoInterval').value)||60,
retry_base_delay:parseFloat($('#refreshRetryDelay').value)||1.0,
token_refresh_before_expiry:parseInt($('#refreshBeforeExpiry').value)||300
};
try{
const r=await fetch('/api/refresh/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)});
const d=await r.json();
if(d.ok){
Toast.success('刷新配置保存成功');
loadRefreshConfig();
}else{
Toast.error(d.error||'保存失败');
}
}catch(e){
console.error('保存刷新配置失败:',e);
Toast.error('保存刷新配置失败');
}
}
// 限速配置
async function loadRateLimitConfig(){
try{
const r=await fetch('/api/settings/rate-limit');
const d=await r.json();
$('#rateLimitEnabled').checked=d.enabled;
$('#minRequestInterval').value=d.min_request_interval||0.5;
$('#maxRequestsPerMinute').value=d.max_requests_per_minute||60;
$('#globalMaxRequestsPerMinute').value=d.global_max_requests_per_minute||120;
// 更新统计
const stats=d.stats||{};
$('#rateLimitStats').innerHTML=`
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:0.5rem">
<span>状态: <span class="badge ${d.enabled?'success':'warn'}">${d.enabled?'已启用':'已禁用'}</span></span>
<span>全局 RPM: ${stats.global_rpm||0}</span>
<span>429 冷却: <span class="badge success">自动 5 分钟</span></span>
</div>
`;
}catch(e){console.error('加载限速配置失败:',e)}
}
async function updateRateLimitConfig(){
const config={
enabled:$('#rateLimitEnabled').checked,
min_request_interval:parseFloat($('#minRequestInterval').value)||0.5,
max_requests_per_minute:parseInt($('#maxRequestsPerMinute').value)||60,
global_max_requests_per_minute:parseInt($('#globalMaxRequestsPerMinute').value)||120
};
try{
await fetch('/api/settings/rate-limit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)});
loadRateLimitConfig();
}catch(e){console.error('保存限速配置失败:',e)}
}
// 还原默认配置函数
async function resetRefreshConfig(){
if(!confirm('确定要还原刷新配置为默认值吗?')) return;
const defaultConfig={
max_retries:3,
concurrency:3,
auto_refresh_interval:60,
retry_base_delay:1.0,
token_refresh_before_expiry:300
};
try{
const r=await fetch('/api/refresh/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(defaultConfig)});
const d=await r.json();
if(d.ok){
Toast.success('已还原为默认配置');
loadRefreshConfig();
}else{
Toast.error(d.error||'还原失败');
}
}catch(e){
Toast.error('还原配置失败');
}
}
async function resetRateLimitConfig(){
if(!confirm('确定要还原限速配置为默认值吗?')) return;
const defaultConfig={
enabled:false,
min_request_interval:0.5,
max_requests_per_minute:60,
global_max_requests_per_minute:120
};
try{
await fetch('/api/settings/rate-limit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(defaultConfig)});
Toast.success('已还原为默认配置');
loadRateLimitConfig();
}catch(e){
Toast.error('还原配置失败');
}
}
async function resetHistoryConfig(){
if(!confirm('确定要还原历史消息配置为默认值吗?')) return;
const defaultConfig={
strategies:['error_retry'],
max_retries:3,
summary_cache_enabled:true,
summary_cache_max_age_seconds:300,
add_warning_header:true
};
try{
await fetch('/api/settings/history',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(defaultConfig)});
Toast.success('已还原为默认配置');
loadHistoryConfig();
}catch(e){
Toast.error('还原配置失败');
}
}
// 页面加载时加载设置
loadHistoryConfig();
loadRateLimitConfig();
loadRefreshConfig();
'''
# ==================== UI 组件库 JavaScript ====================
JS_UI_COMPONENTS = '''
// ==================== Modal 模态框组件 ====================
class Modal {
constructor(options = {}) {
this.title = options.title || '';
this.content = options.content || '';
this.type = options.type || 'default';
this.confirmText = options.confirmText || '确认';
this.cancelText = options.cancelText || '取消';
this.onConfirm = options.onConfirm;
this.onCancel = options.onCancel;
this.showCancel = options.showCancel !== false;
this.element = null;
}
show() {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal ${this.type}">
<div class="modal-header">
<h3>${this.title}</h3>
<button class="modal-close" onclick="this.closest('.modal-overlay').modal.hide()">&times;</button>
</div>
<div class="modal-body">${this.content}</div>
<div class="modal-footer">
${this.showCancel ? `<button class="secondary" onclick="this.closest('.modal-overlay').modal.cancel()">${this.cancelText}</button>` : ''}
<button onclick="this.closest('.modal-overlay').modal.confirm()">${this.confirmText}</button>
</div>
</div>
`;
overlay.modal = this;
this.element = overlay;
document.body.appendChild(overlay);
// 键盘事件
this.keyHandler = (e) => {
if (e.key === 'Escape') this.hide();
if (e.key === 'Enter' && !e.target.matches('textarea')) this.confirm();
};
document.addEventListener('keydown', this.keyHandler);
// 点击遮罩关闭
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.hide();
});
requestAnimationFrame(() => overlay.classList.add('active'));
return this;
}
hide() {
if (this.element) {
this.element.classList.remove('active');
document.removeEventListener('keydown', this.keyHandler);
setTimeout(() => this.element.remove(), 200);
}
}
confirm() {
if (this.onConfirm) this.onConfirm();
this.hide();
}
cancel() {
if (this.onCancel) this.onCancel();
this.hide();
}
setLoading(loading) {
const btn = this.element?.querySelector('.modal-footer button:last-child');
if (btn) {
btn.disabled = loading;
btn.textContent = loading ? '处理中...' : this.confirmText;
}
}
static confirm(title, message, onConfirm) {
return new Modal({ title, content: `<p>${message}</p>`, onConfirm }).show();
}
static alert(title, message) {
return new Modal({ title, content: `<p>${message}</p>`, showCancel: false }).show();
}
static danger(title, message, onConfirm) {
return new Modal({ title, content: `<p>${message}</p>`, type: 'danger', onConfirm, confirmText: '删除' }).show();
}
}
// ==================== Toast 通知组件 ====================
class Toast {
static container = null;
static getContainer() {
if (!this.container) {
this.container = document.createElement('div');
this.container.className = 'toast-container';
document.body.appendChild(this.container);
}
return this.container;
}
static show(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
`;
this.getContainer().appendChild(toast);
if (duration > 0) {
setTimeout(() => toast.remove(), duration);
}
return toast;
}
static success(message, duration) { return this.show(message, 'success', duration); }
static error(message, duration) { return this.show(message, 'error', duration); }
static warning(message, duration) { return this.show(message, 'warning', duration); }
static info(message, duration) { return this.show(message, 'info', duration); }
}
// ==================== Dropdown 下拉菜单组件 ====================
class Dropdown {
constructor(trigger, items) {
this.trigger = trigger;
this.items = items;
this.element = null;
this.init();
}
init() {
const wrapper = document.createElement('div');
wrapper.className = 'dropdown';
this.trigger.parentNode.insertBefore(wrapper, this.trigger);
wrapper.appendChild(this.trigger);
const menu = document.createElement('div');
menu.className = 'dropdown-menu';
menu.innerHTML = this.items.map(item => {
if (item.divider) return '<div class="dropdown-divider"></div>';
return `<div class="dropdown-item ${item.danger ? 'danger' : ''}" data-action="${item.action || ''}">${item.icon || ''}${item.label}</div>`;
}).join('');
wrapper.appendChild(menu);
this.element = wrapper;
this.trigger.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.dropdown-item');
if (item) {
const action = item.dataset.action;
const itemConfig = this.items.find(i => i.action === action);
if (itemConfig?.onClick) itemConfig.onClick();
this.close();
}
});
document.addEventListener('click', () => this.close());
}
toggle() {
this.element.classList.toggle('open');
}
close() {
this.element.classList.remove('open');
}
}
// ==================== 进度条渲染函数 ====================
function renderProgressBar(value, max, options = {}) {
const percent = max > 0 ? (value / max * 100) : 0;
const color = options.color || (percent > 80 ? 'error' : percent > 60 ? 'warning' : 'success');
const size = options.size || '';
const showLabel = options.showLabel !== false;
return `
<div class="progress-bar ${size}">
<div class="progress-fill ${color}" style="width:${percent}%"></div>
</div>
${showLabel ? `<div class="progress-label"><span>${options.leftLabel || ''}</span><span>${options.rightLabel || Math.round(percent) + '%'}</span></div>` : ''}
`;
}
// ==================== 账号卡片渲染函数 ====================
function renderAccountCard(account) {
const quota = account.quota;
const isPriority = account.is_priority;
const isActive = account.is_active;
let statusBadge = '';
if (!account.enabled) statusBadge = '<span class="badge">禁用</span>';
else if (account.cooldown_remaining > 0) statusBadge = `<span class="badge warn">冷却 ${account.cooldown_remaining}s</span>`;
else if (account.available) statusBadge = '<span class="badge success">正常</span>';
else statusBadge = '<span class="badge error">不可用</span>';
let quotaSection = '';
if (quota && !quota.error) {
const usedPercent = quota.usage_limit > 0 ? (quota.current_usage / quota.usage_limit * 100) : 0;
quotaSection = `
<div class="account-quota-section">
<div class="quota-header">
<span>已用/总额</span>
<span>${quota.current_usage.toFixed(1)} / ${quota.usage_limit.toFixed(1)}</span>
</div>
${renderProgressBar(quota.current_usage, quota.usage_limit, {
color: quota.is_low_balance ? 'error' : usedPercent > 60 ? 'warning' : 'success',
rightLabel: usedPercent.toFixed(1) + '%'
})}
<div class="quota-detail">
${quota.free_trial_limit > 0 ? `<span>试用: ${quota.free_trial_usage.toFixed(0)}/${quota.free_trial_limit.toFixed(0)}</span>` : ''}
${quota.bonus_limit > 0 ? `<span>奖励: ${quota.bonus_usage.toFixed(0)}/${quota.bonus_limit.toFixed(0)} (${quota.active_bonuses || 0}个)</span>` : ''}
<span>更新: ${quota.updated_at || '未知'}</span>
</div>
${quota.reset_date_text || quota.free_trial_expiry ? `
<div class="quota-reset-info" style="font-size:0.75rem;color:var(--muted);margin-top:0.5rem">
${quota.reset_date_text ? `<span>🔄 重置: ${quota.reset_date_text}</span>` : ''}
${quota.free_trial_expiry ? `<span>🎁 试用过期: ${quota.trial_expiry_text}</span>` : ''}
</div>
` : ''}
</div>
`;
} else if (quota?.error) {
quotaSection = `<div class="account-quota-section"><span class="badge error">额度获取失败: ${quota.error}</span></div>`;
}
return `
<div class="account-card-enhanced ${isPriority ? 'priority' : ''} ${isActive ? 'active' : ''}" data-id="${account.id}">
<div class="account-card-header">
<div class="account-card-title">
<strong>${account.name}</strong>
<div class="account-card-badges">
${statusBadge}
${isPriority ? `<span class="badge info">优先 #${account.priority_order}</span>` : ''}
${isActive ? '<span class="badge success">活跃</span>' : ''}
${quota?.is_low_balance ? '<span class="badge warn">低额度</span>' : ''}
</div>
</div>
<button class="secondary small" onclick="showAccountMenu('${account.id}', this)">操作 ▼</button>
</div>
${quotaSection}
<div class="account-stats-grid">
<div class="account-stat"><div class="account-stat-value">${account.request_count}</div><div class="account-stat-label">请求数</div></div>
<div class="account-stat"><div class="account-stat-value">${account.error_rate || '0%'}</div><div class="account-stat-label">错误率</div></div>
<div class="account-stat"><div class="account-stat-value">${account.last_used_ago || '-'}</div><div class="account-stat-label">最后使用</div></div>
<div class="account-stat"><div class="account-stat-value">${account.auth_method || '-'}</div><div class="account-stat-label">认证方式</div></div>
</div>
</div>
`;
}
// ==================== 汇总面板渲染函数 ====================
function renderSummaryPanel(summary) {
const strategyLabel = {
lowest_balance: '剩余额度最少优先',
round_robin: '轮询',
least_requests: '请求最少优先',
random: '随机'
}[summary.strategy] || summary.strategy;
return `
<div class="summary-panel">
<div class="summary-grid">
<div class="summary-item"><div class="summary-value">${summary.total_accounts}</div><div class="summary-label">总账号</div></div>
<div class="summary-item success"><div class="summary-value">${summary.available_accounts}</div><div class="summary-label">可用</div></div>
<div class="summary-item warning"><div class="summary-value">${summary.cooldown_accounts}</div><div class="summary-label">冷却中</div></div>
<div class="summary-item error"><div class="summary-value">${summary.unhealthy_accounts + summary.disabled_accounts}</div><div class="summary-label">不可用</div></div>
</div>
<div class="summary-quota">
<div class="quota-header">
<span>总剩余额度</span>
<span style="font-weight:600">${summary.total_balance.toFixed(1)}</span>
</div>
${renderProgressBar(summary.total_usage, summary.total_limit, {
size: 'large',
leftLabel: `已用 ${summary.total_usage.toFixed(0)}`,
rightLabel: `总计 ${summary.total_limit.toFixed(0)}`
})}
</div>
<div class="summary-info">
<span>选择策略: ${strategyLabel}</span>
<span>优先账号: ${summary.priority_accounts.length > 0 ? summary.priority_accounts.join(', ') : '无'}</span>
<span>最后刷新: ${summary.last_refresh || '未刷新'}</span>
</div>
<div class="summary-actions">
<button onclick="refreshAllQuotas()">刷新全部额度</button>
<button class="secondary" onclick="loadAccountsEnhanced()">刷新列表</button>
</div>
</div>
`;
}
// ==================== 账号操作菜单 ====================
let currentAccountMenu = null;
function showAccountMenu(accountId, btn) {
if (currentAccountMenu) {
currentAccountMenu.remove();
currentAccountMenu = null;
}
const menu = document.createElement('div');
menu.className = 'dropdown-menu';
menu.style.cssText = 'display:block;position:absolute;z-index:100;';
menu.innerHTML = `
<div class="dropdown-item" onclick="refreshAccountQuota('${accountId}')">🔄 刷新额度</div>
<div class="dropdown-item" onclick="togglePriority('${accountId}')">⭐ 设为优先</div>
<div class="dropdown-item" onclick="toggleAccount('${accountId}')">🔒 启用/禁用</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item danger" onclick="confirmDeleteAccount('${accountId}')">🗑️ 删除账号</div>
`;
const rect = btn.getBoundingClientRect();
menu.style.top = (rect.bottom + window.scrollY) + 'px';
menu.style.left = (rect.left + window.scrollX - 100) + 'px';
document.body.appendChild(menu);
currentAccountMenu = menu;
setTimeout(() => {
document.addEventListener('click', function closeMenu() {
if (currentAccountMenu) {
currentAccountMenu.remove();
currentAccountMenu = null;
}
document.removeEventListener('click', closeMenu);
}, { once: true });
}, 0);
}
// ==================== 额度管理 API 调用 ====================
async function loadAccountsEnhanced() {
showLoading('#accountsGrid', '加载账号列表...');
try {
const r = await fetchWithRetry('/api/accounts/status');
const d = await r.json();
if (d.ok) {
$('#accountsSummaryCompact').innerHTML = renderSummaryCompact(d.summary);
$('#accountsGrid').innerHTML = d.accounts.map(renderAccountCardCompact).join('');
} else {
$('#accountsGrid').innerHTML = `<p style="color:var(--error);text-align:center;padding:2rem">加载失败: ${d.error || '未知错误'}</p>`;
}
} catch(e) {
$('#accountsGrid').innerHTML = `<p style="color:var(--error);text-align:center;padding:2rem">网络错误,<a href="#" onclick="loadAccountsEnhanced();return false">点击重试</a></p>`;
Toast.error('加载账号列表失败');
}
}
// ==================== 紧凑汇总面板 ====================
function renderSummaryCompact(summary) {
const usedPercent = summary.total_limit > 0 ? (summary.total_usage / summary.total_limit * 100) : 0;
const barColor = usedPercent > 80 ? 'var(--error)' : usedPercent > 60 ? 'var(--warn)' : 'var(--success)';
return `
<div class="summary-compact">
<div class="summary-compact-item">
<span class="summary-compact-value">${summary.total_accounts}</span>
<span class="summary-compact-label">总账号</span>
</div>
<div class="summary-compact-item">
<span class="summary-compact-value" style="color:var(--success)">${summary.available_accounts}</span>
<span class="summary-compact-label">可用</span>
</div>
<div class="summary-compact-item">
<span class="summary-compact-value" style="color:var(--warn)">${summary.cooldown_accounts}</span>
<span class="summary-compact-label">冷却</span>
</div>
<div class="summary-compact-divider"></div>
<div class="summary-quota-bar">
<div style="display:flex;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem">
<span>总额度</span>
<span>${summary.total_balance.toFixed(0)} / ${summary.total_limit.toFixed(0)}</span>
</div>
<div style="height:6px;background:var(--bg);border-radius:3px;overflow:hidden">
<div style="height:100%;width:${usedPercent}%;background:${barColor}"></div>
</div>
</div>
<div class="summary-compact-item" style="margin-left:auto">
<span class="summary-compact-label">${summary.last_refresh || '未刷新'}</span>
</div>
</div>
`;
}
// ==================== 紧凑账号卡片 ====================
function renderAccountCardCompact(account) {
const quota = account.quota;
const isPriority = account.is_priority;
const isLowBalance = quota?.is_low_balance;
const isExhausted = quota?.is_exhausted || (quota && quota.balance <= 0); // 额度耗尽
const isSuspended = quota?.is_suspended; // 账号被封禁
const isUnavailable = !account.available;
let cardClass = 'account-card-compact';
if (isPriority) cardClass += ' priority';
if (isSuspended) cardClass += ' suspended'; // 封禁状态
else if (isExhausted) cardClass += ' exhausted'; // 无额度状态
else if (isLowBalance) cardClass += ' low-balance';
if (isUnavailable) cardClass += ' unavailable';
// 状态徽章
let statusBadges = '';
if (!account.enabled) statusBadges += '<span class="badge">禁用</span>';
else if (account.cooldown_remaining > 0) statusBadges += `<span class="badge warn">冷却</span>`;
else if (account.available) statusBadges += '<span class="badge success">正常</span>';
else statusBadges += '<span class="badge error">异常</span>';
if (isPriority) statusBadges += `<span class="badge info">#${account.priority_order}</span>`;
// Provider 徽章 (Google/Github)
if (account.provider) {
const providerIcon = account.provider === 'Google' ? '🔵' : account.provider === 'Github' ? '⚫' : '';
statusBadges += `<span class="badge" title="${account.provider} 登录">${providerIcon}${account.provider}</span>`;
}
// 状态徽章:封禁(红色)> 无额度(红色)> 低额度(黄色)
if (isSuspended) statusBadges += '<span class="badge error">已封禁</span>';
else if (isExhausted) statusBadges += '<span class="badge error">无额度</span>';
else if (isLowBalance) statusBadges += '<span class="badge warn">低额度</span>';
// Token 过期状态徽章
if (account.token_expired) statusBadges += '<span class="badge error">Token过期</span>';
else if (account.token_expiring_soon) statusBadges += '<span class="badge warn">Token即将过期</span>';
// 额度条 - 根据状态显示不同颜色
let quotaBar = '';
if (quota && !quota.error) {
const usedPercent = quota.usage_limit > 0 ? (quota.current_usage / quota.usage_limit * 100) : 0;
// 颜色逻辑:无额度(红色) > 低额度(黄色) > 正常(绿色)
let barColor = 'var(--success)';
if (isExhausted) barColor = 'var(--error)';
else if (isLowBalance) barColor = 'var(--warn)';
else if (usedPercent > 60) barColor = 'var(--warn)';
quotaBar = `
<div class="account-card-quota">
<div class="account-card-quota-bar">
<div class="account-card-quota-fill" style="width:${usedPercent}%;background:${barColor}"></div>
</div>
<div class="account-card-quota-text">
<span>${quota.current_usage.toFixed(1)} / ${quota.usage_limit.toFixed(1)}</span>
<span>${usedPercent.toFixed(0)}%</span>
</div>
${quota.reset_date_text || quota.trial_expiry_text ? `
<div class="account-card-reset-info" style="font-size:0.65rem;color:var(--muted);margin-top:2px;display:flex;gap:8px;flex-wrap:wrap">
${quota.reset_date_text ? `<span title="下次重置时间">🔄 ${quota.reset_date_text}</span>` : ''}
${quota.trial_expiry_text ? `<span title="免费试用过期">🎁 ${quota.trial_expiry_text}</span>` : ''}
</div>
` : ''}
</div>
`;
} else if (quota?.error) {
// 额度获取失败时显示重试按钮
// 如果是封禁错误,显示封禁状态
const errorMsg = quota.error;
const isSuspendedError = errorMsg && (
errorMsg.toLowerCase().includes('temporarily_suspended') ||
errorMsg.toLowerCase().includes('suspended') ||
errorMsg.toLowerCase().includes('accountsuspendedexception')
);
if (isSuspendedError) {
quotaBar = `
<div class="account-card-quota">
<span style="font-size:0.7rem;color:var(--error)">账号已封禁</span>
<button class="secondary small" style="margin-left:0.5rem;padding:0.15rem 0.4rem;font-size:0.65rem" onclick="event.stopPropagation();refreshSingleAccountQuota('${account.id}')">重试</button>
</div>
`;
} else {
quotaBar = `
<div class="account-card-quota">
<span style="font-size:0.7rem;color:var(--error)">额度获取失败: ${quota.error}</span>
<button class="secondary small" style="margin-left:0.5rem;padding:0.15rem 0.4rem;font-size:0.65rem" onclick="event.stopPropagation();refreshSingleAccountQuota('${account.id}')">重试</button>
</div>
`;
}
} else {
// 未查询额度时显示查询按钮
quotaBar = `
<div class="account-card-quota">
<span style="font-size:0.7rem;color:var(--muted)">额度未查询</span>
<button class="secondary small" style="margin-left:0.5rem;padding:0.15rem 0.4rem;font-size:0.65rem" onclick="event.stopPropagation();refreshSingleAccountQuota('${account.id}')">查询</button>
</div>
`;
}
// Token 过期时间显示
let tokenExpireInfo = '';
if (account.token_expires_at) {
// expires_at 可能是 ISO 字符串或时间戳
let expireDate;
if (typeof account.token_expires_at === 'string') {
// ISO 格式字符串
expireDate = new Date(account.token_expires_at);
} else if (account.token_expires_at > 1000000000000) {
// 毫秒时间戳
expireDate = new Date(account.token_expires_at);
} else {
// 秒时间戳
expireDate = new Date(account.token_expires_at * 1000);
}
const now = new Date();
const diffMs = expireDate - now;
// 检查是否为有效日期
if (!isNaN(expireDate.getTime()) && !isNaN(diffMs)) {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
let expireText = '';
if (diffMs < 0) {
expireText = '已过期';
} else if (diffDays > 0) {
expireText = `${diffDays}天`;
} else if (diffHours > 0) {
expireText = `${diffHours}时`;
} else {
const diffMins = Math.floor(diffMs / (1000 * 60));
expireText = diffMins > 0 ? `${diffMins}分` : '即将过期';
}
tokenExpireInfo = `<span title="Token过期: ${expireDate.toLocaleString()}">Token ${expireText}</span>`;
}
}
return `
<div class="${cardClass}" data-id="${account.id}" id="account-card-${account.id.replace(/[^a-zA-Z0-9]/g, '_')}">
<div class="account-card-top">
<div class="account-card-info">
<div class="account-card-name">${account.name}</div>
<div class="account-card-email">${account.id}</div>
</div>
<div class="account-card-status">${statusBadges}</div>
</div>
${quotaBar}
<div class="account-card-stats">
<span>请求: ${account.request_count}</span>
<span>错误: ${account.error_count}</span>
${tokenExpireInfo}
</div>
<div class="account-card-actions">
<button class="secondary small" onclick="testAccountToken('${account.id}')">测试</button>
<button class="secondary small" onclick="refreshSingleAccountQuota('${account.id}')">刷新</button>
<button class="secondary small" onclick="showEditAccountModal('${account.id}', '${account.name.replace(/'/g, "\\'")}')">编辑</button>
<button class="secondary small" onclick="togglePriority('${account.id}')">${isPriority ? '取消优先' : '优先'}</button>
<button class="secondary small" onclick="toggleAccount('${account.id}')">${account.enabled ? '禁用' : '启用'}</button>
<button class="secondary small" style="color:var(--error)" onclick="confirmDeleteAccount('${account.id}')">删除</button>
</div>
</div>
`;
}
// ==================== 导入导出菜单 ====================
let importExportMenu = null;
function showImportExportMenu(btn) {
if (importExportMenu) {
importExportMenu.remove();
importExportMenu = null;
return;
}
const menu = document.createElement('div');
menu.className = 'dropdown-menu';
menu.style.cssText = 'display:block;position:absolute;z-index:100;min-width:140px;';
menu.innerHTML = `
<div class="dropdown-item" onclick="exportAccounts()">📤 导出账号</div>
<div class="dropdown-item" onclick="importAccounts()">📥 导入账号</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item" onclick="refreshAllTokens()">🔄 刷新 Token</div>
`;
const rect = btn.getBoundingClientRect();
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
menu.style.left = (rect.left + window.scrollX) + 'px';
document.body.appendChild(menu);
importExportMenu = menu;
setTimeout(() => {
document.addEventListener('click', function closeMenu(e) {
if (importExportMenu && !importExportMenu.contains(e.target)) {
importExportMenu.remove();
importExportMenu = null;
}
document.removeEventListener('click', closeMenu);
}, { once: true });
}, 10);
}
async function refreshAllQuotas() {
// 检查是否正在刷新中
if (GlobalProgressBar.isRefreshing) {
Toast.warning('正在刷新中,请稍候...');
return;
}
try {
// 先获取账号数量用于显示
const statusR = await fetch('/api/accounts/status');
const statusD = await statusR.json();
const total = statusD.ok ? statusD.accounts?.length || 0 : 0;
// 显示进度条
GlobalProgressBar.show(total);
// 调用新的批量刷新 API
const r = await fetch('/api/refresh/all', { method: 'POST' });
const d = await r.json();
if (d.ok) {
// 开始轮询进度
GlobalProgressBar.startPolling();
} else {
GlobalProgressBar.hide();
Toast.error('启动刷新失败: ' + (d.error || '未知错误'));
}
} catch(e) {
GlobalProgressBar.hide();
Toast.error('刷新失败: ' + e.message);
}
}
async function refreshAccountQuota(accountId) {
Toast.info('正在刷新额度...');
try {
const r = await fetch(`/api/accounts/${accountId}/refresh-quota`, { method: 'POST' });
const d = await r.json();
if (d.ok) {
Toast.success('额度刷新成功');
loadAccounts();
loadAccountsEnhanced();
} else {
Toast.error(d.error || '刷新失败');
}
} catch(e) {
Toast.error('刷新失败: ' + e.message);
}
}
// ==================== 测试账号 Token ====================
async function testAccountToken(accountId) {
// 显示测试中的模态框
const modal = document.createElement('div');
modal.className = 'modal';
modal.id = 'testTokenModal';
modal.innerHTML = `
<div class="modal-content" style="max-width:500px">
<div class="modal-header">
<h3>测试 Token</h3>
<button class="close-btn" onclick="closeTestTokenModal()">&times;</button>
</div>
<div class="modal-body" id="testTokenResult">
<div style="text-align:center;padding:2rem">
<div class="spinner"></div>
<p style="margin-top:1rem;color:var(--muted)">正在测试 Token...</p>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'flex';
try {
const r = await fetch('/api/accounts/' + accountId + '/test');
const d = await r.json();
const resultDiv = document.getElementById('testTokenResult');
if (!resultDiv) return;
if (d.ok) {
// 测试通过
let testsHtml = '';
for (const [key, test] of Object.entries(d.tests || {})) {
const icon = test.passed ? '✅' : '❌';
const color = test.passed ? 'var(--success)' : 'var(--error)';
testsHtml += `
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid var(--border)">
<span style="font-size:1.2rem">${icon}</span>
<div style="flex:1">
<div style="font-weight:500">${test.message}</div>
${test.suggestion ? `<div style="font-size:0.8rem;color:var(--muted);margin-top:0.25rem">${test.suggestion}</div>` : ''}
${test.latency_ms ? `<div style="font-size:0.75rem;color:var(--muted)">延迟: ${test.latency_ms.toFixed(0)}ms</div>` : ''}
${test.email ? `<div style="font-size:0.75rem;color:var(--muted)">邮箱: ${test.email}</div>` : ''}
</div>
</div>
`;
}
resultDiv.innerHTML = `
<div style="text-align:center;margin-bottom:1rem">
<span style="font-size:3rem">✅</span>
<h3 style="margin:0.5rem 0;color:var(--success)">Token 有效</h3>
<p style="color:var(--muted)">${d.summary}</p>
</div>
<div style="background:var(--bg);border-radius:8px;padding:0.5rem 1rem">
${testsHtml}
</div>
`;
} else {
// 测试失败
let testsHtml = '';
for (const [key, test] of Object.entries(d.tests || {})) {
const icon = test.passed ? '✅' : '❌';
testsHtml += `
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid var(--border)">
<span style="font-size:1.2rem">${icon}</span>
<div style="flex:1">
<div style="font-weight:500">${test.message}</div>
${test.suggestion ? `<div style="font-size:0.8rem;color:var(--warn);margin-top:0.25rem">💡 ${test.suggestion}</div>` : ''}
</div>
</div>
`;
}
resultDiv.innerHTML = `
<div style="text-align:center;margin-bottom:1rem">
<span style="font-size:3rem">❌</span>
<h3 style="margin:0.5rem 0;color:var(--error)">Token 无效</h3>
<p style="color:var(--muted)">${d.summary || d.error || '测试失败'}</p>
</div>
${Object.keys(d.tests || {}).length > 0 ? `
<div style="background:var(--bg);border-radius:8px;padding:0.5rem 1rem">
${testsHtml}
</div>
` : ''}
<div style="margin-top:1rem;text-align:center">
<button class="primary" onclick="refreshSingleAccountToken('${accountId}');closeTestTokenModal()">刷新 Token</button>
</div>
`;
}
} catch(e) {
const resultDiv = document.getElementById('testTokenResult');
if (resultDiv) {
resultDiv.innerHTML = `
<div style="text-align:center;padding:2rem">
<span style="font-size:3rem">⚠️</span>
<h3 style="margin:0.5rem 0;color:var(--error)">测试失败</h3>
<p style="color:var(--muted)">${e.message}</p>
</div>
`;
}
}
}
function closeTestTokenModal() {
const modal = document.getElementById('testTokenModal');
if (modal) modal.remove();
}
// ==================== 单账号额度查询 (任务 19.2) ====================
async function refreshSingleAccountQuota(accountId) {
// 获取按钮元素,显示加载状态
const safeId = accountId.replace(/[^a-zA-Z0-9]/g, '_');
const btn = document.getElementById('quota-btn-' + safeId);
const card = document.getElementById('account-card-' + safeId);
if (btn) {
btn.disabled = true;
btn.dataset.originalText = btn.textContent;
btn.textContent = '查询中...';
}
try {
const r = await fetch(`/api/accounts/${accountId}/refresh-quota`, { method: 'POST' });
const d = await r.json();
if (d.ok) {
Toast.success('额度查询成功');
// 刷新整个账号列表以更新显示
loadAccounts();
loadAccountsEnhanced();
} else {
// 失败时显示错误信息和重试按钮
Toast.error(d.error || '额度查询失败');
if (btn) {
btn.textContent = '重试';
btn.disabled = false;
btn.classList.add('error-state');
}
// 在卡片上显示错误状态
if (card) {
const quotaDiv = card.querySelector('.account-card-quota');
if (quotaDiv) {
quotaDiv.innerHTML = `
<span style="font-size:0.7rem;color:var(--error)">查询失败: ${d.error || '未知错误'}</span>
<button class="secondary small" style="margin-left:0.5rem;padding:0.15rem 0.4rem;font-size:0.65rem" onclick="event.stopPropagation();refreshSingleAccountQuota('${accountId}')">重试</button>
`;
}
}
}
} catch(e) {
Toast.error('网络错误: ' + e.message);
if (btn) {
btn.textContent = '重试';
btn.disabled = false;
}
} finally {
// 恢复按钮状态(如果没有错误)
if (btn && !btn.classList.contains('error-state')) {
btn.disabled = false;
if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
}
}
if (btn) {
btn.classList.remove('error-state');
}
}
}
// ==================== 单账号 Token 刷新 (任务 19.2) ====================
async function refreshSingleAccountToken(accountId) {
// 获取按钮元素,显示加载状态
const safeId = accountId.replace(/[^a-zA-Z0-9]/g, '_');
const btn = document.getElementById('token-btn-' + safeId);
if (btn) {
btn.disabled = true;
btn.dataset.originalText = btn.textContent;
btn.textContent = '刷新中...';
}
try {
const r = await fetch(`/api/accounts/${accountId}/refresh`, { method: 'POST' });
const d = await r.json();
if (d.ok) {
Toast.success('Token 刷新成功');
// 刷新整个账号列表以更新显示
loadAccounts();
loadAccountsEnhanced();
} else {
// 失败时显示错误信息
Toast.error(d.message || d.error || 'Token 刷新失败');
if (btn) {
btn.textContent = '重试';
btn.disabled = false;
}
}
} catch(e) {
Toast.error('网络错误: ' + e.message);
if (btn) {
btn.textContent = '重试';
btn.disabled = false;
}
} finally {
// 恢复按钮状态
if (btn && btn.textContent !== '重试') {
btn.disabled = false;
if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
}
}
}
}
async function togglePriority(accountId) {
try {
// 先检查是否已是优先账号
const r1 = await fetch('/api/priority');
const d1 = await r1.json();
const isPriority = d1.priority_accounts?.some(a => a.id === accountId);
if (isPriority) {
const r = await fetch(`/api/priority/${accountId}`, { method: 'DELETE' });
const d = await r.json();
Toast.show(d.message, d.ok ? 'success' : 'error');
} else {
const r = await fetch(`/api/priority/${accountId}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' });
const d = await r.json();
Toast.show(d.message, d.ok ? 'success' : 'error');
}
loadAccounts();
loadAccountsEnhanced();
} catch(e) {
Toast.error('操作失败: ' + e.message);
}
}
function confirmDeleteAccount(accountId) {
Modal.danger('删除账号', `确定要删除账号 ${accountId} 吗?此操作不可恢复。`, async () => {
try {
const r = await fetch(`/api/accounts/${accountId}`, { method: 'DELETE' });
const d = await r.json();
if (d.ok) {
Toast.success('账号已删除');
loadAccounts();
loadAccountsEnhanced();
} else {
Toast.error('删除失败');
}
} catch(e) {
Toast.error('删除失败: ' + e.message);
}
});
}
// ==================== 账号编辑功能 ====================
function showEditAccountModal(accountId, currentName) {
const modal = new Modal({
title: '编辑账号',
content: `
<div style="display:grid;gap:1rem" id="editAccountForm">
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">账号名称</label>
<input type="text" id="editAccountName" value="${currentName}" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">登录提供商</label>
<select id="editAccountProvider" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
<option value="">未知</option>
<option value="Google">Google</option>
<option value="Github">GitHub</option>
</select>
</div>
<div>
<label style="display:block;font-size:0.875rem;color:var(--muted);margin-bottom:0.25rem">区域</label>
<input type="text" id="editAccountRegion" placeholder="us-east-1" style="width:100%;padding:0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)">
</div>
<div id="tokenInfoSection" style="display:none">
<hr style="border:none;border-top:1px solid var(--border);margin:0.5rem 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<label style="font-size:0.875rem;color:var(--muted)">Token 信息</label>
<button type="button" class="secondary small" id="refreshTokenBtn" onclick="refreshTokenInModal('${accountId}')" style="padding:0.25rem 0.5rem;font-size:0.75rem">🔄 刷新 Token</button>
</div>
<div id="tokenDetails" style="font-size:0.75rem"></div>
</div>
</div>
`,
confirmText: '保存',
onConfirm: async () => {
const name = document.getElementById('editAccountName').value.trim();
const provider = document.getElementById('editAccountProvider').value;
const region = document.getElementById('editAccountRegion').value.trim();
const updateData = {};
if (name) updateData.name = name;
if (provider) updateData.provider = provider;
if (region) updateData.region = region;
try {
const r = await fetch(`/api/accounts/${accountId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(updateData)
});
const d = await r.json();
if (d.ok) {
Toast.success(d.message || '账号已更新');
loadAccounts();
loadAccountsEnhanced();
} else {
Toast.error(d.error || '更新失败');
}
} catch(e) {
Toast.error('更新失败: ' + e.message);
}
}
});
modal.show();
// 加载当前账号信息填充表单
loadAccountForEdit(accountId);
}
async function refreshTokenInModal(accountId) {
const btn = document.getElementById('refreshTokenBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '刷新中...';
}
try {
const r = await fetch(`/api/accounts/${accountId}/refresh`, { method: 'POST' });
const d = await r.json();
if (d.ok) {
Toast.success('Token 刷新成功');
// 重新加载账号信息
await loadAccountForEdit(accountId);
loadAccounts();
loadAccountsEnhanced();
} else {
Toast.error(d.message || d.error || 'Token 刷新失败');
}
} catch(e) {
Toast.error('刷新失败: ' + e.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '🔄 刷新 Token';
}
}
}
function copyToClipboard(text, label) {
navigator.clipboard.writeText(text).then(() => {
Toast.success(label + ' 已复制');
}).catch(() => {
// 降级方案
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
Toast.success(label + ' 已复制');
});
}
function renderTokenField(label, value, fieldId) {
if (!value) return '';
const shortValue = value.length > 50 ? value.substring(0, 50) + '...' : value;
return `
<div style="margin-bottom:0.5rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="color:var(--muted)">${label}:</span>
<button type="button" class="secondary small" onclick="copyToClipboard(document.getElementById('${fieldId}').dataset.full, '${label}')" style="padding:0.15rem 0.4rem;font-size:0.65rem">复制</button>
</div>
<div id="${fieldId}" data-full="${value.replace(/"/g, '&quot;')}" style="background:var(--bg);padding:0.5rem;border-radius:4px;word-break:break-all;font-family:monospace;margin-top:0.25rem;max-height:60px;overflow-y:auto">${shortValue}</div>
</div>
`;
}
async function loadAccountForEdit(accountId) {
try {
const r = await fetch(`/api/accounts/${accountId}`);
const d = await r.json();
const providerSelect = document.getElementById('editAccountProvider');
const regionInput = document.getElementById('editAccountRegion');
const tokenSection = document.getElementById('tokenInfoSection');
const tokenDetails = document.getElementById('tokenDetails');
if (d.credentials) {
if (providerSelect && d.credentials.provider) {
providerSelect.value = d.credentials.provider;
}
if (regionInput && d.credentials.region) {
regionInput.value = d.credentials.region;
}
// 显示 Token 信息
if (tokenSection && tokenDetails) {
tokenSection.style.display = 'block';
let html = '';
// Access Token
if (d.credentials.access_token) {
html += renderTokenField('Access Token', d.credentials.access_token, 'field_access_token');
}
// Refresh Token
if (d.credentials.refresh_token) {
html += renderTokenField('Refresh Token', d.credentials.refresh_token, 'field_refresh_token');
}
// Profile ARN
if (d.credentials.profile_arn) {
html += renderTokenField('Profile ARN', d.credentials.profile_arn, 'field_profile_arn');
}
// Client ID
if (d.credentials.client_id) {
html += renderTokenField('Client ID', d.credentials.client_id, 'field_client_id');
}
// 过期时间
if (d.credentials.expires_at) {
const expiresAt = new Date(d.credentials.expires_at);
const now = new Date();
const diffMs = expiresAt - now;
const diffMins = Math.floor(diffMs / 60000);
let expiryText = expiresAt.toLocaleString();
if (diffMs < 0) {
expiryText += ' <span style="color:var(--error)">(已过期)</span>';
} else if (diffMins < 60) {
expiryText += ' <span style="color:var(--warning)">(' + diffMins + '分钟后过期)</span>';
} else {
expiryText += ' <span style="color:var(--success)">(' + Math.floor(diffMins/60) + '小时后过期)</span>';
}
html += '<div style="margin-bottom:0.5rem"><span style="color:var(--muted)">过期时间:</span> ' + expiryText + '</div>';
}
// Auth Method
if (d.credentials.auth_method) {
html += '<div style="margin-bottom:0.5rem"><span style="color:var(--muted)">认证方式:</span> ' + d.credentials.auth_method + '</div>';
}
tokenDetails.innerHTML = html || '<span style="color:var(--muted)">无 Token 信息</span>';
}
}
} catch(e) {
console.error('加载账号信息失败:', e);
}
}
// ==================== 自动刷新功能 (任务 10.2) ====================
let autoRefreshTimer = null;
const AUTO_REFRESH_INTERVAL = 60000; // 60秒
function startAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(() => {
const accountsTab = document.querySelector('.tab[data-tab="accounts"]');
if (accountsTab && accountsTab.classList.contains('active')) {
loadAccounts();
loadAccountsEnhanced();
}
}, AUTO_REFRESH_INTERVAL);
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
// 页面加载时启动自动刷新
startAutoRefresh();
// 页面初始化:如果默认显示账号页面,则加载账号数据
function initializeDefaultTab() {
const accountsTab = document.querySelector('.tab[data-tab="accounts"]');
const accountsPanel = document.querySelector('#accounts');
// 检查账号标签页和面板是否都处于激活状态(默认页面)
if (accountsTab && accountsTab.classList.contains('active') &&
accountsPanel && accountsPanel.classList.contains('active')) {
// 延迟一点时间确保DOM完全加载
setTimeout(() => {
loadAccounts();
loadAccountsEnhanced();
}, 100);
}
}
// 页面加载完成后初始化默认标签页
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDefaultTab);
} else {
initializeDefaultTab();
}
// ==================== 加载状态指示器 (任务 10.1) ====================
function showLoading(container, message = '加载中...') {
const el = typeof container === 'string' ? document.querySelector(container) : container;
if (el) {
el.innerHTML = `<div style="text-align:center;padding:2rem;color:var(--muted)">
<div style="display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite"></div>
<p style="margin-top:0.5rem">${message}</p>
</div>`;
}
}
// 添加旋转动画
if (!document.querySelector('#spinKeyframes')) {
const style = document.createElement('style');
style.id = 'spinKeyframes';
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);
}
// ==================== 表单验证 (任务 10.3) ====================
function validateToken(token) {
if (!token || token.trim().length === 0) {
return { valid: false, error: 'Token 不能为空' };
}
if (token.trim().length < 20) {
return { valid: false, error: 'Token 格式不正确,长度过短' };
}
return { valid: true };
}
function validateAccountName(name) {
if (!name || name.trim().length === 0) {
return { valid: true, default: '手动添加账号' }; // 名称可选
}
if (name.length > 50) {
return { valid: false, error: '账号名称不能超过50个字符' };
}
return { valid: true };
}
// ==================== 网络错误处理 (任务 10.1) ====================
async function fetchWithRetry(url, options = {}, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
const r = await fetch(url, options);
if (!r.ok && r.status >= 500 && i < retries) {
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
continue;
}
return r;
} catch (e) {
if (i === retries) throw e;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
// ==================== 全局进度条组件 (任务 18.1) ====================
const GlobalProgressBar = {
pollTimer: null,
isRefreshing: false,
// 显示进度条
show(total) {
this.isRefreshing = true;
const bar = $('#globalProgressBar');
if (bar) {
bar.classList.add('active');
// 重置显示
$('#globalProgressTitle').textContent = '正在刷新额度...';
$('#globalProgressCompleted').textContent = '0';
$('#globalProgressTotal').textContent = total || '0';
$('#globalProgressSuccess').textContent = '0';
$('#globalProgressFailed').textContent = '0';
$('#globalProgressFill').style.width = '0%';
$('#globalProgressFill').classList.remove('complete');
$('#globalProgressCurrent').textContent = '准备中...';
$('#globalProgressClose').style.display = 'none';
// 显示 spinner
const spinner = bar.querySelector('.spinner');
if (spinner) spinner.style.display = 'inline-block';
}
// 禁用刷新按钮
this.updateRefreshButton(true);
},
// 更新进度
update(progress) {
if (!progress) return;
const completed = progress.completed || 0;
const total = progress.total || 0;
const success = progress.success || 0;
const failed = progress.failed || 0;
const current = progress.current_account || '';
const isComplete = progress.status === 'completed' || progress.status === 'idle';
// 更新数字
$('#globalProgressCompleted').textContent = completed;
$('#globalProgressTotal').textContent = total;
$('#globalProgressSuccess').textContent = success;
$('#globalProgressFailed').textContent = failed;
// 更新进度条
const percent = total > 0 ? (completed / total * 100) : 0;
const fill = $('#globalProgressFill');
if (fill) {
fill.style.width = percent + '%';
if (isComplete) {
fill.classList.add('complete');
}
}
// 更新当前处理的账号
if (current) {
$('#globalProgressCurrent').textContent = '正在处理: ' + current;
} else if (isComplete) {
$('#globalProgressCurrent').textContent = `刷新完成: 成功 ${success} 个, 失败 ${failed} 个`;
}
// 完成后的处理
if (isComplete) {
this.isRefreshing = false;
$('#globalProgressTitle').textContent = '刷新完成';
$('#globalProgressClose').style.display = 'inline-block';
// 隐藏 spinner
const spinner = $('#globalProgressBar')?.querySelector('.spinner');
if (spinner) spinner.style.display = 'none';
// 恢复刷新按钮
this.updateRefreshButton(false);
// 刷新账号列表
loadAccounts();
loadAccountsEnhanced();
// 显示完成通知
if (failed > 0) {
Toast.warning(`刷新完成: 成功 ${success} 个, 失败 ${failed} 个`);
} else {
Toast.success(`刷新完成: 成功 ${success} 个`);
}
// 5秒后自动关闭进度条
setTimeout(() => this.hide(), 5000);
}
},
// 隐藏进度条
hide() {
const bar = $('#globalProgressBar');
if (bar) {
bar.classList.remove('active');
}
this.isRefreshing = false;
this.stopPolling();
this.updateRefreshButton(false);
},
// 开始轮询进度
startPolling() {
this.stopPolling();
this.pollTimer = setInterval(() => this.pollProgress(), 500);
},
// 停止轮询
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
},
// 轮询进度 API
async pollProgress() {
try {
const r = await fetch('/api/refresh/progress');
const d = await r.json();
if (d.ok) {
// 传入 progress 对象,如果没有则传入整个响应(兼容)
const progress = d.progress || d;
// 添加 status 字段用于判断完成状态
if (!d.is_refreshing && !progress.status) {
progress.status = 'completed';
}
this.update(progress);
// 如果完成则停止轮询
if (!d.is_refreshing || progress.status === 'completed' || progress.status === 'idle') {
this.stopPolling();
}
}
} catch (e) {
console.error('轮询进度失败:', e);
}
},
// 更新刷新按钮状态
updateRefreshButton(disabled) {
// 查找所有刷新额度按钮
const buttons = document.querySelectorAll('button');
buttons.forEach(btn => {
const text = btn.textContent;
const originalText = btn.dataset.originalText;
// 匹配"刷新额度"、"刷新全部额度"或已经变成"刷新中..."的按钮
if (text.includes('刷新额度') || text.includes('刷新全部额度') ||
text === '刷新中...' ||
(originalText && (originalText.includes('刷新额度') || originalText.includes('刷新全部额度')))) {
btn.disabled = disabled;
if (disabled) {
if (!btn.dataset.originalText) {
btn.dataset.originalText = text;
}
btn.textContent = '刷新中...';
} else if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
delete btn.dataset.originalText;
}
}
});
}
};
// ==================== 进度轮询函数 (任务 18.2) ====================
async function pollRefreshProgress() {
return GlobalProgressBar.pollProgress();
}
'''
JS_SCRIPTS = JS_UTILS + JS_TABS + JS_STATUS + JS_DOCS + JS_STATS + JS_LOGS + JS_ACCOUNTS + JS_LOGIN + JS_FLOWS + JS_SETTINGS + JS_UI_COMPONENTS + JS_AUTH
# ==================== 组装最终 HTML ====================
HTML_PAGE = f'''<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiro API</title>
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg">
<style>
{CSS_STYLES}
</style>
</head>
<body>
<div class="container">
{HTML_BODY}
<div class="footer">Kiro API Proxy v1.7.1 - Codex 工具调用 | 环境变量配置 | 限速开关修复</div>
</div>
<script>
{JS_SCRIPTS}
</script>
</body>
</html>'''