修改bug

This commit is contained in:
dekun
2026-05-19 02:42:59 +08:00
parent 80e30255f9
commit 264581caf4
2 changed files with 175 additions and 46 deletions
+43
View File
@@ -0,0 +1,43 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
.venv/
env/
.env
.env.*
!.env.example
# 本地配置与密钥(保留 *.example)
gateway.json
nodes.json
# 运行时数据
gateway_stats.db
gateway_stats.db-*
*.log
logs/
# 测试与工具缓存
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
coverage.xml
# IDE / 系统
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
desktop.ini
# 临时脚本
_patch_*.py
_fix_*.py
+132 -46
View File
@@ -922,6 +922,16 @@ SHELL_HEAD = f"""
}} catch (e) {{}}
return res && res.status ? ('HTTP ' + res.status + ' ' + (res.statusText || '')) : '网络或服务器错误';
}}
(function() {{
if (!localStorage.getItem('web_token')) {{ location.replace(wgApi('/login')); return; }}
document.addEventListener('DOMContentLoaded', function() {{
var btn = document.getElementById('wg-logout');
if (btn) btn.addEventListener('click', function() {{
localStorage.removeItem('web_token');
location.href = wgApi('/login');
}});
}});
}})();
</script>
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="absolute -left-32 top-20 h-72 w-72 rounded-full bg-indigo-500/20 blur-3xl"></div>
@@ -929,16 +939,16 @@ SHELL_HEAD = f"""
</div>
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur-md">
<div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<a href="{app_url('/')}" class="flex items-center gap-2 text-lg font-semibold tracking-tight text-white hover:text-indigo-200 transition">
<a href="{app_url('/home')}" class="flex items-center gap-2 text-lg font-semibold tracking-tight text-white hover:text-indigo-200 transition">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-400 to-cyan-400 text-slate-950 font-bold">AI</span>
<span>中转网关</span>
</a>
<nav class="flex items-center gap-6 text-sm font-medium text-slate-300">
<a href="{app_url('/')}" class="hover:text-white transition">首页</a>
<a href="{app_url('/login')}" class="hover:text-white transition">登录</a>
<a href="{app_url('/home')}" class="hover:text-white transition">首页</a>
<a href="{app_url('/stats')}" class="hover:text-white transition">流量统计</a>
<a href="{app_url('/settings')}" class="hover:text-white transition">系统设置</a>
<a href="{app_url('/user')}" class="rounded-full bg-white/10 px-4 py-2 text-white ring-1 ring-white/15 hover:bg-white/15 transition">用户中心</a>
<button type="button" id="wg-logout" class="text-slate-400 hover:text-white transition">退出</button>
</nav>
</div>
</header>
@@ -956,6 +966,47 @@ SHELL_FOOT = """
"""
LOGIN_SHELL_HEAD = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{title}}</title>
<script src="{TW_SCRIPT}"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-900 text-slate-100 antialiased">
<script>
window.__APP_ROOT__ = {_APP_ROOT_JSON};
function wgApi(path) {{
if (!path || path.charAt(0) !== '/') path = '/' + (path || '');
var root = (window.__APP_ROOT__ || '').replace(/\\/+$/, '');
return root + path;
}}
function wgFmtErr(res, data) {{
try {{
if (data && data.detail !== undefined) {{
var d = data.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d)) return d.map(function(e) {{ return (e.loc?e.loc.join('.'):'') + (e.msg||''); }}).join('');
if (typeof d === 'object') return JSON.stringify(d);
}}
}} catch (e) {{}}
return res && res.status ? ('HTTP ' + res.status) : '网络或服务器错误';
}}
if (localStorage.getItem('web_token')) location.replace(wgApi('/home'));
</script>
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="absolute -left-32 top-20 h-72 w-72 rounded-full bg-indigo-500/20 blur-3xl"></div>
<div class="absolute -right-20 bottom-10 h-96 w-96 rounded-full bg-cyan-500/15 blur-3xl"></div>
</div>
<main class="relative z-10 mx-auto flex min-h-screen max-w-5xl items-center justify-center px-6 py-12">
"""
def page(title: str, inner: str) -> str:
return (
SHELL_HEAD.replace("{title}", title)
@@ -964,12 +1015,20 @@ def page(title: str, inner: str) -> str:
)
def page_login(title: str, inner: str) -> str:
return (
LOGIN_SHELL_HEAD.replace("{title}", title)
+ inner
+ SHELL_FOOT
)
HOME_HTML = page(
"中转网关",
f"""
<motion-wrap class="space-y-12">
<div class="space-y-12">
<div>
<motion-wrap class="flex flex-wrap items-end justify-between gap-4">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">模型分布</h1>
<p class="mt-2 text-sm text-slate-400">各节点模型状态(约每 5 秒刷新;主机默认 127.0.0.1,经 frp 映射端口)</p>
@@ -977,10 +1036,10 @@ HOME_HTML = page(
<a href="{app_url('/settings')}" class="text-sm text-indigo-300 hover:text-white transition">管理节点与模型 →</a>
</div>
<div id="cards-loading" class="mt-6 text-slate-400">加载中…</div>
<motion-wrap id="cards-grid" class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 hidden"></div>
<div id="cards-grid" class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"></div>
<p id="cards-empty" class="mt-6 hidden text-sm text-slate-500">暂无模型卡片,请登录后在系统设置中添加节点与模型。</p>
</div>
<motion-wrap class="rounded-3xl bg-white/5 p-8 ring-1 ring-white/10 backdrop-blur">
<div class="rounded-3xl bg-white/5 p-8 ring-1 ring-white/10 backdrop-blur">
<h2 class="text-lg font-semibold text-white">使用说明</h2>
<ul class="mt-4 space-y-3 text-sm leading-relaxed text-slate-300">
<li>· 对外统一访问本网关(宝塔反代端口 <span class="text-cyan-200">8150</span>),请求 <code class="rounded bg-white/10 px-1 text-cyan-200">/v1/chat/completions</code>Header 携带 <code class="rounded bg-white/10 px-1 text-cyan-200">Authorization: Bearer sk-...</code>。</li>
@@ -995,39 +1054,59 @@ HOME_HTML = page(
const statusBadge = {{ idle: 'bg-emerald-500/20 text-emerald-200', busy: 'bg-amber-500/20 text-amber-200', offline: 'bg-slate-500/20 text-slate-300', disabled: 'bg-slate-600/20 text-slate-400', error: 'bg-rose-500/20 text-rose-200' }};
function esc(s) {{ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
function fmt(n) {{ return Number(n || 0).toLocaleString(); }}
function wgAuthHeaders() {{
var t = localStorage.getItem('web_token');
if (!t) {{ location.replace(wgApi('/login')); return null; }}
return {{ 'Authorization': 'Bearer ' + t }};
}}
let lastCards = [];
function renderCards(cards) {{
const grid = document.getElementById('cards-grid');
const empty = document.getElementById('cards-empty');
grid.innerHTML = '';
if (!cards.length) {{ grid.classList.add('hidden'); empty.classList.remove('hidden'); return; }}
empty.classList.add('hidden'); grid.classList.remove('hidden');
cards.forEach(c => {{
if (!cards.length) {{ grid.innerHTML = ''; empty.classList.remove('hidden'); return; }}
empty.classList.add('hidden');
grid.innerHTML = cards.map(function(c) {{
const st = c.status || 'offline';
const el = document.createElement('article');
el.className = 'rounded-2xl bg-white/5 p-5 ring-1 ' + (statusRing[st]||statusRing.offline) + ' backdrop-blur flex flex-col gap-3';
el.innerHTML = '<motion-wrap class="flex items-start justify-between gap-2"><h3 class="font-semibold text-white leading-snug">'+esc(c.model_label||c.model_id||'未命名')+'</h3><span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium '+(statusBadge[st]||statusBadge.offline)+'">'+esc(c.status_label)+'</span></div>'
+ '<p class="text-xs text-slate-400 font-mono">'+esc(c.model_id||'')+'</p>'
+ '<p class="text-sm text-slate-300">'+esc(c.node_name)+' · <span class="text-cyan-200/90">'+esc(c.endpoint)+'</span></p>'
+ '<motion-wrap class="grid grid-cols-2 gap-2 text-xs text-slate-400 mt-auto pt-2 border-t border-white/10"><div>进行中 <span class="text-white font-medium">'+esc(c.in_flight)+'/'+esc(c.max_concurrent)+'</span></div><div>今日 Token <span class="text-cyan-200 font-medium">'+fmt(c.today_tokens)+'</span></div><div>今日请求 <span class="text-white font-medium">'+fmt(c.today_requests)+'</span></div><div></div></div>';
if (c.last_error && (st==='error'||st==='offline')) {{ const e=document.createElement('p'); e.className='text-xs text-rose-300/90 truncate'; e.title=c.last_error; e.textContent=c.last_error; el.appendChild(e); }}
grid.appendChild(el);
}});
const err = (c.last_error && (st === 'error' || st === 'offline'))
? '<p class="text-xs text-rose-300/90 truncate mt-2" title="'+esc(c.last_error)+'">'+esc(c.last_error)+'</p>' : '';
return '<article class="rounded-2xl bg-white/5 p-5 ring-1 '+(statusRing[st]||statusRing.offline)+' flex flex-col min-h-[190px]">'
+ '<div class="flex items-start justify-between gap-2 mb-2"><h3 class="font-semibold text-white text-base break-words flex-1">'+esc(c.model_label||c.model_id||'未命名')+'</h3>'
+ '<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium '+(statusBadge[st]||statusBadge.offline)+'">'+esc(c.status_label)+'</span></div>'
+ '<p class="text-xs text-slate-500 font-mono truncate mb-1">'+esc(c.model_id||'')+'</p>'
+ '<p class="text-sm text-slate-300 truncate mb-3">'+esc(c.node_name)+' · <span class="text-cyan-200">'+esc(c.endpoint)+'</span></p>'
+ '<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs mt-auto pt-3 border-t border-white/10">'
+ '<div><dt class="text-slate-500">进行中</dt><dd class="text-white font-medium">'+esc(c.in_flight)+' / '+esc(c.max_concurrent)+'</dd></div>'
+ '<div><dt class="text-slate-500">今日 Token</dt><dd class="text-cyan-200 font-medium">'+fmt(c.today_tokens)+'</dd></div>'
+ '<div><dt class="text-slate-500">今日请求</dt><dd class="text-white font-medium">'+fmt(c.today_requests)+'</dd></div>'
+ '<div></div></dl>' + err + '</article>';
}}).join('');
}}
async function refreshCards() {{
const hdrs = wgAuthHeaders();
if (!hdrs) return;
try {{
const res = await fetch(wgApi('/api/models/cards'));
const data = await res.json().catch(() => ({{}}));
const res = await fetch(wgApi('/api/models/cards'), {{ headers: hdrs }});
if (res.status === 401) {{ localStorage.removeItem('web_token'); location.replace(wgApi('/login')); return; }}
const data = await res.json().catch(function() {{ return []; }});
document.getElementById('cards-loading').classList.add('hidden');
if (res.ok) renderCards(Array.isArray(data) ? data : (data.cards || []));
}} catch (_) {{}}
if (res.ok) {{
lastCards = Array.isArray(data) ? data : (data.cards || []);
renderCards(lastCards);
}} else if (lastCards.length) {{
renderCards(lastCards);
}}
refreshCards(); setInterval(refreshCards, 5000);
}} catch (e) {{
if (lastCards.length) renderCards(lastCards);
}}
}}
refreshCards();
setInterval(refreshCards, 5000);
</script>
""",
)
LOGIN_HTML = page(
LOGIN_HTML = page_login(
"登录",
f"""
<div class="mx-auto max-w-md">
@@ -1067,7 +1146,7 @@ LOGIN_HTML = page(
if (!res.ok) {{ err.textContent = wgFmtErr(res, data) || raw.slice(0, 200) || '登录失败'; err.classList.remove('hidden'); return; }}
if (!data.access_token) {{ err.textContent = '服务端未返回令牌'; err.classList.remove('hidden'); return; }}
localStorage.setItem('web_token', data.access_token);
window.location.href = wgApi('/user');
window.location.href = wgApi('/home');
}} catch (ex) {{
err.textContent = '无法连接服务器:' + (ex && ex.message ? ex.message : String(ex));
err.classList.remove('hidden');
@@ -1151,31 +1230,31 @@ SETTINGS_HTML = page(
<p class="mt-2 text-sm text-slate-400">管理节点主机、端口与模型(默认主机 127.0.0.1,对应 frp 映射)</p>
</div>
<p id="msg" class="hidden text-sm"></p>
<motion-wrap id="nodes-list" class="space-y-4"></motion-wrap>
<motion-wrap class="rounded-3xl bg-white/5 p-6 ring-1 ring-white/10">
<div id="nodes-list" class="space-y-4"></div>
<div class="rounded-3xl bg-white/5 p-6 ring-1 ring-white/10">
<h2 class="text-lg font-semibold text-white">添加节点</h2>
<form id="add-form" class="mt-4 space-y-4">
<motion-wrap class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-4 sm:grid-cols-2">
<div><label class="text-sm text-slate-300">节点名称</label>
<input name="name" required class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></motion-wrap>
<input name="name" required class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></div>
<div><label class="text-sm text-slate-300">主机</label>
<input name="host" value="127.0.0.1" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></motion-wrap>
<input name="host" value="127.0.0.1" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></div>
<div><label class="text-sm text-slate-300">端口</label>
<input name="port" type="number" required min="1" max="65535" placeholder="3313" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></motion-wrap>
<input name="port" type="number" required min="1" max="65535" placeholder="3313" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></div>
<div><label class="text-sm text-slate-300">最大并发</label>
<input name="max_concurrent" type="number" value="1" min="1" max="32" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></motion-wrap>
</motion-wrap>
<input name="max_concurrent" type="number" value="1" min="1" max="32" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-white"/></div>
</div>
<div><label class="text-sm text-slate-300">模型列表 <span class="text-slate-500">(每行:模型ID|显示名,显示名可省略)</span></label>
<textarea name="models" rows="4" placeholder="qwen2.5:14b|千问14B&#10;llama3|Llama3" class="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white font-mono"></textarea>
</motion-wrap>
</div>
<label class="flex items-center gap-2 text-sm text-slate-300"><input name="enabled" type="checkbox" checked class="rounded"/> 启用节点</label>
<button type="submit" class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-sm font-semibold text-slate-950">添加节点</button>
</form>
</motion-wrap>
</motion-wrap>
</div>
</div>
<script>
const token = localStorage.getItem('web_token');
if (!token) location.href = wgApi('/login');
if (!token) {{ location.replace(wgApi('/login')); }}
const hdrs = {{ 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }};
function esc(s) {{ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;'); }}
function showMsg(text, ok) {{
@@ -1207,12 +1286,12 @@ SETTINGS_HTML = page(
const card = document.createElement('div');
card.className = 'rounded-2xl bg-white/5 p-5 ring-1 ring-white/10 space-y-3';
const modelsTxt = modelsToText(n.models);
card.innerHTML = '<motion-wrap class="flex flex-wrap items-center justify-between gap-2"><div><span class="font-semibold text-white">'+esc(n.name)+'</span> <span class="text-xs rounded-full px-2 py-0.5 bg-white/10 text-slate-300">'+esc(n.status_label)+'</span></motion-wrap><motion-wrap class="flex gap-2"><button data-test="'+esc(n.id)+'" class="text-xs text-indigo-300 hover:underline">测试</button><button data-del="'+esc(n.id)+'" class="text-xs text-rose-400 hover:underline">删除</button></motion-wrap></motion-wrap>'
card.innerHTML = '<div class="flex flex-wrap items-center justify-between gap-2"><div><span class="font-semibold text-white">'+esc(n.name)+'</span> <span class="text-xs rounded-full px-2 py-0.5 bg-white/10 text-slate-300">'+esc(n.status_label)+'</span></div><div class="flex gap-2"><button data-test="'+esc(n.id)+'" class="text-xs text-indigo-300 hover:underline">测试</button><button data-del="'+esc(n.id)+'" class="text-xs text-rose-400 hover:underline">删除</button></div></div>'
+ '<p class="text-sm text-cyan-200/90 font-mono">'+esc(n.host)+':'+esc(n.port)+' · 进行中 '+esc(n.in_flight)+'/'+esc(n.max_concurrent)+'</p>'
+ '<motion-wrap class="grid gap-2 sm:grid-cols-2 text-sm"><input data-f="name" value="'+esc(n.name)+'" class="rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-white"/>'
+ '<div class="grid gap-2 sm:grid-cols-2 text-sm"><input data-f="name" value="'+esc(n.name)+'" class="rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-white"/>'
+ '<input data-f="host" value="'+esc(n.host)+'" class="rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-white"/>'
+ '<input data-f="port" type="number" value="'+esc(n.port)+'" class="rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-white"/>'
+ '<input data-f="max_concurrent" type="number" value="'+esc(n.max_concurrent)+'" class="rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-white"/></motion-wrap>'
+ '<input data-f="max_concurrent" type="number" value="'+esc(n.max_concurrent)+'" class="rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-white"/></div>'
+ '<textarea data-f="models" rows="3" class="w-full rounded-lg bg-black/30 border border-white/10 px-2 py-1 text-xs text-white font-mono">'+esc(modelsTxt)+'</textarea>'
+ '<label class="flex items-center gap-2 text-sm"><input data-f="enabled" type="checkbox" '+(n.enabled?'checked':'')+'> 启用</label>'
+ '<button data-save="'+esc(n.id)+'" class="rounded-lg bg-white/10 px-3 py-1.5 text-sm text-white ring-1 ring-white/15">保存修改</button>';
@@ -1254,7 +1333,7 @@ SETTINGS_HTML = page(
load().catch(e => showMsg(e.message, false));
</script>
""",
).replace("<div>", "<div>").replace("</motion-wrap>", "</motion-wrap>")
).replace("<div>", "<div>").replace("</div>", "</div>")
STATS_HTML = page(
@@ -1519,8 +1598,8 @@ app.add_middleware(
@app.get("/", response_class=HTMLResponse)
async def home() -> str:
return HOME_HTML
async def root() -> str:
return LOGIN_HTML
@app.get("/login", response_class=HTMLResponse)
@@ -1528,6 +1607,11 @@ async def login_page() -> str:
return LOGIN_HTML
@app.get("/home", response_class=HTMLResponse)
async def home() -> str:
return HOME_HTML
@app.get("/user", response_class=HTMLResponse)
async def user_page() -> str:
return USER_HTML
@@ -1563,7 +1647,9 @@ async def api_me(
@app.get("/api/models/cards")
async def api_model_cards() -> List[Dict[str, Any]]:
async def api_model_cards(
_: Annotated[GateSessionUser, Depends(get_current_web_user)],
) -> List[Dict[str, Any]]:
return await run_in_thread(build_model_cards)