初始化提交
This commit is contained in:
commit
202731df74
28 changed files with 3140 additions and 0 deletions
76
static/admin.css
Normal file
76
static/admin.css
Normal 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
127
static/admin.html
Normal 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')">👁</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')">👁</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')">👁</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
247
static/admin.js
Normal 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">→</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
|
||||
// ─── 弹窗 ──────────────────────────────────────────
|
||||
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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue