Spaces:
Running
Running
| <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')">×</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')">×</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')">×</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> |