Files
dekun 83fd9c1fd9 feat: image lightbox and curl|bash Ubuntu deploy
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>
2026-07-17 20:43:54 +08:00

915 lines
39 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[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>