feat: add wallet types, image upload, backup tabs, and manage.sh

Add mnemonic/key credentials with image upload, tabbed UI for add/query
and settings (auth/types/backup), daily backup to /root, and interactive
deploy script that updates via git pull only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 20:35:24 +08:00
parent 703fd912f8
commit 3e52b8eb87
11 changed files with 1081 additions and 421 deletions
+290 -72
View File
@@ -26,7 +26,7 @@
border-radius: 6px; color: var(--text); font-size: 0.82rem;
}
input, textarea { font-family: var(--mono); }
textarea { min-height: 60px; resize: vertical; }
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; } }
@@ -58,6 +58,7 @@
.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; }
@@ -66,11 +67,22 @@
.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 100px 60px 50px; gap: 6px; margin-bottom: 6px; align-items: end; }
.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; }
.img-preview.lg { max-width: 320px; max-height: 240px; }
.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>
@@ -105,11 +117,16 @@
</div>
</div>
<section class="panel">
<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="搜索:交易所、邮箱、小红书…">
<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>
@@ -120,31 +137,29 @@
</div>
</section>
<section class="panel">
<h2>查询凭证</h2>
<div class="search-row">
<div>
<label>类型筛选</label>
<input type="text" id="queryTypeSearch" placeholder="搜索类型…">
<select id="queryType" class="hidden"></select>
<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 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>
</section>
<section>
<div class="toolbar">
<span id="listCount" class="sub">未查询</span>
<label class="chk"><input type="checkbox" id="maskToggle" checked> 隐藏敏感字段</label>
@@ -155,11 +170,17 @@
<div id="viewSettings" class="wrap hidden">
<div class="top">
<div><h1>系统设置</h1><p class="sub">登录凭据写入 .env · 自定义凭证类型</p></div>
<div><h1>系统设置</h1><p class="sub">账户 · 自定义类型 · 备份恢复</p></div>
<button type="button" class="btn btn-g" id="btnBackApp">返回</button>
</div>
<section class="panel">
<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>
@@ -170,7 +191,7 @@
<p id="authErr" class="err hidden"></p>
</section>
<section class="panel">
<section id="stabTypes" class="panel hidden">
<h2>自定义类型(如小红书、抖音)</h2>
<div class="type-search">
<label>搜索已有自定义类型</label>
@@ -191,12 +212,27 @@
<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>
<script>
const SENSITIVE = new Set(["secret", "password"]);
const EX_LABEL = { binance: "Binance", okx: "OKX", gate: "Gate" };
let allTypes = [];
@@ -204,16 +240,22 @@
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",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
headers,
});
const data = res.headers.get("content-type")?.includes("json") ? await res.json() : null;
const ct = res.headers.get("content-type") || "";
const data = ct.includes("json") ? await res.json() : null;
if (res.status === 401) {
showLogin();
throw new Error("未登录");
@@ -229,6 +271,13 @@
toast._t = setTimeout(() => t.classList.remove("on"), 1800);
}
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));
@@ -259,9 +308,32 @@
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;
@@ -295,6 +367,10 @@
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;
});
@@ -309,35 +385,80 @@
});
}
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 ? " *" : "");
let input;
if (fd.type === "select") {
input = document.createElement("select");
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";
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 {
input = document.createElement("input");
input.type = fd.type === "secret" ? "password" : "text";
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);
}
input.id = "fld_" + fd.key;
const onUpd = () => syncFormVisibility(typeDef);
input.addEventListener("input", onUpd);
input.addEventListener("change", onUpd);
wrap.appendChild(lab);
wrap.appendChild(input);
form.appendChild(wrap);
});
syncFormVisibility(typeDef);
@@ -358,18 +479,17 @@
if (fd.type === "url" && val.startsWith("http")) {
const a = document.createElement("a");
a.href = val; a.target = "_blank"; a.rel = "noopener";
a.textContent = mask(val);
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 = mask(val);
a.textContent = isSensitive(fd) ? mask(val) : val;
return a;
}
const span = document.createElement("span");
span.textContent = SENSITIVE.has(fd.type) || fd.key.includes("password") || fd.key.includes("secret") || fd.key === "webhook"
? mask(val) : mask(val);
span.textContent = isSensitive(fd) ? mask(val) : val;
return span;
}
@@ -380,7 +500,7 @@
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">${mask(rec.title)}</strong>`;
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 () => {
@@ -400,9 +520,22 @@
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;
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);
@@ -420,8 +553,7 @@
v.appendChild(renderValue(fd, val));
}
row.appendChild(v);
const isSec = fd.type === "secret" || fd.key.includes("password") || fd.key === "webhook" || fd.key.includes("secret");
if (isSec || ["text","phone","select"].includes(fd.type)) {
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);
@@ -432,6 +564,10 @@
return card;
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[c]));
}
function renderList() {
const box = $("listBox");
box.innerHTML = "";
@@ -453,8 +589,25 @@
const { data } = await api("/api/settings");
allTypes = [...data.builtin_types, ...data.custom_types];
const sel = $("queryType");
sel.innerHTML = allTypes.map(t => `<option value="${t.id}">${t.label}</option>`).join("");
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) => {
@@ -502,23 +655,11 @@
if (!res.ok) { $("addErr").textContent = data?.error || "添加失败"; $("addErr").classList.remove("hidden"); return; }
$("addForm").innerHTML = "";
selectedAddType = null;
imageDrafts = {};
$("addTypeSearch").value = "";
$("addTypeSelected").textContent = "未选择类型";
queryActive = false;
displayed = [];
masked = true;
$("maskToggle").checked = true;
renderList();
toast("已添加,请查询查看");
};
$("queryTypeSearch").oninput = () => {
const q = $("queryTypeSearch").value.toLowerCase();
const sel = $("queryType");
sel.classList.remove("hidden");
[...sel.options].forEach(o => {
o.hidden = q && !o.text.toLowerCase().includes(q) && !o.value.includes(q);
});
toast("已添加,请到「查询凭证」查看");
switchMainTab("query");
};
$("queryType").onchange = () => {
@@ -528,9 +669,12 @@
$("btnQuery").onclick = async () => {
const type_id = $("queryType").value;
const q = $("queryQ").value.trim();
let url = `/api/credentials?type_id=${encodeURIComponent(type_id)}`;
if (q) url += `&q=${encodeURIComponent(q)}`;
if (type_id === "exchange") url += `&exchange=${encodeURIComponent($("queryExchange").value)}`;
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;
@@ -561,7 +705,15 @@
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="url">链接</option><option value="email">邮箱</option><option value="phone">手机</option></select>
<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();
@@ -602,7 +754,7 @@
customs.forEach(t => {
const div = document.createElement("div");
div.className = "card";
div.innerHTML = `<strong>${t.label}</strong> <code style="color:var(--muted)">${t.id}</code> · ${t.fields.length} 个字段`;
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 () => {
@@ -619,10 +771,76 @@
$("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() {