index.html 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8"/>
  5. <title>v2 前端控制台 · 账号管理 + 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 80px;
  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 { display:flex; flex-direction:column; gap:12px; max-height:400px; 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. .card {
  121. border:1px solid var(--border);
  122. border-radius:14px;
  123. padding:16px;
  124. background:linear-gradient(145deg,rgba(12,19,34,.6),rgba(10,14,26,.8));
  125. display:flex;
  126. flex-direction:column;
  127. gap:12px;
  128. box-shadow:0 4px 16px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.02);
  129. transition:all .2s;
  130. }
  131. .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); }
  132. .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; }
  133. .code {
  134. background:var(--code);
  135. border:1px solid var(--border);
  136. border-radius:12px;
  137. padding:14px;
  138. color:#d8e8ff;
  139. max-height:300px;
  140. overflow:auto;
  141. white-space:pre-wrap;
  142. font-size:13px;
  143. line-height:1.6;
  144. box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
  145. }
  146. .code::-webkit-scrollbar { width:8px; height:8px; }
  147. .code::-webkit-scrollbar-track { background:rgba(0,0,0,.2); border-radius:4px; }
  148. .code::-webkit-scrollbar-thumb { background:rgba(79,143,255,.3); border-radius:4px; }
  149. .right { margin-left:auto; }
  150. .sep { height:1px; background:linear-gradient(90deg,transparent,rgba(79,143,255,.2),transparent); margin:16px 0; }
  151. .footer {
  152. position:fixed;
  153. left:0;right:0;bottom:0;
  154. background:rgba(10,14,26,.85);
  155. backdrop-filter:blur(16px);
  156. border-top:1px solid var(--border);
  157. padding:14px 20px;
  158. box-shadow:0 -4px 20px rgba(0,0,0,.3);
  159. }
  160. .status-ok { color:var(--ok); font-weight:600; }
  161. .status-fail { color:var(--danger); font-weight:600; }
  162. .switch { position:relative; display:inline-block; width:50px; height:26px; }
  163. .switch input { opacity:0; width:0; height:0; }
  164. .slider {
  165. position:absolute;
  166. cursor:pointer;
  167. top:0;left:0;right:0;bottom:0;
  168. background:linear-gradient(135deg,#374151,#1f2937);
  169. transition:.3s;
  170. border-radius:26px;
  171. border:1px solid var(--border);
  172. box-shadow:inset 0 2px 4px rgba(0,0,0,.3);
  173. }
  174. .slider:before {
  175. position:absolute;
  176. content:"";
  177. height:20px;
  178. width:20px;
  179. left:3px;
  180. bottom:2px;
  181. background:linear-gradient(135deg,#f3f4f6,#e5e7eb);
  182. transition:.3s;
  183. border-radius:50%;
  184. box-shadow:0 2px 6px rgba(0,0,0,.3);
  185. }
  186. 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); }
  187. input:checked+.slider:before { transform:translateX(24px); }
  188. @keyframes fadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
  189. .panel { animation:fadeIn .4s ease-out; }
  190. </style>
  191. </head>
  192. <body>
  193. <div class="container">
  194. <h1>v2 前端控制台</h1>
  195. <div class="panel">
  196. <div class="row">
  197. <div class="field" style="max-width:420px">
  198. <label>API Base</label>
  199. <input id="base" value="/" />
  200. </div>
  201. <div class="field" style="max-width:520px">
  202. <label>Authorization(OpenAI风格白名单;仅授权用途;OPENAI_KEYS 为空时可留空)</label>
  203. <input id="auth" placeholder="自定义Key(可留空:开发模式)" />
  204. </div>
  205. <div class="field" style="max-width:300px">
  206. <label>健康检查</label>
  207. <div class="row">
  208. <button class="btn-secondary" onclick="ping()">Ping</button>
  209. <div id="health" class="chip">未检测</div>
  210. </div>
  211. </div>
  212. </div>
  213. <div class="sep"></div>
  214. <div class="row">
  215. <div class="chip mono">OPENAI_KEYS="key1,key2"(白名单,仅授权,与账号无关)</div>
  216. <div class="chip mono">当 OPENAI_KEYS 为空或未配置:开发模式,不校验 Authorization</div>
  217. <div class="chip mono">账号选择:从所有“启用”的账号中随机选择</div>
  218. </div>
  219. </div>
  220. <div class="grid" style="margin-top:12px">
  221. <div class="panel">
  222. <h2>账号管理</h2>
  223. <div class="row">
  224. <button class="btn-secondary" onclick="loadAccounts()">刷新列表</button>
  225. </div>
  226. <div class="list" id="accounts"></div>
  227. <div class="sep"></div>
  228. <h3>创建账号</h3>
  229. <div class="row">
  230. <div class="field"><label>label</label><input id="new_label" /></div>
  231. <div class="field"><label>clientId</label><input id="new_clientId" /></div>
  232. <div class="field"><label>clientSecret</label><input id="new_clientSecret" /></div>
  233. </div>
  234. <div class="row">
  235. <div class="field"><label>refreshToken</label><input id="new_refreshToken" /></div>
  236. <div class="field"><label>accessToken</label><input id="new_accessToken" /></div>
  237. </div>
  238. <div class="row">
  239. <div class="field">
  240. <label>other(JSON,可选)</label>
  241. <textarea id="new_other" placeholder='{"note":"备注"}'></textarea>
  242. </div>
  243. <div class="field" style="max-width:220px">
  244. <label>启用(仅启用账号会被用于请求)</label>
  245. <div>
  246. <label class="switch">
  247. <input id="new_enabled" type="checkbox" checked />
  248. <span class="slider"></span>
  249. </label>
  250. </div>
  251. </div>
  252. </div>
  253. <div class="row">
  254. <button onclick="createAccount()">创建</button>
  255. </div>
  256. <div class="sep"></div>
  257. <h3>URL 登录(5分钟超时)</h3>
  258. <div class="row">
  259. <div class="field"><label>label(可选)</label><input id="auth_label" /></div>
  260. <div class="field" style="max-width:220px">
  261. <label>启用(登录成功后新账号是否启用)</label>
  262. <div>
  263. <label class="switch">
  264. <input id="auth_enabled" type="checkbox" checked />
  265. <span class="slider"></span>
  266. </label>
  267. </div>
  268. </div>
  269. </div>
  270. <div class="row">
  271. <button onclick="startAuth()">开始登录</button>
  272. <button class="btn-secondary" onclick="claimAuth()">等待授权并创建账号</button>
  273. </div>
  274. <div class="field">
  275. <label>登录信息</label>
  276. <pre class="code mono" id="auth_info">尚未开始</pre>
  277. </div>
  278. </div>
  279. <div class="panel">
  280. <h2>Chat 测试(OpenAI 兼容 /v1/chat/completions)</h2>
  281. <div class="row">
  282. <div class="field" style="max-width:300px">
  283. <label>model</label>
  284. <input id="model" value="claude-sonnet-4" />
  285. </div>
  286. <div class="field" style="max-width:180px">
  287. <label>是否流式</label>
  288. <select id="stream">
  289. <option value="false">false(默认)</option>
  290. <option value="true">true(SSE)</option>
  291. </select>
  292. </div>
  293. <button class="right" onclick="send()">发送请求</button>
  294. </div>
  295. <div class="field">
  296. <label>messages(JSON)</label>
  297. <textarea id="messages">[
  298. {"role":"system","content":"你是一个乐于助人的助手"},
  299. {"role":"user","content":"你好,请讲一个简短的故事"}
  300. ]</textarea>
  301. </div>
  302. <div class="field">
  303. <label>响应</label>
  304. <pre class="code mono" id="out"></pre>
  305. </div>
  306. </div>
  307. </div>
  308. </div>
  309. <div class="footer">
  310. <div class="container row">
  311. <div class="muted">提示:在 .env 配置 OPENAI_KEYS 白名单;账号选择与 key 无关,将在“启用”的账号中随机选择。</div>
  312. <div class="right muted">v2 OpenAI-Compatible</div>
  313. </div>
  314. </div>
  315. <script>
  316. function baseUrl(){ return document.getElementById('base').value.trim(); }
  317. function authHeader(){
  318. const v = document.getElementById('auth').value.trim();
  319. return v ? ('Bearer ' + v) : '';
  320. }
  321. function setHealth(text, ok=true) {
  322. const el = document.getElementById('health');
  323. el.textContent = text;
  324. el.style.color = ok ? 'var(--ok)' : 'var(--danger)';
  325. }
  326. function api(path){
  327. const b = baseUrl();
  328. const baseClean = b.replace(/\/+$/, '');
  329. const p = typeof path === 'string' ? path : '';
  330. const pathClean = ('/' + p.replace(/^\/+/, '')).replace(/\/{2,}/g, '/');
  331. return (baseClean ? baseClean : '') + pathClean;
  332. }
  333. async function ping(){
  334. try{
  335. const r = await fetch(api('/healthz'));
  336. const j = await r.json();
  337. if (j && j.status === 'ok') setHealth('Healthy', true);
  338. else setHealth('Unhealthy', false);
  339. } catch(e){
  340. setHealth('Error', false);
  341. }
  342. }
  343. function renderAccounts(list){
  344. const root = document.getElementById('accounts');
  345. root.innerHTML = '';
  346. if (!Array.isArray(list) || list.length === 0) {
  347. const empty = document.createElement('div');
  348. empty.className = 'muted';
  349. empty.textContent = '暂无账号';
  350. root.appendChild(empty);
  351. return;
  352. }
  353. for (const acc of list) {
  354. const card = document.createElement('div');
  355. card.className = 'card';
  356. const header = document.createElement('div');
  357. header.className = 'row';
  358. const name = document.createElement('div');
  359. name.innerHTML = '<strong>' + (acc.label || '(无标签)') + '</strong>';
  360. const id = document.createElement('div');
  361. id.className = 'chip mono';
  362. id.textContent = acc.id;
  363. const spacer = document.createElement('div');
  364. spacer.className = 'right';
  365. // Enabled toggle
  366. const toggleWrap = document.createElement('div');
  367. const toggleLabel = document.createElement('label');
  368. toggleLabel.style.marginRight = '6px';
  369. toggleLabel.className = 'muted';
  370. toggleLabel.textContent = '启用';
  371. const toggle = document.createElement('label');
  372. toggle.className = 'switch';
  373. const chk = document.createElement('input');
  374. chk.type = 'checkbox';
  375. chk.checked = !!acc.enabled;
  376. chk.onchange = async () => {
  377. try {
  378. await updateAccount(acc.id, { enabled: chk.checked });
  379. } catch(e) {
  380. // revert if failed
  381. chk.checked = !chk.checked;
  382. }
  383. };
  384. const slider = document.createElement('span');
  385. slider.className = 'slider';
  386. toggle.appendChild(chk); toggle.appendChild(slider);
  387. toggleWrap.appendChild(toggleLabel); toggleWrap.appendChild(toggle);
  388. const refreshBtn = document.createElement('button');
  389. refreshBtn.className = 'btn-warn';
  390. refreshBtn.textContent = '刷新Token';
  391. refreshBtn.onclick = () => refreshAccount(acc.id);
  392. const delBtn = document.createElement('button');
  393. delBtn.className = 'btn-danger';
  394. delBtn.textContent = '删除';
  395. delBtn.onclick = () => deleteAccount(acc.id);
  396. header.appendChild(name);
  397. header.appendChild(id);
  398. header.appendChild(spacer);
  399. header.appendChild(toggleWrap);
  400. header.appendChild(refreshBtn);
  401. header.appendChild(delBtn);
  402. card.appendChild(header);
  403. const meta = document.createElement('div');
  404. meta.className = 'kvs mono';
  405. function row(k, v) {
  406. const kEl = document.createElement('div'); kEl.className = 'muted'; kEl.textContent = k;
  407. const vEl = document.createElement('div'); vEl.textContent = v ?? '';
  408. meta.appendChild(kEl); meta.appendChild(vEl);
  409. }
  410. row('enabled', String(!!acc.enabled));
  411. row('last_refresh_status', acc.last_refresh_status);
  412. row('last_refresh_time', acc.last_refresh_time);
  413. row('clientId', acc.clientId);
  414. row('hasRefreshToken', acc.refreshToken ? 'yes' : 'no');
  415. row('hasAccessToken', acc.accessToken ? 'yes' : 'no');
  416. row('created_at', acc.created_at);
  417. row('updated_at', acc.updated_at);
  418. if (acc.other) {
  419. row('other', JSON.stringify(acc.other));
  420. }
  421. card.appendChild(meta);
  422. // quick edit form (label, accessToken)
  423. const editRow = document.createElement('div');
  424. editRow.className = 'row';
  425. editRow.style.marginTop = '8px';
  426. const labelField = document.createElement('input');
  427. labelField.placeholder = 'label';
  428. labelField.value = acc.label || '';
  429. const accessField = document.createElement('input');
  430. accessField.placeholder = 'accessToken(可选)';
  431. accessField.value = acc.accessToken || '';
  432. const saveBtn = document.createElement('button');
  433. saveBtn.className = 'btn-secondary';
  434. saveBtn.textContent = '保存';
  435. saveBtn.onclick = async () => {
  436. await updateAccount(acc.id, { label: labelField.value, accessToken: accessField.value });
  437. };
  438. editRow.appendChild(labelField);
  439. editRow.appendChild(accessField);
  440. editRow.appendChild(saveBtn);
  441. card.appendChild(editRow);
  442. root.appendChild(card);
  443. }
  444. }
  445. async function loadAccounts(){
  446. try{
  447. const r = await fetch(api('/v2/accounts'));
  448. const j = await r.json();
  449. renderAccounts(j);
  450. } catch(e){
  451. alert('加载账户失败:' + e);
  452. }
  453. }
  454. async function createAccount(){
  455. const body = {
  456. label: document.getElementById('new_label').value.trim() || null,
  457. clientId: document.getElementById('new_clientId').value.trim(),
  458. clientSecret: document.getElementById('new_clientSecret').value.trim(),
  459. refreshToken: document.getElementById('new_refreshToken').value.trim() || null,
  460. accessToken: document.getElementById('new_accessToken').value.trim() || null,
  461. enabled: document.getElementById('new_enabled').checked,
  462. other: (()=>{
  463. const t = document.getElementById('new_other').value.trim();
  464. if (!t) return null;
  465. try { return JSON.parse(t); } catch { alert('other 不是合法 JSON'); throw new Error('bad other'); }
  466. })()
  467. };
  468. try{
  469. const r = await fetch(api('/v2/accounts'), {
  470. method:'POST',
  471. headers:{ 'content-type':'application/json' },
  472. body: JSON.stringify(body)
  473. });
  474. if (!r.ok) {
  475. const t = await r.text();
  476. throw new Error(t);
  477. }
  478. await loadAccounts();
  479. } catch(e){
  480. alert('创建失败:' + e);
  481. }
  482. }
  483. async function deleteAccount(id){
  484. if (!confirm('确认删除该账号?')) return;
  485. try{
  486. const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id)), { method:'DELETE' });
  487. if (!r.ok) { throw new Error(await r.text()); }
  488. await loadAccounts();
  489. } catch(e){
  490. alert('删除失败:' + e);
  491. }
  492. }
  493. async function updateAccount(id, patch){
  494. try{
  495. const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id)), {
  496. method:'PATCH',
  497. headers:{ 'content-type':'application/json' },
  498. body: JSON.stringify(patch)
  499. });
  500. if (!r.ok) { throw new Error(await r.text()); }
  501. await loadAccounts();
  502. } catch(e){
  503. alert('更新失败:' + e);
  504. }
  505. }
  506. async function refreshAccount(id){
  507. try{
  508. const r = await fetch(api('/v2/accounts/' + encodeURIComponent(id) + '/refresh'), { method:'POST' });
  509. if (!r.ok) { throw new Error(await r.text()); }
  510. await loadAccounts();
  511. } catch(e){
  512. alert('刷新失败:' + e);
  513. }
  514. }
  515. // URL Login (Device Authorization)
  516. let currentAuth = null;
  517. async function startAuth(){
  518. const body = {
  519. label: (document.getElementById('auth_label').value || '').trim() || null,
  520. enabled: document.getElementById('auth_enabled').checked
  521. };
  522. try {
  523. const r = await fetch(api('/v2/auth/start'), {
  524. method: 'POST',
  525. headers: { 'content-type': 'application/json' },
  526. body: JSON.stringify(body)
  527. });
  528. if (!r.ok) throw new Error(await r.text());
  529. const j = await r.json();
  530. currentAuth = j;
  531. const info = [
  532. '验证链接: ' + j.verificationUriComplete,
  533. '用户代码: ' + (j.userCode || ''),
  534. 'authId: ' + j.authId,
  535. 'expiresIn: ' + j.expiresIn + 's',
  536. 'interval: ' + j.interval + 's'
  537. ].join('\\n');
  538. const el = document.getElementById('auth_info');
  539. el.textContent = info + '\\n\\n请在新窗口中打开上述链接完成登录。';
  540. try { window.open(j.verificationUriComplete, '_blank'); } catch {}
  541. } catch(e){
  542. document.getElementById('auth_info').textContent = '启动失败:' + e;
  543. }
  544. }
  545. async function claimAuth(){
  546. if (!currentAuth || !currentAuth.authId) {
  547. document.getElementById('auth_info').textContent = '请先点击“开始登录”。';
  548. return;
  549. }
  550. document.getElementById('auth_info').textContent += '\\n\\n正在等待授权并创建账号(最多5分钟)...';
  551. try{
  552. const r = await fetch(api('/v2/auth/claim/' + encodeURIComponent(currentAuth.authId)), { method: 'POST' });
  553. const text = await r.text();
  554. let j;
  555. try { j = JSON.parse(text); } catch { j = { raw: text }; }
  556. document.getElementById('auth_info').textContent = '完成:\\n' + JSON.stringify(j, null, 2);
  557. await loadAccounts();
  558. } catch(e){
  559. document.getElementById('auth_info').textContent += '\\n失败:' + e;
  560. }
  561. }
  562. async function send() {
  563. const base = baseUrl();
  564. const auth = authHeader();
  565. const model = document.getElementById('model').value.trim();
  566. const stream = document.getElementById('stream').value === 'true';
  567. const out = document.getElementById('out');
  568. out.textContent = '';
  569. let messages;
  570. try { messages = JSON.parse(document.getElementById('messages').value); }
  571. catch(e){ out.textContent = 'messages 不是合法 JSON'; return; }
  572. const body = { model, messages, stream };
  573. const headers = { 'content-type': 'application/json' };
  574. if (auth) headers['authorization'] = auth;
  575. if (!stream) {
  576. const r = await fetch(api('/v1/chat/completions'), {
  577. method:'POST',
  578. headers,
  579. body: JSON.stringify(body)
  580. });
  581. const text = await r.text();
  582. try { out.textContent = JSON.stringify(JSON.parse(text), null, 2); }
  583. catch { out.textContent = text; }
  584. } else {
  585. const r = await fetch(api('/v1/chat/completions'), {
  586. method:'POST',
  587. headers,
  588. body: JSON.stringify(body)
  589. });
  590. const reader = r.body.getReader();
  591. const decoder = new TextDecoder();
  592. while (true) {
  593. const {value, done} = await reader.read();
  594. if (done) break;
  595. out.textContent += decoder.decode(value, {stream:true});
  596. }
  597. }
  598. }
  599. window.addEventListener('DOMContentLoaded', () => {
  600. loadAccounts();
  601. ping();
  602. });
  603. </script>
  604. </body>
  605. </html>