83fd9c1fd9
Add click-to-zoom for uploaded images, move manage.sh to deploy/ for curl|bash bootstrap, and remove Windows docs and PowerShell scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
915 lines
39 KiB
HTML
915 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>凭证保险库</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0d0d0f; --surface: #16161a; --border: #2a2a32;
|
||
--text: #e4e4e7; --muted: #71717a; --accent: #3b82f6;
|
||
--accent-h: #2563eb; --ok: #22c55e; --err: #ef4444;
|
||
--r: 8px; --font: "Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
||
--mono: Consolas,"Cascadia Code",monospace;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; }
|
||
.wrap { max-width: 1000px; margin: 0 auto; padding: 24px 16px 48px; }
|
||
.hidden { display: none !important; }
|
||
h1 { font-size: 1.35rem; }
|
||
.sub { color: var(--muted); font-size: 0.85rem; margin-top: 4px; }
|
||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); padding: 18px; margin-bottom: 18px; }
|
||
.panel h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
|
||
label { display: block; font-size: 0.72rem; color: var(--muted); margin-bottom: 4px; }
|
||
input, select, textarea {
|
||
width: 100%; padding: 9px 11px; background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: 6px; color: var(--text); font-size: 0.82rem;
|
||
}
|
||
input, textarea { font-family: var(--mono); }
|
||
textarea { min-height: 80px; resize: vertical; }
|
||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||
.grid { display: grid; gap: 10px; }
|
||
@media (min-width:700px) { .grid-3 { grid-template-columns: repeat(3,1fr); } .span-all { grid-column: 1/-1; } }
|
||
.btn {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
padding: 8px 14px; border: none; border-radius: 6px; cursor: pointer;
|
||
font-size: 0.82rem; font-weight: 500; font-family: var(--font);
|
||
}
|
||
.btn-p { background: var(--accent); color: #fff; }
|
||
.btn-p:hover { background: var(--accent-h); }
|
||
.btn-g { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
||
.btn-g:hover { color: var(--text); }
|
||
.btn-s { padding: 4px 10px; font-size: 0.72rem; background: var(--bg); border: 1px solid var(--border); color: var(--muted); }
|
||
.btn-s:hover { border-color: var(--accent); color: var(--text); }
|
||
.btn-d { color: var(--err); background: transparent; font-size: 0.72rem; padding: 4px 8px; }
|
||
.btn-s.copied { color: var(--ok); border-color: var(--ok); }
|
||
.top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||
.top-actions { display: flex; gap: 8px; }
|
||
.search-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
|
||
.search-row > * { flex: 1; min-width: 140px; }
|
||
.search-row .btn { flex: 0 0 auto; }
|
||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; margin-bottom: 10px; }
|
||
.card-h { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||
.badge { font-size: 0.65rem; padding: 2px 7px; border-radius: 4px; border: 1px solid var(--border); color: var(--accent); text-transform: uppercase; }
|
||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||
.row-l { width: 110px; flex-shrink: 0; font-size: 0.72rem; color: var(--muted); }
|
||
.row-v { flex: 1; font-family: var(--mono); font-size: 0.8rem; word-break: break-all; }
|
||
.row a { color: var(--accent); text-decoration: none; }
|
||
.row a:hover { text-decoration: underline; }
|
||
.empty { text-align: center; padding: 40px; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--r); }
|
||
.err { color: var(--err); font-size: 0.82rem; margin-top: 8px; }
|
||
.okmsg { color: var(--ok); font-size: 0.82rem; margin-top: 8px; }
|
||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--ok); color: var(--ok); border-radius: 6px; opacity: 0; transition: .2s; z-index: 99; pointer-events: none; }
|
||
.toast.on { opacity: 1; }
|
||
.login-box { max-width: 380px; margin: 80px auto; }
|
||
.type-search { margin-bottom: 12px; }
|
||
.type-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
|
||
.type-item { padding: 8px 12px; cursor: pointer; font-size: 0.82rem; border-bottom: 1px solid var(--border); }
|
||
.type-item:hover, .type-item.on { background: var(--bg); color: var(--accent); }
|
||
.field-builder { border: 1px solid var(--border); border-radius: 6px; padding: 10px; margin-top: 8px; }
|
||
.fb-row { display: grid; grid-template-columns: 1fr 1fr 110px 60px 50px; gap: 6px; margin-bottom: 6px; align-items: end; }
|
||
@media (max-width:700px) { .fb-row { grid-template-columns: 1fr 1fr; } }
|
||
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
|
||
.chk { display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--muted); cursor: pointer; }
|
||
.chk input { width: auto; }
|
||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 0; flex-wrap: wrap; }
|
||
.tab {
|
||
padding: 10px 16px; background: transparent; border: none; border-bottom: 2px solid transparent;
|
||
color: var(--muted); cursor: pointer; font-size: 0.9rem; font-family: var(--font); margin-bottom: -1px;
|
||
}
|
||
.tab:hover { color: var(--text); }
|
||
.tab.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||
.img-preview {
|
||
max-width: 220px; max-height: 160px; border-radius: 6px; border: 1px solid var(--border);
|
||
margin-top: 8px; display: block; cursor: zoom-in;
|
||
}
|
||
.img-preview.lg { max-width: 320px; max-height: 240px; }
|
||
.lightbox {
|
||
position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,.88);
|
||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||
cursor: zoom-out;
|
||
}
|
||
.lightbox.hidden { display: none !important; }
|
||
.lightbox img {
|
||
max-width: min(96vw, 1200px); max-height: 92vh; object-fit: contain;
|
||
border-radius: 8px; box-shadow: 0 8px 40px rgba(0,0,0,.5); cursor: default;
|
||
}
|
||
.lightbox-close {
|
||
position: absolute; top: 16px; right: 16px; background: transparent;
|
||
border: 1px solid var(--border); color: var(--text); border-radius: 6px;
|
||
padding: 6px 12px; cursor: pointer; font-size: 0.85rem;
|
||
}
|
||
.lightbox-close:hover { border-color: var(--accent); color: var(--accent); }
|
||
.backup-info { font-size: 0.82rem; color: var(--muted); line-height: 1.7; margin: 10px 0 16px; }
|
||
.backup-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="viewLogin" class="wrap login-box">
|
||
<h1>凭证保险库</h1>
|
||
<p class="sub" style="margin-bottom:20px">登录后管理 · 浏览器不缓存明文</p>
|
||
<div class="panel">
|
||
<form id="loginForm">
|
||
<div style="margin-bottom:12px">
|
||
<label>用户名</label>
|
||
<input type="text" id="loginUser" autocomplete="username" required>
|
||
</div>
|
||
<div style="margin-bottom:16px">
|
||
<label>密码</label>
|
||
<input type="password" id="loginPass" autocomplete="current-password" required>
|
||
</div>
|
||
<button type="submit" class="btn btn-p" style="width:100%">登录</button>
|
||
<p id="loginErr" class="err hidden"></p>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="viewApp" class="wrap hidden">
|
||
<div class="top">
|
||
<div>
|
||
<h1>凭证保险库</h1>
|
||
<p class="sub">加密存储 · 查询后显示 · 复制为明文</p>
|
||
</div>
|
||
<div class="top-actions">
|
||
<button type="button" class="btn btn-g" id="btnSettings">系统设置</button>
|
||
<button type="button" class="btn btn-g" id="btnLogout">退出</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs" id="mainTabs">
|
||
<button type="button" class="tab on" data-tab="add">添加凭证</button>
|
||
<button type="button" class="tab" data-tab="query">查询凭证</button>
|
||
</div>
|
||
|
||
<section id="tabAdd" class="panel">
|
||
<h2>添加凭证</h2>
|
||
<div class="type-search">
|
||
<label>凭证类型(可搜索)</label>
|
||
<input type="text" id="addTypeSearch" placeholder="搜索:交易所、助记词、邮箱…">
|
||
<div id="addTypeList" class="type-list hidden"></div>
|
||
</div>
|
||
<p id="addTypeSelected" class="sub" style="margin:8px 0">未选择类型</p>
|
||
<form id="addForm" class="grid grid-3" style="margin-top:12px"></form>
|
||
<div class="span-all" style="margin-top:12px">
|
||
<button type="button" class="btn btn-p" id="btnAddSubmit">添加</button>
|
||
<p id="addErr" class="err hidden"></p>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="tabQuery" class="hidden">
|
||
<div class="panel">
|
||
<h2>查询凭证</h2>
|
||
<div class="search-row">
|
||
<div>
|
||
<label>类型筛选</label>
|
||
<select id="queryType"></select>
|
||
</div>
|
||
<div id="queryExchangeWrap" class="hidden">
|
||
<label>交易所</label>
|
||
<select id="queryExchange">
|
||
<option value="binance">Binance</option>
|
||
<option value="okx">OKX</option>
|
||
<option value="gate">Gate</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>关键词(可选)</label>
|
||
<input type="text" id="queryQ" placeholder="名称、账号、邮箱、地址…">
|
||
</div>
|
||
<button type="button" class="btn btn-p" id="btnQuery">确认</button>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<span id="listCount" class="sub">未查询</span>
|
||
<label class="chk"><input type="checkbox" id="maskToggle" checked> 隐藏敏感字段</label>
|
||
</div>
|
||
<div id="listBox"><div class="empty">选择类型并点击「确认」查看</div></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="viewSettings" class="wrap hidden">
|
||
<div class="top">
|
||
<div><h1>系统设置</h1><p class="sub">账户 · 自定义类型 · 备份恢复</p></div>
|
||
<button type="button" class="btn btn-g" id="btnBackApp">返回</button>
|
||
</div>
|
||
|
||
<div class="tabs" id="settingsTabs">
|
||
<button type="button" class="tab on" data-stab="auth">账户密码</button>
|
||
<button type="button" class="tab" data-stab="types">自定义类型</button>
|
||
<button type="button" class="tab" data-stab="backup">备份恢复</button>
|
||
</div>
|
||
|
||
<section id="stabAuth" class="panel">
|
||
<h2>登录账号(写入 .env)</h2>
|
||
<form id="authForm" class="grid grid-3">
|
||
<div><label>新用户名</label><input type="text" id="newUser" required></div>
|
||
<div><label>新密码</label><input type="password" id="newPass" required minlength="6"></div>
|
||
<div><label>当前密码确认</label><input type="password" id="curPass" required></div>
|
||
<div class="span-all"><button type="submit" class="btn btn-p">保存登录设置</button></div>
|
||
</form>
|
||
<p id="authErr" class="err hidden"></p>
|
||
</section>
|
||
|
||
<section id="stabTypes" class="panel hidden">
|
||
<h2>自定义类型(如小红书、抖音)</h2>
|
||
<div class="type-search">
|
||
<label>搜索已有自定义类型</label>
|
||
<input type="text" id="customSearch" placeholder="搜索…">
|
||
</div>
|
||
<div id="customList" style="margin:12px 0"></div>
|
||
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
|
||
<h2 style="margin-bottom:10px">新增类型</h2>
|
||
<div class="grid grid-3">
|
||
<div><label>类型 ID(英文)</label><input type="text" id="newTypeId" placeholder="xiaohongshu"></div>
|
||
<div><label>显示名称</label><input type="text" id="newTypeLabel" placeholder="小红书"></div>
|
||
</div>
|
||
<div class="field-builder" id="fieldBuilder">
|
||
<p class="sub" style="margin-bottom:8px">字段列表</p>
|
||
<div id="fieldRows"></div>
|
||
<button type="button" class="btn btn-g" id="btnAddField" style="margin-top:8px">+ 添加字段</button>
|
||
</div>
|
||
<button type="button" class="btn btn-p" id="btnSaveType" style="margin-top:12px">保存自定义类型</button>
|
||
<p id="typeErr" class="err hidden"></p>
|
||
</section>
|
||
|
||
<section id="stabBackup" class="panel hidden">
|
||
<h2>备份与恢复</h2>
|
||
<div class="backup-info" id="backupInfo">加载中…</div>
|
||
<p class="sub" style="margin-bottom:12px">备份包含:.env、data.json、settings.json、uploads(图片)。服务器每日 03:00 自动备份到 /root/crypto_key_backups/。</p>
|
||
<div class="backup-actions">
|
||
<button type="button" class="btn btn-p" id="btnBackupDownload">下载备份</button>
|
||
<button type="button" class="btn btn-g" id="btnBackupNow">立即备份到服务器</button>
|
||
<label class="btn btn-g" style="cursor:pointer;margin:0">
|
||
选择备份文件恢复
|
||
<input type="file" id="restoreFile" accept=".tar.gz,.tgz,application/gzip" class="hidden">
|
||
</label>
|
||
</div>
|
||
<p id="backupMsg" class="okmsg hidden"></p>
|
||
<p id="backupErr" class="err hidden"></p>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="toast" class="toast"></div>
|
||
|
||
<div id="lightbox" class="lightbox hidden" role="dialog" aria-modal="true" aria-label="图片预览">
|
||
<button type="button" class="lightbox-close" id="lightboxClose">关闭</button>
|
||
<img id="lightboxImg" alt="放大预览">
|
||
</div>
|
||
|
||
<script>
|
||
const EX_LABEL = { binance: "Binance", okx: "OKX", gate: "Gate" };
|
||
|
||
let allTypes = [];
|
||
let selectedAddType = null;
|
||
let masked = true;
|
||
let queryActive = false;
|
||
let displayed = [];
|
||
let imageDrafts = {};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
async function api(path, opts = {}) {
|
||
const headers = { ...(opts.headers || {}) };
|
||
if (!(opts.body instanceof FormData) && !headers["Content-Type"]) {
|
||
headers["Content-Type"] = "application/json";
|
||
}
|
||
const res = await fetch(path, {
|
||
credentials: "same-origin",
|
||
...opts,
|
||
headers,
|
||
});
|
||
const ct = res.headers.get("content-type") || "";
|
||
const data = ct.includes("json") ? await res.json() : null;
|
||
if (res.status === 401) {
|
||
showLogin();
|
||
throw new Error("未登录");
|
||
}
|
||
return { res, data };
|
||
}
|
||
|
||
function toast(msg) {
|
||
const t = $("toast");
|
||
t.textContent = msg;
|
||
t.classList.add("on");
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => t.classList.remove("on"), 1800);
|
||
}
|
||
|
||
function openLightbox(src) {
|
||
if (!src) return;
|
||
$("lightboxImg").src = src;
|
||
$("lightbox").classList.remove("hidden");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeLightbox() {
|
||
$("lightbox").classList.add("hidden");
|
||
$("lightboxImg").src = "";
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
$("lightboxClose").onclick = (e) => { e.stopPropagation(); closeLightbox(); };
|
||
$("lightbox").onclick = (e) => {
|
||
if (e.target === $("lightbox") || e.target === $("lightboxClose")) closeLightbox();
|
||
};
|
||
$("lightboxImg").onclick = (e) => e.stopPropagation();
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && !$("lightbox").classList.contains("hidden")) closeLightbox();
|
||
});
|
||
|
||
function bindZoom(img) {
|
||
img.title = "点击放大";
|
||
img.onclick = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
openLightbox(img.src);
|
||
};
|
||
}
|
||
|
||
function isSensitive(fd) {
|
||
if (!fd) return false;
|
||
if (fd.secret || fd.type === "secret") return true;
|
||
const k = fd.key || "";
|
||
return ["password", "secret", "mnemonic", "private_key", "webhook"].some(x => k.includes(x));
|
||
}
|
||
|
||
function mask(v) {
|
||
if (!masked || v == null || v === "") return v ?? "";
|
||
return "•".repeat(Math.min(String(v).length, 24));
|
||
}
|
||
|
||
async function copyText(text, btn) {
|
||
try { await navigator.clipboard.writeText(text); } catch {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text; document.body.appendChild(ta); ta.select();
|
||
document.execCommand("copy"); document.body.removeChild(ta);
|
||
}
|
||
if (btn) { btn.classList.add("copied"); btn.textContent = "已复制"; setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "复制"; }, 1200); }
|
||
toast("已复制");
|
||
}
|
||
|
||
function showLogin() {
|
||
$("viewLogin").classList.remove("hidden");
|
||
$("viewApp").classList.add("hidden");
|
||
$("viewSettings").classList.add("hidden");
|
||
}
|
||
|
||
function showApp() {
|
||
$("viewLogin").classList.add("hidden");
|
||
$("viewApp").classList.remove("hidden");
|
||
$("viewSettings").classList.add("hidden");
|
||
}
|
||
|
||
function showSettings() {
|
||
$("viewApp").classList.add("hidden");
|
||
$("viewSettings").classList.remove("hidden");
|
||
switchSettingsTab("auth");
|
||
loadSettingsView();
|
||
refreshBackupInfo();
|
||
}
|
||
|
||
function switchMainTab(name) {
|
||
document.querySelectorAll("#mainTabs .tab").forEach(t => t.classList.toggle("on", t.dataset.tab === name));
|
||
$("tabAdd").classList.toggle("hidden", name !== "add");
|
||
$("tabQuery").classList.toggle("hidden", name !== "query");
|
||
}
|
||
|
||
function switchSettingsTab(name) {
|
||
document.querySelectorAll("#settingsTabs .tab").forEach(t => t.classList.toggle("on", t.dataset.stab === name));
|
||
$("stabAuth").classList.toggle("hidden", name !== "auth");
|
||
$("stabTypes").classList.toggle("hidden", name !== "types");
|
||
$("stabBackup").classList.toggle("hidden", name !== "backup");
|
||
if (name === "backup") refreshBackupInfo();
|
||
}
|
||
|
||
document.querySelectorAll("#mainTabs .tab").forEach(btn => {
|
||
btn.onclick = () => switchMainTab(btn.dataset.tab);
|
||
});
|
||
document.querySelectorAll("#settingsTabs .tab").forEach(btn => {
|
||
btn.onclick = () => switchSettingsTab(btn.dataset.stab);
|
||
});
|
||
|
||
function fieldVisible(fd, fields) {
|
||
const when = fd.when;
|
||
if (!when) return true;
|
||
return Object.entries(when).every(([k, v]) => fields[k] === v);
|
||
}
|
||
|
||
function renderTypePicker(listEl, searchEl, onPick, currentId) {
|
||
const q = searchEl.value.trim().toLowerCase();
|
||
const filtered = allTypes.filter(t =>
|
||
!q || t.label.toLowerCase().includes(q) || t.id.includes(q)
|
||
);
|
||
listEl.innerHTML = "";
|
||
if (!filtered.length) {
|
||
listEl.classList.add("hidden");
|
||
return;
|
||
}
|
||
listEl.classList.remove("hidden");
|
||
filtered.forEach(t => {
|
||
const div = document.createElement("div");
|
||
div.className = "type-item" + (t.id === currentId ? " on" : "");
|
||
div.textContent = t.label + (t.builtin ? "" : " (自定义)");
|
||
div.onclick = () => {
|
||
onPick(t);
|
||
listEl.classList.add("hidden");
|
||
searchEl.value = t.label;
|
||
};
|
||
listEl.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function readFormState(typeDef) {
|
||
const state = {};
|
||
typeDef.fields.forEach(fd => {
|
||
if (fd.type === "image") {
|
||
state[fd.key] = imageDrafts[fd.key] || "";
|
||
return;
|
||
}
|
||
const inp = $("fld_" + fd.key);
|
||
if (inp) state[fd.key] = inp.value;
|
||
});
|
||
return state;
|
||
}
|
||
|
||
function syncFormVisibility(typeDef) {
|
||
const state = readFormState(typeDef);
|
||
typeDef.fields.forEach(fd => {
|
||
const wrap = document.querySelector(`#addForm [data-key="${fd.key}"]`);
|
||
if (wrap) wrap.classList.toggle("hidden", !fieldVisible(fd, state));
|
||
});
|
||
}
|
||
|
||
async function uploadImageFile(file) {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const { res, data } = await api("/api/upload", { method: "POST", body: fd, headers: {} });
|
||
if (!res.ok) throw new Error(data?.error || "上传失败");
|
||
return data;
|
||
}
|
||
|
||
function buildAddForm(typeDef) {
|
||
const form = $("addForm");
|
||
form.innerHTML = "";
|
||
imageDrafts = {};
|
||
if (!typeDef) return;
|
||
typeDef.fields.forEach(fd => {
|
||
const wrap = document.createElement("div");
|
||
wrap.dataset.key = fd.key;
|
||
if (fd.type === "image" || fd.type === "textarea") wrap.classList.add("span-all");
|
||
const lab = document.createElement("label");
|
||
lab.textContent = fd.label + (fd.required ? " *" : "");
|
||
wrap.appendChild(lab);
|
||
|
||
if (fd.type === "image") {
|
||
const file = document.createElement("input");
|
||
file.type = "file";
|
||
file.accept = "image/*";
|
||
file.id = "fld_" + fd.key;
|
||
const preview = document.createElement("img");
|
||
preview.className = "img-preview hidden";
|
||
bindZoom(preview);
|
||
const status = document.createElement("p");
|
||
status.className = "sub";
|
||
status.style.marginTop = "6px";
|
||
file.onchange = async () => {
|
||
if (!file.files?.[0]) return;
|
||
status.textContent = "上传中…";
|
||
try {
|
||
const data = await uploadImageFile(file.files[0]);
|
||
imageDrafts[fd.key] = data.path;
|
||
preview.src = data.url;
|
||
preview.classList.remove("hidden");
|
||
status.textContent = "已上传 · 点击图片可放大";
|
||
} catch (e) {
|
||
status.textContent = e.message || "上传失败";
|
||
status.className = "err";
|
||
}
|
||
};
|
||
wrap.appendChild(file);
|
||
wrap.appendChild(preview);
|
||
wrap.appendChild(status);
|
||
} else if (fd.type === "select") {
|
||
const input = document.createElement("select");
|
||
input.id = "fld_" + fd.key;
|
||
(fd.options || []).forEach(o => {
|
||
const opt = document.createElement("option");
|
||
opt.value = o; opt.textContent = EX_LABEL[o] || o;
|
||
input.appendChild(opt);
|
||
});
|
||
input.addEventListener("change", () => syncFormVisibility(typeDef));
|
||
wrap.appendChild(input);
|
||
} else if (fd.type === "textarea" || fd.key === "mnemonic") {
|
||
const input = document.createElement("textarea");
|
||
input.id = "fld_" + fd.key;
|
||
input.placeholder = fd.key === "mnemonic" ? "输入助记词,单词之间用空格分隔" : "";
|
||
wrap.appendChild(input);
|
||
} else {
|
||
const input = document.createElement("input");
|
||
input.type = (fd.type === "secret" || isSensitive(fd)) ? "password" : "text";
|
||
input.id = "fld_" + fd.key;
|
||
if (fd.type === "url") input.placeholder = "https://";
|
||
if (fd.type === "email") input.placeholder = "user@mail.com";
|
||
const onUpd = () => syncFormVisibility(typeDef);
|
||
input.addEventListener("input", onUpd);
|
||
input.addEventListener("change", onUpd);
|
||
wrap.appendChild(input);
|
||
}
|
||
form.appendChild(wrap);
|
||
});
|
||
syncFormVisibility(typeDef);
|
||
}
|
||
|
||
function collectAddFields(typeDef) {
|
||
const state = readFormState(typeDef);
|
||
const fields = {};
|
||
typeDef.fields.forEach(fd => {
|
||
if (!fieldVisible(fd, state)) return;
|
||
fields[fd.key] = (state[fd.key] || "").trim();
|
||
});
|
||
return fields;
|
||
}
|
||
|
||
function renderValue(fd, val) {
|
||
if (!val) return document.createTextNode("—");
|
||
if (fd.type === "url" && val.startsWith("http")) {
|
||
const a = document.createElement("a");
|
||
a.href = val; a.target = "_blank"; a.rel = "noopener";
|
||
a.textContent = isSensitive(fd) ? mask(val) : val;
|
||
return a;
|
||
}
|
||
if (fd.type === "email" && val.includes("@")) {
|
||
const a = document.createElement("a");
|
||
a.href = "mailto:" + val;
|
||
a.textContent = isSensitive(fd) ? mask(val) : val;
|
||
return a;
|
||
}
|
||
const span = document.createElement("span");
|
||
span.textContent = isSensitive(fd) ? mask(val) : val;
|
||
return span;
|
||
}
|
||
|
||
function renderCard(rec) {
|
||
const typeDef = allTypes.find(t => t.id === rec.type_id);
|
||
const card = document.createElement("article");
|
||
card.className = "card";
|
||
const h = document.createElement("div");
|
||
h.className = "card-h";
|
||
const left = document.createElement("div");
|
||
left.innerHTML = `<span class="badge">${typeDef?.label || rec.type_id}</span> <strong style="margin-left:8px">${esc(rec.title)}</strong>`;
|
||
const del = document.createElement("button");
|
||
del.className = "btn btn-d"; del.textContent = "删除";
|
||
del.onclick = async () => {
|
||
if (!confirm("确定删除?")) return;
|
||
await api(`/api/credentials/${rec.id}`, { method: "DELETE" });
|
||
displayed = displayed.filter(r => r.id !== rec.id);
|
||
renderList();
|
||
toast("已删除");
|
||
};
|
||
h.appendChild(left); h.appendChild(del);
|
||
card.appendChild(h);
|
||
|
||
const fields = rec.fields || {};
|
||
(typeDef?.fields || Object.keys(fields).map(k => ({ key: k, label: k, type: "text" }))).forEach(fd => {
|
||
if (!fieldVisible(fd, fields)) return;
|
||
const val = fields[fd.key];
|
||
if (val === undefined || val === "") return;
|
||
const row = document.createElement("div");
|
||
row.className = "row";
|
||
if (fd.type === "image") row.style.alignItems = "flex-start";
|
||
row.innerHTML = `<span class="row-l">${fd.label}</span>`;
|
||
const v = document.createElement("div");
|
||
v.className = "row-v";
|
||
|
||
if (fd.type === "image") {
|
||
const img = document.createElement("img");
|
||
img.className = "img-preview lg";
|
||
img.src = `/api/uploads/${encodeURIComponent(val)}`;
|
||
img.alt = fd.label;
|
||
bindZoom(img);
|
||
v.appendChild(img);
|
||
row.appendChild(v);
|
||
card.appendChild(row);
|
||
return;
|
||
}
|
||
|
||
if (fd.key === "web_url" && val.startsWith("http")) {
|
||
const a = document.createElement("a");
|
||
a.href = val; a.target = "_blank"; a.textContent = mask(val);
|
||
v.appendChild(a);
|
||
} else if (fd.type === "url" || (fd.key === "url" && val)) {
|
||
const url = val.startsWith("http") ? val : "https://" + val;
|
||
const a = document.createElement("a");
|
||
a.href = url; a.target = "_blank"; a.textContent = mask(val);
|
||
v.appendChild(a);
|
||
} else if (fd.type === "email" || fd.key === "email") {
|
||
const a = document.createElement("a");
|
||
a.href = "mailto:" + val; a.textContent = mask(val);
|
||
v.appendChild(a);
|
||
} else {
|
||
v.appendChild(renderValue(fd, val));
|
||
}
|
||
row.appendChild(v);
|
||
if (isSensitive(fd) || ["text","phone","select","textarea"].includes(fd.type)) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button"; btn.className = "btn btn-s"; btn.textContent = "复制";
|
||
btn.onclick = () => copyText(val, btn);
|
||
row.appendChild(btn);
|
||
}
|
||
card.appendChild(row);
|
||
});
|
||
return card;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c]));
|
||
}
|
||
|
||
function renderList() {
|
||
const box = $("listBox");
|
||
box.innerHTML = "";
|
||
if (!queryActive) {
|
||
box.innerHTML = '<div class="empty">选择类型并点击「确认」查看</div>';
|
||
$("listCount").textContent = "未查询";
|
||
return;
|
||
}
|
||
const t = allTypes.find(x => x.id === $("queryType").value);
|
||
$("listCount").textContent = `${t?.label || ""} · 共 ${displayed.length} 条`;
|
||
if (!displayed.length) {
|
||
box.innerHTML = '<div class="empty">无匹配记录</div>';
|
||
return;
|
||
}
|
||
displayed.forEach(r => box.appendChild(renderCard(r)));
|
||
}
|
||
|
||
async function loadTypes() {
|
||
const { data } = await api("/api/settings");
|
||
allTypes = [...data.builtin_types, ...data.custom_types];
|
||
const sel = $("queryType");
|
||
const prev = sel.value;
|
||
sel.innerHTML = '<option value="">全部类型</option>' + allTypes.map(t => `<option value="${t.id}">${t.label}</option>`).join("");
|
||
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
|
||
$("newUser").value = data.username || "";
|
||
return data;
|
||
}
|
||
|
||
async function refreshBackupInfo() {
|
||
try {
|
||
const { data } = await api("/api/backup/status");
|
||
const size = data.last_backup_size ? ` · ${(data.last_backup_size / 1024).toFixed(1)} KB` : "";
|
||
$("backupInfo").innerHTML = `
|
||
最近备份:${data.last_backup || "尚无"} ${size}<br>
|
||
备份目录:${data.auto_backup_dir || "-"}<br>
|
||
最近恢复:${data.last_restore || "无"}
|
||
`;
|
||
} catch {
|
||
$("backupInfo").textContent = "无法获取备份状态";
|
||
}
|
||
}
|
||
|
||
$("loginForm").onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
$("loginErr").classList.add("hidden");
|
||
const { res, data } = await api("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username: $("loginUser").value, password: $("loginPass").value }),
|
||
});
|
||
if (!res.ok) {
|
||
$("loginErr").textContent = data?.error || "登录失败";
|
||
$("loginErr").classList.remove("hidden");
|
||
return;
|
||
}
|
||
$("loginPass").value = "";
|
||
await initApp();
|
||
showApp();
|
||
};
|
||
|
||
$("btnLogout").onclick = async () => {
|
||
await api("/api/auth/logout", { method: "POST" });
|
||
showLogin();
|
||
};
|
||
|
||
$("btnSettings").onclick = showSettings;
|
||
$("btnBackApp").onclick = showApp;
|
||
|
||
$("addTypeSearch").onfocus = () => renderTypePicker($("addTypeList"), $("addTypeSearch"), pickAddType, selectedAddType?.id);
|
||
$("addTypeSearch").oninput = () => renderTypePicker($("addTypeList"), $("addTypeSearch"), pickAddType, selectedAddType?.id);
|
||
|
||
function pickAddType(t) {
|
||
selectedAddType = t;
|
||
$("addTypeSelected").textContent = "已选:" + t.label;
|
||
buildAddForm(t);
|
||
}
|
||
|
||
$("btnAddSubmit").onclick = async () => {
|
||
$("addErr").classList.add("hidden");
|
||
if (!selectedAddType) { $("addErr").textContent = "请先选择类型"; $("addErr").classList.remove("hidden"); return; }
|
||
const fields = collectAddFields(selectedAddType);
|
||
const { res, data } = await api("/api/credentials", {
|
||
method: "POST",
|
||
body: JSON.stringify({ type_id: selectedAddType.id, fields }),
|
||
});
|
||
if (!res.ok) { $("addErr").textContent = data?.error || "添加失败"; $("addErr").classList.remove("hidden"); return; }
|
||
$("addForm").innerHTML = "";
|
||
selectedAddType = null;
|
||
imageDrafts = {};
|
||
$("addTypeSearch").value = "";
|
||
$("addTypeSelected").textContent = "未选择类型";
|
||
toast("已添加,请到「查询凭证」查看");
|
||
switchMainTab("query");
|
||
};
|
||
|
||
$("queryType").onchange = () => {
|
||
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
|
||
};
|
||
|
||
$("btnQuery").onclick = async () => {
|
||
const type_id = $("queryType").value;
|
||
const q = $("queryQ").value.trim();
|
||
let url = `/api/credentials?`;
|
||
const params = [];
|
||
if (type_id) params.push(`type_id=${encodeURIComponent(type_id)}`);
|
||
if (q) params.push(`q=${encodeURIComponent(q)}`);
|
||
if (type_id === "exchange") params.push(`exchange=${encodeURIComponent($("queryExchange").value)}`);
|
||
url += params.join("&");
|
||
const { data } = await api(url);
|
||
displayed = data;
|
||
queryActive = true;
|
||
renderList();
|
||
};
|
||
|
||
$("maskToggle").onchange = () => { masked = $("maskToggle").checked; if (queryActive) renderList(); };
|
||
|
||
$("authForm").onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
$("authErr").classList.add("hidden");
|
||
const { res, data } = await api("/api/settings/auth", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
username: $("newUser").value,
|
||
new_password: $("newPass").value,
|
||
current_password: $("curPass").value,
|
||
}),
|
||
});
|
||
if (!res.ok) { $("authErr").textContent = data?.error || "保存失败"; $("authErr").classList.remove("hidden"); return; }
|
||
$("curPass").value = $("newPass").value = "";
|
||
toast("登录设置已写入 .env");
|
||
};
|
||
|
||
function addFieldRow() {
|
||
const row = document.createElement("div");
|
||
row.className = "fb-row";
|
||
row.innerHTML = `
|
||
<input placeholder="key" class="fb-key">
|
||
<input placeholder="显示名" class="fb-label">
|
||
<select class="fb-type">
|
||
<option value="text">文本</option>
|
||
<option value="secret">敏感</option>
|
||
<option value="textarea">多行</option>
|
||
<option value="url">链接</option>
|
||
<option value="email">邮箱</option>
|
||
<option value="phone">手机</option>
|
||
<option value="image">图片</option>
|
||
</select>
|
||
<label class="chk"><input type="checkbox" class="fb-req">必填</label>
|
||
<button type="button" class="btn btn-d fb-del">删</button>`;
|
||
row.querySelector(".fb-del").onclick = () => row.remove();
|
||
$("fieldRows").appendChild(row);
|
||
}
|
||
|
||
$("btnAddField").onclick = addFieldRow;
|
||
|
||
$("btnSaveType").onclick = async () => {
|
||
$("typeErr").classList.add("hidden");
|
||
const fields = [...$("fieldRows").querySelectorAll(".fb-row")].map(r => ({
|
||
key: r.querySelector(".fb-key").value.trim().toLowerCase(),
|
||
label: r.querySelector(".fb-label").value.trim(),
|
||
type: r.querySelector(".fb-type").value,
|
||
required: r.querySelector(".fb-req").checked,
|
||
})).filter(f => f.key);
|
||
const { res, data } = await api("/api/settings/types", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
id: $("newTypeId").value.trim().toLowerCase(),
|
||
label: $("newTypeLabel").value.trim(),
|
||
fields,
|
||
}),
|
||
});
|
||
if (!res.ok) { $("typeErr").textContent = data?.error || "保存失败"; $("typeErr").classList.remove("hidden"); return; }
|
||
$("newTypeId").value = $("newTypeLabel").value = "";
|
||
$("fieldRows").innerHTML = "";
|
||
addFieldRow();
|
||
await loadTypes();
|
||
loadSettingsView();
|
||
toast("类型已添加");
|
||
};
|
||
|
||
function loadSettingsView() {
|
||
const q = $("customSearch").value.toLowerCase();
|
||
const customs = allTypes.filter(t => !t.builtin && (!q || t.label.toLowerCase().includes(q) || t.id.includes(q)));
|
||
$("customList").innerHTML = customs.length ? "" : '<p class="sub">暂无自定义类型</p>';
|
||
customs.forEach(t => {
|
||
const div = document.createElement("div");
|
||
div.className = "card";
|
||
div.innerHTML = `<strong>${esc(t.label)}</strong> <code style="color:var(--muted)">${esc(t.id)}</code> · ${t.fields.length} 个字段`;
|
||
const btn = document.createElement("button");
|
||
btn.className = "btn btn-d"; btn.textContent = "删除"; btn.style.marginTop = "8px";
|
||
btn.onclick = async () => {
|
||
if (!confirm(`删除类型 ${t.label} 及其所有记录?`)) return;
|
||
await api(`/api/settings/types/${t.id}`, { method: "DELETE" });
|
||
await loadTypes();
|
||
loadSettingsView();
|
||
toast("已删除");
|
||
};
|
||
div.appendChild(btn);
|
||
$("customList").appendChild(div);
|
||
});
|
||
}
|
||
|
||
$("customSearch").oninput = loadSettingsView;
|
||
|
||
$("btnBackupDownload").onclick = async () => {
|
||
$("backupErr").classList.add("hidden");
|
||
$("backupMsg").classList.add("hidden");
|
||
try {
|
||
const res = await fetch("/api/backup/export", { credentials: "same-origin" });
|
||
if (res.status === 401) { showLogin(); return; }
|
||
if (!res.ok) throw new Error("下载失败");
|
||
const blob = await res.blob();
|
||
const cd = res.headers.get("Content-Disposition") || "";
|
||
const m = cd.match(/filename="?([^"]+)"?/);
|
||
const name = m ? m[1] : `crypto_key_backup_${Date.now()}.tar.gz`;
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
$("backupMsg").textContent = "备份已下载";
|
||
$("backupMsg").classList.remove("hidden");
|
||
refreshBackupInfo();
|
||
} catch (e) {
|
||
$("backupErr").textContent = e.message || "下载失败";
|
||
$("backupErr").classList.remove("hidden");
|
||
}
|
||
};
|
||
|
||
$("btnBackupNow").onclick = async () => {
|
||
$("backupErr").classList.add("hidden");
|
||
$("backupMsg").classList.add("hidden");
|
||
const { res, data } = await api("/api/backup/now", { method: "POST", body: "{}" });
|
||
if (!res.ok) {
|
||
$("backupErr").textContent = data?.error || "备份失败";
|
||
$("backupErr").classList.remove("hidden");
|
||
return;
|
||
}
|
||
$("backupMsg").textContent = "已备份到:" + (data.path || data.last_backup_path || "");
|
||
$("backupMsg").classList.remove("hidden");
|
||
refreshBackupInfo();
|
||
toast("服务器备份完成");
|
||
};
|
||
|
||
$("restoreFile").onchange = async () => {
|
||
const file = $("restoreFile").files?.[0];
|
||
if (!file) return;
|
||
if (!confirm("恢复将覆盖当前 .env、data.json、settings.json 与 uploads,确定继续?")) {
|
||
$("restoreFile").value = "";
|
||
return;
|
||
}
|
||
$("backupErr").classList.add("hidden");
|
||
$("backupMsg").classList.add("hidden");
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const { res, data } = await api("/api/backup/restore", { method: "POST", body: fd, headers: {} });
|
||
$("restoreFile").value = "";
|
||
if (!res.ok) {
|
||
$("backupErr").textContent = data?.error || "恢复失败";
|
||
$("backupErr").classList.remove("hidden");
|
||
return;
|
||
}
|
||
$("backupMsg").textContent = data.message || "恢复成功";
|
||
$("backupMsg").classList.remove("hidden");
|
||
toast("恢复成功");
|
||
await loadTypes();
|
||
refreshBackupInfo();
|
||
};
|
||
|
||
async function initApp() {
|
||
await loadTypes();
|
||
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
|
||
if (!$("fieldRows").children.length) addFieldRow();
|
||
switchMainTab("add");
|
||
}
|
||
|
||
async function boot() {
|
||
const { data } = await api("/api/auth/status");
|
||
if (data.logged_in) {
|
||
await initApp();
|
||
showApp();
|
||
} else showLogin();
|
||
}
|
||
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|