AIClient-2-API/static/potluck-user.html
leonai 7f6bf6f06b feat(api-potluck): 插件 - API 大锅饭 - 升级 V1.0.1
1. 新增用户凭证数据管理模块(user-data-manager.js),支持凭证关联、资源包计算和配置热更新
2. 实现资源包机制:每个健康凭证提供额外调用次数,支持有效期管理和自动过期清理
3. 新增系统配置API:支持动态调整默认限额、资源包次数和有效期
4. 新增批量操作API:批量应用限额和同步资源包状态到所有Key
5. 实现凭证健康检查:从主服务ProviderPoolManager同步凭证状态
6. 新增用户端API Key重置功能,支持数据自动迁移
7. 重构前端界面:采用GitHub风格深色主题,优化移动端响应式布局
8. 新增定时健康检查调度器,自动同步所有用户凭证状态
2026-01-09 21:47:47 +08:00

2045 lines
No EOL
82 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 大锅饭 - 我的用量</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
min-height: 100vh;
color: #e6edf3;
}
/* 顶部导航 */
.navbar {
background: #161b22;
border-bottom: 1px solid #30363d;
position: sticky;
top: 0;
z-index: 100;
}
.navbar-inner {
max-width: 1000px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: 700;
color: #fff;
}
.navbar-brand .icon { font-size: 28px; }
.navbar-brand .badge {
background: linear-gradient(135deg, #f472b6, #ec4899);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.navbar-user {
display: flex;
align-items: center;
gap: 16px;
}
.navbar-user .welcome {
color: #8b949e;
font-size: 14px;
}
.navbar-user .welcome strong { color: #e6edf3; }
.btn-logout {
background: linear-gradient(135deg, #f472b6, #ec4899);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.btn-logout:hover { opacity: 0.9; transform: translateY(-1px); }
/* 主容器 */
.main-container {
max-width: 1000px;
margin: 0 auto;
padding: 30px 24px;
}
/* Tab 导航 */
.tab-nav {
display: flex;
gap: 8px;
border-bottom: 1px solid #30363d;
margin-bottom: 30px;
justify-content: center;
}
.tab-btn {
background: none;
border: none;
color: #8b949e;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-btn:hover { color: #e6edf3; }
.tab-btn.active {
color: #e6edf3;
border-bottom-color: #f472b6;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* 区块标题 */
.section-title {
font-size: 18px;
font-weight: 600;
color: #e6edf3;
margin-bottom: 20px;
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
/* 第一行3列带边框卡片 */
.stats-row-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.stat-card-bordered {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 20px;
text-align: center;
position: relative;
}
.stat-card-bordered::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
height: 3px;
border-radius: 12px 12px 0 0;
}
.stat-card-bordered.purple::before { background: linear-gradient(90deg, #a855f7, #7c3aed); }
.stat-card-bordered.pink::before { background: linear-gradient(90deg, #ec4899, #f472b6); }
.stat-card-bordered.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
.stat-card-bordered.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.stat-card-bordered.orange::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.stat-card-bordered .label {
font-size: 12px;
color: #8b949e;
margin-bottom: 10px;
}
.stat-card-bordered .value {
font-size: 28px;
font-weight: 700;
color: #e6edf3;
}
.stat-card-bordered .value .dim {
font-size: 18px;
color: #8b949e;
font-weight: 400;
}
.stat-card-bordered.purple .value > span:first-child { color: #a855f7; }
.stat-card-bordered.green .value > span:first-child { color: #10b981; }
.stat-card-bordered.cyan .value { color: #22d3ee; }
/* 第二行2列大卡片 */
.stats-row-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat-card-large {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 28px 24px;
text-align: center;
}
.stat-card-large .value {
font-size: 36px;
font-weight: 700;
margin-bottom: 8px;
}
.stat-card-large .value .highlight { color: #22d3ee; }
.stat-card-large .value .highlight.green { color: #10b981; }
.stat-card-large .value .dim { font-size: 24px; color: #8b949e; font-weight: 400; }
.stat-card-large .label { font-size: 13px; color: #8b949e; }
.stat-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 20px;
text-align: center;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.purple::before { background: linear-gradient(90deg, #a855f7, #7c3aed); }
.stat-card.pink::before { background: linear-gradient(90deg, #ec4899, #f472b6); }
.stat-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.stat-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
.stat-card.orange::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.stat-card .label {
font-size: 12px;
color: #8b949e;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
}
.stat-card.purple .value { color: #a855f7; }
.stat-card.pink .value { color: #ec4899; }
.stat-card.cyan .value { color: #22d3ee; }
.stat-card.green .value { color: #10b981; }
.stat-card.orange .value { color: #f59e0b; }
.stat-card .sub { font-size: 14px; color: #8b949e; }
/* 大统计卡片 */
.stats-large {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-large {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 24px;
text-align: center;
}
.stat-large .value {
font-size: 42px;
font-weight: 700;
margin-bottom: 8px;
}
.stat-large .value .highlight { color: #22d3ee; }
.stat-large .value .dim { color: #8b949e; }
.stat-large .label { font-size: 13px; color: #8b949e; }
/* 操作卡片 */
.action-card {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
border: 1px solid #4c1d95;
border-radius: 12px;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.action-card-content {
display: flex;
align-items: center;
gap: 16px;
}
.action-card-icon {
width: 48px;
height: 48px;
background: rgba(168, 85, 247, 0.2);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.action-card-text h4 {
font-size: 15px;
font-weight: 600;
color: #e6edf3;
margin-bottom: 4px;
}
.action-card-text p {
font-size: 13px;
color: #8b949e;
}
.btn-action {
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
white-space: nowrap;
}
.btn-action:hover { opacity: 0.9; transform: translateY(-1px); }
/* 凭证列表 */
.credentials-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.credential-item {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
transition: all 0.2s;
}
.credential-item:hover {
border-color: #8b949e;
}
.credential-info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
min-width: 0;
}
.credential-icon {
width: 40px;
height: 40px;
background: #21262d;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.credential-details { flex: 1; min-width: 0; }
.credential-name {
font-size: 14px;
font-weight: 600;
color: #e6edf3;
margin-bottom: 4px;
}
.credential-path {
font-size: 12px;
color: #8b949e;
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.credential-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 4px 10px;
border-radius: 20px;
flex-shrink: 0;
}
.credential-status.healthy {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.credential-status.unhealthy {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.credential-status.unknown {
background: rgba(139, 148, 158, 0.15);
color: #8b949e;
}
.credential-status.checking {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.credential-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-icon {
background: #21262d;
border: 1px solid #30363d;
color: #8b949e;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.btn-icon:hover {
background: #30363d;
color: #e6edf3;
}
.btn-icon.danger:hover {
background: rgba(239, 68, 68, 0.15);
border-color: #ef4444;
color: #ef4444;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #8b949e;
}
.empty-state .icon { font-size: 48px; margin-bottom: 16px; }
.empty-state p { font-size: 14px; }
/* 登录页面 */
.login-container {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 16px;
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-box h2 {
font-size: 24px;
font-weight: 700;
color: #e6edf3;
text-align: center;
margin-bottom: 8px;
}
.login-box .subtitle {
text-align: center;
color: #8b949e;
font-size: 14px;
margin-bottom: 30px;
}
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
font-size: 13px;
color: #8b949e;
margin-bottom: 8px;
}
.form-input {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 16px;
color: #e6edf3;
font-size: 14px;
font-family: monospace;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.15);
}
.form-input::placeholder { color: #484f58; }
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #8b949e;
cursor: pointer;
}
.form-checkbox input {
width: 16px;
height: 16px;
accent-color: #a855f7;
}
.btn-login {
width: 100%;
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
border: none;
padding: 14px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-login:hover { opacity: 0.9; }
.btn-login:disabled {
background: #30363d;
color: #484f58;
cursor: not-allowed;
}
/* 模态框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-overlay.active { display: flex; }
.modal {
background: #161b22;
border: 1px solid #30363d;
border-radius: 16px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
color: #e6edf3;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: none;
border: none;
color: #8b949e;
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.modal-close:hover { color: #e6edf3; }
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #30363d;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-secondary {
background: #21262d;
border: 1px solid #30363d;
color: #e6edf3;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover { background: #30363d; }
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #a855f7);
border: none;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover { opacity: 0.9; }
.btn-primary:disabled {
background: #30363d;
color: #484f58;
cursor: not-allowed;
}
/* 模式切换 */
.mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.mode-btn {
flex: 1;
background: #21262d;
border: 2px solid #30363d;
color: #8b949e;
padding: 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.mode-btn:hover { border-color: #8b949e; }
.mode-btn.active {
background: rgba(168, 85, 247, 0.15);
border-color: #a855f7;
color: #a855f7;
}
/* 文件上传区域 */
.upload-zone {
border: 2px dashed #30363d;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 16px;
}
.upload-zone:hover {
border-color: #a855f7;
background: rgba(168, 85, 247, 0.05);
}
.upload-zone .icon { font-size: 40px; margin-bottom: 12px; }
.upload-zone p { color: #8b949e; font-size: 14px; }
.upload-zone .hint { font-size: 12px; color: #484f58; margin-top: 8px; }
/* 文件列表 */
.file-list {
background: #0d1117;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #161b22;
border-radius: 6px;
margin-bottom: 6px;
}
.file-item:last-child { margin-bottom: 0; }
.file-item-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.file-item-name {
font-size: 13px;
color: #e6edf3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item-fields {
font-size: 11px;
color: #8b949e;
}
.file-item-remove {
background: none;
border: none;
color: #8b949e;
cursor: pointer;
padding: 4px;
}
.file-item-remove:hover { color: #ef4444; }
/* 验证结果 */
.validation-result {
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.validation-result.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.validation-result.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.validation-result h4 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.validation-result.success h4 { color: #10b981; }
.validation-result.error h4 { color: #ef4444; }
.validation-result ul {
list-style: none;
font-size: 13px;
}
.validation-result li {
padding: 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.validation-result .found { color: #10b981; }
.validation-result .missing { color: #ef4444; }
/* JSON 预览 */
.json-preview {
background: #0d1117;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.json-preview-header {
font-size: 13px;
font-weight: 600;
color: #8b949e;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.json-preview pre {
font-family: monospace;
font-size: 12px;
color: #7ee787;
white-space: pre-wrap;
word-break: break-all;
max-height: 150px;
overflow: auto;
}
/* 提示信息 */
.info-box {
background: rgba(168, 85, 247, 0.1);
border: 1px solid rgba(168, 85, 247, 0.3);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 20px;
}
.info-box p {
font-size: 13px;
color: #c4b5fd;
}
.info-box code {
background: rgba(0, 0, 0, 0.3);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 14px 20px;
color: #e6edf3;
font-size: 14px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 2000;
display: flex;
align-items: center;
gap: 10px;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { border-left: 4px solid #10b981; }
.toast.error { border-left: 4px solid #ef4444; }
/* 响应式 */
@media (max-width: 768px) {
.navbar-inner { padding: 0 16px; }
.navbar-user .welcome { display: none; }
.btn-logout span:first-child { display: none; }
.main-container { padding: 20px 16px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.stats-row-3 { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stats-row-2 { grid-template-columns: 1fr; gap: 8px; }
.stat-card-bordered .value { font-size: 22px; }
.stat-card-bordered .value .dim { font-size: 14px; }
.stat-card-large .value { font-size: 28px; }
.stat-card-large .value .dim { font-size: 18px; }
.stats-large { grid-template-columns: 1fr; }
.action-card { flex-direction: column; gap: 16px; text-align: center; }
.action-card-content { flex-direction: column; }
.credential-item { flex-direction: column; align-items: flex-start; }
.credential-actions { width: 100%; justify-content: flex-end; }
.tab-nav { overflow-x: auto; }
.tab-btn { white-space: nowrap; padding: 12px 16px; }
}
@media (max-width: 480px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.stats-row-3 { grid-template-columns: 1fr; }
.stat-card-bordered { padding: 16px; }
.stat-card-bordered .value { font-size: 24px; }
.stat-card .value { font-size: 28px; }
.stat-large .value { font-size: 32px; }
.upload-providers-grid { grid-template-columns: 1fr; }
}
/* 提供商上传卡片 */
.upload-providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.upload-provider-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: all 0.2s;
}
.upload-provider-card:hover {
border-color: #8b949e;
}
.upload-provider-icon {
font-size: 32px;
margin-bottom: 12px;
}
.upload-provider-info {
margin-bottom: 16px;
}
.upload-provider-name {
font-size: 15px;
font-weight: 600;
color: #e6edf3;
margin-bottom: 4px;
}
.upload-provider-type {
font-size: 11px;
color: #8b949e;
font-family: monospace;
}
.btn-upload {
width: 100%;
background: #21262d;
border: 1px solid #30363d;
color: #e6edf3;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-upload:hover {
background: #30363d;
border-color: #8b949e;
}
.btn-upload:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.upload-provider-status {
margin-top: 10px;
font-size: 12px;
min-height: 18px;
word-break: break-all;
}
.upload-provider-status.success { color: #10b981; }
.upload-provider-status.error { color: #ef4444; }
</style>
</head>
<body>
<!-- 顶部导航 -->
<nav class="navbar">
<div class="navbar-inner">
<div class="navbar-brand">
<span class="icon">🍲</span>
<span>API 大锅饭</span>
<span class="badge">用户版</span>
</div>
<div class="navbar-user" id="navbarUser" style="display: none;">
<span class="welcome">欢迎, <strong id="navUserName">-</strong></span>
<button class="btn-logout" onclick="logout()">
<span></span> 退出登录
</button>
</div>
</div>
</nav>
<!-- 登录页面 -->
<div class="login-container" id="loginContainer">
<div class="login-box">
<h2>🔑 登录</h2>
<p class="subtitle">使用您的 API Key 登录查看用量</p>
<div class="form-group">
<label>API Key</label>
<input type="text" class="form-input" id="apiKeyInput" placeholder="maki_xxxxxxxx..." autocomplete="off">
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="rememberKey" checked>
<span>记住我的 Key</span>
</label>
</div>
<button class="btn-login" id="loginBtn" onclick="login()">登录</button>
</div>
</div>
<!-- 主内容区 -->
<div class="main-container" id="mainContainer" style="display: none;">
<!-- Tab 导航 -->
<div class="tab-nav">
<button class="tab-btn active" data-tab="stats" onclick="switchTab('stats')">个人统计</button>
<button class="tab-btn" data-tab="credentials" onclick="switchTab('credentials')">凭证管理</button>
<button class="tab-btn" data-tab="apikey" onclick="switchTab('apikey')">API密钥</button>
<button class="tab-btn" data-tab="upload" onclick="switchTab('upload')">上传凭证</button>
</div>
<!-- 个人统计 Tab -->
<div class="tab-content active" id="tabStats">
<h3 class="section-title">个人使用统计</h3>
<!-- 第一行3个小统计卡片 -->
<div class="stats-row-3">
<div class="stat-card-bordered purple">
<div class="label">每日用量</div>
<div class="value"><span id="statToday">0</span><span class="dim"> / <span id="statLimit">0</span></span></div>
</div>
<div class="stat-card-bordered green">
<div class="label">资源包</div>
<div class="value"><span id="statBonusUsed">0</span><span class="dim"> / <span id="statBonusTotal">0</span></span></div>
</div>
<div class="stat-card-bordered cyan">
<div class="label">累计调用</div>
<div class="value" id="statTotal">0</div>
</div>
</div>
<!-- 第二行2个大统计卡片 -->
<div class="stats-row-2">
<div class="stat-card-large">
<div class="value">
<span class="highlight" id="usageToday">0</span>
<span class="dim"> / <span id="usageLimit">0</span></span>
</div>
<div class="label">总已使用 / 总配额上限</div>
</div>
<div class="stat-card-large">
<div class="value">
<span class="highlight green" id="credentialCount">0</span>
</div>
<div class="label">有效凭证数</div>
</div>
</div>
<!-- 操作卡片 -->
<div class="action-card">
<div class="action-card-content">
<div class="action-card-icon">🎁</div>
<div class="action-card-text">
<h4>获取凭证,上传使用</h4>
<p>通过 AWS SSO 或 OAuth 授权获取凭证文件</p>
</div>
</div>
<button class="btn-action" onclick="switchTab('upload')">
<span></span> 立即上传
</button>
</div>
</div>
<!-- 凭证管理 Tab -->
<div class="tab-content" id="tabCredentials">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 class="section-title" style="margin-bottom: 0;">我的凭证</h3>
<button class="btn-secondary" onclick="loadCredentials()">🔄 刷新</button>
</div>
<div class="credentials-list" id="credentialsList">
<div class="empty-state">
<div class="icon">📭</div>
<p>暂无凭证,请通过上传功能添加</p>
</div>
</div>
</div>
<!-- API密钥 Tab -->
<div class="tab-content" id="tabApikey">
<h3 class="section-title">API密钥</h3>
<!-- API Key 显示区域 -->
<div style="background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; margin-bottom: 24px;">
<input type="text" class="form-input" id="displayApiKeyFull" readonly
style="font-family: monospace; font-size: 14px; text-align: center; margin-bottom: 16px; background: #0d1117;">
<div style="display: flex; gap: 12px; justify-content: center;">
<button class="btn-action" style="flex: 1; max-width: 200px; background: linear-gradient(135deg, #2563eb, #3b82f6); justify-content: center;" onclick="copyApiKey()">
<span id="copyKeyIcon">📋</span> <span id="copyKeyText">复制</span>
</button>
<button class="btn-action" style="flex: 1; max-width: 200px; background: linear-gradient(135deg, #f59e0b, #fbbf24); justify-content: center;" onclick="showRegenerateModal()">
<span>🔄</span> 更改
</button>
</div>
</div>
<div class="info-box" style="background: rgba(139, 148, 158, 0.1); border-color: rgba(139, 148, 158, 0.3);">
<p style="color: #8b949e; font-size: 13px;">
💡 API Key 用于访问 API 服务。请妥善保管,不要泄露给他人。如果 Key 泄露,请立即更改。
</p>
</div>
</div>
<!-- 上传凭证 Tab -->
<div class="tab-content" id="tabUpload">
<h3 class="section-title">上传授权凭证</h3>
<!-- Kiro AWS 导入卡片 -->
<div class="action-card" style="margin-bottom: 16px;">
<div class="action-card-content">
<div class="action-card-icon">☁️</div>
<div class="action-card-text">
<h4>Kiro AWS 导入</h4>
<p>从 AWS SSO cache 导入 Kiro 凭据</p>
</div>
</div>
<button class="btn-action" onclick="showAwsImportModal()">
<span>📤</span> 导入
</button>
</div>
<!-- Kiro 批量导入卡片 -->
<div class="action-card" style="background: linear-gradient(135deg, #134e4a 0%, #115e59 100%); border-color: #14b8a6; margin-bottom: 24px;">
<div class="action-card-content">
<div class="action-card-icon" style="background: rgba(20, 184, 166, 0.2);">🔄</div>
<div class="action-card-text">
<h4>Kiro RefreshToken 批量导入</h4>
<p>批量导入 Refresh Token</p>
</div>
</div>
<button class="btn-action" style="background: linear-gradient(135deg, #0d9488, #14b8a6);" onclick="showBatchImportModal()">
<span>📋</span> 导入
</button>
</div>
<!-- 授权文件上传 -->
<h3 class="section-title" style="margin-top: 32px;">授权文件上传</h3>
<p style="color: #8b949e; font-size: 13px; margin-bottom: 16px;">上传 OAuth 授权文件到对应的提供商目录</p>
<div class="upload-providers-grid">
<div class="upload-provider-card" data-provider="gemini-cli-oauth">
<div class="upload-provider-icon">🔷</div>
<div class="upload-provider-info">
<div class="upload-provider-name">Gemini CLI</div>
<div class="upload-provider-type">gemini-cli-oauth</div>
</div>
<input type="file" class="provider-file-input" accept=".json,.txt,.key,.pem" style="display:none">
<button class="btn-upload" onclick="triggerProviderUpload(this)">
<span class="btn-upload-text">选择文件</span>
<span class="btn-upload-loading" style="display:none">上传中...</span>
</button>
<div class="upload-provider-status"></div>
</div>
<div class="upload-provider-card" data-provider="gemini-antigravity">
<div class="upload-provider-icon">🌀</div>
<div class="upload-provider-info">
<div class="upload-provider-name">Antigravity</div>
<div class="upload-provider-type">gemini-antigravity</div>
</div>
<input type="file" class="provider-file-input" accept=".json,.txt,.key,.pem" style="display:none">
<button class="btn-upload" onclick="triggerProviderUpload(this)">
<span class="btn-upload-text">选择文件</span>
<span class="btn-upload-loading" style="display:none">上传中...</span>
</button>
<div class="upload-provider-status"></div>
</div>
<div class="upload-provider-card" data-provider="claude-kiro-oauth">
<div class="upload-provider-icon">🤖</div>
<div class="upload-provider-info">
<div class="upload-provider-name">Kiro (Claude)</div>
<div class="upload-provider-type">claude-kiro-oauth</div>
</div>
<input type="file" class="provider-file-input" accept=".json,.txt,.key,.pem" style="display:none">
<button class="btn-upload" onclick="triggerProviderUpload(this)">
<span class="btn-upload-text">选择文件</span>
<span class="btn-upload-loading" style="display:none">上传中...</span>
</button>
<div class="upload-provider-status"></div>
</div>
<div class="upload-provider-card" data-provider="openai-qwen-oauth">
<div class="upload-provider-icon">🌐</div>
<div class="upload-provider-info">
<div class="upload-provider-name">Qwen (OpenAI)</div>
<div class="upload-provider-type">openai-qwen-oauth</div>
</div>
<input type="file" class="provider-file-input" accept=".json,.txt,.key,.pem" style="display:none">
<button class="btn-upload" onclick="triggerProviderUpload(this)">
<span class="btn-upload-text">选择文件</span>
<span class="btn-upload-loading" style="display:none">上传中...</span>
</button>
<div class="upload-provider-status"></div>
</div>
<div class="upload-provider-card" data-provider="openai-iflow">
<div class="upload-provider-icon">🔄</div>
<div class="upload-provider-info">
<div class="upload-provider-name">iFlow (OpenAI)</div>
<div class="upload-provider-type">openai-iflow</div>
</div>
<input type="file" class="provider-file-input" accept=".json,.txt,.key,.pem" style="display:none">
<button class="btn-upload" onclick="triggerProviderUpload(this)">
<span class="btn-upload-text">选择文件</span>
<span class="btn-upload-loading" style="display:none">上传中...</span>
</button>
<div class="upload-provider-status"></div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast"></div>
<!-- AWS 导入模态框 -->
<div class="modal-overlay" id="awsImportModal">
<div class="modal">
<div class="modal-header">
<h3>☁️ Kiro AWS 导入</h3>
<button class="modal-close" onclick="closeModal('awsImportModal')">&times;</button>
</div>
<div class="modal-body">
<div class="info-box">
<p>从 AWS SSO cache 目录导入凭据文件</p>
<p style="margin-top: 8px;"><code>C:\Users\{username}\.aws\sso\cache</code></p>
</div>
<!-- 模式切换 -->
<div class="mode-toggle">
<button class="mode-btn active" id="modeFileBtn" onclick="switchAwsMode('file')">
📁 文件上传
</button>
<button class="mode-btn" id="modeJsonBtn" onclick="switchAwsMode('json')">
📝 JSON 粘贴
</button>
</div>
<!-- 文件上传模式 -->
<div id="fileModeSection">
<div class="upload-zone" id="uploadZone">
<input type="file" id="awsFilesInput" multiple accept=".json" style="display: none;">
<div class="icon">📤</div>
<p>拖拽 JSON 文件到此处,或点击选择</p>
<p class="hint">支持多文件上传,系统将智能合并</p>
</div>
<div class="file-list" id="fileList" style="display: none;"></div>
</div>
<!-- JSON 输入模式 -->
<div id="jsonModeSection" style="display: none;">
<textarea class="form-input" id="awsJsonInput" rows="10" style="resize: vertical; font-family: monospace;" placeholder='{
"clientId": "...",
"clientSecret": "...",
"accessToken": "...",
"refreshToken": "..."
}'></textarea>
</div>
<!-- 验证结果 -->
<div class="validation-result" id="validationResult" style="display: none;"></div>
<!-- JSON 预览 -->
<div class="json-preview" id="jsonPreview" style="display: none;">
<div class="json-preview-header">👁️ 合并预览(敏感信息已脱敏)</div>
<pre id="jsonPreviewContent"></pre>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal('awsImportModal')">取消</button>
<button class="btn-primary" id="awsImportBtn" onclick="startAwsImport()" disabled>导入</button>
</div>
</div>
</div>
<!-- 批量导入模态框 -->
<div class="modal-overlay" id="batchImportModal">
<div class="modal">
<div class="modal-header">
<h3>🔄 Kiro RefreshToken 批量导入</h3>
<button class="modal-close" onclick="closeModal('batchImportModal')">&times;</button>
</div>
<div class="modal-body">
<div class="info-box" style="background: rgba(20, 184, 166, 0.1); border-color: rgba(20, 184, 166, 0.3);">
<p style="color: #5eead4;">请输入 refreshToken每行一个。系统将自动刷新并生成凭据文件。</p>
</div>
<textarea class="form-input" id="batchTokensInput" rows="10" style="resize: vertical; font-family: monospace;" placeholder="aorAxxxxxxxx
aorAyyyyyyyy"></textarea>
<div id="batchProgress" style="display: none; margin-top: 16px;">
<div style="display: flex; justify-content: space-between; font-size: 12px; color: #8b949e; margin-bottom: 8px;">
<span id="batchProgressText">正在导入...</span>
<span id="batchProgressPercent">0%</span>
</div>
<div style="height: 6px; background: #21262d; border-radius: 3px; overflow: hidden;">
<div id="batchProgressBar" style="height: 100%; width: 0%; background: linear-gradient(90deg, #14b8a6, #5eead4); transition: width 0.3s;"></div>
</div>
<div id="batchResults" style="margin-top: 12px; max-height: 150px; overflow-y: auto; font-size: 12px; font-family: monospace;"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal('batchImportModal')">取消</button>
<button class="btn-primary" style="background: linear-gradient(135deg, #0d9488, #14b8a6);" onclick="startBatchImport()">开始导入</button>
</div>
</div>
</div>
<!-- 重置 Key 模态框 -->
<div class="modal-overlay" id="regenerateKeyModal">
<div class="modal" style="max-width: 480px;">
<div class="modal-header">
<h3>🔑 重置 API Key</h3>
<button class="modal-close" onclick="closeModal('regenerateKeyModal')">&times;</button>
</div>
<div class="modal-body">
<!-- 确认阶段 -->
<div id="regenerateConfirmSection">
<div class="info-box" style="background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.3);">
<p style="color: #fca5a5;">⚠️ 重置后旧 Key 将立即失效,请确认操作:</p>
</div>
<ul style="color: #8b949e; font-size: 13px; margin: 16px 0; padding-left: 20px;">
<li style="margin-bottom: 8px;">旧 API Key 将无法继续使用</li>
<li style="margin-bottom: 8px;">需要使用新 Key 重新配置客户端</li>
<li>您的凭证数据会自动迁移到新 Key</li>
</ul>
</div>
<!-- 结果阶段 -->
<div id="regenerateResultSection" style="display: none;">
<div class="info-box" style="background: rgba(16, 185, 129, 0.1); border-color: rgba(16, 185, 129, 0.3);">
<p style="color: #6ee7b7;">✅ API Key 重置成功!请妥善保存新 Key。</p>
</div>
<div style="margin-top: 16px;">
<label style="font-size: 12px; color: #8b949e; display: block; margin-bottom: 8px;">新 API Key</label>
<div style="display: flex; gap: 8px;">
<input type="text" class="form-input" id="newKeyDisplay" readonly style="flex: 1; font-family: monospace; font-size: 13px;">
<button class="btn-secondary" onclick="copyNewKey()" style="white-space: nowrap;">
<span id="copyBtnText">📋 复制</span>
</button>
</div>
</div>
<p style="font-size: 12px; color: #f59e0b; margin-top: 12px;">⚠️ 关闭此窗口后将无法再次查看完整 Key</p>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal('regenerateKeyModal')" id="regenerateCancelBtn">取消</button>
<button class="btn-primary" style="background: linear-gradient(135deg, #dc2626, #ef4444);" onclick="confirmRegenerateKey()" id="regenerateConfirmBtn">确认重置</button>
</div>
</div>
</div>
<script>
const API_BASE = '/api/potluckuser';
let currentApiKey = '';
let isLoggedIn = false;
// AWS 导入状态
let awsUploadedFiles = [];
let awsMergedCredentials = null;
let awsCurrentMode = 'file';
// 提供商配置
const providerIcons = {
'claude-kiro-oauth': '🤖',
'gemini-cli-oauth': '🔷',
'gemini-antigravity': '🌀',
'openai-qwen-oauth': '🌐',
'openai-iflow': '🔄'
};
const providerNames = {
'claude-kiro-oauth': 'Kiro (Claude)',
'gemini-cli-oauth': 'Gemini CLI',
'gemini-antigravity': 'Antigravity',
'openai-qwen-oauth': 'Qwen (OpenAI)',
'openai-iflow': 'iFlow (OpenAI)'
};
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initUploadZone();
initProviderUploads();
const savedKey = localStorage.getItem('potluck_user_key');
if (savedKey) {
document.getElementById('apiKeyInput').value = savedKey;
login();
}
document.getElementById('apiKeyInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') login();
});
document.getElementById('awsJsonInput').addEventListener('input', () => {
if (awsCurrentMode === 'json') validateAwsJsonInput();
});
});
// 初始化提供商上传
function initProviderUploads() {
document.querySelectorAll('.upload-provider-card').forEach(card => {
const input = card.querySelector('.provider-file-input');
const btn = card.querySelector('.btn-upload');
const status = card.querySelector('.upload-provider-status');
const providerType = card.dataset.provider;
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const allowedExtensions = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(ext)) {
status.textContent = '不支持的文件类型';
status.className = 'upload-provider-status error';
input.value = '';
return;
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = '文件大小不能超过 5MB';
status.className = 'upload-provider-status error';
input.value = '';
return;
}
btn.disabled = true;
btn.querySelector('.btn-upload-text').style.display = 'none';
btn.querySelector('.btn-upload-loading').style.display = 'inline';
status.textContent = '';
status.className = 'upload-provider-status';
try {
const formData = new FormData();
formData.append('file', file);
formData.append('provider', providerType);
const response = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentApiKey}` },
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
status.textContent = '✓ ' + (data.filePath || '上传成功');
status.className = 'upload-provider-status success';
showToast('文件上传成功', 'success');
loadCredentials();
} else {
throw new Error(data.error?.message || '上传失败');
}
} catch (error) {
status.textContent = '✗ ' + error.message;
status.className = 'upload-provider-status error';
showToast('上传失败: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.querySelector('.btn-upload-text').style.display = 'inline';
btn.querySelector('.btn-upload-loading').style.display = 'none';
input.value = '';
}
});
});
}
// 触发提供商文件上传
function triggerProviderUpload(btn) {
const card = btn.closest('.upload-provider-card');
const input = card.querySelector('.provider-file-input');
input.click();
}
// 登录
async function login() {
const apiKey = document.getElementById('apiKeyInput').value.trim();
if (!apiKey) {
showToast('请输入 API Key', 'error');
return;
}
if (!apiKey.startsWith('maki_')) {
showToast('API Key 格式不正确', 'error');
return;
}
currentApiKey = apiKey;
document.getElementById('loginBtn').disabled = true;
try {
const response = await fetch(`${API_BASE}/usage`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error?.message || '登录失败');
}
if (document.getElementById('rememberKey').checked) {
localStorage.setItem('potluck_user_key', apiKey);
}
isLoggedIn = true;
displayUserInfo(data.data);
document.getElementById('loginContainer').style.display = 'none';
document.getElementById('mainContainer').style.display = 'block';
document.getElementById('navbarUser').style.display = 'flex';
loadCredentials();
showToast('登录成功', 'success');
} catch (error) {
showToast(error.message, 'error');
} finally {
document.getElementById('loginBtn').disabled = false;
}
}
// 退出登录
function logout() {
currentApiKey = '';
isLoggedIn = false;
localStorage.removeItem('potluck_user_key');
document.getElementById('loginContainer').style.display = 'flex';
document.getElementById('mainContainer').style.display = 'none';
document.getElementById('navbarUser').style.display = 'none';
document.getElementById('apiKeyInput').value = '';
showToast('已退出登录', 'success');
}
// 显示用户信息
function displayUserInfo(data) {
document.getElementById('navUserName').textContent = data.name || '用户';
// 每日用量: 已用/限额
document.getElementById('statToday').textContent = data.usage.today;
document.getElementById('statLimit').textContent = data.usage.limit;
// 资源包: 已用/总量
const bonusUsed = data.bonusUsed || 0;
const bonusTotal = data.bonusTotal || 0;
document.getElementById('statBonusUsed').textContent = bonusUsed;
document.getElementById('statBonusTotal').textContent = bonusTotal;
// 累计调用
document.getElementById('statTotal').textContent = data.total || 0;
// 总使用 = 每日已用 + 资源包已用
const totalUsed = data.usage.today + bonusUsed;
document.getElementById('usageToday').textContent = totalUsed;
// 总配额上限 = 每日限额 + 资源包总量
const totalQuota = data.usage.limit + bonusTotal;
document.getElementById('usageLimit').textContent = totalQuota;
// 显示完整 API Key在 API密钥 Tab
document.getElementById('displayApiKeyFull').value = currentApiKey;
}
// 复制到剪贴板(兼容方案)
function copyToClipboardFallback(text, successMsg = '已复制到剪贴板') {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showToast(successMsg, 'success');
} catch (err) {
showToast('复制失败,请手动复制', 'error');
}
document.body.removeChild(textArea);
}
// 复制 API Key
function copyApiKey() {
const text = currentApiKey;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
document.getElementById('copyKeyIcon').textContent = '✓';
document.getElementById('copyKeyText').textContent = '已复制';
showToast('已复制到剪贴板', 'success');
setTimeout(() => {
document.getElementById('copyKeyIcon').textContent = '📋';
document.getElementById('copyKeyText').textContent = '复制';
}, 2000);
}).catch(() => copyToClipboardFallback(text));
} else {
copyToClipboardFallback(text);
}
}
// 打开重置 Key 模态框
function showRegenerateModal() {
// 重置模态框状态
document.getElementById('regenerateConfirmSection').style.display = 'block';
document.getElementById('regenerateResultSection').style.display = 'none';
document.getElementById('regenerateConfirmBtn').style.display = 'inline-flex';
document.getElementById('regenerateCancelBtn').textContent = '取消';
document.getElementById('regenerateKeyModal').classList.add('active');
}
// 确认重置 Key
async function confirmRegenerateKey() {
const confirmBtn = document.getElementById('regenerateConfirmBtn');
confirmBtn.disabled = true;
confirmBtn.textContent = '重置中...';
try {
const response = await fetch(`${API_BASE}/regenerate-key`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentApiKey}` }
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error?.message || data.error || '重置失败');
}
const newKey = data.data.newKey;
// 更新本地存储和当前 Key
currentApiKey = newKey;
if (localStorage.getItem('potluck_user_key')) {
localStorage.setItem('potluck_user_key', newKey);
}
// 显示结果
document.getElementById('regenerateConfirmSection').style.display = 'none';
document.getElementById('regenerateResultSection').style.display = 'block';
document.getElementById('newKeyDisplay').value = newKey;
document.getElementById('regenerateConfirmBtn').style.display = 'none';
document.getElementById('regenerateCancelBtn').textContent = '关闭';
// 更新 API密钥 Tab 的显示
document.getElementById('displayApiKeyFull').value = newKey;
showToast('API Key 已重置', 'success');
} catch (error) {
showToast('重置失败: ' + error.message, 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = '确认重置';
}
}
// 复制新 Key模态框中
function copyNewKey() {
const text = document.getElementById('newKeyDisplay').value;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
const btnText = document.getElementById('copyBtnText');
btnText.textContent = '✓ 已复制';
showToast('已复制到剪贴板', 'success');
setTimeout(() => { btnText.textContent = '📋 复制'; }, 2000);
}).catch(() => copyToClipboardFallback(text));
} else {
copyToClipboardFallback(text);
}
}
// Tab 切换
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabId);
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === `tab${tabId.charAt(0).toUpperCase() + tabId.slice(1)}`);
});
if (tabId === 'credentials') loadCredentials();
}
// 加载凭证列表(自动检查健康状态)
async function loadCredentials(autoCheck = true) {
const listEl = document.getElementById('credentialsList');
listEl.innerHTML = '<div style="text-align: center; color: #8b949e; padding: 40px;">加载中...</div>';
try {
let credentials;
if (autoCheck) {
// 调用批量检查 API同时获取最新状态
const response = await fetch(`${API_BASE}/credentials/check-all`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentApiKey}` }
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error?.message || '加载失败');
credentials = data.data.credentials || [];
} else {
// 仅获取列表,不检查
const response = await fetch(`${API_BASE}/credentials`, {
headers: { 'Authorization': `Bearer ${currentApiKey}` }
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error?.message || '加载失败');
credentials = data.data || [];
}
document.getElementById('credentialCount').textContent = credentials.filter(c => c.isHealthy).length;
if (credentials.length === 0) {
listEl.innerHTML = '<div class="empty-state"><div class="icon">📭</div><p>暂无凭证,请通过上传功能添加</p></div>';
return;
}
// 按添加时间倒序排序
credentials.sort((a, b) => new Date(b.addedAt || 0) - new Date(a.addedAt || 0));
listEl.innerHTML = '';
credentials.forEach(cred => {
listEl.appendChild(createCredentialItem(cred));
});
} catch (error) {
listEl.innerHTML = `<div style="text-align: center; color: #ef4444; padding: 40px;">加载失败: ${error.message}</div>`;
}
}
// 创建凭证项
function createCredentialItem(cred) {
const item = document.createElement('div');
item.className = 'credential-item';
const icon = providerIcons[cred.provider] || '📄';
const name = providerNames[cred.provider] || cred.provider;
const shortPath = cred.path.length > 50 ? '...' + cred.path.slice(-47) : cred.path;
// 格式化添加日期
let addedDateStr = '';
if (cred.addedAt) {
const d = new Date(cred.addedAt);
addedDateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
let statusClass = 'unknown', statusText = '未检查', statusIcon = '❓';
if (cred.isHealthy === true) {
statusClass = 'healthy';
statusText = cred.healthMessage || '正常';
statusIcon = '✓';
} else if (cred.isHealthy === false) {
statusClass = 'unhealthy';
statusText = cred.healthMessage || '异常';
statusIcon = '✗';
}
// 资源包信息
let bonusHtml = '';
if (cred.bonus) {
const usedPercent = cred.bonus.total > 0 ? Math.round((cred.bonus.usedCount / cred.bonus.total) * 100) : 0;
const bonusColor = cred.bonus.remaining > 0 ? '#10b981' : '#8b949e';
bonusHtml = `
<div class="credential-bonus" style="display: flex; align-items: center; gap: 8px; margin-left: 12px;">
<span style="font-size: 12px; color: #8b949e;">资源包:</span>
<span style="font-size: 13px; font-weight: 600; color: ${bonusColor};">${cred.bonus.remaining}/${cred.bonus.total}</span>
</div>
`;
}
item.innerHTML = `
<div class="credential-info">
<div class="credential-icon">${icon}</div>
<div class="credential-details">
<div class="credential-name">${name}${addedDateStr ? `<span style="color: #8b949e; font-weight: 400; font-size: 12px; margin-left: 8px;">${addedDateStr}</span>` : ''}</div>
<div class="credential-path" title="${cred.path}">${shortPath}</div>
</div>
</div>
<div style="display: flex; align-items: center;">
${bonusHtml}
<div class="credential-status ${statusClass}" id="status-${cred.id}">
<span>${statusIcon}</span>
<span>${statusText}</span>
</div>
</div>
`;
return item;
}
// 模态框控制
function showAwsImportModal() {
awsUploadedFiles = [];
awsMergedCredentials = null;
awsCurrentMode = 'file';
document.getElementById('awsFilesInput').value = '';
document.getElementById('awsJsonInput').value = '';
document.getElementById('fileList').style.display = 'none';
document.getElementById('fileList').innerHTML = '';
document.getElementById('validationResult').style.display = 'none';
document.getElementById('jsonPreview').style.display = 'none';
document.getElementById('awsImportBtn').disabled = true;
switchAwsMode('file');
document.getElementById('awsImportModal').classList.add('active');
}
function showBatchImportModal() {
document.getElementById('batchTokensInput').value = '';
document.getElementById('batchProgress').style.display = 'none';
document.getElementById('batchResults').innerHTML = '';
document.getElementById('batchImportModal').classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
// AWS 模式切换
function switchAwsMode(mode) {
awsCurrentMode = mode;
document.getElementById('modeFileBtn').classList.toggle('active', mode === 'file');
document.getElementById('modeJsonBtn').classList.toggle('active', mode === 'json');
document.getElementById('fileModeSection').style.display = mode === 'file' ? 'block' : 'none';
document.getElementById('jsonModeSection').style.display = mode === 'json' ? 'block' : 'none';
if (mode === 'file') validateAwsFiles();
else validateAwsJsonInput();
}
// 初始化上传区域
function initUploadZone() {
const zone = document.getElementById('uploadZone');
const input = document.getElementById('awsFilesInput');
zone.addEventListener('click', () => input.click());
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.style.borderColor = '#a855f7';
zone.style.background = 'rgba(168, 85, 247, 0.05)';
});
zone.addEventListener('dragleave', (e) => {
e.preventDefault();
zone.style.borderColor = '#30363d';
zone.style.background = 'transparent';
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.style.borderColor = '#30363d';
zone.style.background = 'transparent';
const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.json'));
if (files.length > 0) processAwsFiles(files);
});
input.addEventListener('change', () => {
const files = Array.from(input.files);
if (files.length > 0) processAwsFiles(files);
});
}
// 处理上传文件
async function processAwsFiles(files) {
for (const file of files) {
const existingIndex = awsUploadedFiles.findIndex(f => f.name === file.name);
try {
const content = await file.text();
const json = JSON.parse(content);
if (existingIndex >= 0) {
awsUploadedFiles[existingIndex] = { name: file.name, content: json };
} else {
awsUploadedFiles.push({ name: file.name, content: json });
}
} catch (error) {
showToast(`解析失败: ${file.name}`, 'error');
}
}
renderFileList();
document.getElementById('awsFilesInput').value = '';
validateAwsFiles();
}
// 渲染文件列表
function renderFileList() {
const listEl = document.getElementById('fileList');
if (awsUploadedFiles.length === 0) {
listEl.style.display = 'none';
return;
}
listEl.style.display = 'block';
listEl.innerHTML = awsUploadedFiles.map(file => {
const fields = Object.keys(file.content).slice(0, 3).join(', ');
return `
<div class="file-item">
<div class="file-item-info">
<span>📄</span>
<div>
<div class="file-item-name">${file.name}</div>
<div class="file-item-fields">${fields}...</div>
</div>
</div>
<button class="file-item-remove" onclick="removeFile('${file.name}')">✕</button>
</div>
`;
}).join('');
}
function removeFile(filename) {
awsUploadedFiles = awsUploadedFiles.filter(f => f.name !== filename);
renderFileList();
validateAwsFiles();
}
// 验证文件
function validateAwsFiles() {
if (awsUploadedFiles.length === 0) {
hideValidation();
return;
}
awsMergedCredentials = {};
let expiresAtFromRefreshTokenFile = null;
for (const file of awsUploadedFiles) {
if (file.content.refreshToken && file.content.expiresAt) {
expiresAtFromRefreshTokenFile = file.content.expiresAt;
}
Object.assign(awsMergedCredentials, file.content);
}
if (expiresAtFromRefreshTokenFile) {
awsMergedCredentials.expiresAt = expiresAtFromRefreshTokenFile;
}
showValidationResult();
}
// 验证 JSON 输入
function validateAwsJsonInput() {
const inputValue = document.getElementById('awsJsonInput').value.trim();
if (!inputValue) {
hideValidation();
return;
}
try {
awsMergedCredentials = JSON.parse(inputValue);
showValidationResult();
} catch (error) {
document.getElementById('validationResult').style.display = 'block';
document.getElementById('validationResult').className = 'validation-result error';
document.getElementById('validationResult').innerHTML = `<h4>❌ JSON 解析错误</h4><p style="font-size: 12px; color: #8b949e;">${error.message}</p>`;
document.getElementById('jsonPreview').style.display = 'none';
document.getElementById('awsImportBtn').disabled = true;
awsMergedCredentials = null;
}
}
function hideValidation() {
document.getElementById('validationResult').style.display = 'none';
document.getElementById('jsonPreview').style.display = 'none';
document.getElementById('awsImportBtn').disabled = true;
awsMergedCredentials = null;
}
function showValidationResult() {
const fields = [
{ key: 'clientId', has: !!awsMergedCredentials.clientId },
{ key: 'clientSecret', has: !!awsMergedCredentials.clientSecret },
{ key: 'accessToken', has: !!awsMergedCredentials.accessToken },
{ key: 'refreshToken', has: !!awsMergedCredentials.refreshToken }
];
const isValid = fields.every(f => f.has);
const resultEl = document.getElementById('validationResult');
resultEl.style.display = 'block';
resultEl.className = `validation-result ${isValid ? 'success' : 'error'}`;
resultEl.innerHTML = `
<h4>${isValid ? '✅ 验证通过' : '❌ 验证失败'}</h4>
<ul>
${fields.map(f => `<li><span class="${f.has ? 'found' : 'missing'}">${f.has ? '✓' : '✗'}</span> ${f.key}</li>`).join('')}
</ul>
`;
document.getElementById('awsImportBtn').disabled = !isValid;
// 显示预览
const previewEl = document.getElementById('jsonPreview');
const contentEl = document.getElementById('jsonPreviewContent');
previewEl.style.display = 'block';
const previewData = { ...awsMergedCredentials };
if (previewData.clientSecret) previewData.clientSecret = previewData.clientSecret.substring(0, 8) + '...' + previewData.clientSecret.slice(-4);
if (previewData.accessToken) previewData.accessToken = previewData.accessToken.substring(0, 20) + '...' + previewData.accessToken.slice(-10);
if (previewData.refreshToken) previewData.refreshToken = previewData.refreshToken.substring(0, 10) + '...' + previewData.refreshToken.slice(-6);
contentEl.textContent = JSON.stringify(previewData, null, 2);
}
// AWS 导入
async function startAwsImport() {
if (!awsMergedCredentials) return;
const btn = document.getElementById('awsImportBtn');
btn.disabled = true;
btn.textContent = '导入中...';
try {
if (!awsMergedCredentials.authMethod) {
awsMergedCredentials.authMethod = 'builder-id';
}
const response = await fetch(`${API_BASE}/kiro/import-aws-credentials`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ credentials: awsMergedCredentials })
});
const data = await response.json();
if (response.ok && data.success) {
showToast('AWS 凭据导入成功', 'success');
closeModal('awsImportModal');
loadCredentials();
} else if (data.error === 'duplicate') {
showToast('凭据已存在', 'error');
} else {
throw new Error(data.error || '导入失败');
}
} catch (error) {
showToast('导入错误: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '导入';
}
}
// 批量导入
async function startBatchImport() {
const tokens = document.getElementById('batchTokensInput').value.split('\n').filter(t => t.trim());
if (tokens.length === 0) {
showToast('请输入至少一个 refreshToken', 'error');
return;
}
const progressDiv = document.getElementById('batchProgress');
const progressBar = document.getElementById('batchProgressBar');
const progressText = document.getElementById('batchProgressText');
const progressPercent = document.getElementById('batchProgressPercent');
const resultsDiv = document.getElementById('batchResults');
progressDiv.style.display = 'block';
progressBar.style.width = '0%';
resultsDiv.innerHTML = '';
try {
const response = await fetch(`${API_BASE}/kiro/batch-import-tokens`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshTokens: tokens })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
if (data.current) {
const percent = Math.round((data.index / data.total) * 100);
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
progressText.textContent = `正在导入 ${data.index}/${data.total}`;
const color = data.current.success ? '#10b981' : '#ef4444';
const icon = data.current.success ? '✓' : '✗';
const msg = data.current.success ? data.current.path : data.current.error;
resultsDiv.innerHTML += `<div style="color:${color}">${icon} Token ${data.current.index}: ${msg}</div>`;
resultsDiv.scrollTop = resultsDiv.scrollHeight;
}
if (data.successCount !== undefined) {
showToast(`导入完成: 成功 ${data.successCount}, 失败 ${data.failedCount}`, 'success');
loadCredentials();
setTimeout(() => closeModal('batchImportModal'), 2000);
}
} catch (e) {}
}
}
}
} catch (error) {
showToast('导入失败: ' + error.message, 'error');
}
}
// Toast
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
}
</script>
</body>
</html>