|
|
@@ -0,0 +1,634 @@
|
|
|
+<!doctype html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="utf-8"/>
|
|
|
+ <title>v2 前端控制台 · 账号管理 + 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 80px;
|
|
|
+ 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 { display:flex; flex-direction:column; gap:12px; max-height:400px; 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); }
|
|
|
+ .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; }
|
|
|
+ .footer {
|
|
|
+ position:fixed;
|
|
|
+ left:0;right:0;bottom:0;
|
|
|
+ background:rgba(10,14,26,.85);
|
|
|
+ backdrop-filter:blur(16px);
|
|
|
+ border-top:1px solid var(--border);
|
|
|
+ padding:14px 20px;
|
|
|
+ box-shadow:0 -4px 20px rgba(0,0,0,.3);
|
|
|
+ }
|
|
|
+ .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; }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <h1>v2 前端控制台</h1>
|
|
|
+ <div class="panel">
|
|
|
+ <div class="row">
|
|
|
+ <div class="field" style="max-width:420px">
|
|
|
+ <label>API Base</label>
|
|
|
+ <input id="base" value="/" />
|
|
|
+ </div>
|
|
|
+ <div class="field" style="max-width:520px">
|
|
|
+ <label>Authorization(OpenAI风格白名单;仅授权用途;OPENAI_KEYS 为空时可留空)</label>
|
|
|
+ <input id="auth" placeholder="自定义Key(可留空:开发模式)" />
|
|
|
+ </div>
|
|
|
+ <div class="field" style="max-width:300px">
|
|
|
+ <label>健康检查</label>
|
|
|
+ <div class="row">
|
|
|
+ <button class="btn-secondary" onclick="ping()">Ping</button>
|
|
|
+ <div id="health" class="chip">未检测</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="sep"></div>
|
|
|
+ <div class="row">
|
|
|
+ <div class="chip mono">OPENAI_KEYS="key1,key2"(白名单,仅授权,与账号无关)</div>
|
|
|
+ <div class="chip mono">当 OPENAI_KEYS 为空或未配置:开发模式,不校验 Authorization</div>
|
|
|
+ <div class="chip mono">账号选择:从所有“启用”的账号中随机选择</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="grid" style="margin-top:12px">
|
|
|
+ <div class="panel">
|
|
|
+ <h2>账号管理</h2>
|
|
|
+ <div class="row">
|
|
|
+ <button class="btn-secondary" onclick="loadAccounts()">刷新列表</button>
|
|
|
+ </div>
|
|
|
+ <div class="list" id="accounts"></div>
|
|
|
+ <div class="sep"></div>
|
|
|
+ <h3>创建账号</h3>
|
|
|
+ <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 class="sep"></div>
|
|
|
+ <h3>URL 登录(5分钟超时)</h3>
|
|
|
+ <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 class="panel">
|
|
|
+ <h2>Chat 测试(OpenAI 兼容 /v1/chat/completions)</h2>
|
|
|
+ <div class="row">
|
|
|
+ <div class="field" style="max-width:300px">
|
|
|
+ <label>model</label>
|
|
|
+ <input id="model" value="claude-sonnet-4" />
|
|
|
+ </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>
|
|
|
+ <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>
|
|
|
+
|
|
|
+ <div class="footer">
|
|
|
+ <div class="container row">
|
|
|
+ <div class="muted">提示:在 .env 配置 OPENAI_KEYS 白名单;账号选择与 key 无关,将在“启用”的账号中随机选择。</div>
|
|
|
+ <div class="right muted">v2 OpenAI-Compatible</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+<script>
|
|
|
+function baseUrl(){ return document.getElementById('base').value.trim(); }
|
|
|
+function authHeader(){
|
|
|
+ const v = document.getElementById('auth').value.trim();
|
|
|
+ return v ? ('Bearer ' + v) : '';
|
|
|
+}
|
|
|
+function setHealth(text, ok=true) {
|
|
|
+ const el = document.getElementById('health');
|
|
|
+ el.textContent = text;
|
|
|
+ el.style.color = ok ? 'var(--ok)' : 'var(--danger)';
|
|
|
+}
|
|
|
+
|
|
|
+function api(path){
|
|
|
+ const b = baseUrl();
|
|
|
+ const baseClean = b.replace(/\/+$/, '');
|
|
|
+ const p = typeof path === 'string' ? path : '';
|
|
|
+ const pathClean = ('/' + p.replace(/^\/+/, '')).replace(/\/{2,}/g, '/');
|
|
|
+ return (baseClean ? baseClean : '') + pathClean;
|
|
|
+}
|
|
|
+
|
|
|
+async function ping(){
|
|
|
+ try{
|
|
|
+ const r = await fetch(api('/healthz'));
|
|
|
+ const j = await r.json();
|
|
|
+ if (j && j.status === 'ok') setHealth('Healthy', true);
|
|
|
+ else setHealth('Unhealthy', false);
|
|
|
+ } catch(e){
|
|
|
+ setHealth('Error', false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderAccounts(list){
|
|
|
+ const root = document.getElementById('accounts');
|
|
|
+ root.innerHTML = '';
|
|
|
+ if (!Array.isArray(list) || list.length === 0) {
|
|
|
+ const empty = document.createElement('div');
|
|
|
+ empty.className = 'muted';
|
|
|
+ empty.textContent = '暂无账号';
|
|
|
+ root.appendChild(empty);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (const acc of list) {
|
|
|
+ const card = document.createElement('div');
|
|
|
+ card.className = 'card';
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ // revert if failed
|
|
|
+ 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('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 (label, accessToken)
|
|
|
+ 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);
|
|
|
+
|
|
|
+ root.appendChild(card);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadAccounts(){
|
|
|
+ try{
|
|
|
+ const r = await fetch(api('/v2/accounts'));
|
|
|
+ const j = await r.json();
|
|
|
+ renderAccounts(j);
|
|
|
+ } 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 fetch(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 fetch(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 fetch(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 fetch(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 fetch(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 fetch(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 base = baseUrl();
|
|
|
+ const auth = authHeader();
|
|
|
+ const model = document.getElementById('model').value.trim();
|
|
|
+ const stream = document.getElementById('stream').value === 'true';
|
|
|
+ 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' };
|
|
|
+ if (auth) headers['authorization'] = auth;
|
|
|
+
|
|
|
+ if (!stream) {
|
|
|
+ const r = await fetch(api('/v1/chat/completions'), {
|
|
|
+ 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 fetch(api('/v1/chat/completions'), {
|
|
|
+ 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', () => {
|
|
|
+ loadAccounts();
|
|
|
+ ping();
|
|
|
+});
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|