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:
+290
-72
@@ -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 => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[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() {
|
||||
|
||||
Reference in New Issue
Block a user