index.html 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8"/>
  5. <title>Amazonq2api 前端控制台 · 账号管理 + Chat 测试</title>
  6. <meta name="viewport" content="width=device-width,initial-scale=1"/>
  7. <style>
  8. :root {
  9. --bg:#0a0e1a;
  10. --panel:#0f1420;
  11. --muted:#8b95a8;
  12. --text:#e8f0ff;
  13. --accent:#4f8fff;
  14. --danger:#ff4757;
  15. --ok:#2ed573;
  16. --warn:#ffa502;
  17. --border:#1a2332;
  18. --chip:#141b28;
  19. --code:#0d1218;
  20. --glow:rgba(79,143,255,.15);
  21. }
  22. * { box-sizing:border-box; }
  23. html, body { height:100%; margin:0; }
  24. body {
  25. padding:0 0 40px;
  26. background:radial-gradient(ellipse at top, #0f1624 0%, #0a0e1a 100%);
  27. color:var(--text);
  28. font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Noto Sans,Arial,sans-serif;
  29. line-height:1.6;
  30. }
  31. h1,h2,h3 { font-weight:700; letter-spacing:-.02em; margin:0; }
  32. h1 { font-size:28px; margin:24px 0 12px; background:linear-gradient(135deg,#4f8fff,#7b9fff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
  33. h2 { font-size:18px; margin:20px 0 16px; color:#c5d4ff; }
  34. h3 { font-size:15px; margin:16px 0 10px; color:#a8b8d8; }
  35. .container { max-width:1280px; margin:0 auto; padding:20px; }
  36. .grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
  37. @media(max-width:1024px){ .grid { grid-template-columns:1fr; } }
  38. .panel {
  39. background:linear-gradient(145deg,rgba(15,20,32,.8),rgba(10,14,26,.9));
  40. border:1px solid var(--border);
  41. border-radius:16px;
  42. padding:24px;
  43. box-shadow:0 20px 60px rgba(0,0,0,.4),0 0 0 1px rgba(79,143,255,.08),inset 0 1px 0 rgba(255,255,255,.03);
  44. backdrop-filter:blur(12px);
  45. transition:transform .2s,box-shadow .2s;
  46. }
  47. .panel:hover { transform:translateY(-2px); box-shadow:0 24px 70px rgba(0,0,0,.5),0 0 0 1px rgba(79,143,255,.12),inset 0 1px 0 rgba(255,255,255,.04); }
  48. .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
  49. label { color:var(--muted); font-size:13px; font-weight:500; letter-spacing:.01em; }
  50. .field { display:flex; flex-direction:column; gap:8px; flex:1; min-width:200px; }
  51. input,textarea,select {
  52. background:rgba(12,16,28,.6);
  53. color:var(--text);
  54. border:1px solid var(--border);
  55. border-radius:12px;
  56. padding:12px 14px;
  57. outline:none;
  58. transition:all .2s;
  59. font-size:14px;
  60. box-shadow:inset 0 1px 2px rgba(0,0,0,.2);
  61. }
  62. input:focus,textarea:focus,select:focus {
  63. border-color:var(--accent);
  64. box-shadow:0 0 0 3px var(--glow),inset 0 1px 2px rgba(0,0,0,.2);
  65. background:rgba(12,16,28,.8);
  66. }
  67. textarea { min-height:140px; resize:vertical; font-family:ui-monospace,monospace; }
  68. button {
  69. background:linear-gradient(135deg,#2563eb,#1e40af);
  70. color:#fff;
  71. border:none;
  72. border-radius:12px;
  73. padding:12px 20px;
  74. font-weight:600;
  75. font-size:14px;
  76. cursor:pointer;
  77. transition:all .2s;
  78. box-shadow:0 4px 16px rgba(37,99,235,.3),inset 0 1px 0 rgba(255,255,255,.1);
  79. position:relative;
  80. overflow:hidden;
  81. }
  82. button:before {
  83. content:'';
  84. position:absolute;
  85. top:0;left:0;right:0;bottom:0;
  86. background:linear-gradient(135deg,rgba(255,255,255,.1),transparent);
  87. opacity:0;
  88. transition:opacity .2s;
  89. }
  90. button:hover { transform:translateY(-1px); box-shadow:0 6px 20px rgba(37,99,235,.4),inset 0 1px 0 rgba(255,255,255,.15); }
  91. button:hover:before { opacity:1; }
  92. button:active { transform:translateY(0); }
  93. button:disabled { opacity:.5; cursor:not-allowed; transform:none; }
  94. .btn-secondary { background:linear-gradient(135deg,#1e293b,#0f172a); box-shadow:0 4px 16px rgba(15,23,42,.3),inset 0 1px 0 rgba(255,255,255,.05); }
  95. .btn-secondary:hover { box-shadow:0 6px 20px rgba(15,23,42,.4),inset 0 1px 0 rgba(255,255,255,.08); }
  96. .btn-danger { background:linear-gradient(135deg,#dc2626,#991b1b); box-shadow:0 4px 16px rgba(220,38,38,.3),inset 0 1px 0 rgba(255,255,255,.1); }
  97. .btn-danger:hover { box-shadow:0 6px 20px rgba(220,38,38,.4),inset 0 1px 0 rgba(255,255,255,.15); }
  98. .btn-warn { background:linear-gradient(135deg,#f59e0b,#d97706); box-shadow:0 4px 16px rgba(245,158,11,.3),inset 0 1px 0 rgba(255,255,255,.1); }
  99. .btn-warn:hover { box-shadow:0 6px 20px rgba(245,158,11,.4),inset 0 1px 0 rgba(255,255,255,.15); }
  100. .kvs { display:grid; grid-template-columns:160px 1fr; gap:10px 16px; font-size:13px; }
  101. .muted { color:var(--muted); }
  102. .chip {
  103. display:inline-flex;
  104. align-items:center;
  105. gap:6px;
  106. padding:6px 12px;
  107. background:rgba(20,27,40,.8);
  108. border:1px solid var(--border);
  109. border-radius:20px;
  110. color:#a8b8ff;
  111. font-size:12px;
  112. font-weight:500;
  113. box-shadow:0 2px 8px rgba(0,0,0,.2);
  114. }
  115. .list { position:relative; max-height:600px; overflow:auto; padding:2px; }
  116. .list::-webkit-scrollbar { width:8px; }
  117. .list::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
  118. .list::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
  119. .list::-webkit-scrollbar-thumb:hover { background:rgba(79,143,255,.5); }
  120. .list-viewport { position:relative; }
  121. .list-content { display:flex; flex-direction:column; gap:12px; }
  122. .card {
  123. border:1px solid var(--border);
  124. border-radius:14px;
  125. padding:16px;
  126. background:linear-gradient(145deg,rgba(12,19,34,.6),rgba(10,14,26,.8));
  127. display:flex;
  128. flex-direction:column;
  129. gap:12px;
  130. box-shadow:0 4px 16px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.02);
  131. transition:all .2s;
  132. }
  133. .card:hover { border-color:rgba(79,143,255,.3); box-shadow:0 6px 20px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.03); }
  134. .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; }
  135. .code {
  136. background:var(--code);
  137. border:1px solid var(--border);
  138. border-radius:12px;
  139. padding:14px;
  140. color:#d8e8ff;
  141. max-height:300px;
  142. overflow:auto;
  143. white-space:pre-wrap;
  144. font-size:13px;
  145. line-height:1.6;
  146. box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
  147. }
  148. .code::-webkit-scrollbar { width:8px; height:8px; }
  149. .code::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
  150. .code::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
  151. .right { margin-left:auto; }
  152. .sep { height:1px; background:linear-gradient(90deg,transparent,rgba(79,143,255,.2),transparent); margin:16px 0; }
  153. .status-ok { color:var(--ok); font-weight:600; }
  154. .status-fail { color:var(--danger); font-weight:600; }
  155. .switch { position:relative; display:inline-block; width:50px; height:26px; }
  156. .switch input { opacity:0; width:0; height:0; }
  157. .slider {
  158. position:absolute;
  159. cursor:pointer;
  160. top:0;left:0;right:0;bottom:0;
  161. background:linear-gradient(135deg,#374151,#1f2937);
  162. transition:.3s;
  163. border-radius:26px;
  164. border:1px solid var(--border);
  165. box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
  166. }
  167. .slider:before {
  168. position:absolute;
  169. content:"";
  170. height:20px;
  171. width:20px;
  172. left:3px;
  173. bottom:2px;
  174. background:linear-gradient(135deg,#f3f4f6,#e5e7eb);
  175. transition:.3s;
  176. border-radius:50%;
  177. box-shadow:0 2px 6px rgba(0,0,0,.3);
  178. }
  179. input:checked+.slider { background:linear-gradient(135deg,#3b82f6,#2563eb); box-shadow:0 0 12px rgba(59,130,246,.4),inset 0 2px 4px rgba(0,0,0,.2); }
  180. input:checked+.slider:before { transform:translateX(24px); }
  181. @keyframes fadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
  182. .panel { animation:fadeIn .4s ease-out; }
  183. .tabs { display:flex; gap:8px; margin:20px 0 16px; border-bottom:2px solid var(--border); }
  184. .tab { padding:12px 24px; background:transparent; border:none; color:var(--muted); font-weight:600; font-size:14px; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all .2s; }
  185. .tab:hover { color:var(--text); background:rgba(79,143,255,.05); }
  186. .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
  187. .tab-content { display:none; }
  188. .tab-content.active { display:block; }
  189. </style>
  190. </head>
  191. <body>
  192. <div class="container">
  193. <h1>Q2api 前端控制台</h1>
  194. <div class="tabs">
  195. <button class="tab active" onclick="switchTab('accounts')">账号管理</button>
  196. <button class="tab" onclick="switchTab('create')">创建账号</button>
  197. <button class="tab" onclick="switchTab('login')">URL登录</button>
  198. <button class="tab" onclick="switchTab('chat')">Chat测试</button>
  199. </div>
  200. <div id="tab-accounts" class="tab-content active">
  201. <div class="panel">
  202. <h2>账号管理 <span id="accountCount" style="font-size: 0.8em; color: #666;"></span></h2>
  203. <div class="row">
  204. <button class="btn-secondary" onclick="loadAccounts()">刷新列表</button>
  205. <div style="margin-left: 20px;">
  206. <label><input type="radio" name="accountFilter" value="all" checked onchange="loadAccounts()"> 全部</label>
  207. <label style="margin-left: 10px;"><input type="radio" name="accountFilter" value="enabled" onchange="loadAccounts()"> 已启用</label>
  208. <label style="margin-left: 10px;"><input type="radio" name="accountFilter" value="disabled" onchange="loadAccounts()"> 已禁用</label>
  209. </div>
  210. <div style="margin-left: 20px;">
  211. <label>排序:
  212. <select id="sortBy" onchange="loadAccounts()">
  213. <option value="created_at">创建日期</option>
  214. <option value="success_count">成功次数</option>
  215. </select>
  216. </label>
  217. <label style="margin-left: 10px;">
  218. <select id="sortOrder" onchange="loadAccounts()">
  219. <option value="desc">降序</option>
  220. <option value="asc">升序</option>
  221. </select>
  222. </label>
  223. </div>
  224. </div>
  225. <div class="list" id="accounts"></div>
  226. </div>
  227. </div>
  228. <div id="tab-create" class="tab-content">
  229. <div class="panel">
  230. <h2>创建账号</h2>
  231. <div class="row">
  232. <div class="field"><label>label</label><input id="new_label" /></div>
  233. <div class="field"><label>clientId</label><input id="new_clientId" /></div>
  234. <div class="field"><label>clientSecret</label><input id="new_clientSecret" /></div>
  235. </div>
  236. <div class="row">
  237. <div class="field"><label>refreshToken</label><input id="new_refreshToken" /></div>
  238. <div class="field"><label>accessToken</label><input id="new_accessToken" /></div>
  239. </div>
  240. <div class="row">
  241. <div class="field">
  242. <label>other(JSON,可选)</label>
  243. <textarea id="new_other" placeholder='{"note":"备注"}'></textarea>
  244. </div>
  245. <div class="field" style="max-width:220px">
  246. <label>启用(仅启用账号会被用于请求)</label>
  247. <div>
  248. <label class="switch">
  249. <input id="new_enabled" type="checkbox" checked />
  250. <span class="slider"></span>
  251. </label>
  252. </div>
  253. </div>
  254. </div>
  255. <div class="row">
  256. <button onclick="createAccount()">创建</button>
  257. </div>
  258. </div>
  259. </div>
  260. <div id="tab-login" class="tab-content">
  261. <div class="panel">
  262. <h2>URL 登录(5分钟超时)</h2>
  263. <div class="row">
  264. <div class="field"><label>label(可选)</label><input id="auth_label" /></div>
  265. <div class="field" style="max-width:220px">
  266. <label>启用(登录成功后新账号是否启用)</label>
  267. <div>
  268. <label class="switch">
  269. <input id="auth_enabled" type="checkbox" checked />
  270. <span class="slider"></span>
  271. </label>
  272. </div>
  273. </div>
  274. </div>
  275. <div class="row">
  276. <button onclick="startAuth()">开始登录</button>
  277. <button class="btn-secondary" onclick="claimAuth()">等待授权并创建账号</button>
  278. </div>
  279. <div class="field">
  280. <label>登录信息</label>
  281. <pre class="code mono" id="auth_info">尚未开始</pre>
  282. </div>
  283. </div>
  284. </div>
  285. <div id="tab-chat" class="tab-content">
  286. <div class="panel">
  287. <h2>Chat 测试(/v2/chat/test)</h2>
  288. <div class="row">
  289. <div class="field" style="max-width:300px">
  290. <label>model</label>
  291. <input id="model" value="claude-haiku-4.5" />
  292. </div>
  293. <div class="field" style="max-width:180px">
  294. <label>是否流式</label>
  295. <select id="stream">
  296. <option value="false">false(默认)</option>
  297. <option value="true">true(SSE)</option>
  298. </select>
  299. </div>
  300. <div class="field" style="max-width:200px">
  301. <label>账号</label>
  302. <select id="chatAccount">
  303. <option value="">随机</option>
  304. </select>
  305. </div>
  306. <button class="right" onclick="send()">发送请求</button>
  307. </div>
  308. <div class="field">
  309. <label>messages(JSON)</label>
  310. <textarea id="messages">[
  311. {"role":"system","content":"你是一个乐于助人的助手"},
  312. {"role":"user","content":"你好,请讲一个简短的故事"}
  313. ]</textarea>
  314. </div>
  315. <div class="field">
  316. <label>响应</label>
  317. <pre class="code mono" id="out"></pre>
  318. </div>
  319. </div>
  320. </div>
  321. </div>
  322. <script>
  323. function switchTab(name) {
  324. document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  325. document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
  326. event.target.classList.add('active');
  327. document.getElementById('tab-' + name).classList.add('active');
  328. }
  329. function api(path){
  330. const pathClean = ('/' + (path || '').replace(/^\/+/, '')).replace(/\/{2,}/g, '/');
  331. return pathClean;
  332. }
  333. // Authentication helpers
  334. function getAuthPassword() {
  335. return localStorage.getItem('adminPassword');
  336. }
  337. function getAuthHeaders() {
  338. const password = getAuthPassword();
  339. if (!password) return {};
  340. return { 'Authorization': `Bearer ${password}` };
  341. }
  342. // Authenticated fetch wrapper
  343. async function authFetch(url, options = {}) {
  344. const headers = { ...getAuthHeaders(), ...options.headers };
  345. const response = await fetch(url, { ...options, headers });
  346. if (response.status === 401) {
  347. localStorage.removeItem('adminPassword');
  348. window.location.href = '/login';
  349. throw new Error('Unauthorized');
  350. }
  351. return response;
  352. }
  353. // Check authentication on page load - removed, combined with loadAccounts below
  354. // Virtual scroll state
  355. let accountsData = [];
  356. let virtualScroll = null;
  357. // Create account card element
  358. function createAccountCard(acc) {
  359. const card = document.createElement('div');
  360. card.className = 'card';
  361. card.dataset.accountId = acc.id;
  362. const header = document.createElement('div');
  363. header.className = 'row';
  364. const name = document.createElement('div');
  365. name.innerHTML = '<strong>' + (acc.label || '(无标签)') + '</strong>';
  366. const id = document.createElement('div');
  367. id.className = 'chip mono';
  368. id.textContent = acc.id;
  369. const spacer = document.createElement('div');
  370. spacer.className = 'right';
  371. // Enabled toggle
  372. const toggleWrap = document.createElement('div');
  373. const toggleLabel = document.createElement('label');
  374. toggleLabel.style.marginRight = '6px';
  375. toggleLabel.className = 'muted';
  376. toggleLabel.textContent = '启用';
  377. const toggle = document.createElement('label');
  378. toggle.className = 'switch';
  379. const chk = document.createElement('input');
  380. chk.type = 'checkbox';
  381. chk.checked = !!acc.enabled;
  382. chk.onchange = async () => {
  383. try {
  384. await updateAccount(acc.id, { enabled: chk.checked });
  385. } catch(e) {
  386. chk.checked = !chk.checked;
  387. }
  388. };
  389. const slider = document.createElement('span');
  390. slider.className = 'slider';
  391. toggle.appendChild(chk); toggle.appendChild(slider);
  392. toggleWrap.appendChild(toggleLabel); toggleWrap.appendChild(toggle);
  393. const refreshBtn = document.createElement('button');
  394. refreshBtn.className = 'btn-warn';
  395. refreshBtn.textContent = '刷新Token';
  396. refreshBtn.onclick = () => refreshAccount(acc.id);
  397. const delBtn = document.createElement('button');
  398. delBtn.className = 'btn-danger';
  399. delBtn.textContent = '删除';
  400. delBtn.onclick = () => deleteAccount(acc.id);
  401. header.appendChild(name);
  402. header.appendChild(id);
  403. header.appendChild(spacer);
  404. header.appendChild(toggleWrap);
  405. header.appendChild(refreshBtn);
  406. header.appendChild(delBtn);
  407. card.appendChild(header);
  408. const meta = document.createElement('div');
  409. meta.className = 'kvs mono';
  410. function row(k, v) {
  411. const kEl = document.createElement('div'); kEl.className = 'muted'; kEl.textContent = k;
  412. const vEl = document.createElement('div'); vEl.textContent = v ?? '';
  413. meta.appendChild(kEl); meta.appendChild(vEl);
  414. }
  415. row('enabled', String(!!acc.enabled));
  416. row('success_count', acc.success_count ?? 0);
  417. row('error_count', acc.error_count ?? 0);
  418. row('last_refresh_status', acc.last_refresh_status);
  419. row('last_refresh_time', acc.last_refresh_time);
  420. row('clientId', acc.clientId);
  421. row('hasRefreshToken', acc.refreshToken ? 'yes' : 'no');
  422. row('hasAccessToken', acc.accessToken ? 'yes' : 'no');
  423. row('created_at', acc.created_at);
  424. row('updated_at', acc.updated_at);
  425. if (acc.other) {
  426. row('other', JSON.stringify(acc.other));
  427. }
  428. card.appendChild(meta);
  429. // quick edit form
  430. const editRow = document.createElement('div');
  431. editRow.className = 'row';
  432. editRow.style.marginTop = '8px';
  433. const labelField = document.createElement('input');
  434. labelField.placeholder = 'label';
  435. labelField.value = acc.label || '';
  436. const accessField = document.createElement('input');
  437. accessField.placeholder = 'accessToken(可选)';
  438. accessField.value = acc.accessToken || '';
  439. const saveBtn = document.createElement('button');
  440. saveBtn.className = 'btn-secondary';
  441. saveBtn.textContent = '保存';
  442. saveBtn.onclick = async () => {
  443. await updateAccount(acc.id, { label: labelField.value, accessToken: accessField.value });
  444. };
  445. editRow.appendChild(labelField);
  446. editRow.appendChild(accessField);
  447. editRow.appendChild(saveBtn);
  448. card.appendChild(editRow);
  449. return card;
  450. }
  451. // Virtual scroll implementation with auto height adjustment
  452. class VirtualScroll {
  453. constructor(container, items, itemHeight, bufferSize = 3) {
  454. this.container = container;
  455. this.items = items;
  456. this.itemHeight = itemHeight;
  457. this.bufferSize = bufferSize;
  458. this.visibleStart = 0;
  459. this.visibleEnd = 0;
  460. this.renderedItems = new Map();
  461. this.measuredHeights = new Map();
  462. this.heightAdjusted = false;
  463. this.viewport = document.createElement('div');
  464. this.viewport.className = 'list-viewport';
  465. this.content = document.createElement('div');
  466. this.content.className = 'list-content';
  467. this.viewport.appendChild(this.content);
  468. this.container.innerHTML = '';
  469. this.container.appendChild(this.viewport);
  470. this.container.addEventListener('scroll', () => this.onScroll());
  471. this.render();
  472. // Auto-measure after first render
  473. requestAnimationFrame(() => this.measureAndAdjust());
  474. }
  475. onScroll() {
  476. requestAnimationFrame(() => this.render());
  477. }
  478. measureAndAdjust() {
  479. let maxHeight = 0;
  480. let needsAdjustment = false;
  481. // Measure all currently rendered items
  482. for (const [idx, element] of this.renderedItems.entries()) {
  483. const rect = element.getBoundingClientRect();
  484. const actualHeight = rect.height + 12; // +12 for gap
  485. this.measuredHeights.set(idx, actualHeight);
  486. if (actualHeight > maxHeight) {
  487. maxHeight = actualHeight;
  488. }
  489. // Check if actual height exceeds estimated height
  490. if (actualHeight > this.itemHeight * 0.95) {
  491. needsAdjustment = true;
  492. }
  493. }
  494. // Auto-adjust if needed
  495. if (needsAdjustment && maxHeight > this.itemHeight) {
  496. console.log(`[VirtualScroll] 检测到重影,调整高度: ${this.itemHeight}px -> ${Math.ceil(maxHeight)}px`);
  497. this.itemHeight = Math.ceil(maxHeight);
  498. this.heightAdjusted = true;
  499. // Re-render with new height
  500. this.reposition();
  501. }
  502. }
  503. reposition() {
  504. // Reposition all rendered items with new height
  505. for (const [idx, element] of this.renderedItems.entries()) {
  506. element.style.top = (idx * this.itemHeight) + 'px';
  507. }
  508. // Update viewport height
  509. this.viewport.style.height = (this.items.length * this.itemHeight) + 'px';
  510. }
  511. render() {
  512. const scrollTop = this.container.scrollTop;
  513. const containerHeight = this.container.clientHeight;
  514. const start = Math.floor(scrollTop / this.itemHeight);
  515. const end = Math.ceil((scrollTop + containerHeight) / this.itemHeight);
  516. const bufferedStart = Math.max(0, start - this.bufferSize);
  517. const bufferedEnd = Math.min(this.items.length, end + this.bufferSize);
  518. if (bufferedStart === this.visibleStart && bufferedEnd === this.visibleEnd) {
  519. return;
  520. }
  521. this.visibleStart = bufferedStart;
  522. this.visibleEnd = bufferedEnd;
  523. // Set viewport height
  524. this.viewport.style.height = (this.items.length * this.itemHeight) + 'px';
  525. // Remove items outside visible range
  526. for (const [idx, element] of this.renderedItems.entries()) {
  527. if (idx < bufferedStart || idx >= bufferedEnd) {
  528. element.remove();
  529. this.renderedItems.delete(idx);
  530. }
  531. }
  532. // Add items in visible range
  533. const fragment = document.createDocumentFragment();
  534. let newItemsAdded = false;
  535. for (let i = bufferedStart; i < bufferedEnd; i++) {
  536. if (!this.renderedItems.has(i)) {
  537. const item = createAccountCard(this.items[i]);
  538. item.style.position = 'absolute';
  539. item.style.top = (i * this.itemHeight) + 'px';
  540. item.style.left = '0';
  541. item.style.right = '0';
  542. this.renderedItems.set(i, item);
  543. fragment.appendChild(item);
  544. newItemsAdded = true;
  545. }
  546. }
  547. if (fragment.childNodes.length > 0) {
  548. this.content.appendChild(fragment);
  549. // Measure new items after they're added to DOM
  550. if (newItemsAdded && !this.heightAdjusted) {
  551. requestAnimationFrame(() => this.measureAndAdjust());
  552. }
  553. }
  554. }
  555. update(items) {
  556. this.items = items;
  557. this.renderedItems.clear();
  558. this.measuredHeights.clear();
  559. this.heightAdjusted = false;
  560. this.content.innerHTML = '';
  561. this.visibleStart = -1; // Force re-render
  562. this.visibleEnd = -1;
  563. requestAnimationFrame(() => {
  564. this.render();
  565. this.measureAndAdjust();
  566. });
  567. }
  568. destroy() {
  569. this.container.removeEventListener('scroll', this.onScroll);
  570. this.renderedItems.clear();
  571. this.measuredHeights.clear();
  572. }
  573. }
  574. function renderAccounts(list){
  575. accountsData = list;
  576. const root = document.getElementById('accounts');
  577. if (!Array.isArray(list) || list.length === 0) {
  578. if (virtualScroll) {
  579. virtualScroll.destroy();
  580. virtualScroll = null;
  581. }
  582. root.innerHTML = '';
  583. const empty = document.createElement('div');
  584. empty.className = 'muted';
  585. empty.textContent = '暂无账号';
  586. root.appendChild(empty);
  587. return;
  588. }
  589. // Estimate item height (card height ~280px with gap)
  590. const estimatedItemHeight = 292;
  591. if (virtualScroll) {
  592. virtualScroll.update(list);
  593. } else {
  594. virtualScroll = new VirtualScroll(root, list, estimatedItemHeight);
  595. }
  596. }
  597. function populateChatAccountSelector(accounts) {
  598. const sel = document.getElementById('chatAccount');
  599. if (!sel) return;
  600. const current = sel.value;
  601. sel.innerHTML = '<option value="">随机</option>';
  602. (accounts || []).forEach(acc => {
  603. const opt = document.createElement('option');
  604. opt.value = acc.id;
  605. opt.textContent = (acc.label || acc.id) + (acc.enabled ? '' : ' (禁用)');
  606. sel.appendChild(opt);
  607. });
  608. if (current) sel.value = current;
  609. }
  610. async function loadAccounts(){
  611. try{
  612. const filter = document.querySelector('input[name="accountFilter"]:checked')?.value || 'all';
  613. const sortBy = document.getElementById('sortBy')?.value || 'created_at';
  614. const sortOrder = document.getElementById('sortOrder')?.value || 'desc';
  615. let url = '/v2/accounts?';
  616. const params = [];
  617. if (filter === 'enabled') params.push('enabled=true');
  618. else if (filter === 'disabled') params.push('enabled=false');
  619. params.push(`sort_by=${sortBy}`);
  620. params.push(`sort_order=${sortOrder}`);
  621. url += params.join('&');
  622. const r = await authFetch(api(url));
  623. const j = await r.json();
  624. document.getElementById('accountCount').textContent = `(${j.count})`;
  625. renderAccounts(j.accounts);
  626. populateChatAccountSelector(j.accounts);
  627. } catch(e){
  628. alert('加载账户失败:' + e);
  629. }
  630. }
  631. async function createAccount(){
  632. const body = {
  633. label: document.getElementById('new_label').value.trim() || null,
  634. clientId: document.getElementById('new_clientId').value.trim(),
  635. clientSecret: document.getElementById('new_clientSecret').value.trim(),
  636. refreshToken: document.getElementById('new_refreshToken').value.trim() || null,
  637. accessToken: document.getElementById('new_accessToken').value.trim() || null,
  638. enabled: document.getElementById('new_enabled').checked,
  639. other: (()=>{
  640. const t = document.getElementById('new_other').value.trim();
  641. if (!t) return null;
  642. try { return JSON.parse(t); } catch { alert('other 不是合法 JSON'); throw new Error('bad other'); }
  643. })()
  644. };
  645. try{
  646. const r = await authFetch(api('/v2/accounts'), {
  647. method:'POST',
  648. headers:{ 'content-type':'application/json' },
  649. body: JSON.stringify(body)
  650. });
  651. if (!r.ok) {
  652. const t = await r.text();
  653. throw new Error(t);
  654. }
  655. await loadAccounts();
  656. } catch(e){
  657. alert('创建失败:' + e);
  658. }
  659. }
  660. async function deleteAccount(id){
  661. if (!confirm('确认删除该账号?')) return;
  662. try{
  663. const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id)), { method:'DELETE' });
  664. if (!r.ok) { throw new Error(await r.text()); }
  665. await loadAccounts();
  666. } catch(e){
  667. alert('删除失败:' + e);
  668. }
  669. }
  670. async function updateAccount(id, patch){
  671. try{
  672. const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id)), {
  673. method:'PATCH',
  674. headers:{ 'content-type':'application/json' },
  675. body: JSON.stringify(patch)
  676. });
  677. if (!r.ok) { throw new Error(await r.text()); }
  678. await loadAccounts();
  679. } catch(e){
  680. alert('更新失败:' + e);
  681. }
  682. }
  683. async function refreshAccount(id){
  684. try{
  685. const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id) + '/refresh'), { method:'POST' });
  686. if (!r.ok) { throw new Error(await r.text()); }
  687. await loadAccounts();
  688. } catch(e){
  689. alert('刷新失败:' + e);
  690. }
  691. }
  692. // URL Login (Device Authorization)
  693. let currentAuth = null;
  694. async function startAuth(){
  695. const body = {
  696. label: (document.getElementById('auth_label').value || '').trim() || null,
  697. enabled: document.getElementById('auth_enabled').checked
  698. };
  699. try {
  700. const r = await authFetch(api('/v2/auth/start'), {
  701. method: 'POST',
  702. headers: { 'content-type': 'application/json' },
  703. body: JSON.stringify(body)
  704. });
  705. if (!r.ok) throw new Error(await r.text());
  706. const j = await r.json();
  707. currentAuth = j;
  708. const info = [
  709. '验证链接: ' + j.verificationUriComplete,
  710. '用户代码: ' + (j.userCode || ''),
  711. 'authId: ' + j.authId,
  712. 'expiresIn: ' + j.expiresIn + 's',
  713. 'interval: ' + j.interval + 's'
  714. ].join('\\n');
  715. const el = document.getElementById('auth_info');
  716. el.textContent = info + '\\n\\n请在新窗口中打开上述链接完成登录。';
  717. try { window.open(j.verificationUriComplete, '_blank'); } catch {}
  718. } catch(e){
  719. document.getElementById('auth_info').textContent = '启动失败:' + e;
  720. }
  721. }
  722. async function claimAuth(){
  723. if (!currentAuth || !currentAuth.authId) {
  724. document.getElementById('auth_info').textContent = '请先点击“开始登录”。';
  725. return;
  726. }
  727. document.getElementById('auth_info').textContent += '\\n\\n正在等待授权并创建账号(最多5分钟)...';
  728. try{
  729. const r = await authFetch(api('/v2/auth/claim/' + encodeURIComponent(currentAuth.authId)), { method: 'POST' });
  730. const text = await r.text();
  731. let j;
  732. try { j = JSON.parse(text); } catch { j = { raw: text }; }
  733. document.getElementById('auth_info').textContent = '完成:\\n' + JSON.stringify(j, null, 2);
  734. await loadAccounts();
  735. } catch(e){
  736. document.getElementById('auth_info').textContent += '\\n失败:' + e;
  737. }
  738. }
  739. async function send() {
  740. const model = document.getElementById('model').value.trim();
  741. const stream = document.getElementById('stream').value === 'true';
  742. const accountId = document.getElementById('chatAccount')?.value || '';
  743. const out = document.getElementById('out');
  744. out.textContent = '';
  745. let messages;
  746. try { messages = JSON.parse(document.getElementById('messages').value); }
  747. catch(e){ out.textContent = 'messages 不是合法 JSON'; return; }
  748. const body = { model, messages, stream };
  749. const headers = { 'content-type': 'application/json' };
  750. const endpoint = accountId ? `/v2/chat/test?account_id=${encodeURIComponent(accountId)}` : '/v2/chat/test';
  751. if (!stream) {
  752. const r = await authFetch(api(endpoint), {
  753. method:'POST',
  754. headers,
  755. body: JSON.stringify(body)
  756. });
  757. const text = await r.text();
  758. try { out.textContent = JSON.stringify(JSON.parse(text), null, 2); }
  759. catch { out.textContent = text; }
  760. } else {
  761. const r = await authFetch(api(endpoint), {
  762. method:'POST',
  763. headers,
  764. body: JSON.stringify(body)
  765. });
  766. const reader = r.body.getReader();
  767. const decoder = new TextDecoder();
  768. while (true) {
  769. const {value, done} = await reader.read();
  770. if (done) break;
  771. out.textContent += decoder.decode(value, {stream:true});
  772. }
  773. }
  774. }
  775. window.addEventListener('DOMContentLoaded', () => {
  776. // Check authentication first
  777. const password = getAuthPassword();
  778. if (!password) {
  779. window.location.href = '/login';
  780. return;
  781. }
  782. // Only load accounts if authenticated
  783. loadAccounts();
  784. });
  785. </script>
  786. </body>
  787. </html>