copilot-router 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +287 -0
- package/public/index.html +27 -365
- package/public/style.css +279 -0
package/package.json
CHANGED
package/public/app.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Internationalization (i18n)
|
|
2
|
+
const i18n = {
|
|
3
|
+
zh: {
|
|
4
|
+
subtitle: 'GitHub Copilot API 代理服务',
|
|
5
|
+
tab_login: '设备码登录',
|
|
6
|
+
tab_direct: '直接添加 Token',
|
|
7
|
+
tab_manage: '管理 Tokens',
|
|
8
|
+
login_desc: '使用 GitHub 设备码流程登录,支持添加多个账号实现负载均衡。',
|
|
9
|
+
btn_start_login: '开始登录',
|
|
10
|
+
enter_code: '请在 GitHub 输入此验证码:',
|
|
11
|
+
open_github: '点击这里打开 GitHub 验证页面 →',
|
|
12
|
+
waiting_auth: '等待授权中... 请在 GitHub 完成验证',
|
|
13
|
+
login_success: '✅ 登录成功!',
|
|
14
|
+
btn_add_another: '添加另一个账号',
|
|
15
|
+
login_failed: '登录失败',
|
|
16
|
+
btn_retry: '重试',
|
|
17
|
+
direct_desc: '如果你已经有 GitHub Token,可以直接添加:',
|
|
18
|
+
token_placeholder: '输入 GitHub Token (ghu_xxx 或 gho_xxx)',
|
|
19
|
+
btn_add_token: '添加 Token',
|
|
20
|
+
btn_refresh: '刷新列表',
|
|
21
|
+
click_refresh: '点击刷新加载 Token 列表',
|
|
22
|
+
no_tokens: '暂无 Token,请先登录添加',
|
|
23
|
+
token_count: '共 {total} 个 Token,{active} 个活跃',
|
|
24
|
+
active: '✅ 活跃',
|
|
25
|
+
inactive: '❌ 停用',
|
|
26
|
+
requests: '请求',
|
|
27
|
+
btn_delete: '删除',
|
|
28
|
+
confirm_delete: '确定要删除这个 Token 吗?',
|
|
29
|
+
delete_failed: '删除失败',
|
|
30
|
+
load_failed: '加载失败',
|
|
31
|
+
add_failed: '添加失败',
|
|
32
|
+
add_success: '✅ 添加成功',
|
|
33
|
+
enter_token: '请输入 Token',
|
|
34
|
+
connect_failed: '无法连接服务器',
|
|
35
|
+
timeout: '验证超时,请重试',
|
|
36
|
+
auth_failed: '授权失败',
|
|
37
|
+
account_added: '已添加账号'
|
|
38
|
+
},
|
|
39
|
+
en: {
|
|
40
|
+
subtitle: 'GitHub Copilot API Proxy Service',
|
|
41
|
+
tab_login: 'Device Code Login',
|
|
42
|
+
tab_direct: 'Add Token Directly',
|
|
43
|
+
tab_manage: 'Manage Tokens',
|
|
44
|
+
login_desc: 'Login using GitHub device code flow, supports adding multiple accounts for load balancing.',
|
|
45
|
+
btn_start_login: 'Start Login',
|
|
46
|
+
enter_code: 'Enter this code on GitHub:',
|
|
47
|
+
open_github: 'Click here to open GitHub verification page →',
|
|
48
|
+
waiting_auth: 'Waiting for authorization... Please complete verification on GitHub',
|
|
49
|
+
login_success: '✅ Login successful!',
|
|
50
|
+
btn_add_another: 'Add Another Account',
|
|
51
|
+
login_failed: 'Login failed',
|
|
52
|
+
btn_retry: 'Retry',
|
|
53
|
+
direct_desc: 'If you already have a GitHub Token, you can add it directly:',
|
|
54
|
+
token_placeholder: 'Enter GitHub Token (ghu_xxx or gho_xxx)',
|
|
55
|
+
btn_add_token: 'Add Token',
|
|
56
|
+
btn_refresh: 'Refresh List',
|
|
57
|
+
click_refresh: 'Click refresh to load Token list',
|
|
58
|
+
no_tokens: 'No tokens yet, please login to add',
|
|
59
|
+
token_count: 'Total {total} tokens, {active} active',
|
|
60
|
+
active: '✅ Active',
|
|
61
|
+
inactive: '❌ Inactive',
|
|
62
|
+
requests: 'Requests',
|
|
63
|
+
btn_delete: 'Delete',
|
|
64
|
+
confirm_delete: 'Are you sure you want to delete this token?',
|
|
65
|
+
delete_failed: 'Delete failed',
|
|
66
|
+
load_failed: 'Load failed',
|
|
67
|
+
add_failed: 'Add failed',
|
|
68
|
+
add_success: '✅ Added successfully',
|
|
69
|
+
enter_token: 'Please enter Token',
|
|
70
|
+
connect_failed: 'Cannot connect to server',
|
|
71
|
+
timeout: 'Verification timeout, please retry',
|
|
72
|
+
auth_failed: 'Authorization failed',
|
|
73
|
+
account_added: 'Account added'
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let currentLang = localStorage.getItem('copilot-router-lang') || 'zh';
|
|
78
|
+
|
|
79
|
+
function t(key, params = {}) {
|
|
80
|
+
let text = i18n[currentLang][key] || key;
|
|
81
|
+
Object.keys(params).forEach(k => {
|
|
82
|
+
text = text.replace(`{${k}}`, params[k]);
|
|
83
|
+
});
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function updatePageLanguage() {
|
|
88
|
+
document.documentElement.lang = currentLang === 'zh' ? 'zh-CN' : 'en';
|
|
89
|
+
document.getElementById('langBtn').textContent = currentLang === 'zh' ? 'EN' : '中文';
|
|
90
|
+
|
|
91
|
+
// Update all elements with data-i18n attribute
|
|
92
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
93
|
+
const key = el.getAttribute('data-i18n');
|
|
94
|
+
el.textContent = t(key);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Update placeholders
|
|
98
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
99
|
+
const key = el.getAttribute('data-i18n-placeholder');
|
|
100
|
+
el.placeholder = t(key);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toggleLanguage() {
|
|
105
|
+
currentLang = currentLang === 'zh' ? 'en' : 'zh';
|
|
106
|
+
localStorage.setItem('copilot-router-lang', currentLang);
|
|
107
|
+
updatePageLanguage();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Apply language settings on page load
|
|
111
|
+
document.addEventListener('DOMContentLoaded', updatePageLanguage);
|
|
112
|
+
|
|
113
|
+
let deviceCode = null;
|
|
114
|
+
let pollInterval = null;
|
|
115
|
+
|
|
116
|
+
function showTab(tab) {
|
|
117
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
118
|
+
document.querySelectorAll('.tabs .tab').forEach((t, i) => {
|
|
119
|
+
if ((tab === 'login' && i === 0) || (tab === 'direct' && i === 1) || (tab === 'tokens' && i === 2)) {
|
|
120
|
+
t.classList.add('active');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
document.getElementById('tab-login').style.display = tab === 'login' ? 'block' : 'none';
|
|
124
|
+
document.getElementById('tab-direct').style.display = tab === 'direct' ? 'block' : 'none';
|
|
125
|
+
document.getElementById('tab-tokens').style.display = tab === 'tokens' ? 'block' : 'none';
|
|
126
|
+
|
|
127
|
+
if (tab === 'tokens') loadTokens();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function startLogin() {
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch('/auth/login', { method: 'POST' });
|
|
133
|
+
const data = await res.json();
|
|
134
|
+
|
|
135
|
+
if (data.error) {
|
|
136
|
+
showError(data.error.message);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
deviceCode = data;
|
|
141
|
+
document.getElementById('userCode').textContent = data.user_code;
|
|
142
|
+
document.getElementById('verifyLink').href = data.verification_uri;
|
|
143
|
+
|
|
144
|
+
showStep('step2');
|
|
145
|
+
startPolling();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
showError(t('connect_failed'));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startPolling() {
|
|
152
|
+
pollInterval = setInterval(async () => {
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch('/auth/complete', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
device_code: deviceCode.device_code,
|
|
159
|
+
interval: deviceCode.interval,
|
|
160
|
+
expires_in: deviceCode.expires_in
|
|
161
|
+
})
|
|
162
|
+
});
|
|
163
|
+
const data = await res.json();
|
|
164
|
+
|
|
165
|
+
// Handle new response format
|
|
166
|
+
if (data.status === 'pending' || data.status === 'processing' || data.status === 'slow_down') {
|
|
167
|
+
// Waiting for authorization or processing, continue polling
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (data.status === 'success') {
|
|
172
|
+
clearInterval(pollInterval);
|
|
173
|
+
pollInterval = null;
|
|
174
|
+
document.getElementById('successMessage').textContent =
|
|
175
|
+
`${t('account_added')}: ${data.username || 'Unknown'}`;
|
|
176
|
+
showStep('step3');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (data.error) {
|
|
181
|
+
clearInterval(pollInterval);
|
|
182
|
+
pollInterval = null;
|
|
183
|
+
showError(data.error.message || t('auth_failed'));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
// Network error, continue polling
|
|
188
|
+
console.error('Polling error:', e);
|
|
189
|
+
}
|
|
190
|
+
}, (deviceCode.interval + 1) * 1000);
|
|
191
|
+
|
|
192
|
+
// Timeout handling
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
if (pollInterval) {
|
|
195
|
+
clearInterval(pollInterval);
|
|
196
|
+
showError(t('timeout'));
|
|
197
|
+
}
|
|
198
|
+
}, deviceCode.expires_in * 1000);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function addTokenDirect() {
|
|
202
|
+
const token = document.getElementById('directToken').value.trim();
|
|
203
|
+
if (!token) {
|
|
204
|
+
document.getElementById('directResult').innerHTML =
|
|
205
|
+
`<div class="status error" style="margin-top:15px">${t('enter_token')}</div>`;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch('/auth/tokens', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ github_token: token })
|
|
214
|
+
});
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
|
|
217
|
+
if (data.error) {
|
|
218
|
+
document.getElementById('directResult').innerHTML =
|
|
219
|
+
`<div class="status error" style="margin-top:15px">${data.error.message}</div>`;
|
|
220
|
+
} else {
|
|
221
|
+
document.getElementById('directResult').innerHTML =
|
|
222
|
+
`<div class="status success" style="margin-top:15px">${t('add_success')}: ${data.username}</div>`;
|
|
223
|
+
document.getElementById('directToken').value = '';
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
document.getElementById('directResult').innerHTML =
|
|
227
|
+
`<div class="status error" style="margin-top:15px">${t('add_failed')}</div>`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function loadTokens() {
|
|
232
|
+
try {
|
|
233
|
+
const res = await fetch('/auth/tokens');
|
|
234
|
+
const data = await res.json();
|
|
235
|
+
|
|
236
|
+
if (!data.tokens || data.tokens.length === 0) {
|
|
237
|
+
document.getElementById('tokenList').innerHTML =
|
|
238
|
+
`<p style="color: #666; text-align: center; padding: 20px;">${t('no_tokens')}</p>`;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let html = `<p style="margin-bottom:15px">${t('token_count', { total: data.total, active: data.active })}</p>`;
|
|
243
|
+
for (const tok of data.tokens) {
|
|
244
|
+
html += `
|
|
245
|
+
<div class="token-item">
|
|
246
|
+
<div class="token-info">
|
|
247
|
+
<strong>${tok.username || 'Unknown'}</strong><br>
|
|
248
|
+
<small>ID: ${tok.id} | ${tok.is_active ? t('active') : t('inactive')} | ${t('requests')}: ${tok.request_count}</small>
|
|
249
|
+
</div>
|
|
250
|
+
<button class="btn-delete" onclick="deleteToken(${tok.id})">${t('btn_delete')}</button>
|
|
251
|
+
</div>
|
|
252
|
+
`;
|
|
253
|
+
}
|
|
254
|
+
document.getElementById('tokenList').innerHTML = html;
|
|
255
|
+
} catch (e) {
|
|
256
|
+
document.getElementById('tokenList').innerHTML =
|
|
257
|
+
`<p style="color: red; text-align: center; padding: 20px;">${t('load_failed')}</p>`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function deleteToken(id) {
|
|
262
|
+
if (!confirm(t('confirm_delete'))) return;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await fetch(`/auth/tokens/${id}`, { method: 'DELETE' });
|
|
266
|
+
loadTokens();
|
|
267
|
+
} catch (e) {
|
|
268
|
+
alert(t('delete_failed'));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showStep(stepId) {
|
|
273
|
+
document.querySelectorAll('#tab-login .step').forEach(s => s.classList.remove('active'));
|
|
274
|
+
document.getElementById(stepId).classList.add('active');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function showError(message) {
|
|
278
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
279
|
+
document.getElementById('errorMessage').textContent = message;
|
|
280
|
+
showStep('stepError');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resetLogin() {
|
|
284
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
285
|
+
deviceCode = null;
|
|
286
|
+
showStep('step1');
|
|
287
|
+
}
|
package/public/index.html
CHANGED
|
@@ -1,419 +1,81 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="zh-CN">
|
|
3
|
+
|
|
3
4
|
<head>
|
|
4
5
|
<meta charset="UTF-8">
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>Copilot Router
|
|
7
|
-
<style>
|
|
8
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
-
body {
|
|
10
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
12
|
-
min-height: 100vh;
|
|
13
|
-
display: flex;
|
|
14
|
-
align-items: center;
|
|
15
|
-
justify-content: center;
|
|
16
|
-
padding: 20px;
|
|
17
|
-
}
|
|
18
|
-
.container {
|
|
19
|
-
background: white;
|
|
20
|
-
border-radius: 16px;
|
|
21
|
-
padding: 40px;
|
|
22
|
-
max-width: 500px;
|
|
23
|
-
width: 100%;
|
|
24
|
-
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
25
|
-
}
|
|
26
|
-
h1 {
|
|
27
|
-
color: #333;
|
|
28
|
-
margin-bottom: 10px;
|
|
29
|
-
font-size: 28px;
|
|
30
|
-
}
|
|
31
|
-
.subtitle {
|
|
32
|
-
color: #666;
|
|
33
|
-
margin-bottom: 30px;
|
|
34
|
-
}
|
|
35
|
-
.step {
|
|
36
|
-
display: none;
|
|
37
|
-
animation: fadeIn 0.3s ease;
|
|
38
|
-
}
|
|
39
|
-
.step.active { display: block; }
|
|
40
|
-
@keyframes fadeIn {
|
|
41
|
-
from { opacity: 0; transform: translateY(10px); }
|
|
42
|
-
to { opacity: 1; transform: translateY(0); }
|
|
43
|
-
}
|
|
44
|
-
button {
|
|
45
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
46
|
-
color: white;
|
|
47
|
-
border: none;
|
|
48
|
-
padding: 14px 28px;
|
|
49
|
-
border-radius: 8px;
|
|
50
|
-
font-size: 16px;
|
|
51
|
-
cursor: pointer;
|
|
52
|
-
width: 100%;
|
|
53
|
-
transition: transform 0.2s, box-shadow 0.2s;
|
|
54
|
-
}
|
|
55
|
-
button:hover {
|
|
56
|
-
transform: translateY(-2px);
|
|
57
|
-
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
|
58
|
-
}
|
|
59
|
-
button:disabled {
|
|
60
|
-
background: #ccc;
|
|
61
|
-
cursor: not-allowed;
|
|
62
|
-
transform: none;
|
|
63
|
-
box-shadow: none;
|
|
64
|
-
}
|
|
65
|
-
.code-box {
|
|
66
|
-
background: #f5f5f5;
|
|
67
|
-
border: 2px dashed #667eea;
|
|
68
|
-
border-radius: 12px;
|
|
69
|
-
padding: 30px;
|
|
70
|
-
text-align: center;
|
|
71
|
-
margin: 20px 0;
|
|
72
|
-
}
|
|
73
|
-
.user-code {
|
|
74
|
-
font-size: 36px;
|
|
75
|
-
font-weight: bold;
|
|
76
|
-
color: #667eea;
|
|
77
|
-
letter-spacing: 4px;
|
|
78
|
-
font-family: monospace;
|
|
79
|
-
}
|
|
80
|
-
.verify-link {
|
|
81
|
-
display: inline-block;
|
|
82
|
-
margin-top: 15px;
|
|
83
|
-
color: #764ba2;
|
|
84
|
-
text-decoration: none;
|
|
85
|
-
font-weight: 500;
|
|
86
|
-
}
|
|
87
|
-
.verify-link:hover { text-decoration: underline; }
|
|
88
|
-
.status {
|
|
89
|
-
padding: 15px;
|
|
90
|
-
border-radius: 8px;
|
|
91
|
-
margin: 20px 0;
|
|
92
|
-
text-align: center;
|
|
93
|
-
}
|
|
94
|
-
.status.waiting {
|
|
95
|
-
background: #fff3cd;
|
|
96
|
-
color: #856404;
|
|
97
|
-
}
|
|
98
|
-
.status.success {
|
|
99
|
-
background: #d4edda;
|
|
100
|
-
color: #155724;
|
|
101
|
-
}
|
|
102
|
-
.status.error {
|
|
103
|
-
background: #f8d7da;
|
|
104
|
-
color: #721c24;
|
|
105
|
-
}
|
|
106
|
-
.spinner {
|
|
107
|
-
display: inline-block;
|
|
108
|
-
width: 20px;
|
|
109
|
-
height: 20px;
|
|
110
|
-
border: 3px solid #f3f3f3;
|
|
111
|
-
border-top: 3px solid #667eea;
|
|
112
|
-
border-radius: 50%;
|
|
113
|
-
animation: spin 1s linear infinite;
|
|
114
|
-
margin-right: 10px;
|
|
115
|
-
vertical-align: middle;
|
|
116
|
-
}
|
|
117
|
-
@keyframes spin {
|
|
118
|
-
0% { transform: rotate(0deg); }
|
|
119
|
-
100% { transform: rotate(360deg); }
|
|
120
|
-
}
|
|
121
|
-
.token-list {
|
|
122
|
-
margin-top: 20px;
|
|
123
|
-
}
|
|
124
|
-
.token-item {
|
|
125
|
-
background: #f8f9fa;
|
|
126
|
-
padding: 15px;
|
|
127
|
-
border-radius: 8px;
|
|
128
|
-
margin-bottom: 10px;
|
|
129
|
-
display: flex;
|
|
130
|
-
justify-content: space-between;
|
|
131
|
-
align-items: center;
|
|
132
|
-
}
|
|
133
|
-
.token-info strong {
|
|
134
|
-
color: #333;
|
|
135
|
-
}
|
|
136
|
-
.token-info small {
|
|
137
|
-
color: #666;
|
|
138
|
-
}
|
|
139
|
-
.btn-delete {
|
|
140
|
-
background: #dc3545;
|
|
141
|
-
padding: 8px 16px;
|
|
142
|
-
font-size: 14px;
|
|
143
|
-
width: auto;
|
|
144
|
-
}
|
|
145
|
-
.tabs {
|
|
146
|
-
display: flex;
|
|
147
|
-
margin-bottom: 20px;
|
|
148
|
-
border-bottom: 2px solid #eee;
|
|
149
|
-
}
|
|
150
|
-
.tab {
|
|
151
|
-
padding: 10px 20px;
|
|
152
|
-
cursor: pointer;
|
|
153
|
-
border-bottom: 2px solid transparent;
|
|
154
|
-
margin-bottom: -2px;
|
|
155
|
-
color: #666;
|
|
156
|
-
}
|
|
157
|
-
.tab.active {
|
|
158
|
-
border-bottom-color: #667eea;
|
|
159
|
-
color: #667eea;
|
|
160
|
-
font-weight: 500;
|
|
161
|
-
}
|
|
162
|
-
input[type="text"] {
|
|
163
|
-
width: 100%;
|
|
164
|
-
padding: 12px;
|
|
165
|
-
border: 2px solid #ddd;
|
|
166
|
-
border-radius: 8px;
|
|
167
|
-
font-size: 14px;
|
|
168
|
-
margin-bottom: 15px;
|
|
169
|
-
}
|
|
170
|
-
input[type="text"]:focus {
|
|
171
|
-
border-color: #667eea;
|
|
172
|
-
outline: none;
|
|
173
|
-
}
|
|
174
|
-
</style>
|
|
7
|
+
<title>Copilot Router</title>
|
|
8
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
175
9
|
</head>
|
|
10
|
+
|
|
176
11
|
<body>
|
|
177
12
|
<div class="container">
|
|
13
|
+
<button class="lang-switch" onclick="toggleLanguage()" id="langBtn">EN</button>
|
|
178
14
|
<h1>🚀 Copilot Router</h1>
|
|
179
|
-
<p class="subtitle">GitHub Copilot API 代理服务</p>
|
|
15
|
+
<p class="subtitle" data-i18n="subtitle">GitHub Copilot API 代理服务</p>
|
|
180
16
|
|
|
181
17
|
<div class="tabs">
|
|
182
|
-
<div class="tab active" onclick="showTab('login')">设备码登录</div>
|
|
183
|
-
<div class="tab" onclick="showTab('direct')">直接添加 Token</div>
|
|
184
|
-
<div class="tab" onclick="showTab('tokens')">管理 Tokens</div>
|
|
18
|
+
<div class="tab active" onclick="showTab('login')" data-i18n="tab_login">设备码登录</div>
|
|
19
|
+
<div class="tab" onclick="showTab('direct')" data-i18n="tab_direct">直接添加 Token</div>
|
|
20
|
+
<div class="tab" onclick="showTab('tokens')" data-i18n="tab_manage">管理 Tokens</div>
|
|
185
21
|
</div>
|
|
186
22
|
|
|
187
23
|
<!-- 设备码登录 -->
|
|
188
24
|
<div id="tab-login">
|
|
189
25
|
<div id="step1" class="step active">
|
|
190
|
-
<p style="margin-bottom: 20px;">使用 GitHub 设备码流程登录,支持添加多个账号实现负载均衡。</p>
|
|
191
|
-
<button onclick="startLogin()">开始登录</button>
|
|
26
|
+
<p style="margin-bottom: 20px;" data-i18n="login_desc">使用 GitHub 设备码流程登录,支持添加多个账号实现负载均衡。</p>
|
|
27
|
+
<button onclick="startLogin()" data-i18n="btn_start_login">开始登录</button>
|
|
192
28
|
</div>
|
|
193
29
|
|
|
194
30
|
<div id="step2" class="step">
|
|
195
31
|
<div class="code-box">
|
|
196
|
-
<p>请在 GitHub 输入此验证码:</p>
|
|
32
|
+
<p data-i18n="enter_code">请在 GitHub 输入此验证码:</p>
|
|
197
33
|
<div class="user-code" id="userCode">----</div>
|
|
198
|
-
<a href="#" id="verifyLink" class="verify-link" target="_blank">
|
|
34
|
+
<a href="#" id="verifyLink" class="verify-link" target="_blank" data-i18n="open_github">
|
|
199
35
|
点击这里打开 GitHub 验证页面 →
|
|
200
36
|
</a>
|
|
201
37
|
</div>
|
|
202
38
|
<div class="status waiting">
|
|
203
39
|
<span class="spinner"></span>
|
|
204
|
-
|
|
40
|
+
<span data-i18n="waiting_auth">等待授权中... 请在 GitHub 完成验证</span>
|
|
205
41
|
</div>
|
|
206
42
|
</div>
|
|
207
43
|
|
|
208
44
|
<div id="step3" class="step">
|
|
209
|
-
<div class="status success">
|
|
45
|
+
<div class="status success" data-i18n="login_success">
|
|
210
46
|
✅ 登录成功!
|
|
211
47
|
</div>
|
|
212
48
|
<p id="successMessage" style="text-align: center; margin: 20px 0;"></p>
|
|
213
|
-
<button onclick="resetLogin()">添加另一个账号</button>
|
|
49
|
+
<button onclick="resetLogin()" data-i18n="btn_add_another">添加另一个账号</button>
|
|
214
50
|
</div>
|
|
215
51
|
|
|
216
52
|
<div id="stepError" class="step">
|
|
217
53
|
<div class="status error">
|
|
218
|
-
❌ <span id="errorMessage">登录失败</span>
|
|
54
|
+
❌ <span id="errorMessage" data-i18n="login_failed">登录失败</span>
|
|
219
55
|
</div>
|
|
220
|
-
<button onclick="resetLogin()" style="margin-top: 15px;">重试</button>
|
|
56
|
+
<button onclick="resetLogin()" style="margin-top: 15px;" data-i18n="btn_retry">重试</button>
|
|
221
57
|
</div>
|
|
222
58
|
</div>
|
|
223
59
|
|
|
224
60
|
<!-- 直接添加 Token -->
|
|
225
61
|
<div id="tab-direct" style="display: none;">
|
|
226
|
-
<p style="margin-bottom: 20px;">如果你已经有 GitHub Token,可以直接添加:</p>
|
|
227
|
-
<input type="text" id="directToken" placeholder="
|
|
228
|
-
|
|
62
|
+
<p style="margin-bottom: 20px;" data-i18n="direct_desc">如果你已经有 GitHub Token,可以直接添加:</p>
|
|
63
|
+
<input type="text" id="directToken" data-i18n-placeholder="token_placeholder"
|
|
64
|
+
placeholder="输入 GitHub Token (ghu_xxx 或 gho_xxx)">
|
|
65
|
+
<button onclick="addTokenDirect()" data-i18n="btn_add_token">添加 Token</button>
|
|
229
66
|
<div id="directResult"></div>
|
|
230
67
|
</div>
|
|
231
68
|
|
|
232
69
|
<!-- 管理 Tokens -->
|
|
233
70
|
<div id="tab-tokens" style="display: none;">
|
|
234
|
-
<button onclick="loadTokens()">刷新列表</button>
|
|
71
|
+
<button onclick="loadTokens()" data-i18n="btn_refresh">刷新列表</button>
|
|
235
72
|
<div id="tokenList" class="token-list">
|
|
236
|
-
<p style="color: #666; text-align: center; padding: 20px;">点击刷新加载 Token 列表</p>
|
|
73
|
+
<p style="color: #666; text-align: center; padding: 20px;" data-i18n="click_refresh">点击刷新加载 Token 列表</p>
|
|
237
74
|
</div>
|
|
238
75
|
</div>
|
|
239
76
|
</div>
|
|
240
77
|
|
|
241
|
-
<script>
|
|
242
|
-
let deviceCode = null;
|
|
243
|
-
let pollInterval = null;
|
|
244
|
-
|
|
245
|
-
function showTab(tab) {
|
|
246
|
-
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
247
|
-
document.querySelectorAll('.tabs .tab').forEach((t, i) => {
|
|
248
|
-
if ((tab === 'login' && i === 0) || (tab === 'direct' && i === 1) || (tab === 'tokens' && i === 2)) {
|
|
249
|
-
t.classList.add('active');
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
document.getElementById('tab-login').style.display = tab === 'login' ? 'block' : 'none';
|
|
253
|
-
document.getElementById('tab-direct').style.display = tab === 'direct' ? 'block' : 'none';
|
|
254
|
-
document.getElementById('tab-tokens').style.display = tab === 'tokens' ? 'block' : 'none';
|
|
255
|
-
|
|
256
|
-
if (tab === 'tokens') loadTokens();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async function startLogin() {
|
|
260
|
-
try {
|
|
261
|
-
const res = await fetch('/auth/login', { method: 'POST' });
|
|
262
|
-
const data = await res.json();
|
|
263
|
-
|
|
264
|
-
if (data.error) {
|
|
265
|
-
showError(data.error.message);
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
deviceCode = data;
|
|
270
|
-
document.getElementById('userCode').textContent = data.user_code;
|
|
271
|
-
document.getElementById('verifyLink').href = data.verification_uri;
|
|
272
|
-
|
|
273
|
-
showStep('step2');
|
|
274
|
-
startPolling();
|
|
275
|
-
} catch (e) {
|
|
276
|
-
showError('无法连接服务器');
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function startPolling() {
|
|
281
|
-
pollInterval = setInterval(async () => {
|
|
282
|
-
try {
|
|
283
|
-
const res = await fetch('/auth/complete', {
|
|
284
|
-
method: 'POST',
|
|
285
|
-
headers: { 'Content-Type': 'application/json' },
|
|
286
|
-
body: JSON.stringify({
|
|
287
|
-
device_code: deviceCode.device_code,
|
|
288
|
-
interval: deviceCode.interval,
|
|
289
|
-
expires_in: deviceCode.expires_in
|
|
290
|
-
})
|
|
291
|
-
});
|
|
292
|
-
const data = await res.json();
|
|
293
|
-
|
|
294
|
-
// 处理新的响应格式
|
|
295
|
-
if (data.status === 'pending' || data.status === 'processing' || data.status === 'slow_down') {
|
|
296
|
-
// 还在等待授权或处理中,继续轮询
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (data.status === 'success') {
|
|
301
|
-
clearInterval(pollInterval);
|
|
302
|
-
pollInterval = null;
|
|
303
|
-
document.getElementById('successMessage').textContent =
|
|
304
|
-
`已添加账号: ${data.username || 'Unknown'}`;
|
|
305
|
-
showStep('step3');
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (data.error) {
|
|
310
|
-
clearInterval(pollInterval);
|
|
311
|
-
pollInterval = null;
|
|
312
|
-
showError(data.error.message || '授权失败');
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
} catch (e) {
|
|
316
|
-
// 网络错误,继续轮询
|
|
317
|
-
console.error('Polling error:', e);
|
|
318
|
-
}
|
|
319
|
-
}, (deviceCode.interval + 1) * 1000);
|
|
320
|
-
|
|
321
|
-
// 超时处理
|
|
322
|
-
setTimeout(() => {
|
|
323
|
-
if (pollInterval) {
|
|
324
|
-
clearInterval(pollInterval);
|
|
325
|
-
showError('验证超时,请重试');
|
|
326
|
-
}
|
|
327
|
-
}, deviceCode.expires_in * 1000);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async function addTokenDirect() {
|
|
331
|
-
const token = document.getElementById('directToken').value.trim();
|
|
332
|
-
if (!token) {
|
|
333
|
-
document.getElementById('directResult').innerHTML =
|
|
334
|
-
'<div class="status error" style="margin-top:15px">请输入 Token</div>';
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
const res = await fetch('/auth/tokens', {
|
|
340
|
-
method: 'POST',
|
|
341
|
-
headers: { 'Content-Type': 'application/json' },
|
|
342
|
-
body: JSON.stringify({ github_token: token })
|
|
343
|
-
});
|
|
344
|
-
const data = await res.json();
|
|
345
|
-
|
|
346
|
-
if (data.error) {
|
|
347
|
-
document.getElementById('directResult').innerHTML =
|
|
348
|
-
`<div class="status error" style="margin-top:15px">${data.error.message}</div>`;
|
|
349
|
-
} else {
|
|
350
|
-
document.getElementById('directResult').innerHTML =
|
|
351
|
-
`<div class="status success" style="margin-top:15px">✅ 添加成功: ${data.username}</div>`;
|
|
352
|
-
document.getElementById('directToken').value = '';
|
|
353
|
-
}
|
|
354
|
-
} catch (e) {
|
|
355
|
-
document.getElementById('directResult').innerHTML =
|
|
356
|
-
'<div class="status error" style="margin-top:15px">添加失败</div>';
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function loadTokens() {
|
|
361
|
-
try {
|
|
362
|
-
const res = await fetch('/auth/tokens');
|
|
363
|
-
const data = await res.json();
|
|
364
|
-
|
|
365
|
-
if (!data.tokens || data.tokens.length === 0) {
|
|
366
|
-
document.getElementById('tokenList').innerHTML =
|
|
367
|
-
'<p style="color: #666; text-align: center; padding: 20px;">暂无 Token,请先登录添加</p>';
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
let html = `<p style="margin-bottom:15px">共 ${data.total} 个 Token,${data.active} 个活跃</p>`;
|
|
372
|
-
for (const t of data.tokens) {
|
|
373
|
-
html += `
|
|
374
|
-
<div class="token-item">
|
|
375
|
-
<div class="token-info">
|
|
376
|
-
<strong>${t.username || 'Unknown'}</strong><br>
|
|
377
|
-
<small>ID: ${t.id} | ${t.is_active ? '✅ 活跃' : '❌ 停用'} | 请求: ${t.request_count}</small>
|
|
378
|
-
</div>
|
|
379
|
-
<button class="btn-delete" onclick="deleteToken(${t.id})">删除</button>
|
|
380
|
-
</div>
|
|
381
|
-
`;
|
|
382
|
-
}
|
|
383
|
-
document.getElementById('tokenList').innerHTML = html;
|
|
384
|
-
} catch (e) {
|
|
385
|
-
document.getElementById('tokenList').innerHTML =
|
|
386
|
-
'<p style="color: red; text-align: center; padding: 20px;">加载失败</p>';
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function deleteToken(id) {
|
|
391
|
-
if (!confirm('确定要删除这个 Token 吗?')) return;
|
|
392
|
-
|
|
393
|
-
try {
|
|
394
|
-
await fetch(`/auth/tokens/${id}`, { method: 'DELETE' });
|
|
395
|
-
loadTokens();
|
|
396
|
-
} catch (e) {
|
|
397
|
-
alert('删除失败');
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function showStep(stepId) {
|
|
402
|
-
document.querySelectorAll('#tab-login .step').forEach(s => s.classList.remove('active'));
|
|
403
|
-
document.getElementById(stepId).classList.add('active');
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function showError(message) {
|
|
407
|
-
if (pollInterval) clearInterval(pollInterval);
|
|
408
|
-
document.getElementById('errorMessage').textContent = message;
|
|
409
|
-
showStep('stepError');
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function resetLogin() {
|
|
413
|
-
if (pollInterval) clearInterval(pollInterval);
|
|
414
|
-
deviceCode = null;
|
|
415
|
-
showStep('step1');
|
|
416
|
-
}
|
|
417
|
-
</script>
|
|
78
|
+
<script src="/static/app.js"></script>
|
|
418
79
|
</body>
|
|
419
|
-
|
|
80
|
+
|
|
81
|
+
</html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
9
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
10
|
+
min-height: 100vh;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.container {
|
|
18
|
+
background: white;
|
|
19
|
+
border-radius: 16px;
|
|
20
|
+
padding: 40px;
|
|
21
|
+
max-width: 500px;
|
|
22
|
+
width: 100%;
|
|
23
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
24
|
+
position: relative;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.lang-switch {
|
|
28
|
+
position: absolute;
|
|
29
|
+
top: 15px;
|
|
30
|
+
right: 15px;
|
|
31
|
+
background: rgba(255, 255, 255, 0.8);
|
|
32
|
+
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
33
|
+
padding: 4px 10px;
|
|
34
|
+
border-radius: 12px;
|
|
35
|
+
font-size: 12px;
|
|
36
|
+
font-weight: 500;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
color: #666;
|
|
39
|
+
transition: all 0.2s ease;
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 4px;
|
|
43
|
+
z-index: 10;
|
|
44
|
+
backdrop-filter: blur(5px);
|
|
45
|
+
width: auto;
|
|
46
|
+
box-shadow: none;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.lang-switch:hover {
|
|
50
|
+
background: white;
|
|
51
|
+
transform: translateY(-1px);
|
|
52
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
53
|
+
color: #667eea;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.lang-switch::before {
|
|
57
|
+
content: "🌐";
|
|
58
|
+
font-size: 14px;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
h1 {
|
|
62
|
+
color: #333;
|
|
63
|
+
margin-bottom: 10px;
|
|
64
|
+
font-size: 28px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.subtitle {
|
|
68
|
+
color: #666;
|
|
69
|
+
margin-bottom: 30px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.step {
|
|
73
|
+
display: none;
|
|
74
|
+
animation: fadeIn 0.3s ease;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.step.active {
|
|
78
|
+
display: block;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@keyframes fadeIn {
|
|
82
|
+
from {
|
|
83
|
+
opacity: 0;
|
|
84
|
+
transform: translateY(10px);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
to {
|
|
88
|
+
opacity: 1;
|
|
89
|
+
transform: translateY(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
button {
|
|
94
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
95
|
+
color: white;
|
|
96
|
+
border: none;
|
|
97
|
+
padding: 14px 28px;
|
|
98
|
+
border-radius: 8px;
|
|
99
|
+
font-size: 16px;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
width: 100%;
|
|
102
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
button:hover {
|
|
106
|
+
transform: translateY(-2px);
|
|
107
|
+
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
button:disabled {
|
|
111
|
+
background: #ccc;
|
|
112
|
+
cursor: not-allowed;
|
|
113
|
+
transform: none;
|
|
114
|
+
box-shadow: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.code-box {
|
|
118
|
+
background: #f5f5f5;
|
|
119
|
+
border: 2px dashed #667eea;
|
|
120
|
+
border-radius: 12px;
|
|
121
|
+
padding: 30px;
|
|
122
|
+
text-align: center;
|
|
123
|
+
margin: 20px 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.user-code {
|
|
127
|
+
font-size: 36px;
|
|
128
|
+
font-weight: bold;
|
|
129
|
+
color: #667eea;
|
|
130
|
+
letter-spacing: 4px;
|
|
131
|
+
font-family: monospace;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.verify-link {
|
|
135
|
+
display: inline-block;
|
|
136
|
+
margin-top: 15px;
|
|
137
|
+
color: #764ba2;
|
|
138
|
+
text-decoration: none;
|
|
139
|
+
font-weight: 500;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.verify-link:hover {
|
|
143
|
+
text-decoration: underline;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.status {
|
|
147
|
+
padding: 15px;
|
|
148
|
+
border-radius: 8px;
|
|
149
|
+
margin: 20px 0;
|
|
150
|
+
text-align: center;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.status.waiting {
|
|
154
|
+
background: #fff3cd;
|
|
155
|
+
color: #856404;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.status.success {
|
|
159
|
+
background: #d4edda;
|
|
160
|
+
color: #155724;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.status.error {
|
|
164
|
+
background: #f8d7da;
|
|
165
|
+
color: #721c24;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.spinner {
|
|
169
|
+
display: inline-block;
|
|
170
|
+
width: 20px;
|
|
171
|
+
height: 20px;
|
|
172
|
+
border: 3px solid #f3f3f3;
|
|
173
|
+
border-top: 3px solid #667eea;
|
|
174
|
+
border-radius: 50%;
|
|
175
|
+
animation: spin 1s linear infinite;
|
|
176
|
+
margin-right: 10px;
|
|
177
|
+
vertical-align: middle;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@keyframes spin {
|
|
181
|
+
0% {
|
|
182
|
+
transform: rotate(0deg);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
100% {
|
|
186
|
+
transform: rotate(360deg);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.token-list {
|
|
191
|
+
margin-top: 20px;
|
|
192
|
+
max-height: 350px;
|
|
193
|
+
overflow-y: auto;
|
|
194
|
+
padding-right: 5px;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.token-list::-webkit-scrollbar {
|
|
198
|
+
width: 6px;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.token-list::-webkit-scrollbar-track {
|
|
202
|
+
background: transparent;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.token-list::-webkit-scrollbar-thumb {
|
|
206
|
+
background: #ddd;
|
|
207
|
+
border-radius: 3px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.token-list::-webkit-scrollbar-thumb:hover {
|
|
211
|
+
background: #ccc;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.token-item {
|
|
215
|
+
background: #fff;
|
|
216
|
+
border: 1px solid #eee;
|
|
217
|
+
padding: 12px 16px;
|
|
218
|
+
border-radius: 10px;
|
|
219
|
+
margin-bottom: 8px;
|
|
220
|
+
display: flex;
|
|
221
|
+
justify-content: space-between;
|
|
222
|
+
align-items: center;
|
|
223
|
+
transition: all 0.2s;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.token-item:hover {
|
|
227
|
+
border-color: #667eea;
|
|
228
|
+
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
|
|
229
|
+
transform: translateY(-1px);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.token-info strong {
|
|
233
|
+
color: #333;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.token-info small {
|
|
237
|
+
color: #666;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.btn-delete {
|
|
241
|
+
background: #dc3545;
|
|
242
|
+
padding: 8px 16px;
|
|
243
|
+
font-size: 14px;
|
|
244
|
+
width: auto;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.tabs {
|
|
248
|
+
display: flex;
|
|
249
|
+
margin-bottom: 20px;
|
|
250
|
+
border-bottom: 2px solid #eee;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.tab {
|
|
254
|
+
padding: 10px 20px;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
border-bottom: 2px solid transparent;
|
|
257
|
+
margin-bottom: -2px;
|
|
258
|
+
color: #666;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.tab.active {
|
|
262
|
+
border-bottom-color: #667eea;
|
|
263
|
+
color: #667eea;
|
|
264
|
+
font-weight: 500;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
input[type="text"] {
|
|
268
|
+
width: 100%;
|
|
269
|
+
padding: 12px;
|
|
270
|
+
border: 2px solid #ddd;
|
|
271
|
+
border-radius: 8px;
|
|
272
|
+
font-size: 14px;
|
|
273
|
+
margin-bottom: 15px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
input[type="text"]:focus {
|
|
277
|
+
border-color: #667eea;
|
|
278
|
+
outline: none;
|
|
279
|
+
}
|