Files
kirogo/web/index.html
2026-05-11 22:31:31 +08:00

2628 lines
131 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">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Kiro-Go</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f8fafc;
color: #1e293b;
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
padding: 16px;
}
.login-box {
background: #fff;
padding: 32px 24px;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 380px;
}
.login-box h1 {
text-align: center;
color: #7c3aed;
margin-bottom: 8px;
font-size: 22px;
justify-content: center;
}
.login-box p {
text-align: center;
color: #64748b;
margin-bottom: 24px;
font-size: 14px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 6px;
color: #374151;
font-size: 14px;
font-weight: 500;
}
input[type="text"],
input[type="password"],
select,
textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
background: #fff;
color: #1e293b;
-webkit-appearance: none;
}
input:focus,
textarea:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
textarea {
resize: vertical;
min-height: 100px;
font-family: monospace;
font-size: 14px;
}
.btn {
padding: 12px 18px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
touch-action: manipulation;
}
.btn-primary {
background: #7c3aed;
color: white;
}
.btn-primary:hover,
.btn-primary:active {
background: #6d28d9;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-secondary {
background: #f1f5f9;
color: #374151;
}
.btn-success {
background: #10B981;
color: white;
}
.btn-sm {
padding: 8px 12px;
font-size: 13px;
}
.card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
gap: 12px;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 10px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #7c3aed;
}
.stat-label {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.badge-success {
background: #dcfce7;
color: #16a34a;
}
.badge-warning {
background: #fef3c7;
color: #d97706;
}
.badge-error {
background: #fee2e2;
color: #dc2626;
}
.badge-info {
background: #ede9fe;
color: #7c3aed;
}
.badge-pro {
background: #3b82f6;
color: white;
}
.badge-proplus {
background: #8b5cf6;
color: white;
}
.badge-power {
background: #f59e0b;
color: white;
}
.badge-free {
background: #6b7280;
color: white;
}
.badge-trial {
background: #10b981;
color: white;
}
.badge-banned {
background: #dc2626;
color: white;
}
.badge-suspended {
background: #f59e0b;
color: white;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
padding: 16px;
overflow-y: auto;
}
.modal.active {
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 5vh;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 20px;
width: 100%;
max-width: 600px;
margin-bottom: 20px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: #64748b;
padding: 0 8px;
}
.modal-footer {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #cbd5e1;
border-radius: 24px;
transition: 0.3s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.3s;
}
input:checked+.slider {
background: #7c3aed;
}
input:checked+.slider:before {
transform: translateX(20px);
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
background: #f1f5f9;
padding: 4px;
border-radius: 10px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
color: #64748b;
font-weight: 500;
font-size: 14px;
white-space: nowrap;
flex-shrink: 0;
}
.tab:hover {
color: #374151;
}
.tab.active {
background: #fff;
color: #7c3aed;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.hidden {
display: none !important;
}
.message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.message-error {
background: #fee2e2;
color: #dc2626;
}
.endpoint {
background: #f8fafc;
padding: 12px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
word-break: break-all;
}
.endpoint span {
flex: 1;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.header h1 {
font-size: 18px;
color: #7c3aed;
}
.account-card {
background: #fff;
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-left: 4px solid #7c3aed;
}
.account-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
gap: 10px;
}
.account-info {
flex: 1;
min-width: 0;
}
.account-email {
font-weight: 600;
font-size: 14px;
color: #1e293b;
word-break: break-all;
}
.account-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
.account-usage {
margin: 10px 0;
}
.usage-bar {
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: #7c3aed;
border-radius: 3px;
transition: width 0.3s;
}
.usage-fill.high {
background: #f59e0b;
}
.usage-fill.critical {
background: #ef4444;
}
.usage-text {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #64748b;
margin-top: 4px;
}
.usage-label {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
font-weight: 500;
}
.account-usage+.account-usage {
margin-top: 8px;
}
.badge-trial {
background: #10b981;
color: white;
}
.account-stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
.account-stat {
text-align: center;
}
.account-stat-value {
font-weight: 600;
font-size: 13px;
}
.account-stat-label {
font-size: 10px;
color: #64748b;
}
.account-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section h4 {
font-size: 13px;
color: #64748b;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.detail-item {
background: #f8fafc;
padding: 10px 12px;
border-radius: 8px;
}
.detail-label {
font-size: 11px;
color: #64748b;
margin-bottom: 2px;
}
.detail-value {
font-size: 13px;
font-weight: 500;
word-break: break-all;
}
.model-list {
display: grid;
gap: 8px;
max-height: 250px;
overflow-y: auto;
}
.model-item {
background: #f8fafc;
padding: 14px 16px;
border-radius: 8px;
margin-bottom: 10px;
transition: background 0.2s ease;
}
.model-item:last-child {
margin-bottom: 0;
}
.model-item:hover {
background: #f1f5f9;
}
.model-name {
font-weight: 600;
font-size: 14px;
color: #0f172a;
line-height: 1.4;
}
.model-credit {
font-size: 13px;
margin-top: 4px;
}
.credit-ratio {
color: #7c3aed;
font-weight: 600;
}
.model-info {
font-size: 12px;
color: #64748b;
margin-top: 6px;
line-height: 1.5;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
}
.btn-icon svg {
width: 18px;
height: 18px;
}
.loading {
opacity: 0.6;
pointer-events: none;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
}
.logo svg {
width: 24px;
height: 24px;
color: #7c3aed;
}
.machine-id-row {
display: flex;
gap: 8px;
align-items: stretch;
flex-wrap: wrap;
}
.machine-id-row input {
flex: 1;
min-width: 200px;
font-family: monospace;
font-size: 11px;
}
.machine-id-row .btn {
flex-shrink: 0;
}
.lang-switch {
display: flex;
gap: 4px;
background: #f1f5f9;
padding: 2px;
border-radius: 6px;
}
.lang-btn {
padding: 4px 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
border-radius: 4px;
color: #64748b;
}
.lang-btn.active {
background: #fff;
color: #7c3aed;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
@media (max-width: 640px) {
.container {
padding: 12px;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.stat-card {
padding: 10px 6px;
}
.stat-value {
font-size: 18px;
}
.stat-label {
font-size: 10px;
}
.card {
padding: 14px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
.card-actions {
width: 100%;
}
.card-actions .btn {
flex: 1;
text-align: center;
}
.account-header {
flex-direction: column;
}
.account-actions {
width: 100%;
justify-content: flex-start;
}
.account-actions .btn {
padding: 8px 10px;
font-size: 12px;
}
.account-stats {
grid-template-columns: repeat(2, 1fr);
}
.detail-grid {
grid-template-columns: 1fr;
}
.tabs {
gap: 2px;
}
.tab {
padding: 10px 12px;
font-size: 13px;
}
.modal-content {
padding: 16px;
}
.endpoint {
flex-direction: column;
align-items: stretch;
}
.endpoint .btn {
width: 100%;
}
.machine-id-row {
flex-direction: column;
}
.machine-id-row input {
min-width: 100%;
}
.machine-id-row .btn {
width: 100%;
}
}
@media (min-width: 641px) {
.detail-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 隐私模式开关样式 */
.privacy-toggle {
white-space: nowrap;
}
@media (max-width: 640px) {
.privacy-toggle {
order: -1;
width: 100%;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 8px;
}
.privacy-toggle span {
font-size: 14px;
}
}
</style>
</head>
<body>
<div id="loginPage" class="login-wrapper">
<div class="login-box">
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
<div class="lang-switch">
<button class="lang-btn" data-lang="zh" onclick="setLang('zh')">中文</button>
<button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
</div>
</div>
<h1 class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>Kiro-Go</h1>
<p data-i18n="login.subtitle"></p>
<div class="form-group">
<label data-i18n="login.password"></label>
<input type="password" id="pwdField" data-i18n-placeholder="login.passwordPlaceholder"
autocomplete="off">
</div>
<button type="button" class="btn btn-primary" style="width:100%" onclick="login()"
data-i18n="login.submit"></button>
<div id="loginError" class="message message-error hidden" style="margin-top:12px"></div>
</div>
</div>
<div id="mainPage" class="container hidden">
<div class="header">
<h1 class="logo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>Kiro-Go</h1>
<div class="header-right">
<div class="lang-switch">
<button class="lang-btn" data-lang="zh" onclick="setLang('zh')">中文</button>
<button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
</div>
<span class="badge badge-success" id="statusBadge" data-i18n="status.running"></span>
<span class="badge badge-info" id="versionBadge" style="cursor:pointer" onclick="checkUpdate(true)"></span>
<button class="btn btn-sm btn-danger" onclick="logout()" data-i18n="common.logout" style="padding:4px 12px;font-size:12px"></button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="statAccounts">0</div>
<div class="stat-label" data-i18n="stats.accounts"></div>
</div>
<div class="stat-card">
<div class="stat-value" id="statRequests">0</div>
<div class="stat-label" data-i18n="stats.requests"></div>
</div>
<div class="stat-card">
<div class="stat-value" id="statSuccess">0</div>
<div class="stat-label" data-i18n="stats.success"></div>
</div>
<div class="stat-card">
<div class="stat-value" id="statFailed">0</div>
<div class="stat-label" data-i18n="stats.failed"></div>
</div>
<div class="stat-card">
<div class="stat-value" id="statTokens">0</div>
<div class="stat-label">Tokens</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statCredits">0</div>
<div class="stat-label">Credits</div>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="accounts" data-i18n="tabs.accounts"></div>
<div class="tab" data-tab="settings" data-i18n="tabs.settings"></div>
<div class="tab" data-tab="api" data-i18n="tabs.api"></div>
</div>
<div id="tabAccounts" class="tab-content">
<div class="card">
<div class="card-header">
<span class="card-title" data-i18n="accounts.title"></span>
<div class="card-actions">
<div class="privacy-toggle" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
<span style="font-size:13px;color:#374151;font-weight:500" data-i18n="privacy.label"></span>
<label class="switch" style="margin:0">
<input type="checkbox" id="privacyModeToggle" checked onchange="togglePrivacyMode()">
<span class="slider"></span>
</label>
</div>
<button class="btn btn-secondary btn-sm" onclick="showExportModal()" data-i18n="accounts.export"></button>
<button class="btn btn-primary btn-sm" onclick="showModal('add')" data-i18n="accounts.add"></button>
</div>
</div>
<div id="accountsToolbar" style="display:flex;gap:8px;align-items:center;padding:0 20px 12px">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;user-select:none;flex-shrink:0"><input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll(this.checked)" style="width:15px;height:15px;cursor:pointer"> <span style="font-size:13px;color:#374151" data-i18n="batch.selectAll"></span></label>
<div style="height:16px;width:1px;background:#e2e8f0;flex-shrink:0"></div>
<div id="batchBar" style="display:none;align-items:center;gap:6px;flex-shrink:0">
<span id="batchCount" style="font-size:12px;color:#7c3aed;font-weight:600;white-space:nowrap"></span>
<button class="btn btn-sm btn-primary" onclick="batchAction('enable')" style="padding:3px 10px;font-size:12px" data-i18n="batch.enable"></button>
<button class="btn btn-sm btn-secondary" onclick="batchAction('disable')" style="padding:3px 10px;font-size:12px" data-i18n="batch.disable"></button>
<button class="btn btn-sm btn-secondary" onclick="batchAction('refresh')" style="padding:3px 10px;font-size:12px" data-i18n="batch.refresh"></button>
<div style="height:16px;width:1px;background:#e2e8f0;flex-shrink:0"></div>
</div>
<div style="display:flex;gap:6px;align-items:center;margin-left:auto;flex-shrink:0">
<input type="text" id="filterSearch" oninput="onFilterChange()" style="width:260px;padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;outline:none" data-i18n-placeholder="filter.search">
<select id="filterStatusSelect" onchange="onFilterChange()" style="padding:4px 6px;border:1px solid #e2e8f0;border-radius:6px;font-size:12px;background:#fff;cursor:pointer">
<option value="all" data-i18n="filter.all"></option>
<option value="enabled" data-i18n="filter.enabled"></option>
<option value="disabled" data-i18n="filter.disabled"></option>
<option value="banned" data-i18n="filter.banned"></option>
</select>
</div>
</div>
<div id="accountsList"></div>
</div>
</div>
<div id="tabSettings" class="tab-content hidden">
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.apiSettings"></span></div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:10px">
<label class="switch"><input type="checkbox" id="requireApiKey"><span
class="slider"></span></label>
<span data-i18n="settings.enableApiKey"></span>
</label>
</div>
<div class="form-group"><label>API Key</label><div style="display:flex;gap:8px;align-items:stretch"><input type="text" id="apiKeyInput" style="flex:1"
data-i18n-placeholder="settings.apiKeyPlaceholder"><button class="btn btn-sm btn-secondary" onclick="generateApiKey()" data-i18n="settings.generateApiKey"></button></div></div>
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="common.save"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.thinkingSettings"></span></div>
<div class="form-group">
<label data-i18n="settings.thinkingSuffix"></label>
<input type="text" id="thinkingSuffix" placeholder="-thinking">
<small style="color:#64748b;font-size:12px;margin-top:4px;display:block"
data-i18n="settings.thinkingSuffixHint"></small>
</div>
<div class="form-group">
<label data-i18n="settings.openaiFormat"></label>
<select id="openaiThinkingFormat">
<option value="reasoning_content">reasoning_content (DeepSeek)</option>
<option value="thinking">&lt;thinking&gt; (Claude)</option>
<option value="think">&lt;think&gt; (OpenAI)</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.claudeFormat"></label>
<select id="claudeThinkingFormat">
<option value="thinking">&lt;thinking&gt; (Claude)</option>
<option value="think">&lt;think&gt; (OpenAI)</option>
<option value="reasoning_content" data-i18n="settings.noTag"></option>
</select>
</div>
<button class="btn btn-primary" onclick="saveThinkingConfig()"
data-i18n="settings.saveThinking"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.endpointSettings"></span></div>
<div class="form-group">
<label data-i18n="settings.preferredEndpoint"></label>
<select id="preferredEndpoint">
<option value="auto" data-i18n="settings.endpointAuto"></option>
<option value="codewhisperer">CodeWhisperer</option>
<option value="amazonq">AmazonQ</option>
</select>
<small style="color:#64748b;font-size:12px;margin-top:4px;display:block"
data-i18n="settings.endpointHint"></small>
</div>
<button class="btn btn-primary" onclick="saveEndpointConfig()"
data-i18n="settings.saveEndpoint"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.adminPassword"></span></div>
<div class="form-group"><label data-i18n="settings.newPassword"></label><input type="password"
id="newPassword" data-i18n-placeholder="settings.newPasswordPlaceholder"></div>
<button class="btn btn-primary" onclick="changePassword()" data-i18n="settings.changePassword"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.proxySettings"></span></div>
<div class="form-group">
<label data-i18n="settings.proxyType"></label>
<select id="proxyType" onchange="onProxyTypeChange()">
<option value="none" data-i18n="settings.proxyNone"></option>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
</select>
</div>
<div id="proxyFields" style="display:none">
<div class="form-group">
<label data-i18n="settings.proxyHost"></label>
<div style="display:flex;gap:8px;align-items:stretch">
<input type="text" id="proxyHost" style="flex:1" placeholder="127.0.0.1">
<input type="number" id="proxyPort" style="width:90px" placeholder="1080" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label data-i18n="settings.proxyAuth"></label>
<div style="display:flex;gap:8px">
<input type="text" id="proxyUsername" style="flex:1" data-i18n-placeholder="settings.proxyUsername" autocomplete="off">
<input type="password" id="proxyPassword" style="flex:1" data-i18n-placeholder="settings.proxyPassword" autocomplete="new-password">
</div>
</div>
</div>
<button class="btn btn-primary" onclick="saveProxyConfig()" data-i18n="settings.saveProxy"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.statistics"></span></div>
<button class="btn btn-danger" onclick="resetStats()" data-i18n="settings.resetStats"></button>
</div>
</div>
<div id="tabApi" class="tab-content hidden">
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="api.endpoints"></span></div>
<p style="margin-bottom:10px;font-weight:500;font-size:14px">Claude API</p>
<div class="endpoint"><span id="claudeEndpoint"></span><button class="btn btn-sm btn-secondary"
onclick="copy('claudeEndpoint')" data-i18n="common.copy"></button></div>
<p style="margin:14px 0 10px;font-weight:500;font-size:14px">OpenAI API</p>
<div class="endpoint"><span id="openaiEndpoint"></span><button class="btn btn-sm btn-secondary"
onclick="copy('openaiEndpoint')" data-i18n="common.copy"></button></div>
<p style="margin:14px 0 10px;font-weight:500;font-size:14px" data-i18n="api.modelList"></p>
<div class="endpoint"><span id="modelsEndpoint"></span><button class="btn btn-sm btn-secondary"
onclick="copy('modelsEndpoint')" data-i18n="common.copy"></button></div>
<p style="margin:14px 0 10px;font-weight:500;font-size:14px" data-i18n="api.stats"></p>
<div class="endpoint"><span id="statsEndpoint"></span><button class="btn btn-sm btn-secondary"
onclick="copy('statsEndpoint')" data-i18n="common.copy"></button></div>
<p style="font-size:12px;color:#64748b;margin-top:4px" data-i18n="api.statsHint"></p>
</div>
</div>
</div>
<div id="addModal" class="modal">
<div class="modal-content">
<div class="modal-header"><span class="modal-title" id="modalTitle"></span><button class="modal-close"
onclick="closeModal()">&times;</button></div>
<div id="modalBody"></div>
</div>
</div>
<div id="detailModal" class="modal">
<div class="modal-content">
<div class="modal-header"><span class="modal-title" data-i18n="detail.title"></span><button
class="modal-close" onclick="closeDetailModal()">&times;</button></div>
<div id="detailBody"></div>
</div>
</div>
<div id="exportModal" class="modal">
<div class="modal-content">
<div class="modal-header"><span class="modal-title" data-i18n="export.title"></span><button
class="modal-close" onclick="closeExportModal()">&times;</button></div>
<div id="exportBody"></div>
</div>
</div>
<div id="updateModal" class="modal">
<div class="modal-content">
<div class="modal-header"><span class="modal-title" data-i18n="update.title"></span><button
class="modal-close" onclick="closeUpdateModal()">&times;</button></div>
<div id="updateBody"></div>
</div>
</div>
<script>
const i18n = {
zh: {
'login.subtitle': '请输入管理密码登录',
'login.password': '管理密码',
'login.passwordPlaceholder': '输入密码',
'login.submit': '登录',
'login.error': '密码错误',
'login.connectError': '连接失败',
'status.running': '运行中',
'stats.accounts': '账号',
'stats.requests': '请求',
'stats.success': '成功',
'stats.failed': '失败',
'tabs.accounts': '账号',
'tabs.settings': '设置',
'tabs.api': 'API',
'accounts.title': '账号列表',
'accounts.add': '添加账号',
'accounts.empty': '暂无账号',
'accounts.refresh': '刷新',
'accounts.detail': '详情',
'accounts.enable': '启用',
'accounts.disable': '禁用',
'accounts.delete': '删除',
'accounts.requests': '请求',
'accounts.tokens': 'Tokens',
'accounts.credits': 'Credits',
'accounts.expiry': '到期',
'accounts.noToken': '无Token',
'accounts.expired': '已过期',
'accounts.disabled': '已禁用',
'accounts.normal': '正常',
'accounts.enabled': '已启用',
'accounts.banned': '已封禁',
'accounts.suspended': '已暂停',
'accounts.refreshFailed': '刷新失败',
'accounts.confirmDelete': '确定删除?',
'accounts.mainQuota': '主配额',
'accounts.trialQuota': '试用配额',
'accounts.trial': '试用中',
'accounts.trialExpired': '已过期',
'accounts.trialToday': '今天到期',
'accounts.copyJSON': '复制 JSON',
'accounts.copyJSONSuccess': 'JSON 已复制到剪贴板',
'accounts.trialDays': '天后到期',
'time.expired': '已过期',
'time.minutes': '分钟',
'time.hours': '小时',
'time.days': '天',
'settings.apiSettings': 'API 设置',
'settings.enableApiKey': '启用 API Key 验证',
'settings.apiKeyPlaceholder': '留空则不验证',
'settings.generateApiKey': '随机生成',
'settings.thinkingSettings': 'Thinking 模式设置',
'settings.thinkingSuffix': '触发后缀',
'settings.thinkingSuffixHint': '模型名称加此后缀即启用思考模式,如 claude-sonnet-4.5-thinking',
'settings.openaiFormat': 'OpenAI API 输出格式',
'settings.claudeFormat': 'Claude API 输出格式',
'settings.noTag': '直接输出 (无标签)',
'settings.saveThinking': '保存 Thinking 设置',
'settings.thinkingSaved': 'Thinking 设置已保存',
'settings.endpointSettings': 'Kiro 端点设置',
'settings.preferredEndpoint': '首选端点',
'settings.endpointAuto': '自动选择',
'settings.endpointHint': '选择首选端点,自动选择模式下会根据可用性自动选择端点',
'settings.saveEndpoint': '保存端点设置',
'settings.endpointSaved': '端点设置已保存',
'settings.adminPassword': '管理密码',
'settings.newPassword': '新密码',
'settings.newPasswordPlaceholder': '输入新密码',
'settings.changePassword': '修改密码',
'settings.passwordRequired': '请输入新密码',
'settings.passwordChanged': '密码已修改',
'settings.statistics': '统计',
'settings.resetStats': '重置统计',
'settings.confirmReset': '确定重置统计?',
'settings.proxySettings': '出站代理设置',
'settings.proxyType': '代理类型',
'settings.proxyNone': '直连(不使用代理)',
'settings.proxyHost': '地址 / 端口',
'settings.proxyAuth': '认证(可选)',
'settings.proxyUsername': '用户名',
'settings.proxyPassword': '密码',
'settings.proxyHostRequired': '请填写代理地址和端口',
'settings.saveProxy': '保存代理设置',
'settings.proxySaved': '代理设置已保存',
'api.endpoints': 'API 端点',
'api.modelList': '模型列表',
'api.stats': '统计数据',
'api.statsHint': '需要在请求头中携带 API Key 鉴权Authorization: Bearer sk-xxx未启用 API Key 验证时无需鉴权',
'detail.title': '账号详情',
'detail.basicInfo': '基本信息',
'detail.email': '邮箱',
'detail.userId': '用户ID',
'detail.authMethod': '认证方式',
'detail.region': 'Region',
'detail.machineId': '机器码',
'detail.generate': '生成',
'detail.subscription': '订阅信息',
'detail.subscriptionType': '订阅类型',
'detail.tokenExpiry': 'Token到期',
'detail.usage': '用量',
'detail.mainQuota': '主配额',
'detail.trialQuota': '试用配额',
'detail.trialStatus': '试用状态',
'detail.trialExpiry': '试用到期',
'detail.resetDate': '重置日期',
'detail.statistics': '统计',
'detail.requestCount': '请求数',
'detail.errorCount': '错误数',
'detail.totalTokens': '总Tokens',
'detail.totalCredits': '总Credits',
'detail.models': '可用模型',
'detail.loadModels': '加载',
'detail.loading': '加载中...',
'detail.noModels': '无可用模型',
'detail.loadFailed': '加载失败',
'detail.machineIdError': '机器码格式错误',
'detail.saved': '已保存',
'detail.saveFailed': '保存失败',
'detail.generateFailed': '生成失败',
'modal.addAccount': '添加账号',
'modal.builderIdTitle': 'AWS Builder ID',
'modal.builderIdDesc': '通过 AWS Builder ID 授权登录添加个人账号',
'modal.iamTitle': 'IAM Identity Center (企业 SSO) 登录',
'modal.iamDesc': '通过 IAM Identity Center (企业 SSO) 授权添加企业账号',
'modal.ssoTitle': 'SSO Token',
'modal.ssoDesc': '通过浏览器 x-amz-sso_authn Token 添加账号',
'modal.localTitle': 'Kiro 本地缓存',
'modal.localDesc': '通过 Kiro IDE 本地缓存文件添加账号',
'modal.credentialsTitle': '凭证 JSON',
'modal.credentialsDesc': '通过 Kiro Account Manager 导出的凭证添加账号',
'builderid.startLogin': '开始登录',
'builderid.verifyCode': '请在浏览器中输入上方验证码',
'builderid.verifyUrl': '验证链接',
'builderid.open': '打开',
'builderid.waiting': '等待授权中...',
'builderid.success': '登录成功',
'iam.startUrl': 'Start URL',
'iam.loginUrl': '登录链接',
'iam.completeLogin': '请在浏览器中完成登录,然后粘贴回调 URL',
'iam.callbackUrl': '回调 URL',
'iam.complete': '完成登录',
'sso.howToGet': '如何获取 Token?',
'sso.step1': '在浏览器中访问并登录',
'sso.step2': '按 F12 打开开发者工具 → Application → Cookies',
'sso.step3': '找到并复制',
'sso.tokenLabel': 'x-amz-sso_authn',
'sso.tokenHint': '*支持批量导入,每行一个 Token',
'sso.tokenPlaceholder': '粘贴 x-amz-sso_authn 值',
'sso.importSuccess': '成功添加 {0} 个账号',
'sso.importPartial': '{0} 个失败',
'local.fileLocation': '文件位置',
'local.loginChannel': '登录渠道',
'local.tokenFile': 'kiro-auth-token.json',
'local.tokenRequired': '*必填',
'local.clientFile': '{hash}.json',
'local.clientRequired': '*IdC 登录必填',
'local.pasteOrUpload': '粘贴文件内容或上传文件',
'local.upload': '上传',
'local.tokenMissing': '请提供 kiro-auth-token.json 内容',
'local.tokenInvalid': 'kiro-auth-token.json 格式错误',
'local.refreshTokenMissing': '缺少 refreshToken',
'local.clientMissing': 'IdC 登录需要提供 {hash}.json 内容',
'local.clientInvalid': '{hash}.json 格式错误',
'local.clientSecretMissing': '缺少 clientId 或 clientSecret',
'local.importSuccess': '导入成功',
'credentials.label': '凭证 JSON',
'credentials.authMethod': '认证方式',
'credentials.authHint': '*影响 Token 刷新方式',
'credentials.social': 'Social (AWS Builder ID / Google / GitHub)',
'credentials.idc': 'IAM Identity Center (企业 SSO)',
'credentials.jsonError': 'JSON 格式错误',
'credentials.batchHint': '支持单个对象、JSON 数组或 Kiro Account Manager 导出格式批量导入。必填: refreshToken。可选: provider (BuilderId/Enterprise/Github/Google), clientId, clientSecret',
'common.save': '保存设置',
'common.saved': '保存',
'common.copy': '复制',
'common.copied': '已复制',
'common.cancel': '取消',
'common.back': '返回',
'common.add': '添加',
'common.failed': '失败',
'common.saveFailed': '保存失败',
'common.logout': '退出登录',
'accounts.export': '导出',
'export.title': '导出账号',
'export.selectAll': '全选',
'export.deselectAll': '取消全选',
'export.selected': '已选 {0} 个',
'export.downloadJson': '下载 JSON',
'export.showJson': '显示 JSON',
'export.copyJson': '复制 JSON',
'export.copied': '已复制到剪贴板',
'export.noSelection': '请至少选择一个账号',
'update.title': '版本更新',
'update.current': '当前版本',
'update.latest': '最新版本',
'update.newVersion': '发现新版本',
'update.upToDate': '已是最新版本',
'update.checkFailed': '检查更新失败',
'update.goDownload': '前往下载',
'update.changelog': '更新内容',
'privacy.label': '隐私模式',
'privacy.tooltip': '开启后邮箱将脱敏显示',
'batch.enable': '批量启用',
'batch.disable': '批量禁用',
'batch.refresh': '批量刷新',
'batch.selected': '已选 {0} 个',
'batch.selectAll': '全选',
'batch.confirmEnable': '确定批量启用 {0} 个账号?',
'batch.confirmDisable': '确定批量禁用 {0} 个账号?',
'batch.confirmRefresh': '确定批量刷新 {0} 个账号?',
'batch.refreshResult': '刷新完成:成功 {0},失败 {1}',
'batch.done': '操作完成',
'filter.search': '搜索邮箱/昵称...',
'filter.all': '全部',
'filter.enabled': '已启用',
'filter.disabled': '已禁用',
'filter.banned': '已封禁',
'accounts.weight': '权重',
'detail.weight': '请求权重',
'detail.weightHint': '0-1=普通, 2+=高优先级'
},
en: {
'login.subtitle': 'Enter admin password to login',
'login.password': 'Admin Password',
'login.passwordPlaceholder': 'Enter password',
'login.submit': 'Login',
'login.error': 'Wrong password',
'login.connectError': 'Connection failed',
'status.running': 'Running',
'stats.accounts': 'Accounts',
'stats.requests': 'Requests',
'stats.success': 'Success',
'stats.failed': 'Failed',
'tabs.accounts': 'Accounts',
'tabs.settings': 'Settings',
'tabs.api': 'API',
'accounts.title': 'Account List',
'accounts.add': 'Add Account',
'accounts.empty': 'No accounts',
'accounts.refresh': 'Refresh',
'accounts.detail': 'Details',
'accounts.enable': 'Enable',
'accounts.disable': 'Disable',
'accounts.delete': 'Delete',
'accounts.requests': 'Requests',
'accounts.tokens': 'Tokens',
'accounts.credits': 'Credits',
'accounts.expiry': 'Expiry',
'accounts.noToken': 'No Token',
'accounts.expired': 'Expired',
'accounts.disabled': 'Disabled',
'accounts.normal': 'Active',
'accounts.enabled': 'Enabled',
'accounts.banned': 'Banned',
'accounts.suspended': 'Suspended',
'accounts.refreshFailed': 'Refresh failed',
'accounts.confirmDelete': 'Confirm delete?',
'accounts.copyJSON': 'Copy JSON',
'accounts.copyJSONSuccess': 'JSON copied to clipboard',
'time.expired': 'Expired',
'time.minutes': 'min',
'time.hours': 'hr',
'time.days': 'd',
'settings.apiSettings': 'API Settings',
'settings.enableApiKey': 'Enable API Key Verification',
'settings.apiKeyPlaceholder': 'Leave empty to disable',
'settings.generateApiKey': 'Generate',
'settings.thinkingSettings': 'Thinking Mode Settings',
'settings.thinkingSuffix': 'Trigger Suffix',
'settings.thinkingSuffixHint': 'Add this suffix to model name to enable thinking mode, e.g. claude-sonnet-4.5-thinking',
'settings.openaiFormat': 'OpenAI API Output Format',
'settings.claudeFormat': 'Claude API Output Format',
'settings.noTag': 'Direct output (no tag)',
'settings.saveThinking': 'Save Thinking Settings',
'settings.thinkingSaved': 'Thinking settings saved',
'settings.endpointSettings': 'Kiro Endpoint Settings',
'settings.preferredEndpoint': 'Preferred Endpoint',
'settings.endpointAuto': 'Auto',
'settings.endpointHint': 'Select preferred endpoint. In auto-select mode, the endpoint is automatically selected based on availability.',
'settings.saveEndpoint': 'Save Endpoint Settings',
'settings.endpointSaved': 'Endpoint settings saved',
'settings.adminPassword': 'Admin Password',
'settings.newPassword': 'New Password',
'settings.newPasswordPlaceholder': 'Enter new password',
'settings.changePassword': 'Change Password',
'settings.passwordRequired': 'Please enter new password',
'settings.passwordChanged': 'Password changed',
'settings.statistics': 'Statistics',
'settings.resetStats': 'Reset Statistics',
'settings.confirmReset': 'Confirm reset statistics?',
'settings.proxySettings': 'Outbound Proxy Settings',
'settings.proxyType': 'Proxy Type',
'settings.proxyNone': 'Direct (no proxy)',
'settings.proxyHost': 'Host / Port',
'settings.proxyAuth': 'Authentication (optional)',
'settings.proxyUsername': 'Username',
'settings.proxyPassword': 'Password',
'settings.proxyHostRequired': 'Please enter proxy host and port',
'settings.saveProxy': 'Save Proxy Settings',
'settings.proxySaved': 'Proxy settings saved',
'api.endpoints': 'API Endpoints',
'api.modelList': 'Model List',
'api.stats': 'Statistics',
'api.statsHint': 'Requires API Key in header (Authorization: Bearer sk-xxx). No auth needed if API Key verification is disabled.',
'detail.title': 'Account Details',
'detail.basicInfo': 'Basic Info',
'detail.email': 'Email',
'detail.userId': 'User ID',
'detail.authMethod': 'Auth Method',
'detail.region': 'Region',
'detail.machineId': 'Machine ID',
'detail.generate': 'Generate',
'detail.subscription': 'Subscription',
'detail.subscriptionType': 'Type',
'detail.tokenExpiry': 'Token Expiry',
'detail.usage': 'Usage',
'detail.resetDate': 'Reset Date',
'detail.statistics': 'Statistics',
'detail.requestCount': 'Requests',
'detail.errorCount': 'Errors',
'detail.totalTokens': 'Total Tokens',
'detail.totalCredits': 'Total Credits',
'detail.models': 'Available Models',
'detail.loadModels': 'Load',
'detail.loading': 'Loading...',
'detail.noModels': 'No models available',
'detail.loadFailed': 'Load failed',
'detail.machineIdError': 'Invalid machine ID format',
'detail.saved': 'Saved',
'detail.saveFailed': 'Save failed',
'detail.generateFailed': 'Generate failed',
'modal.addAccount': 'Add Account',
'modal.builderIdTitle': 'AWS Builder ID',
'modal.builderIdDesc': 'Add personal account via AWS Builder ID authorization',
'modal.iamTitle': 'IAM Identity Center (Enterprise SSO)',
'modal.iamDesc': 'Add enterprise account via IAM Identity Center SSO',
'modal.ssoTitle': 'SSO Token',
'modal.ssoDesc': 'Add account via browser x-amz-sso_authn token',
'modal.localTitle': 'Kiro Local Cache',
'modal.localDesc': 'Add account via Kiro IDE local cache files',
'modal.credentialsTitle': 'Credentials JSON',
'modal.credentialsDesc': 'Add account via Kiro Account Manager exported credentials',
'builderid.startLogin': 'Start Login',
'builderid.verifyCode': 'Enter the code above in your browser',
'builderid.verifyUrl': 'Verification URL',
'builderid.open': 'Open',
'builderid.waiting': 'Waiting for authorization...',
'builderid.success': 'Login successful',
'iam.startUrl': 'Start URL',
'iam.loginUrl': 'Login URL',
'iam.completeLogin': 'Complete login in browser, then paste callback URL',
'iam.callbackUrl': 'Callback URL',
'iam.complete': 'Complete Login',
'sso.howToGet': 'How to get Token?',
'sso.step1': 'Visit and login to',
'sso.step2': 'Press F12 to open DevTools → Application → Cookies',
'sso.step3': 'Find and copy',
'sso.tokenLabel': 'x-amz-sso_authn',
'sso.tokenHint': '*Supports batch import, one token per line',
'sso.tokenPlaceholder': 'Paste x-amz-sso_authn value',
'sso.importSuccess': 'Successfully added {0} accounts',
'sso.importPartial': ', {0} failed',
'local.fileLocation': 'File Location',
'local.loginChannel': 'Login Channel',
'local.tokenFile': 'kiro-auth-token.json',
'local.tokenRequired': '*Required',
'local.clientFile': '{hash}.json',
'local.clientRequired': '*Required for IdC login',
'local.pasteOrUpload': 'Paste content or upload file',
'local.upload': 'Upload',
'local.tokenMissing': 'Please provide kiro-auth-token.json content',
'local.tokenInvalid': 'Invalid kiro-auth-token.json format',
'local.refreshTokenMissing': 'Missing refreshToken',
'local.clientMissing': 'IdC login requires {hash}.json content',
'local.clientInvalid': 'Invalid {hash}.json format',
'local.clientSecretMissing': 'Missing clientId or clientSecret',
'local.importSuccess': 'Import successful',
'credentials.label': 'Credentials JSON',
'credentials.authMethod': 'Auth Method',
'credentials.authHint': '*Affects token refresh method',
'credentials.social': 'Social (AWS Builder ID / Google / GitHub)',
'credentials.idc': 'IAM Identity Center (Enterprise SSO)',
'credentials.jsonError': 'Invalid JSON format',
'credentials.batchHint': 'Supports single object, JSON array, or Kiro Account Manager export format. Required: refreshToken. Optional: provider (BuilderId/Enterprise/Github/Google), clientId, clientSecret',
'common.save': 'Save Settings',
'common.saved': 'Saved',
'common.copy': 'Copy',
'common.copied': 'Copied',
'common.cancel': 'Cancel',
'common.back': 'Back',
'common.add': 'Add',
'common.failed': 'Failed',
'common.saveFailed': 'Save failed',
'common.logout': 'Logout',
'accounts.export': 'Export',
'export.title': 'Export Accounts',
'export.selectAll': 'Select All',
'export.deselectAll': 'Deselect All',
'export.selected': '{0} selected',
'export.downloadJson': 'Download JSON',
'export.showJson': 'Show JSON',
'export.copyJson': 'Copy JSON',
'export.copied': 'Copied to clipboard',
'export.noSelection': 'Please select at least one account',
'update.title': 'Version Update',
'update.current': 'Current',
'update.latest': 'Latest',
'update.newVersion': 'New version available',
'update.upToDate': 'Up to date',
'update.checkFailed': 'Update check failed',
'update.goDownload': 'Download',
'update.changelog': 'Changelog',
'privacy.label': 'Privacy Mode',
'privacy.tooltip': 'Mask email addresses when enabled',
'batch.enable': 'Batch Enable',
'batch.disable': 'Batch Disable',
'batch.refresh': 'Batch Refresh',
'batch.selected': '{0} selected',
'batch.selectAll': 'Select All',
'batch.confirmEnable': 'Enable {0} accounts?',
'batch.confirmDisable': 'Disable {0} accounts?',
'batch.confirmRefresh': 'Refresh {0} accounts?',
'batch.refreshResult': 'Refresh done: {0} success, {1} failed',
'batch.done': 'Done',
'filter.search': 'Search email/nickname...',
'filter.all': 'All',
'filter.enabled': 'Enabled',
'filter.disabled': 'Disabled',
'filter.banned': 'Banned',
'accounts.weight': 'Weight',
'detail.weight': 'Request Weight',
'detail.weightHint': '0-1=normal, 2+=higher priority'
}
};
let currentLang = localStorage.getItem('kiro_lang') || 'zh';
function t(key, ...args) {
let text = i18n[currentLang][key] || i18n['zh'][key] || key;
args.forEach((arg, idx) => { text = text.replace('{' + idx + '}', arg); });
return text;
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('kiro_lang', lang);
document.documentElement.lang = lang;
updateLangButtons();
applyTranslations();
renderAccounts();
}
function updateLangButtons() {
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.lang === currentLang);
});
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.dataset.i18nPlaceholder);
});
}
let password = localStorage.getItem('admin_password') || '';
const baseUrl = location.origin;
let accountsData = [];
let selectedAccounts = new Set();
let filterKeyword = '';
let filterStatus = 'all';
// 隐私模式状态管理
let privacyModeEnabled = true;
// 初始化隐私模式
function initPrivacyMode() {
try {
const saved = localStorage.getItem('privacyMode');
privacyModeEnabled = saved === null ? true : saved === 'true';
const toggle = document.getElementById('privacyModeToggle');
if (toggle) toggle.checked = privacyModeEnabled;
} catch (e) {
console.warn('localStorage not available:', e);
}
}
// 切换隐私模式
function togglePrivacyMode() {
const toggle = document.getElementById('privacyModeToggle');
privacyModeEnabled = toggle.checked;
try {
localStorage.setItem('privacyMode', privacyModeEnabled);
} catch (e) {
console.warn('localStorage not available:', e);
}
renderAccounts();
}
// 邮箱脱敏函数
function maskEmail(email) {
if (!privacyModeEnabled || !email || email.indexOf('@') === -1) {
return email;
}
const [localPart, domain] = email.split('@');
// 本地部分脱敏:保留前 2 个字符
const maskedLocal = localPart.length <= 2
? localPart
: localPart.substring(0, 2) + '***';
// 域名部分脱敏
const domainParts = domain.split('.');
if (domainParts.length >= 2) {
const tld = domainParts[domainParts.length - 1]; // 顶级域名
const sld = domainParts[domainParts.length - 2]; // 二级域名
const maskedSld = sld.length <= 2
? sld
: sld.substring(0, 2) + '***';
// 子域名脱敏
const subdomains = domainParts.slice(0, -2).map(sub =>
sub.length <= 2 ? sub : sub.substring(0, 2) + '***'
);
return maskedLocal + '@' + [...subdomains, maskedSld, tld].join('.');
}
return maskedLocal + '@' + domain;
}
// 统一获取显示用邮箱
function getDisplayEmail(email, accountId) {
const raw = email || (accountId ? accountId.substring(0, 12) + '...' : '-');
return maskEmail(raw);
}
document.addEventListener('DOMContentLoaded', function () {
updateLangButtons();
applyTranslations();
initPrivacyMode();
if (password) tryAutoLogin();
document.getElementById('pwdField').addEventListener('keypress', e => { if (e.key === 'Enter') login(); });
document.querySelectorAll('.tab').forEach(tab => { tab.onclick = () => switchTab(tab.dataset.tab); });
});
async function tryAutoLogin() {
// 72h 过期检查
const loginTime = parseInt(localStorage.getItem('admin_login_time') || '0');
if (loginTime && Date.now() - loginTime > 72 * 3600 * 1000) {
localStorage.removeItem('admin_password');
localStorage.removeItem('admin_login_time');
password = '';
return;
}
try {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
if (res.ok) { showMain(); loadData(); }
} catch (e) { }
}
async function login() {
password = document.getElementById('pwdField').value;
try {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
if (res.ok) {
localStorage.setItem('admin_password', password);
localStorage.setItem('admin_login_time', Date.now().toString());
showMain(); loadData();
} else {
document.getElementById('loginError').textContent = t('login.error');
document.getElementById('loginError').classList.remove('hidden');
}
} catch (e) {
document.getElementById('loginError').textContent = t('login.connectError');
document.getElementById('loginError').classList.remove('hidden');
}
}
function logout() {
localStorage.removeItem('admin_password');
localStorage.removeItem('admin_login_time');
location.reload();
}
function showMain() {
document.getElementById('loginPage').classList.add('hidden');
document.getElementById('mainPage').classList.remove('hidden');
}
async function loadData() {
await Promise.all([loadStats(), loadAccounts(), loadSettings(), loadVersion()]);
document.getElementById('claudeEndpoint').textContent = baseUrl + '/v1/messages';
document.getElementById('openaiEndpoint').textContent = baseUrl + '/v1/chat/completions';
document.getElementById('modelsEndpoint').textContent = baseUrl + '/v1/models';
document.getElementById('statsEndpoint').textContent = baseUrl + '/v1/stats';
// 自动检查更新
setTimeout(() => checkUpdate(false), 2000);
}
async function loadStats() {
const res = await fetch('/admin/api/status', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
document.getElementById('statAccounts').textContent = d.accounts || 0;
document.getElementById('statRequests').textContent = d.totalRequests || 0;
document.getElementById('statSuccess').textContent = d.successRequests || 0;
document.getElementById('statFailed').textContent = d.failedRequests || 0;
document.getElementById('statTokens').textContent = formatNum(d.totalTokens || 0);
document.getElementById('statCredits').textContent = (d.totalCredits || 0).toFixed(1);
}
async function loadAccounts() {
const res = await fetch('/admin/api/accounts', { headers: { 'X-Admin-Password': password } });
accountsData = await res.json();
renderAccounts();
}
function getFilteredAccounts() {
return accountsData.filter(a => {
if (filterStatus === 'enabled' && !a.enabled) return false;
if (filterStatus === 'disabled' && (a.enabled || (a.banStatus && a.banStatus !== 'ACTIVE'))) return false;
if (filterStatus === 'banned' && (!a.banStatus || a.banStatus === 'ACTIVE')) return false;
if (filterKeyword) {
const kw = filterKeyword.toLowerCase();
const email = (a.email || '').toLowerCase();
if (!email.includes(kw)) return false;
}
return true;
});
}
function onFilterChange() {
filterKeyword = document.getElementById('filterSearch').value;
filterStatus = document.getElementById('filterStatusSelect').value;
renderAccounts();
}
function toggleSelectAll(checked) {
const filtered = getFilteredAccounts();
if (checked) {
filtered.forEach(a => selectedAccounts.add(a.id));
} else {
selectedAccounts.clear();
}
renderAccounts();
updateBatchBar();
}
function toggleSelectAccount(id) {
if (selectedAccounts.has(id)) {
selectedAccounts.delete(id);
} else {
selectedAccounts.add(id);
}
updateBatchBar();
const cb = document.getElementById('selectAllCheckbox');
if (cb) {
const filtered = getFilteredAccounts();
cb.checked = filtered.length > 0 && filtered.every(a => selectedAccounts.has(a.id));
}
}
function updateBatchBar() {
const bar = document.getElementById('batchBar');
const count = selectedAccounts.size;
if (count > 0) {
bar.style.display = 'flex';
document.getElementById('batchCount').textContent = t('batch.selected', count);
} else {
bar.style.display = 'none';
}
}
async function batchAction(action) {
const ids = Array.from(selectedAccounts);
if (ids.length === 0) return;
const confirmKey = 'batch.confirm' + action.charAt(0).toUpperCase() + action.slice(1);
if (!confirm(t(confirmKey, ids.length))) return;
try {
const res = await fetch('/admin/api/accounts/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ ids, action })
});
const d = await res.json();
if (action === 'refresh' && d.success) {
alert(t('batch.refreshResult', d.refreshed, d.failed));
}
selectedAccounts.clear();
updateBatchBar();
loadAccounts();
loadStats();
} catch (e) {
alert(t('common.failed'));
}
}
function renderAccounts() {
const container = document.getElementById('accountsList');
if (!container) return;
const filtered = getFilteredAccounts();
if (filtered.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#64748b;padding:30px">' + t('accounts.empty') + '</p>';
return;
}
container.innerHTML = filtered.map(a => {
const usagePercent = (a.usagePercent || 0) * 100;
const usageClass = usagePercent > 90 ? 'critical' : usagePercent > 70 ? 'high' : '';
const trialUsagePercent = (a.trialUsagePercent || 0) * 100;
const trialUsageClass = trialUsagePercent > 90 ? 'critical' : trialUsagePercent > 70 ? 'high' : '';
const isSelected = selectedAccounts.has(a.id);
const weightVal = a.weight || 0;
const weightBadge = weightVal >= 2 ? '<span class="badge" style="background:#f59e0b;color:#fff">W:' + weightVal + '</span>' : '';
return '<div class="account-card" style="' + (isSelected ? 'border-color:#7c3aed;background:#faf5ff' : '') + '">' +
'<div class="account-header">' +
'<div class="account-info" style="display:flex;align-items:center;gap:8px">' +
'<input type="checkbox" ' + (isSelected ? 'checked' : '') + ' onchange="toggleSelectAccount(\'' + a.id + '\')" style="cursor:pointer;width:16px;height:16px;flex-shrink:0">' +
'<div>' +
'<div class="account-email">' + getDisplayEmail(a.email, a.id) + '</div>' +
'<div class="account-meta">' +
getSubBadge(a.subscriptionType) +
getTrialBadge(a) +
weightBadge +
'<span class="badge badge-info">' + formatAuthMethod(a.provider || a.authMethod) + '</span>' +
getStatusBadge(a) +
'</div>' +
'</div>' +
'</div>' +
'<div class="account-actions">' +
'<button class="btn btn-sm btn-icon btn-secondary" onclick="refreshAccount(\'' + a.id + '\')" title="' + t('accounts.refresh') + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg></button>' +
'<button class="btn btn-sm btn-icon btn-secondary" onclick="showDetail(\'' + a.id + '\')" title="' + t('accounts.detail') + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>' +
'<button class="btn btn-sm btn-icon btn-secondary" onclick="copyAccountJSON(\'' + a.id + '\', this)" title="' + t('accounts.copyJSON') + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>' +
(a.banStatus && a.banStatus !== 'ACTIVE' ? '' :
'<button class="btn btn-sm ' + (a.enabled ? 'btn-secondary' : 'btn-primary') + '" onclick="toggleAccount(\'' + a.id + '\',' + !a.enabled + ')">' + (a.enabled ? t('accounts.disable') : t('accounts.enable')) + '</button>') +
'<button class="btn btn-sm btn-danger" onclick="deleteAccount(\'' + a.id + '\')">' + t('accounts.delete') + '</button>' +
'</div>' +
'</div>' +
(a.usageLimit > 0 ? '<div class="account-usage"><div class="usage-label">' + t('accounts.mainQuota') + '</div><div class="usage-bar"><div class="usage-fill ' + usageClass + '" style="width:' + usagePercent + '%"></div></div><div class="usage-text"><span>' + (a.usageCurrent?.toFixed(1) || 0) + ' / ' + (a.usageLimit?.toFixed(0) || 0) + '</span><span>' + usagePercent.toFixed(1) + '%</span></div></div>' : '') +
(a.trialUsageLimit > 0 ? '<div class="account-usage"><div class="usage-label">' + t('accounts.trialQuota') + ' ' + formatTrialExpiry(a.trialExpiresAt) + '</div><div class="usage-bar"><div class="usage-fill ' + trialUsageClass + '" style="width:' + trialUsagePercent + '%"></div></div><div class="usage-text"><span>' + (a.trialUsageCurrent?.toFixed(1) || 0) + ' / ' + (a.trialUsageLimit?.toFixed(0) || 0) + '</span><span>' + trialUsagePercent.toFixed(1) + '%</span></div></div>' : '') +
'<div class="account-stats">' +
'<div class="account-stat"><div class="account-stat-value">' + (a.requestCount || 0) + '</div><div class="account-stat-label">' + t('accounts.requests') + '</div></div>' +
'<div class="account-stat"><div class="account-stat-value">' + formatNum(a.totalTokens || 0) + '</div><div class="account-stat-label">' + t('accounts.tokens') + '</div></div>' +
'<div class="account-stat"><div class="account-stat-value">' + (a.totalCredits || 0).toFixed(1) + '</div><div class="account-stat-label">' + t('accounts.credits') + '</div></div>' +
'<div class="account-stat"><div class="account-stat-value">' + formatTokenExpiry(a.expiresAt) + '</div><div class="account-stat-label">' + t('accounts.expiry') + '</div></div>' +
'<div class="account-stat"><div class="account-stat-value"><select onchange="quickSetWeight(\'' + a.id + '\',this.value)" style="width:52px;padding:1px 2px;border:1px solid #e2e8f0;border-radius:4px;font-size:12px;text-align:center;cursor:pointer;background:#fff">' +
[0,1,2,3,4,5].map(w => '<option value="' + w + '"' + (weightVal === w ? ' selected' : '') + '>' + w + '</option>').join('') +
'</select></div><div class="account-stat-label">' + t('accounts.weight') + '</div></div>' +
'</div>' +
'</div>';
}).join('');
}
function getSubBadge(type) {
const subType = (type || '').toUpperCase();
if (subType.includes('POWER')) return '<span class="badge badge-power">POWER</span>';
if (subType.includes('PRO_PLUS') || subType.includes('PROPLUS')) return '<span class="badge badge-proplus">PRO+</span>';
if (subType.includes('PRO')) return '<span class="badge badge-pro">PRO</span>';
return '<span class="badge badge-free">FREE</span>';
}
function getTrialBadge(account) {
if (account.trialStatus === 'ACTIVE' && account.trialUsageLimit > 0) {
return '<span class="badge badge-trial">' + t('accounts.trial') + '</span>';
}
return '';
}
function formatTrialExpiry(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffDays = Math.ceil((date - now) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return '(' + t('accounts.trialExpired') + ')';
if (diffDays === 0) return '(' + t('accounts.trialToday') + ')';
if (diffDays <= 7) return '(' + diffDays + t('accounts.trialDays') + ')';
return '';
}
function formatAuthMethod(method) {
if (!method) return '-';
if (method === 'idc') return 'Enterprise';
if (method === 'social') return 'Social';
return method;
}
function getStatusBadge(a) {
let badges = [];
// 检查是否为封禁状态
const isBanned = a.banStatus && a.banStatus !== 'ACTIVE';
if (isBanned) {
// 封禁账号:显示"封禁 + 禁用"
if (a.banStatus === 'BANNED') {
badges.push('<span class="badge badge-banned">' + t('accounts.banned') + '</span>');
} else if (a.banStatus === 'SUSPENDED') {
badges.push('<span class="badge badge-suspended">' + t('accounts.suspended') + '</span>');
}
// 封禁账号必定显示禁用状态
badges.push('<span class="badge badge-warning">' + t('accounts.disabled') + '</span>');
} else {
// 正常账号:显示"正常 + 启用/禁用"
// 检查Token状态
if (!a.hasToken) {
badges.push('<span class="badge badge-error">' + t('accounts.noToken') + '</span>');
} else if (a.expiresAt && a.expiresAt < Date.now() / 1000) {
badges.push('<span class="badge badge-warning">' + t('accounts.expired') + '</span>');
} else {
// 有效Token的正常账号显示"正常"
badges.push('<span class="badge badge-success">' + t('accounts.normal') + '</span>');
}
// 显示启用/禁用状态
if (a.enabled) {
badges.push('<span class="badge badge-info">' + t('accounts.enabled') + '</span>');
} else {
badges.push('<span class="badge badge-warning">' + t('accounts.disabled') + '</span>');
}
}
return badges.join('');
}
function formatTokenExpiry(ts) {
if (!ts) return '-';
const diff = ts - Date.now() / 1000;
if (diff <= 0) return t('time.expired');
if (diff < 3600) return Math.floor(diff / 60) + t('time.minutes');
if (diff < 86400) return Math.floor(diff / 3600) + t('time.hours');
return Math.floor(diff / 86400) + t('time.days');
}
async function refreshAccount(id) {
const card = event.target.closest('.account-card');
if (card) card.classList.add('loading');
try {
const res = await fetch('/admin/api/accounts/' + id + '/refresh', { method: 'POST', headers: { 'X-Admin-Password': password } });
const d = await res.json();
if (d.success) { loadAccounts(); } else { alert(t('accounts.refreshFailed') + ': ' + d.error); }
} catch (e) { alert(t('accounts.refreshFailed')); }
if (card) card.classList.remove('loading');
}
async function showDetail(id) {
const a = accountsData.find(x => x.id === id);
if (!a) return;
document.getElementById('detailBody').innerHTML =
'<div class="detail-section"><h4>' + t('detail.basicInfo') + '</h4><div class="detail-grid">' +
'<div class="detail-item"><div class="detail-label">' + t('detail.email') + '</div><div class="detail-value">' + getDisplayEmail(a.email, null) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.userId') + '</div><div class="detail-value">' + (a.userId || '-') + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.authMethod') + '</div><div class="detail-value">' + formatAuthMethod(a.provider || a.authMethod) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.region') + '</div><div class="detail-value">' + (a.region || 'us-east-1') + '</div></div>' +
'</div></div>' +
'<div class="detail-section"><h4>' + t('detail.machineId') + '</h4><div class="machine-id-row">' +
'<input type="text" id="machineIdInput" value="' + (a.machineId || '') + '" placeholder="UUID">' +
'<button class="btn btn-sm btn-secondary" onclick="generateMachineId()">' + t('detail.generate') + '</button>' +
'<button class="btn btn-sm btn-primary" onclick="saveMachineId(\'' + id + '\')">' + t('common.saved').split(' ')[0] + '</button>' +
'</div></div>' +
'<div class="detail-section"><h4>' + t('detail.weight') + '</h4><div class="machine-id-row">' +
'<input type="number" id="weightInput" value="' + (a.weight || 0) + '" min="0" max="10" style="width:80px">' +
'<span style="color:#64748b;font-size:12px;flex:1">' + t('detail.weightHint') + '</span>' +
'<button class="btn btn-sm btn-primary" onclick="saveWeight(\'' + id + '\')">' + t('common.saved').split(' ')[0] + '</button>' +
'</div></div>' +
'<div class="detail-section"><h4>' + t('detail.subscription') + '</h4><div class="detail-grid">' +
'<div class="detail-item"><div class="detail-label">' + t('detail.subscriptionType') + '</div><div class="detail-value">' + (a.subscriptionTitle || a.subscriptionType || '-') + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.tokenExpiry') + '</div><div class="detail-value">' + (a.expiresAt ? new Date(a.expiresAt * 1000).toLocaleString() : '-') + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.mainQuota') + '</div><div class="detail-value">' + (a.usageCurrent?.toFixed(1) || 0) + ' / ' + (a.usageLimit?.toFixed(0) || 0) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.resetDate') + '</div><div class="detail-value">' + (a.nextResetDate || '-') + '</div></div>' +
(a.trialUsageLimit > 0 ? '<div class="detail-item"><div class="detail-label">' + t('detail.trialQuota') + '</div><div class="detail-value">' + (a.trialUsageCurrent?.toFixed(1) || 0) + ' / ' + (a.trialUsageLimit?.toFixed(0) || 0) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.trialStatus') + '</div><div class="detail-value">' + (a.trialStatus || '-') + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.trialExpiry') + '</div><div class="detail-value">' + (a.trialExpiresAt ? new Date(a.trialExpiresAt * 1000).toLocaleString() : '-') + '</div></div>' : '') +
'</div></div>' +
'<div class="detail-section"><h4>' + t('detail.statistics') + '</h4><div class="detail-grid">' +
'<div class="detail-item"><div class="detail-label">' + t('detail.requestCount') + '</div><div class="detail-value">' + (a.requestCount || 0) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.errorCount') + '</div><div class="detail-value">' + (a.errorCount || 0) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.totalTokens') + '</div><div class="detail-value">' + formatNum(a.totalTokens || 0) + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('detail.totalCredits') + '</div><div class="detail-value">' + (a.totalCredits || 0).toFixed(2) + '</div></div>' +
'</div></div>' +
'<div class="detail-section"><h4>' + t('detail.models') + ' <button class="btn btn-sm btn-secondary" onclick="loadModels(\'' + id + '\')" style="margin-left:8px">' + t('detail.loadModels') + '</button></h4><div id="modelsList" class="model-list"></div></div>';
document.getElementById('detailModal').classList.add('active');
}
async function loadModels(id) {
const container = document.getElementById('modelsList');
container.innerHTML = '<p style="color:#64748b">' + t('detail.loading') + '</p>';
try {
const res = await fetch('/admin/api/accounts/' + id + '/models', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
if (d.success && d.models) {
// 按 credit 比例排序auto 模型优先)
const sortedModels = d.models.sort((a, b) => {
if (a.modelId === 'auto') return -1;
if (b.modelId === 'auto') return 1;
return (a.rateMultiplier || 1) - (b.rateMultiplier || 1);
});
container.innerHTML = sortedModels.map(m => {
const creditRatio = m.rateMultiplier || 1;
return '<div class="model-item">' +
'<div class="model-name">' + m.modelId + '</div>' +
'<div class="model-credit"><span class="credit-ratio">' + creditRatio + 'x credit</span></div>' +
'<div class="model-info">' + (m.description || '') + '</div>' +
'</div>';
}).join('') || '<p style="color:#64748b">' + t('detail.noModels') + '</p>';
} else {
container.innerHTML = '<p style="color:#ef4444">' + t('detail.loadFailed') + ': ' + (d.error || '') + '</p>';
}
} catch (e) { container.innerHTML = '<p style="color:#ef4444">' + t('detail.loadFailed') + '</p>'; }
}
function closeDetailModal() { document.getElementById('detailModal').classList.remove('active'); }
async function generateMachineId() {
try {
const res = await fetch('/admin/api/generate-machine-id', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
if (d.machineId) document.getElementById('machineIdInput').value = d.machineId;
} catch (e) { alert(t('detail.generateFailed')); }
}
async function saveMachineId(id) {
const machineId = document.getElementById('machineIdInput').value.trim();
if (machineId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(machineId) && !/^[0-9a-f]{32}$/i.test(machineId)) {
alert(t('detail.machineIdError')); return;
}
try {
const res = await fetch('/admin/api/accounts/' + id, {
method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ machineId })
});
const d = await res.json();
if (d.success) { alert(t('detail.saved')); loadAccounts(); } else { alert(t('detail.saveFailed') + ': ' + d.error); }
} catch (e) { alert(t('detail.saveFailed')); }
}
async function saveWeight(id) {
const weight = parseInt(document.getElementById('weightInput').value) || 0;
try {
const res = await fetch('/admin/api/accounts/' + id, {
method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ weight })
});
const d = await res.json();
if (d.success) { alert(t('detail.saved')); loadAccounts(); } else { alert(t('detail.saveFailed') + ': ' + d.error); }
} catch (e) { alert(t('detail.saveFailed')); }
}
async function quickSetWeight(id, value) {
const weight = parseInt(value) || 0;
try {
await fetch('/admin/api/accounts/' + id, {
method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ weight })
});
const acc = accountsData.find(a => a.id === id);
if (acc) acc.weight = weight;
} catch (e) { /* silent */ }
}
async function loadSettings() {
const res = await fetch('/admin/api/settings', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
document.getElementById('requireApiKey').checked = d.requireApiKey;
document.getElementById('apiKeyInput').value = d.apiKey || '';
loadThinkingConfig();
loadEndpointConfig();
loadProxyConfig();
}
async function loadThinkingConfig() {
const res = await fetch('/admin/api/thinking', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
document.getElementById('thinkingSuffix').value = d.suffix || '-thinking';
document.getElementById('openaiThinkingFormat').value = d.openaiFormat || 'reasoning_content';
document.getElementById('claudeThinkingFormat').value = d.claudeFormat || 'thinking';
}
async function saveThinkingConfig() {
const res = await fetch('/admin/api/thinking', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ suffix: document.getElementById('thinkingSuffix').value || '-thinking', openaiFormat: document.getElementById('openaiThinkingFormat').value, claudeFormat: document.getElementById('claudeThinkingFormat').value })
});
const d = await res.json();
if (d.success) { alert(t('settings.thinkingSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }
}
async function loadEndpointConfig() {
const res = await fetch('/admin/api/endpoint', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
document.getElementById('preferredEndpoint').value = d.preferredEndpoint || 'auto';
}
async function saveEndpointConfig() {
const res = await fetch('/admin/api/endpoint', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ preferredEndpoint: document.getElementById('preferredEndpoint').value })
});
const d = await res.json();
if (d.success) { alert(t('settings.endpointSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }
}
async function loadProxyConfig() {
const res = await fetch('/admin/api/proxy', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
const proxyURL = d.proxyURL || '';
if (!proxyURL) {
document.getElementById('proxyType').value = 'none';
document.getElementById('proxyFields').style.display = 'none';
return;
}
try {
const u = new URL(proxyURL);
const scheme = u.protocol.replace(':', '');
document.getElementById('proxyType').value = scheme.startsWith('socks5') ? 'socks5' : 'http';
document.getElementById('proxyHost').value = u.hostname;
document.getElementById('proxyPort').value = u.port;
document.getElementById('proxyUsername').value = decodeURIComponent(u.username);
document.getElementById('proxyPassword').value = decodeURIComponent(u.password);
document.getElementById('proxyFields').style.display = '';
} catch(e) {
document.getElementById('proxyType').value = 'none';
document.getElementById('proxyFields').style.display = 'none';
}
}
function onProxyTypeChange() {
const type = document.getElementById('proxyType').value;
document.getElementById('proxyFields').style.display = type === 'none' ? 'none' : '';
}
async function saveProxyConfig() {
const type = document.getElementById('proxyType').value;
let proxyURL = '';
if (type !== 'none') {
const host = document.getElementById('proxyHost').value.trim();
const port = document.getElementById('proxyPort').value.trim();
if (!host || !port) { alert(t('settings.proxyHostRequired')); return; }
const user = document.getElementById('proxyUsername').value.trim();
const pass = document.getElementById('proxyPassword').value.trim();
const auth = user ? (pass ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : `${encodeURIComponent(user)}@`) : '';
proxyURL = `${type}://${auth}${host}:${port}`;
}
const res = await fetch('/admin/api/proxy', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ proxyURL })
});
const d = await res.json();
if (d.success) { alert(t('settings.proxySaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }
}
function generateApiKey() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let key = 'sk-';
for (let i = 0; i < 32; i++) key += chars.charAt(Math.floor(Math.random() * chars.length));
document.getElementById('apiKeyInput').value = key;
}
async function saveSettings() {
const requireApiKey = document.getElementById('requireApiKey').checked;
const apiKeyInput = document.getElementById('apiKeyInput');
if (requireApiKey && !apiKeyInput.value.trim()) {
generateApiKey();
}
await fetch('/admin/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ requireApiKey, apiKey: apiKeyInput.value })
});
alert(t('detail.saved'));
}
async function changePassword() {
const newPwd = document.getElementById('newPassword').value;
if (!newPwd) return alert(t('settings.passwordRequired'));
await fetch('/admin/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ password: newPwd })
});
password = newPwd;
localStorage.setItem('admin_password', password);
alert(t('settings.passwordChanged'));
document.getElementById('newPassword').value = '';
}
async function resetStats() {
if (!confirm(t('settings.confirmReset'))) return;
await fetch('/admin/api/stats/reset', { method: 'POST', headers: { 'X-Admin-Password': password } });
loadStats();
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(tabEl => tabEl.classList.toggle('active', tabEl.dataset.tab === tab));
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1)).classList.remove('hidden');
}
async function toggleAccount(id, enabled) {
await fetch('/admin/api/accounts/' + id, {
method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ enabled })
});
loadAccounts();
}
async function deleteAccount(id) {
if (!confirm(t('accounts.confirmDelete'))) return;
await fetch('/admin/api/accounts/' + id, { method: 'DELETE', headers: { 'X-Admin-Password': password } });
loadAccounts(); loadStats();
}
function formatNum(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
}
function copy(id) {
navigator.clipboard.writeText(document.getElementById(id).textContent);
alert(t('common.copied'));
}
async function copyAccountJSON(accountId, buttonElement) {
try {
// 从后端获取完整账号信息(包含敏感字段)
const res = await fetch('/admin/api/accounts/' + accountId + '/full', {
headers: { 'X-Admin-Password': password }
});
if (!res.ok) {
throw new Error('Failed to fetch account data');
}
const account = await res.json();
const { clientId, clientSecret, accessToken, refreshToken } = account;
const jsonString = JSON.stringify({ clientId, clientSecret, accessToken, refreshToken }, null, 2);
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(jsonString);
} else {
const textarea = document.createElement('textarea');
textarea.value = jsonString;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
if (!success) throw new Error('execCommand failed');
}
showCopySuccess(buttonElement);
alert(t('accounts.copyJSONSuccess'));
} catch (error) {
console.error('Copy failed:', error);
alert(t('common.failed'));
}
}
function showCopySuccess(buttonElement) {
const originalHTML = buttonElement.innerHTML;
const originalClass = buttonElement.className;
buttonElement.disabled = true;
buttonElement.className = 'btn btn-sm btn-icon btn-success';
buttonElement.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
setTimeout(() => {
buttonElement.disabled = false;
buttonElement.className = originalClass;
buttonElement.innerHTML = originalHTML;
}, 800);
}
function showModal(type) {
const modal = document.getElementById('addModal');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'add') {
title.textContent = t('modal.addAccount');
body.innerHTML =
'<div style="display:flex;flex-direction:column;gap:12px">' +
'<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal(\'builderid\')" onmouseover="this.style.borderColor=\'#7c3aed\'" onmouseout="this.style.borderColor=\'transparent\'"><div style="font-weight:600;margin-bottom:6px">' + t('modal.builderIdTitle') + '</div><div style="font-size:13px;color:#64748b">' + t('modal.builderIdDesc') + '</div></div>' +
'<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal(\'iam\')" onmouseover="this.style.borderColor=\'#7c3aed\'" onmouseout="this.style.borderColor=\'transparent\'"><div style="font-weight:600;margin-bottom:6px">' + t('modal.iamTitle') + '</div><div style="font-size:13px;color:#64748b">' + t('modal.iamDesc') + '</div></div>' +
'<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal(\'sso\')" onmouseover="this.style.borderColor=\'#7c3aed\'" onmouseout="this.style.borderColor=\'transparent\'"><div style="font-weight:600;margin-bottom:6px">' + t('modal.ssoTitle') + '</div><div style="font-size:13px;color:#64748b">' + t('modal.ssoDesc') + '</div></div>' +
'<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal(\'local\')" onmouseover="this.style.borderColor=\'#7c3aed\'" onmouseout="this.style.borderColor=\'transparent\'"><div style="font-weight:600;margin-bottom:6px">' + t('modal.localTitle') + '</div><div style="font-size:13px;color:#64748b">' + t('modal.localDesc') + '</div></div>' +
'<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal(\'credentials\')" onmouseover="this.style.borderColor=\'#7c3aed\'" onmouseout="this.style.borderColor=\'transparent\'"><div style="font-weight:600;margin-bottom:6px">' + t('modal.credentialsTitle') + '</div><div style="font-size:13px;color:#64748b">' + t('modal.credentialsDesc') + '</div></div>' +
'</div>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">' + t('common.cancel') + '</button></div>';
} else if (type === 'builderid') {
title.textContent = t('modal.builderIdTitle');
body.innerHTML =
'<p style="font-size:13px;color:#64748b;margin-bottom:16px">' + t('modal.builderIdDesc') + '</p>' +
'<div id="builderIdStep1"><div class="form-group"><label>Region</label><input type="text" id="builderIdRegion" value="us-east-1"></div>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal(\'add\')">' + t('common.back') + '</button><button class="btn btn-primary" onclick="startBuilderIdLogin()">' + t('builderid.startLogin') + '</button></div></div>' +
'<div id="builderIdStep2" class="hidden">' +
'<div class="message" style="background:#ede9fe;color:#7c3aed;text-align:center"><p style="font-size:18px;font-weight:600;margin-bottom:8px" id="builderIdUserCode"></p><p style="font-size:12px">' + t('builderid.verifyCode') + '</p></div>' +
'<div class="form-group" style="margin-top:16px"><label>' + t('builderid.verifyUrl') + '</label><div class="endpoint" style="margin-bottom:0"><span id="builderIdVerifyUrl" style="font-size:12px"></span></div>' +
'<div style="display:flex;gap:8px;margin-top:8px"><button class="btn btn-sm btn-secondary" style="flex:1" onclick="window.open(document.getElementById(\'builderIdVerifyUrl\').textContent,\'_blank\')">' + t('builderid.open') + '</button><button class="btn btn-sm btn-secondary" style="flex:1" onclick="navigator.clipboard.writeText(document.getElementById(\'builderIdVerifyUrl\').textContent);alert(\'' + t('common.copied') + '\')">' + t('common.copy') + '</button></div></div>' +
'<p id="builderIdStatus" style="color:#64748b;margin:16px 0;font-size:13px;text-align:center">' + t('builderid.waiting') + '</p>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="cancelBuilderIdLogin()">' + t('common.cancel') + '</button></div>' +
'</div>';
} else if (type === 'local') {
title.textContent = t('modal.localTitle');
body.innerHTML =
'<p style="font-size:13px;color:#64748b;margin-bottom:16px">' + t('modal.localDesc') + '</p>' +
'<div style="font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.6"><p style="margin-bottom:8px"><b>' + t('local.fileLocation') + '</b></p><p style="margin-bottom:4px">Windows: <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:11px">%USERPROFILE%\\.aws\\sso\\cache\\</code></p><p>macOS/Linux: <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:11px">~/.aws/sso/cache/</code></p></div>' +
'<div class="form-group"><label>' + t('local.loginChannel') + '</label><select id="localProvider" onchange="updateLocalFields()"><option value="BuilderId">AWS Builder ID</option><option value="Enterprise">IAM Identity Center (Enterprise SSO)</option><option value="Google">Google</option><option value="Github">GitHub</option></select></div>' +
'<div class="form-group"><label>' + t('local.tokenFile') + ' <span style="font-weight:normal;color:#64748b;font-size:12px">' + t('local.tokenRequired') + '</span></label><div style="display:flex;gap:8px;align-items:stretch"><textarea id="localTokenJson" placeholder="' + t('local.pasteOrUpload') + '" style="flex:1;min-height:80px;font-size:12px"></textarea><label class="btn btn-secondary" style="display:flex;align-items:center;cursor:pointer">' + t('local.upload') + '<input type="file" accept=".json" style="display:none" onchange="loadLocalFile(this,\'localTokenJson\')"></label></div></div>' +
'<div id="localClientGroup" class="form-group"><label>' + t('local.clientFile') + ' <span style="font-weight:normal;color:#64748b;font-size:12px">' + t('local.clientRequired') + '</span></label><div style="display:flex;gap:8px;align-items:stretch"><textarea id="localClientJson" placeholder="' + t('local.pasteOrUpload') + '" style="flex:1;min-height:80px;font-size:12px"></textarea><label class="btn btn-secondary" style="display:flex;align-items:center;cursor:pointer">' + t('local.upload') + '<input type="file" accept=".json" style="display:none" onchange="loadLocalFile(this,\'localClientJson\')"></label></div></div>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal(\'add\')">' + t('common.back') + '</button><button class="btn btn-primary" onclick="importLocalKiro()">' + t('common.add') + '</button></div>';
} else if (type === 'credentials') {
title.textContent = t('modal.credentialsTitle');
body.innerHTML =
'<p style="font-size:13px;color:#64748b;margin-bottom:16px">' + t('modal.credentialsDesc') + '</p>' +
'<div style="font-size:12px;color:#64748b;margin-bottom:12px;line-height:1.5">' + t('credentials.batchHint') + '</div>' +
'<div class="form-group"><label>' + t('credentials.label') + '</label><textarea id="credJson" placeholder=\'[{"refreshToken":"xxx","provider":"BuilderId"},{"refreshToken":"yyy","clientId":"...","clientSecret":"...","provider":"Enterprise"}]\' style="min-height:120px"></textarea></div>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal(\'add\')">' + t('common.back') + '</button><button class="btn btn-primary" onclick="importCredentials()">' + t('common.add') + '</button></div>';
} else if (type === 'sso') {
title.textContent = t('modal.ssoTitle');
body.innerHTML =
'<div style="font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.6"><p style="margin-bottom:8px"><b>' + t('sso.howToGet') + '</b></p><ol style="margin:0;padding-left:20px"><li>' + t('sso.step1') + ' <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">view.awsapps.com/start</code></li><li>' + t('sso.step2') + '</li><li>' + t('sso.step3') + ' <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">x-amz-sso_authn</code></li></ol></div>' +
'<div class="form-group"><label>' + t('sso.tokenLabel') + ' <span style="font-weight:normal;color:#64748b;font-size:12px">' + t('sso.tokenHint') + '</span></label><textarea id="ssoToken" placeholder="' + t('sso.tokenPlaceholder') + '" style="min-height:120px"></textarea></div>' +
'<div class="form-group"><label>Region</label><input type="text" id="ssoRegion" value="us-east-1"></div>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal(\'add\')">' + t('common.back') + '</button><button class="btn btn-primary" onclick="importSsoToken()">' + t('common.add') + '</button></div>';
} else if (type === 'iam') {
title.textContent = t('modal.iamTitle');
body.innerHTML =
'<p style="font-size:13px;color:#64748b;margin-bottom:16px">' + t('modal.iamDesc') + '</p>' +
'<div class="form-group"><label>' + t('iam.startUrl') + '</label><input type="text" id="iamStartUrl" placeholder="https://xxx.awsapps.com/start"></div>' +
'<div class="form-group"><label>Region</label><input type="text" id="iamRegion" value="us-east-1"></div>' +
'<div id="iamStep2" class="hidden"><div class="form-group"><label>' + t('iam.loginUrl') + '</label><div class="endpoint" style="margin-bottom:0"><span id="iamAuthUrl" style="font-size:11px"></span></div><div style="display:flex;gap:8px;margin-top:8px"><button class="btn btn-sm btn-secondary" style="flex:1" onclick="window.open(document.getElementById(\'iamAuthUrl\').textContent,\'_blank\')">' + t('builderid.open') + '</button><button class="btn btn-sm btn-secondary" style="flex:1" onclick="navigator.clipboard.writeText(document.getElementById(\'iamAuthUrl\').textContent);alert(\'' + t('common.copied') + '\')">' + t('common.copy') + '</button></div></div><p style="color:#16a34a;margin:12px 0;font-size:14px">' + t('iam.completeLogin') + '</p><div class="form-group"><label>' + t('iam.callbackUrl') + '</label><input type="text" id="iamCallback" placeholder="http://127.0.0.1:xxx/?code=..."></div></div>' +
'<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal(\'add\')">' + t('common.back') + '</button><button class="btn btn-primary" id="iamBtn" onclick="startIamSso()">' + t('builderid.startLogin') + '</button></div>';
}
modal.classList.add('active');
}
function closeModal() {
document.getElementById('addModal').classList.remove('active');
iamSession = '';
if (builderIdPollTimer) { clearTimeout(builderIdPollTimer); builderIdPollTimer = null; }
builderIdSession = '';
}
function loadLocalFile(input, targetId) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => { document.getElementById(targetId).value = e.target.result; };
reader.readAsText(file);
}
function updateLocalFields() {
const provider = document.getElementById('localProvider').value;
const clientGroup = document.getElementById('localClientGroup');
clientGroup.style.display = (provider === 'Google' || provider === 'Github') ? 'none' : 'block';
}
async function importLocalKiro() {
const provider = document.getElementById('localProvider').value;
const tokenJson = document.getElementById('localTokenJson').value.trim();
const clientJson = document.getElementById('localClientJson').value.trim();
const isSocial = provider === 'Google' || provider === 'Github';
if (!tokenJson) { alert(t('local.tokenMissing')); return; }
let tokenData, clientData;
try { tokenData = JSON.parse(tokenJson); } catch { alert(t('local.tokenInvalid')); return; }
if (!tokenData.refreshToken) { alert(t('local.refreshTokenMissing')); return; }
if (!isSocial) {
if (!clientJson) { alert(t('local.clientMissing')); return; }
try { clientData = JSON.parse(clientJson); } catch { alert(t('local.clientInvalid')); return; }
if (!clientData.clientId || !clientData.clientSecret) { alert(t('local.clientSecretMissing')); return; }
}
// 根据是否有 clientData 判断认证方式
const authMethod = clientData ? 'idc' : 'social';
const payload = { refreshToken: tokenData.refreshToken, accessToken: tokenData.accessToken || '', clientId: clientData?.clientId || '', clientSecret: clientData?.clientSecret || '', authMethod: authMethod, provider: provider };
const res = await fetch('/admin/api/auth/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify(payload) });
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert(t('local.importSuccess') + ': ' + (d.account?.email || d.account?.id)); autoRefreshNewAccount(d.account?.id); }
else alert(t('common.failed') + ': ' + d.error);
}
async function importCredentials() {
try {
const json = JSON.parse(document.getElementById('credJson').value.trim());
// 兼容 Kiro Account Manager 导出格式 {version, accounts: [...]}
let items;
if (json.accounts && Array.isArray(json.accounts)) {
// AccountExportData 格式,从 accounts[].credentials 提取
items = json.accounts.map(a => {
const c = a.credentials || {};
return {
refreshToken: c.refreshToken || a.refreshToken,
clientId: c.clientId || a.clientId,
clientSecret: c.clientSecret || a.clientSecret,
region: c.region || a.region,
// 不传 accessToken强制后端用 refreshToken 刷新获取新 token
authMethod: c.authMethod || a.authMethod,
provider: c.provider || a.provider || a.idp
};
});
} else {
items = Array.isArray(json) ? json : [json];
}
let success = 0, failed = 0, errors = [], newIds = [];
for (const item of items) {
if (!item.refreshToken) { failed++; errors.push('missing refreshToken'); continue; }
// 映射 authMethod: IdC/idc -> idc, social -> social
let authMethod = item.authMethod || '';
if (item.clientId && item.clientSecret) {
authMethod = 'idc';
} else if (!authMethod || authMethod === 'social') {
authMethod = 'social';
} else {
authMethod = authMethod.toLowerCase() === 'idc' ? 'idc' : 'social';
}
// 映射 provider
let provider = item.provider || '';
if (!provider && authMethod === 'social') provider = 'Google';
if (!provider && authMethod === 'idc') provider = 'BuilderId';
const payload = { refreshToken: item.refreshToken, accessToken: item.accessToken || '', clientId: item.clientId || '', clientSecret: item.clientSecret || '', authMethod: authMethod, provider: provider, region: item.region || 'us-east-1' };
try {
const res = await fetch('/admin/api/auth/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify(payload) });
const d = await res.json();
if (d.success) { success++; if (d.account?.id) newIds.push(d.account.id); } else { failed++; errors.push(d.error || 'unknown'); }
} catch { failed++; errors.push('request failed'); }
}
closeModal(); loadAccounts(); loadStats();
let msg = t('sso.importSuccess', success);
if (failed > 0) msg += t('sso.importPartial', failed);
alert(msg);
newIds.forEach(id => autoRefreshNewAccount(id));
} catch (e) { alert(t('credentials.jsonError')); }
}
async function importSsoToken() {
const res = await fetch('/admin/api/auth/sso-token', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ bearerToken: document.getElementById('ssoToken').value, region: document.getElementById('ssoRegion').value }) });
const d = await res.json();
if (d.success) {
closeModal(); loadAccounts(); loadStats();
const count = d.accounts?.length || 0;
const errCount = d.errors?.length || 0;
let msg = t('sso.importSuccess', count);
if (errCount > 0) msg += t('sso.importPartial', errCount);
alert(msg);
if (d.accounts) d.accounts.forEach(a => autoRefreshNewAccount(a.id));
} else alert(t('common.failed') + ': ' + d.error);
}
let builderIdSession = '';
let builderIdPollTimer = null;
async function startBuilderIdLogin() {
const region = document.getElementById('builderIdRegion').value || 'us-east-1';
const res = await fetch('/admin/api/auth/builderid/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ region }) });
const d = await res.json();
if (d.sessionId) {
builderIdSession = d.sessionId;
document.getElementById('builderIdUserCode').textContent = d.userCode;
document.getElementById('builderIdVerifyUrl').textContent = d.verificationUri;
document.getElementById('builderIdStep1').classList.add('hidden');
document.getElementById('builderIdStep2').classList.remove('hidden');
pollBuilderIdAuth(d.interval || 5);
} else alert(t('common.failed') + ': ' + d.error);
}
function pollBuilderIdAuth(interval) {
builderIdPollTimer = setTimeout(async () => {
const res = await fetch('/admin/api/auth/builderid/poll', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ sessionId: builderIdSession }) });
const d = await res.json();
if (d.completed) {
closeModal(); loadAccounts(); loadStats();
alert(t('builderid.success') + ': ' + (d.account?.email || d.account?.id));
autoRefreshNewAccount(d.account?.id);
} else if (d.success && !d.completed) {
document.getElementById('builderIdStatus').textContent = t('builderid.waiting');
pollBuilderIdAuth(d.interval || interval);
} else {
alert(t('common.failed') + ': ' + d.error);
cancelBuilderIdLogin();
}
}, interval * 1000);
}
function cancelBuilderIdLogin() {
if (builderIdPollTimer) { clearTimeout(builderIdPollTimer); builderIdPollTimer = null; }
builderIdSession = '';
showModal('add');
}
let iamSession = '';
async function startIamSso() {
if (iamSession) {
const res = await fetch('/admin/api/auth/iam-sso/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ sessionId: iamSession, callbackUrl: document.getElementById('iamCallback').value }) });
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert(t('builderid.success') + ': ' + (d.account?.email || d.account?.id)); autoRefreshNewAccount(d.account?.id); }
else alert(t('common.failed') + ': ' + d.error);
} else {
const res = await fetch('/admin/api/auth/iam-sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, body: JSON.stringify({ startUrl: document.getElementById('iamStartUrl').value, region: document.getElementById('iamRegion').value }) });
const d = await res.json();
if (d.authorizeUrl) {
iamSession = d.sessionId;
document.getElementById('iamAuthUrl').textContent = d.authorizeUrl;
document.getElementById('iamStep2').classList.remove('hidden');
document.getElementById('iamBtn').textContent = t('iam.complete');
} else alert(t('common.failed') + ': ' + d.error);
}
}
setInterval(() => { if (!document.getElementById('mainPage').classList.contains('hidden')) loadStats(); }, 10000);
// ==================== 版本检查 ====================
let currentVersion = '';
async function loadVersion() {
try {
const res = await fetch('/admin/api/version', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
currentVersion = d.version || '';
document.getElementById('versionBadge').textContent = 'v' + currentVersion;
} catch (e) { }
}
async function checkUpdate(manual) {
try {
const res = await fetch('https://raw.githubusercontent.com/Quorinex/Kiro-Go/main/version.json?t=' + Date.now());
if (!res.ok) throw new Error('Fetch failed');
const d = await res.json();
const latestVersion = (d.version || '').replace(/^v/, '');
if (latestVersion && latestVersion !== currentVersion && compareVersions(latestVersion, currentVersion) > 0) {
showUpdateModal(latestVersion, d.download || 'https://github.com/Quorinex/Kiro-Go', d.changelog || '');
} else if (manual) {
alert(t('update.upToDate') + ' (v' + currentVersion + ')');
}
} catch (e) {
if (manual) alert(t('update.checkFailed'));
}
}
function compareVersions(a, b) {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
function showUpdateModal(version, url, changelog) {
const body = document.getElementById('updateBody');
body.innerHTML =
'<div style="text-align:center;margin-bottom:20px">' +
'<div style="font-size:48px;margin-bottom:12px">🎉</div>' +
'<p style="font-size:16px;font-weight:600;color:#7c3aed">' + t('update.newVersion') + '</p>' +
'</div>' +
'<div class="detail-grid" style="margin-bottom:16px">' +
'<div class="detail-item"><div class="detail-label">' + t('update.current') + '</div><div class="detail-value">v' + currentVersion + '</div></div>' +
'<div class="detail-item"><div class="detail-label">' + t('update.latest') + '</div><div class="detail-value" style="color:#16a34a">v' + version + '</div></div>' +
'</div>' +
(changelog ? '<div style="margin-bottom:16px"><div style="font-size:13px;font-weight:600;margin-bottom:8px">' + t('update.changelog') + '</div><div style="background:#f8fafc;padding:12px;border-radius:8px;font-size:12px;max-height:200px;overflow-y:auto;white-space:pre-wrap;line-height:1.6">' + escapeHtml(changelog) + '</div></div>' : '') +
'<div style="text-align:center"><a href="' + url + '" target="_blank" class="btn btn-primary" style="text-decoration:none;display:inline-block">' + t('update.goDownload') + '</a></div>';
document.getElementById('updateModal').classList.add('active');
}
function closeUpdateModal() { document.getElementById('updateModal').classList.remove('active'); }
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ==================== 导出功能 ====================
let exportSelectedIds = new Set();
function showExportModal() {
if (accountsData.length === 0) { alert(t('accounts.empty')); return; }
exportSelectedIds = new Set(accountsData.map(a => a.id));
renderExportModal();
document.getElementById('exportModal').classList.add('active');
}
function closeExportModal() { document.getElementById('exportModal').classList.remove('active'); }
function renderExportModal() {
const body = document.getElementById('exportBody');
const allSelected = exportSelectedIds.size === accountsData.length;
body.innerHTML =
'<div style="margin-bottom:12px;display:flex;justify-content:space-between;align-items:center">' +
'<span style="font-size:13px;color:#64748b">' + t('export.selected', exportSelectedIds.size) + '</span>' +
'<button class="btn btn-sm btn-secondary" onclick="toggleExportSelectAll()">' + (allSelected ? t('export.deselectAll') : t('export.selectAll')) + '</button>' +
'</div>' +
'<div style="max-height:300px;overflow-y:auto;margin-bottom:16px">' +
accountsData.map(a => {
const checked = exportSelectedIds.has(a.id) ? 'checked' : '';
return '<label style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;margin-bottom:4px;background:' + (exportSelectedIds.has(a.id) ? '#f0f4ff' : '#f8fafc') + '">' +
'<input type="checkbox" ' + checked + ' onchange="toggleExportAccount(\'' + a.id + '\')" style="width:16px;height:16px">' +
'<div style="flex:1;min-width:0"><div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + getDisplayEmail(a.email, a.id) + '</div>' +
'<div style="font-size:11px;color:#64748b">' + formatAuthMethod(a.provider || a.authMethod) + ' · ' + (a.subscriptionType || 'FREE') + '</div></div>' +
'</label>';
}).join('') +
'</div>' +
'<div id="exportJsonPreview" class="hidden" style="margin-bottom:12px"><textarea id="exportJsonText" readonly style="width:100%;min-height:150px;max-height:300px;font-family:monospace;font-size:11px;background:#f8fafc;resize:vertical"></textarea></div>' +
'<div class="modal-footer" style="flex-wrap:wrap">' +
'<button class="btn btn-secondary" onclick="closeExportModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-secondary" onclick="exportShowJson()">' + t('export.showJson') + '</button>' +
'<button class="btn btn-secondary" onclick="exportCopyJson()">' + t('export.copyJson') + '</button>' +
'<button class="btn btn-primary" onclick="exportDownloadJson()">' + t('export.downloadJson') + '</button>' +
'</div>';
}
function toggleExportAccount(id) {
if (exportSelectedIds.has(id)) exportSelectedIds.delete(id);
else exportSelectedIds.add(id);
renderExportModal();
}
function toggleExportSelectAll() {
if (exportSelectedIds.size === accountsData.length) exportSelectedIds.clear();
else exportSelectedIds = new Set(accountsData.map(a => a.id));
renderExportModal();
}
async function getExportData() {
if (exportSelectedIds.size === 0) { alert(t('export.noSelection')); return null; }
const res = await fetch('/admin/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ ids: Array.from(exportSelectedIds) })
});
if (!res.ok) {
const error = await res.json();
alert(t('common.failed') + ': ' + (error.error || 'Unknown error'));
return null;
}
return await res.json();
}
async function exportShowJson() {
const data = await getExportData();
if (!data) return;
const preview = document.getElementById('exportJsonPreview');
const textarea = document.getElementById('exportJsonText');
preview.classList.remove('hidden');
textarea.value = JSON.stringify(data, null, 2);
}
async function exportCopyJson() {
const data = await getExportData();
if (!data) return;
const filtered = data.accounts.map(a => {
const { clientId, clientSecret, accessToken, refreshToken } = a.credentials || {};
return { clientId, clientSecret, accessToken, refreshToken };
});
await navigator.clipboard.writeText(JSON.stringify(filtered, null, 2));
alert(t('export.copied'));
}
async function exportDownloadJson() {
const data = await getExportData();
if (!data) return;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'kiro-accounts-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
}
// ==================== 添加账号后自动刷新 ====================
async function autoRefreshNewAccount(accountId) {
try {
await fetch('/admin/api/accounts/' + accountId + '/refresh', {
method: 'POST', headers: { 'X-Admin-Password': password }
});
} catch (e) { }
loadAccounts();
}
</script>
</body>
</html>