| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8"/>
- <title>Amazonq2api 前端控制台 · 账号管理 + Chat 测试</title>
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
- <style>
- :root {
- --bg:#0a0e1a;
- --panel:#0f1420;
- --muted:#8b95a8;
- --text:#e8f0ff;
- --accent:#4f8fff;
- --danger:#ff4757;
- --ok:#2ed573;
- --warn:#ffa502;
- --border:#1a2332;
- --chip:#141b28;
- --code:#0d1218;
- --glow:rgba(79,143,255,.15);
- }
- * { box-sizing:border-box; }
- html, body { height:100%; margin:0; }
- body {
- padding:0 0 40px;
- background:radial-gradient(ellipse at top, #0f1624 0%, #0a0e1a 100%);
- color:var(--text);
- font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Noto Sans,Arial,sans-serif;
- line-height:1.6;
- }
- h1,h2,h3 { font-weight:700; letter-spacing:-.02em; margin:0; }
- 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; }
- h2 { font-size:18px; margin:20px 0 16px; color:#c5d4ff; }
- h3 { font-size:15px; margin:16px 0 10px; color:#a8b8d8; }
- .container { max-width:1280px; margin:0 auto; padding:20px; }
- .grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
- @media(max-width:1024px){ .grid { grid-template-columns:1fr; } }
- .panel {
- background:linear-gradient(145deg,rgba(15,20,32,.8),rgba(10,14,26,.9));
- border:1px solid var(--border);
- border-radius:16px;
- padding:24px;
- 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);
- backdrop-filter:blur(12px);
- transition:transform .2s,box-shadow .2s;
- }
- .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); }
- .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
- label { color:var(--muted); font-size:13px; font-weight:500; letter-spacing:.01em; }
- .field { display:flex; flex-direction:column; gap:8px; flex:1; min-width:200px; }
- input,textarea,select {
- background:rgba(12,16,28,.6);
- color:var(--text);
- border:1px solid var(--border);
- border-radius:12px;
- padding:12px 14px;
- outline:none;
- transition:all .2s;
- font-size:14px;
- box-shadow:inset 0 1px 2px rgba(0,0,0,.2);
- }
- input:focus,textarea:focus,select:focus {
- border-color:var(--accent);
- box-shadow:0 0 0 3px var(--glow),inset 0 1px 2px rgba(0,0,0,.2);
- background:rgba(12,16,28,.8);
- }
- textarea { min-height:140px; resize:vertical; font-family:ui-monospace,monospace; }
- button {
- background:linear-gradient(135deg,#2563eb,#1e40af);
- color:#fff;
- border:none;
- border-radius:12px;
- padding:12px 20px;
- font-weight:600;
- font-size:14px;
- cursor:pointer;
- transition:all .2s;
- box-shadow:0 4px 16px rgba(37,99,235,.3),inset 0 1px 0 rgba(255,255,255,.1);
- position:relative;
- overflow:hidden;
- }
- button:before {
- content:'';
- position:absolute;
- top:0;left:0;right:0;bottom:0;
- background:linear-gradient(135deg,rgba(255,255,255,.1),transparent);
- opacity:0;
- transition:opacity .2s;
- }
- button:hover { transform:translateY(-1px); box-shadow:0 6px 20px rgba(37,99,235,.4),inset 0 1px 0 rgba(255,255,255,.15); }
- button:hover:before { opacity:1; }
- button:active { transform:translateY(0); }
- button:disabled { opacity:.5; cursor:not-allowed; transform:none; }
- .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); }
- .btn-secondary:hover { box-shadow:0 6px 20px rgba(15,23,42,.4),inset 0 1px 0 rgba(255,255,255,.08); }
- .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); }
- .btn-danger:hover { box-shadow:0 6px 20px rgba(220,38,38,.4),inset 0 1px 0 rgba(255,255,255,.15); }
- .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); }
- .btn-warn:hover { box-shadow:0 6px 20px rgba(245,158,11,.4),inset 0 1px 0 rgba(255,255,255,.15); }
- .kvs { display:grid; grid-template-columns:160px 1fr; gap:10px 16px; font-size:13px; }
- .muted { color:var(--muted); }
- .chip {
- display:inline-flex;
- align-items:center;
- gap:6px;
- padding:6px 12px;
- background:rgba(20,27,40,.8);
- border:1px solid var(--border);
- border-radius:20px;
- color:#a8b8ff;
- font-size:12px;
- font-weight:500;
- box-shadow:0 2px 8px rgba(0,0,0,.2);
- }
- .list { position:relative; max-height:600px; overflow:auto; padding:2px; }
- .list::-webkit-scrollbar { width:8px; }
- .list::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
- .list::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
- .list::-webkit-scrollbar-thumb:hover { background:rgba(79,143,255,.5); }
- .list-viewport { position:relative; }
- .list-content { display:flex; flex-direction:column; gap:12px; }
- .card {
- border:1px solid var(--border);
- border-radius:14px;
- padding:16px;
- background:linear-gradient(145deg,rgba(12,19,34,.6),rgba(10,14,26,.8));
- display:flex;
- flex-direction:column;
- gap:12px;
- box-shadow:0 4px 16px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.02);
- transition:all .2s;
- }
- .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); }
- .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; }
- .code {
- background:var(--code);
- border:1px solid var(--border);
- border-radius:12px;
- padding:14px;
- color:#d8e8ff;
- max-height:300px;
- overflow:auto;
- white-space:pre-wrap;
- font-size:13px;
- line-height:1.6;
- box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
- }
- .code::-webkit-scrollbar { width:8px; height:8px; }
- .code::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
- .code::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
- .right { margin-left:auto; }
- .sep { height:1px; background:linear-gradient(90deg,transparent,rgba(79,143,255,.2),transparent); margin:16px 0; }
- .status-ok { color:var(--ok); font-weight:600; }
- .status-fail { color:var(--danger); font-weight:600; }
- .switch { position:relative; display:inline-block; width:50px; height:26px; }
- .switch input { opacity:0; width:0; height:0; }
- .slider {
- position:absolute;
- cursor:pointer;
- top:0;left:0;right:0;bottom:0;
- background:linear-gradient(135deg,#374151,#1f2937);
- transition:.3s;
- border-radius:26px;
- border:1px solid var(--border);
- box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
- }
- .slider:before {
- position:absolute;
- content:"";
- height:20px;
- width:20px;
- left:3px;
- bottom:2px;
- background:linear-gradient(135deg,#f3f4f6,#e5e7eb);
- transition:.3s;
- border-radius:50%;
- box-shadow:0 2px 6px rgba(0,0,0,.3);
- }
- 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); }
- input:checked+.slider:before { transform:translateX(24px); }
- @keyframes fadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
- .panel { animation:fadeIn .4s ease-out; }
- .tabs { display:flex; gap:8px; margin:20px 0 16px; border-bottom:2px solid var(--border); }
- .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; }
- .tab:hover { color:var(--text); background:rgba(79,143,255,.05); }
- .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
- .tab-content { display:none; }
- .tab-content.active { display:block; }
- </style>
- </head>
- <body>
- <div class="container">
- <h1>Q2api 前端控制台</h1>
- <div class="tabs">
- <button class="tab active" onclick="switchTab('accounts')">账号管理</button>
- <button class="tab" onclick="switchTab('create')">创建账号</button>
- <button class="tab" onclick="switchTab('login')">URL登录</button>
- <button class="tab" onclick="switchTab('chat')">Chat测试</button>
- </div>
- <div id="tab-accounts" class="tab-content active">
- <div class="panel">
- <h2>账号管理 <span id="accountCount" style="font-size: 0.8em; color: #666;"></span></h2>
- <div class="row">
- <button class="btn-secondary" onclick="loadAccounts()">刷新列表</button>
- <div style="margin-left: 20px;">
- <label><input type="radio" name="accountFilter" value="all" checked onchange="loadAccounts()"> 全部</label>
- <label style="margin-left: 10px;"><input type="radio" name="accountFilter" value="enabled" onchange="loadAccounts()"> 已启用</label>
- <label style="margin-left: 10px;"><input type="radio" name="accountFilter" value="disabled" onchange="loadAccounts()"> 已禁用</label>
- </div>
- <div style="margin-left: 20px;">
- <label>排序:
- <select id="sortBy" onchange="loadAccounts()">
- <option value="created_at">创建日期</option>
- <option value="success_count">成功次数</option>
- </select>
- </label>
- <label style="margin-left: 10px;">
- <select id="sortOrder" onchange="loadAccounts()">
- <option value="desc">降序</option>
- <option value="asc">升序</option>
- </select>
- </label>
- </div>
- </div>
- <div class="list" id="accounts"></div>
- </div>
- </div>
- <div id="tab-create" class="tab-content">
- <div class="panel">
- <h2>创建账号</h2>
- <div class="row">
- <div class="field"><label>label</label><input id="new_label" /></div>
- <div class="field"><label>clientId</label><input id="new_clientId" /></div>
- <div class="field"><label>clientSecret</label><input id="new_clientSecret" /></div>
- </div>
- <div class="row">
- <div class="field"><label>refreshToken</label><input id="new_refreshToken" /></div>
- <div class="field"><label>accessToken</label><input id="new_accessToken" /></div>
- </div>
- <div class="row">
- <div class="field">
- <label>other(JSON,可选)</label>
- <textarea id="new_other" placeholder='{"note":"备注"}'></textarea>
- </div>
- <div class="field" style="max-width:220px">
- <label>启用(仅启用账号会被用于请求)</label>
- <div>
- <label class="switch">
- <input id="new_enabled" type="checkbox" checked />
- <span class="slider"></span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <button onclick="createAccount()">创建</button>
- </div>
- </div>
- </div>
- <div id="tab-login" class="tab-content">
- <div class="panel">
- <h2>URL 登录(5分钟超时)</h2>
- <div class="row">
- <div class="field"><label>label(可选)</label><input id="auth_label" /></div>
- <div class="field" style="max-width:220px">
- <label>启用(登录成功后新账号是否启用)</label>
- <div>
- <label class="switch">
- <input id="auth_enabled" type="checkbox" checked />
- <span class="slider"></span>
- </label>
- </div>
- </div>
- </div>
- <div class="row">
- <button onclick="startAuth()">开始登录</button>
- <button class="btn-secondary" onclick="claimAuth()">等待授权并创建账号</button>
- </div>
- <div class="field">
- <label>登录信息</label>
- <pre class="code mono" id="auth_info">尚未开始</pre>
- </div>
- </div>
- </div>
- <div id="tab-chat" class="tab-content">
- <div class="panel">
- <h2>Chat 测试(/v2/chat/test)</h2>
- <div class="row">
- <div class="field" style="max-width:300px">
- <label>model</label>
- <input id="model" value="claude-haiku-4.5" />
- </div>
- <div class="field" style="max-width:180px">
- <label>是否流式</label>
- <select id="stream">
- <option value="false">false(默认)</option>
- <option value="true">true(SSE)</option>
- </select>
- </div>
- <div class="field" style="max-width:200px">
- <label>账号</label>
- <select id="chatAccount">
- <option value="">随机</option>
- </select>
- </div>
- <button class="right" onclick="send()">发送请求</button>
- </div>
- <div class="field">
- <label>messages(JSON)</label>
- <textarea id="messages">[
- {"role":"system","content":"你是一个乐于助人的助手"},
- {"role":"user","content":"你好,请讲一个简短的故事"}
- ]</textarea>
- </div>
- <div class="field">
- <label>响应</label>
- <pre class="code mono" id="out"></pre>
- </div>
- </div>
- </div>
- </div>
- <script>
- function switchTab(name) {
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
- document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
- event.target.classList.add('active');
- document.getElementById('tab-' + name).classList.add('active');
- }
- function api(path){
- const pathClean = ('/' + (path || '').replace(/^\/+/, '')).replace(/\/{2,}/g, '/');
- return pathClean;
- }
- // Authentication helpers
- function getAuthPassword() {
- return localStorage.getItem('adminPassword');
- }
- function getAuthHeaders() {
- const password = getAuthPassword();
- if (!password) return {};
- return { 'Authorization': `Bearer ${password}` };
- }
- // Authenticated fetch wrapper
- async function authFetch(url, options = {}) {
- const headers = { ...getAuthHeaders(), ...options.headers };
- const response = await fetch(url, { ...options, headers });
- if (response.status === 401) {
- localStorage.removeItem('adminPassword');
- window.location.href = '/login';
- throw new Error('Unauthorized');
- }
- return response;
- }
- // Check authentication on page load - removed, combined with loadAccounts below
- // Virtual scroll state
- let accountsData = [];
- let virtualScroll = null;
- // Create account card element
- function createAccountCard(acc) {
- const card = document.createElement('div');
- card.className = 'card';
- card.dataset.accountId = acc.id;
- const header = document.createElement('div');
- header.className = 'row';
- const name = document.createElement('div');
- name.innerHTML = '<strong>' + (acc.label || '(无标签)') + '</strong>';
- const id = document.createElement('div');
- id.className = 'chip mono';
- id.textContent = acc.id;
- const spacer = document.createElement('div');
- spacer.className = 'right';
- // Enabled toggle
- const toggleWrap = document.createElement('div');
- const toggleLabel = document.createElement('label');
- toggleLabel.style.marginRight = '6px';
- toggleLabel.className = 'muted';
- toggleLabel.textContent = '启用';
- const toggle = document.createElement('label');
- toggle.className = 'switch';
- const chk = document.createElement('input');
- chk.type = 'checkbox';
- chk.checked = !!acc.enabled;
- chk.onchange = async () => {
- try {
- await updateAccount(acc.id, { enabled: chk.checked });
- } catch(e) {
- chk.checked = !chk.checked;
- }
- };
- const slider = document.createElement('span');
- slider.className = 'slider';
- toggle.appendChild(chk); toggle.appendChild(slider);
- toggleWrap.appendChild(toggleLabel); toggleWrap.appendChild(toggle);
- const refreshBtn = document.createElement('button');
- refreshBtn.className = 'btn-warn';
- refreshBtn.textContent = '刷新Token';
- refreshBtn.onclick = () => refreshAccount(acc.id);
- const delBtn = document.createElement('button');
- delBtn.className = 'btn-danger';
- delBtn.textContent = '删除';
- delBtn.onclick = () => deleteAccount(acc.id);
- header.appendChild(name);
- header.appendChild(id);
- header.appendChild(spacer);
- header.appendChild(toggleWrap);
- header.appendChild(refreshBtn);
- header.appendChild(delBtn);
- card.appendChild(header);
- const meta = document.createElement('div');
- meta.className = 'kvs mono';
- function row(k, v) {
- const kEl = document.createElement('div'); kEl.className = 'muted'; kEl.textContent = k;
- const vEl = document.createElement('div'); vEl.textContent = v ?? '';
- meta.appendChild(kEl); meta.appendChild(vEl);
- }
- row('enabled', String(!!acc.enabled));
- row('success_count', acc.success_count ?? 0);
- row('error_count', acc.error_count ?? 0);
- row('last_refresh_status', acc.last_refresh_status);
- row('last_refresh_time', acc.last_refresh_time);
- row('clientId', acc.clientId);
- row('hasRefreshToken', acc.refreshToken ? 'yes' : 'no');
- row('hasAccessToken', acc.accessToken ? 'yes' : 'no');
- row('created_at', acc.created_at);
- row('updated_at', acc.updated_at);
- if (acc.other) {
- row('other', JSON.stringify(acc.other));
- }
- card.appendChild(meta);
- // quick edit form
- const editRow = document.createElement('div');
- editRow.className = 'row';
- editRow.style.marginTop = '8px';
- const labelField = document.createElement('input');
- labelField.placeholder = 'label';
- labelField.value = acc.label || '';
- const accessField = document.createElement('input');
- accessField.placeholder = 'accessToken(可选)';
- accessField.value = acc.accessToken || '';
- const saveBtn = document.createElement('button');
- saveBtn.className = 'btn-secondary';
- saveBtn.textContent = '保存';
- saveBtn.onclick = async () => {
- await updateAccount(acc.id, { label: labelField.value, accessToken: accessField.value });
- };
- editRow.appendChild(labelField);
- editRow.appendChild(accessField);
- editRow.appendChild(saveBtn);
- card.appendChild(editRow);
- return card;
- }
- // Virtual scroll implementation with auto height adjustment
- class VirtualScroll {
- constructor(container, items, itemHeight, bufferSize = 3) {
- this.container = container;
- this.items = items;
- this.itemHeight = itemHeight;
- this.bufferSize = bufferSize;
- this.visibleStart = 0;
- this.visibleEnd = 0;
- this.renderedItems = new Map();
- this.measuredHeights = new Map();
- this.heightAdjusted = false;
-
- this.viewport = document.createElement('div');
- this.viewport.className = 'list-viewport';
- this.content = document.createElement('div');
- this.content.className = 'list-content';
- this.viewport.appendChild(this.content);
-
- this.container.innerHTML = '';
- this.container.appendChild(this.viewport);
-
- this.container.addEventListener('scroll', () => this.onScroll());
- this.render();
-
- // Auto-measure after first render
- requestAnimationFrame(() => this.measureAndAdjust());
- }
-
- onScroll() {
- requestAnimationFrame(() => this.render());
- }
-
- measureAndAdjust() {
- let maxHeight = 0;
- let needsAdjustment = false;
-
- // Measure all currently rendered items
- for (const [idx, element] of this.renderedItems.entries()) {
- const rect = element.getBoundingClientRect();
- const actualHeight = rect.height + 12; // +12 for gap
- this.measuredHeights.set(idx, actualHeight);
-
- if (actualHeight > maxHeight) {
- maxHeight = actualHeight;
- }
-
- // Check if actual height exceeds estimated height
- if (actualHeight > this.itemHeight * 0.95) {
- needsAdjustment = true;
- }
- }
-
- // Auto-adjust if needed
- if (needsAdjustment && maxHeight > this.itemHeight) {
- console.log(`[VirtualScroll] 检测到重影,调整高度: ${this.itemHeight}px -> ${Math.ceil(maxHeight)}px`);
- this.itemHeight = Math.ceil(maxHeight);
- this.heightAdjusted = true;
-
- // Re-render with new height
- this.reposition();
- }
- }
-
- reposition() {
- // Reposition all rendered items with new height
- for (const [idx, element] of this.renderedItems.entries()) {
- element.style.top = (idx * this.itemHeight) + 'px';
- }
-
- // Update viewport height
- this.viewport.style.height = (this.items.length * this.itemHeight) + 'px';
- }
-
- render() {
- const scrollTop = this.container.scrollTop;
- const containerHeight = this.container.clientHeight;
-
- const start = Math.floor(scrollTop / this.itemHeight);
- const end = Math.ceil((scrollTop + containerHeight) / this.itemHeight);
-
- const bufferedStart = Math.max(0, start - this.bufferSize);
- const bufferedEnd = Math.min(this.items.length, end + this.bufferSize);
-
- if (bufferedStart === this.visibleStart && bufferedEnd === this.visibleEnd) {
- return;
- }
-
- this.visibleStart = bufferedStart;
- this.visibleEnd = bufferedEnd;
-
- // Set viewport height
- this.viewport.style.height = (this.items.length * this.itemHeight) + 'px';
-
- // Remove items outside visible range
- for (const [idx, element] of this.renderedItems.entries()) {
- if (idx < bufferedStart || idx >= bufferedEnd) {
- element.remove();
- this.renderedItems.delete(idx);
- }
- }
-
- // Add items in visible range
- const fragment = document.createDocumentFragment();
- let newItemsAdded = false;
-
- for (let i = bufferedStart; i < bufferedEnd; i++) {
- if (!this.renderedItems.has(i)) {
- const item = createAccountCard(this.items[i]);
- item.style.position = 'absolute';
- item.style.top = (i * this.itemHeight) + 'px';
- item.style.left = '0';
- item.style.right = '0';
- this.renderedItems.set(i, item);
- fragment.appendChild(item);
- newItemsAdded = true;
- }
- }
-
- if (fragment.childNodes.length > 0) {
- this.content.appendChild(fragment);
-
- // Measure new items after they're added to DOM
- if (newItemsAdded && !this.heightAdjusted) {
- requestAnimationFrame(() => this.measureAndAdjust());
- }
- }
- }
-
- update(items) {
- this.items = items;
- this.renderedItems.clear();
- this.measuredHeights.clear();
- this.heightAdjusted = false;
- this.content.innerHTML = '';
- this.visibleStart = -1; // Force re-render
- this.visibleEnd = -1;
- requestAnimationFrame(() => {
- this.render();
- this.measureAndAdjust();
- });
- }
-
- destroy() {
- this.container.removeEventListener('scroll', this.onScroll);
- this.renderedItems.clear();
- this.measuredHeights.clear();
- }
- }
- function renderAccounts(list){
- accountsData = list;
- const root = document.getElementById('accounts');
- if (!Array.isArray(list) || list.length === 0) {
- if (virtualScroll) {
- virtualScroll.destroy();
- virtualScroll = null;
- }
- root.innerHTML = '';
- const empty = document.createElement('div');
- empty.className = 'muted';
- empty.textContent = '暂无账号';
- root.appendChild(empty);
- return;
- }
- // Estimate item height (card height ~280px with gap)
- const estimatedItemHeight = 292;
- if (virtualScroll) {
- virtualScroll.update(list);
- } else {
- virtualScroll = new VirtualScroll(root, list, estimatedItemHeight);
- }
- }
- function populateChatAccountSelector(accounts) {
- const sel = document.getElementById('chatAccount');
- if (!sel) return;
- const current = sel.value;
- sel.innerHTML = '<option value="">随机</option>';
- (accounts || []).forEach(acc => {
- const opt = document.createElement('option');
- opt.value = acc.id;
- opt.textContent = (acc.label || acc.id) + (acc.enabled ? '' : ' (禁用)');
- sel.appendChild(opt);
- });
- if (current) sel.value = current;
- }
- async function loadAccounts(){
- try{
- const filter = document.querySelector('input[name="accountFilter"]:checked')?.value || 'all';
- const sortBy = document.getElementById('sortBy')?.value || 'created_at';
- const sortOrder = document.getElementById('sortOrder')?.value || 'desc';
- let url = '/v2/accounts?';
- const params = [];
- if (filter === 'enabled') params.push('enabled=true');
- else if (filter === 'disabled') params.push('enabled=false');
- params.push(`sort_by=${sortBy}`);
- params.push(`sort_order=${sortOrder}`);
- url += params.join('&');
- const r = await authFetch(api(url));
- const j = await r.json();
- document.getElementById('accountCount').textContent = `(${j.count})`;
- renderAccounts(j.accounts);
- populateChatAccountSelector(j.accounts);
- } catch(e){
- alert('加载账户失败:' + e);
- }
- }
- async function createAccount(){
- const body = {
- label: document.getElementById('new_label').value.trim() || null,
- clientId: document.getElementById('new_clientId').value.trim(),
- clientSecret: document.getElementById('new_clientSecret').value.trim(),
- refreshToken: document.getElementById('new_refreshToken').value.trim() || null,
- accessToken: document.getElementById('new_accessToken').value.trim() || null,
- enabled: document.getElementById('new_enabled').checked,
- other: (()=>{
- const t = document.getElementById('new_other').value.trim();
- if (!t) return null;
- try { return JSON.parse(t); } catch { alert('other 不是合法 JSON'); throw new Error('bad other'); }
- })()
- };
- try{
- const r = await authFetch(api('/v2/accounts'), {
- method:'POST',
- headers:{ 'content-type':'application/json' },
- body: JSON.stringify(body)
- });
- if (!r.ok) {
- const t = await r.text();
- throw new Error(t);
- }
- await loadAccounts();
- } catch(e){
- alert('创建失败:' + e);
- }
- }
- async function deleteAccount(id){
- if (!confirm('确认删除该账号?')) return;
- try{
- const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id)), { method:'DELETE' });
- if (!r.ok) { throw new Error(await r.text()); }
- await loadAccounts();
- } catch(e){
- alert('删除失败:' + e);
- }
- }
- async function updateAccount(id, patch){
- try{
- const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id)), {
- method:'PATCH',
- headers:{ 'content-type':'application/json' },
- body: JSON.stringify(patch)
- });
- if (!r.ok) { throw new Error(await r.text()); }
- await loadAccounts();
- } catch(e){
- alert('更新失败:' + e);
- }
- }
- async function refreshAccount(id){
- try{
- const r = await authFetch(api('/v2/accounts/' + encodeURIComponent(id) + '/refresh'), { method:'POST' });
- if (!r.ok) { throw new Error(await r.text()); }
- await loadAccounts();
- } catch(e){
- alert('刷新失败:' + e);
- }
- }
- // URL Login (Device Authorization)
- let currentAuth = null;
- async function startAuth(){
- const body = {
- label: (document.getElementById('auth_label').value || '').trim() || null,
- enabled: document.getElementById('auth_enabled').checked
- };
- try {
- const r = await authFetch(api('/v2/auth/start'), {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify(body)
- });
- if (!r.ok) throw new Error(await r.text());
- const j = await r.json();
- currentAuth = j;
- const info = [
- '验证链接: ' + j.verificationUriComplete,
- '用户代码: ' + (j.userCode || ''),
- 'authId: ' + j.authId,
- 'expiresIn: ' + j.expiresIn + 's',
- 'interval: ' + j.interval + 's'
- ].join('\\n');
- const el = document.getElementById('auth_info');
- el.textContent = info + '\\n\\n请在新窗口中打开上述链接完成登录。';
- try { window.open(j.verificationUriComplete, '_blank'); } catch {}
- } catch(e){
- document.getElementById('auth_info').textContent = '启动失败:' + e;
- }
- }
- async function claimAuth(){
- if (!currentAuth || !currentAuth.authId) {
- document.getElementById('auth_info').textContent = '请先点击“开始登录”。';
- return;
- }
- document.getElementById('auth_info').textContent += '\\n\\n正在等待授权并创建账号(最多5分钟)...';
- try{
- const r = await authFetch(api('/v2/auth/claim/' + encodeURIComponent(currentAuth.authId)), { method: 'POST' });
- const text = await r.text();
- let j;
- try { j = JSON.parse(text); } catch { j = { raw: text }; }
- document.getElementById('auth_info').textContent = '完成:\\n' + JSON.stringify(j, null, 2);
- await loadAccounts();
- } catch(e){
- document.getElementById('auth_info').textContent += '\\n失败:' + e;
- }
- }
- async function send() {
- const model = document.getElementById('model').value.trim();
- const stream = document.getElementById('stream').value === 'true';
- const accountId = document.getElementById('chatAccount')?.value || '';
- const out = document.getElementById('out');
- out.textContent = '';
- let messages;
- try { messages = JSON.parse(document.getElementById('messages').value); }
- catch(e){ out.textContent = 'messages 不是合法 JSON'; return; }
- const body = { model, messages, stream };
- const headers = { 'content-type': 'application/json' };
- const endpoint = accountId ? `/v2/chat/test?account_id=${encodeURIComponent(accountId)}` : '/v2/chat/test';
- if (!stream) {
- const r = await authFetch(api(endpoint), {
- method:'POST',
- headers,
- body: JSON.stringify(body)
- });
- const text = await r.text();
- try { out.textContent = JSON.stringify(JSON.parse(text), null, 2); }
- catch { out.textContent = text; }
- } else {
- const r = await authFetch(api(endpoint), {
- method:'POST',
- headers,
- body: JSON.stringify(body)
- });
- const reader = r.body.getReader();
- const decoder = new TextDecoder();
- while (true) {
- const {value, done} = await reader.read();
- if (done) break;
- out.textContent += decoder.decode(value, {stream:true});
- }
- }
- }
- window.addEventListener('DOMContentLoaded', () => {
- // Check authentication first
- const password = getAuthPassword();
- if (!password) {
- window.location.href = '/login';
- return;
- }
- // Only load accounts if authenticated
- loadAccounts();
- });
- </script>
- </body>
- </html>
|