初始化提交

This commit is contained in:
h88782481 2026-03-09 14:18:42 +08:00
commit 202731df74
28 changed files with 3140 additions and 0 deletions

76
static/admin.css Normal file
View file

@ -0,0 +1,76 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0b1120;--surface:#151d2e;--card:#1a2332;--input:#212d3f;
--border:#2a3a50;--text:#e2e8f0;--muted:#8899ab;--primary:#3b82f6;
--primary-hover:#2563eb;--green:#22c55e;--red:#ef4444;--yellow:#eab308;
--radius:10px;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;line-height:1.6}
input,select,button,textarea{font-family:inherit;font-size:inherit}
a{color:var(--primary);text-decoration:none}
code{background:var(--input);padding:1px 5px;border-radius:4px;font-size:12px;font-family:Consolas,Monaco,monospace}
.container{max-width:960px;margin:0 auto;padding:0 20px}
#login{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(145deg,#0b1120 0%,#121a2e 50%,#0b1120 100%)}
.login-card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px;width:380px;box-shadow:0 20px 60px rgba(0,0,0,.4)}
.login-card h1{font-size:22px;font-weight:700;margin-bottom:6px;text-align:center}
.login-card p{color:var(--muted);font-size:13px;text-align:center;margin-bottom:28px}
.field{margin-bottom:16px}
.field label{display:block;font-size:13px;color:var(--muted);margin-bottom:6px;font-weight:500}
.input-wrap{position:relative}
.input-wrap input,.input-wrap select{width:100%;background:var(--input);border:1px solid var(--border);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;outline:none;transition:border-color .2s}
.input-wrap input:focus,.input-wrap select:focus{border-color:var(--primary)}
.input-wrap .eye{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;font-size:16px}
select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238899ab'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border-radius:8px;border:none;font-size:14px;font-weight:500;cursor:pointer;transition:all .15s}
.btn-primary{background:var(--primary);color:#fff}.btn-primary:hover{background:var(--primary-hover)}
.btn-green{background:var(--green);color:#fff}.btn-green:hover{opacity:.9}
.btn-red{background:transparent;color:var(--red);border:1px solid var(--red)}.btn-red:hover{background:var(--red);color:#fff}
.btn-ghost{background:transparent;color:var(--muted);border:1px solid var(--border)}.btn-ghost:hover{color:var(--text);border-color:var(--muted)}
.btn-sm{padding:6px 12px;font-size:13px;border-radius:6px}
.btn-block{width:100%;justify-content:center}
.btn:disabled{opacity:.5;cursor:not-allowed}
#dashboard{display:none}
header{background:var(--surface);border-bottom:1px solid var(--border);padding:14px 0;position:sticky;top:0;z-index:50}
header .inner{display:flex;align-items:center}
header h1{font-size:17px;font-weight:700;flex:1}
header .right{display:flex;align-items:center;gap:12px}
.status{font-size:12px;padding:4px 10px;border-radius:20px;background:rgba(34,197,94,.15);color:var(--green)}
main{padding:28px 0 60px}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:24px}
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.card-header h2{font-size:16px;font-weight:600}
.card-header .badge{font-size:12px;color:var(--muted);background:var(--input);padding:3px 10px;border-radius:12px}
.hint{font-size:12px;color:var(--muted);margin-top:4px}
.mapping-list{display:flex;flex-direction:column;gap:10px}
.mapping-item{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;transition:border-color .2s}
.mapping-item:hover{border-color:var(--primary)}
.mapping-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.mapping-name{font-weight:600;font-size:15px;color:var(--primary)}
.mapping-arrow{color:var(--muted);font-size:13px}
.mapping-upstream{font-size:14px;color:var(--text)}
.mapping-meta{display:flex;gap:8px;flex-wrap:wrap}
.tag{font-size:11px;padding:2px 8px;border-radius:4px;font-weight:500}
.tag-anthropic{background:rgba(249,115,22,.15);color:#fb923c}
.tag-openai{background:rgba(16,185,129,.15);color:#34d399}
.tag-auto{background:rgba(139,92,246,.15);color:#a78bfa}
.tag-override{background:rgba(59,130,246,.1);color:var(--primary)}
.mapping-actions{margin-left:auto;display:flex;gap:6px}
.empty{text-align:center;padding:40px;color:var(--muted)}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
.modal-overlay.active{display:flex}
.modal{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px;width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.modal h3{font-size:17px;font-weight:600;margin-bottom:20px}
.modal-footer{display:flex;justify-content:flex-end;gap:10px;margin-top:24px;padding-top:16px;border-top:1px solid var(--border)}
.toast-area{position:fixed;top:20px;right:20px;z-index:200;display:flex;flex-direction:column;gap:8px}
.toast{padding:12px 20px;border-radius:8px;font-size:14px;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.3);animation:slideIn .3s ease}
.toast-ok{background:#065f46;color:#a7f3d0}
.toast-err{background:#7f1d1d;color:#fca5a5}
@keyframes slideIn{from{transform:translateX(100px);opacity:0}to{transform:none;opacity:1}}

127
static/admin.html Normal file
View file

@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 2 Cursor - 管理面板</title>
<link rel="stylesheet" href="/static/admin.css">
</head>
<body>
<!-- 登录 -->
<div id="login">
<div class="login-card">
<h1>API 2 Cursor</h1>
<p>模型映射管理面板</p>
<div class="field">
<label>ACCESS_API_KEY</label>
<div class="input-wrap">
<input type="password" id="loginKey" placeholder="请输入访问密钥" onkeydown="if(event.key==='Enter')doLogin()">
<button class="eye" onclick="togglePwd('loginKey')">&#128065;</button>
</div>
</div>
<button class="btn btn-primary btn-block" onclick="doLogin()">登 录</button>
</div>
</div>
<!-- 仪表盘 -->
<div id="dashboard">
<header>
<div class="container inner">
<h1>API 2 Cursor</h1>
<div class="right">
<span class="status" id="statusBadge">已连接</span>
<button class="btn btn-ghost btn-sm" onclick="doLogout()">退出</button>
</div>
</div>
</header>
<main class="container">
<!-- 全局设置 -->
<div class="card">
<div class="card-header">
<h2>全局设置</h2>
</div>
<div class="field">
<label>中转站地址 (Proxy Target URL)</label>
<div class="input-wrap"><input type="text" id="targetUrl" placeholder="https://your-relay.com"></div>
<div class="hint" id="envUrl"></div>
</div>
<div class="field">
<label>中转站 API Key</label>
<div class="input-wrap">
<input type="password" id="proxyKey" placeholder="sk-xxx 或 Bearer token">
<button class="eye" onclick="togglePwd('proxyKey')">&#128065;</button>
</div>
<div class="hint" id="envKey"></div>
</div>
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
</div>
<!-- 模型映射 -->
<div class="card">
<div class="card-header">
<h2>模型映射</h2>
<button class="btn btn-green btn-sm" onclick="openAddModal()">+ 添加映射</button>
</div>
<div class="hint" style="margin-top:-12px;margin-bottom:16px">
Cursor 发送请求时会带上模型名 → 代理根据映射表找到上游实际模型名和后端协议进行转发。
<br>提示:建议在 Cursor 里使用 Claude 风格的模型名(如 <code>claude-sonnet-4-5-20250929</code>),这样 Cursor 会走 <code>/v1/chat/completions</code> 格式GPT 风格的模型名会走 <code>/v1/responses</code> 格式,两种都已支持。
</div>
<div id="mappingList"></div>
</div>
</main>
</div>
<!-- 弹窗 -->
<div class="modal-overlay" id="modal">
<div class="modal">
<h3 id="modalTitle">添加模型映射</h3>
<div class="field">
<label>Cursor 模型名 <span style="color:var(--red)">*</span></label>
<div class="input-wrap"><input type="text" id="mName" placeholder="例: claude-sonnet-4-5-20250929"></div>
<div class="hint">在 Cursor 自定义模型中添加的名称</div>
</div>
<div class="field">
<label>上游实际模型名 <span style="color:var(--red)">*</span></label>
<div class="input-wrap"><input type="text" id="mUpstream" placeholder="例: gpt-5.4 或 claude-sonnet-4-5-20250929"></div>
<div class="hint">发送到中转站的真实模型名称</div>
</div>
<div class="field">
<label>后端类型</label>
<div class="input-wrap">
<select id="mBackend">
<option value="auto">自动检测</option>
<option value="anthropic">Anthropic (/v1/messages)</option>
<option value="openai">OpenAI (/v1/chat/completions)</option>
</select>
</div>
<div class="hint">
<b>anthropic</b>:转换为 Anthropic Messages 格式 — 适用于中转站通过 <code>/v1/messages</code> 提供 Claude 模型<br>
<b>openai</b>:保持 OpenAI Chat Completions 格式 — 适用于 GPT、DeepSeek、Codex 或通过 <code>/v1/chat/completions</code> 提供所有模型的中转站<br>
<b>自动检测</b>:根据上游模型名判断(含 claude → anthropic其他 → openai
</div>
</div>
<div class="field">
<label>自定义地址 <span style="color:var(--muted)">(可选,留空使用全局设置)</span></label>
<div class="input-wrap"><input type="text" id="mUrl" placeholder="留空则使用全局中转站地址"></div>
</div>
<div class="field">
<label>自定义 API Key <span style="color:var(--muted)">(可选,留空使用全局设置)</span></label>
<div class="input-wrap">
<input type="password" id="mKey" placeholder="留空则使用全局 API Key">
<button class="eye" onclick="togglePwd('mKey')">&#128065;</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="modalSaveBtn" onclick="saveMapping()">保存</button>
</div>
</div>
</div>
<div class="toast-area" id="toasts"></div>
<script src="/static/admin.js"></script>
</body>
</html>

247
static/admin.js Normal file
View file

@ -0,0 +1,247 @@
const API = '';
let authKey = '';
let editingName = null;
function togglePwd(id) {
const el = document.getElementById(id);
el.type = el.type === 'password' ? 'text' : 'password';
}
function toast(msg, ok = true) {
const area = document.getElementById('toasts');
const el = document.createElement('div');
el.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
el.textContent = msg;
area.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
if (authKey) headers['Authorization'] = 'Bearer ' + authKey;
const res = await fetch(API + path, { ...opts, headers });
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
const text = await res.text();
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.substring(0, 100));
throw new Error('服务器返回了非 JSON 响应');
}
const data = await res.json();
if (!res.ok) {
const e = data.error;
const msg = (typeof e === 'object' && e !== null) ? (e.message || JSON.stringify(e)) : (e || data.message || 'HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
// ─── 登录 ───────────────────────────────────────────
async function doLogin() {
const key = document.getElementById('loginKey').value.trim();
if (!key) { toast('请输入密钥', false); return; }
try {
const r = await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ key }) });
if (r.ok) {
authKey = key;
sessionStorage.setItem('_ak', key);
document.getElementById('login').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
loadDashboard();
}
} catch (e) {
toast('密钥无效', false);
}
}
function doLogout() {
authKey = '';
sessionStorage.removeItem('_ak');
document.getElementById('dashboard').style.display = 'none';
document.getElementById('login').style.display = 'flex';
}
// ─── 仪表盘 ─────────────────────────────────────────
async function loadDashboard() {
try {
const s = await api('/api/admin/settings');
document.getElementById('targetUrl').value = s.proxy_target_url || '';
document.getElementById('proxyKey').value = s.proxy_api_key || '';
document.getElementById('envUrl').textContent = s.env_target_url ? '环境变量: ' + s.env_target_url : '';
document.getElementById('envKey').textContent = s.env_api_key ? '环境变量: (已配置)' : '环境变量: (未设置)';
await loadMappings();
checkHealth();
} catch (e) {
toast('加载设置失败: ' + e.message, false);
}
}
async function checkHealth() {
try {
const r = await fetch(API + '/health');
const d = await r.json();
const b = document.getElementById('statusBadge');
if (d.status === 'ok') {
b.textContent = '已连接';
b.style.background = 'rgba(34,197,94,.15)';
b.style.color = 'var(--green)';
} else {
b.textContent = '异常';
}
} catch {
const b = document.getElementById('statusBadge');
b.textContent = '离线';
b.style.background = 'rgba(239,68,68,.15)';
b.style.color = 'var(--red)';
}
}
async function saveSettings() {
try {
await api('/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({
proxy_target_url: document.getElementById('targetUrl').value.trim(),
proxy_api_key: document.getElementById('proxyKey').value.trim(),
}),
});
toast('设置已保存');
} catch (e) {
toast('保存失败: ' + e.message, false);
}
}
// ─── 模型映射 ───────────────────────────────────────
async function loadMappings() {
const mappings = await api('/api/admin/mappings');
const el = document.getElementById('mappingList');
const keys = Object.keys(mappings);
if (!keys.length) {
el.innerHTML = '<div class="empty">暂无模型映射<br><span style="font-size:13px">点击「+ 添加映射」开始配置</span></div>';
return;
}
el.innerHTML = '<div class="mapping-list">' + keys.map(name => {
const m = mappings[name];
const backend = m.backend || 'auto';
const tagClass = backend === 'anthropic' ? 'tag-anthropic' : backend === 'openai' ? 'tag-openai' : 'tag-auto';
const tagLabel = backend === 'auto' ? '自动' : backend;
const hasOverride = m.target_url || m.api_key;
return `<div class="mapping-item">
<div class="mapping-top">
<span class="mapping-name">${esc(name)}</span>
<span class="mapping-arrow">&rarr;</span>
<span class="mapping-upstream">${esc(m.upstream_model || name)}</span>
<div class="mapping-meta">
<span class="tag ${tagClass}">${tagLabel}</span>
${hasOverride ? '<span class="tag tag-override">自定义地址</span>' : ''}
</div>
<div class="mapping-actions">
<button class="btn btn-ghost btn-sm" onclick="openEditModal('${esc(name)}')">编辑</button>
<button class="btn btn-red btn-sm" onclick="deleteMapping('${esc(name)}')">删除</button>
</div>
</div>
</div>`;
}).join('') + '</div>';
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// ─── 弹窗 ──────────────────────────────────────────
function openAddModal() {
editingName = null;
document.getElementById('modalTitle').textContent = '添加模型映射';
document.getElementById('mName').value = '';
document.getElementById('mName').disabled = false;
document.getElementById('mUpstream').value = '';
document.getElementById('mBackend').value = 'auto';
document.getElementById('mUrl').value = '';
document.getElementById('mKey').value = '';
document.getElementById('modal').classList.add('active');
}
async function openEditModal(name) {
editingName = name;
document.getElementById('modalTitle').textContent = '编辑模型映射';
try {
const mappings = await api('/api/admin/mappings');
const m = mappings[name];
if (!m) { toast('映射未找到', false); return; }
document.getElementById('mName').value = name;
document.getElementById('mName').disabled = false;
document.getElementById('mUpstream').value = m.upstream_model || '';
document.getElementById('mBackend').value = m.backend || 'auto';
document.getElementById('mUrl').value = m.target_url || '';
document.getElementById('mKey').value = m.api_key || '';
document.getElementById('modal').classList.add('active');
} catch (e) {
toast('错误: ' + e.message, false);
}
}
function closeModal() {
document.getElementById('modal').classList.remove('active');
editingName = null;
}
async function saveMapping() {
const name = document.getElementById('mName').value.trim();
const upstream = document.getElementById('mUpstream').value.trim();
if (!name) { toast('请填写 Cursor 模型名', false); return; }
if (!upstream) { toast('请填写上游模型名', false); return; }
const payload = {
name,
upstream_model: upstream,
backend: document.getElementById('mBackend').value,
target_url: document.getElementById('mUrl').value.trim(),
api_key: document.getElementById('mKey').value.trim(),
};
try {
if (editingName) {
await api('/api/admin/mappings/' + encodeURIComponent(editingName), {
method: 'PUT', body: JSON.stringify(payload),
});
toast('映射已更新');
} else {
await api('/api/admin/mappings', {
method: 'POST', body: JSON.stringify(payload),
});
toast('映射已添加');
}
closeModal();
await loadMappings();
} catch (e) {
toast('操作失败: ' + e.message, false);
}
}
async function deleteMapping(name) {
if (!confirm('确定要删除映射「' + name + '」吗?')) return;
try {
await api('/api/admin/mappings/' + encodeURIComponent(name), { method: 'DELETE' });
toast('映射已删除');
await loadMappings();
} catch (e) {
toast('删除失败: ' + e.message, false);
}
}
// ─── 初始化 ─────────────────────────────────────────
(function init() {
const saved = sessionStorage.getItem('_ak');
if (saved) {
authKey = saved;
document.getElementById('login').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
loadDashboard();
}
})();
document.getElementById('modal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});