640 lines
27 KiB
HTML
640 lines
27 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: 60px; 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; }
|
||
.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 100px 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; }
|
||
</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>
|
||
|
||
<section 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 class="panel">
|
||
<h2>查询凭证</h2>
|
||
<div class="search-row">
|
||
<div>
|
||
<label>类型筛选</label>
|
||
<input type="text" id="queryTypeSearch" placeholder="搜索类型…">
|
||
<select id="queryType" class="hidden"></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>
|
||
</section>
|
||
|
||
<section>
|
||
<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">登录凭据写入 .env · 自定义凭证类型</p></div>
|
||
<button type="button" class="btn btn-g" id="btnBackApp">返回</button>
|
||
</div>
|
||
|
||
<section 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 class="panel">
|
||
<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>
|
||
</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 = [];
|
||
let selectedAddType = null;
|
||
let masked = true;
|
||
let queryActive = false;
|
||
let displayed = [];
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
async function api(path, opts = {}) {
|
||
const res = await fetch(path, {
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||
...opts,
|
||
});
|
||
const data = res.headers.get("content-type")?.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 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");
|
||
loadSettingsView();
|
||
}
|
||
|
||
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 => {
|
||
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));
|
||
});
|
||
}
|
||
|
||
function buildAddForm(typeDef) {
|
||
const form = $("addForm");
|
||
form.innerHTML = "";
|
||
if (!typeDef) return;
|
||
typeDef.fields.forEach(fd => {
|
||
const wrap = document.createElement("div");
|
||
wrap.dataset.key = fd.key;
|
||
const lab = document.createElement("label");
|
||
lab.textContent = fd.label + (fd.required ? " *" : "");
|
||
let input;
|
||
if (fd.type === "select") {
|
||
input = document.createElement("select");
|
||
(fd.options || []).forEach(o => {
|
||
const opt = document.createElement("option");
|
||
opt.value = o; opt.textContent = EX_LABEL[o] || o;
|
||
input.appendChild(opt);
|
||
});
|
||
} else {
|
||
input = document.createElement("input");
|
||
input.type = fd.type === "secret" ? "password" : "text";
|
||
if (fd.type === "url") input.placeholder = "https://";
|
||
if (fd.type === "email") input.placeholder = "user@mail.com";
|
||
}
|
||
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);
|
||
}
|
||
|
||
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 = mask(val);
|
||
return a;
|
||
}
|
||
if (fd.type === "email" && val.includes("@")) {
|
||
const a = document.createElement("a");
|
||
a.href = "mailto:" + val;
|
||
a.textContent = mask(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);
|
||
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">${mask(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";
|
||
row.innerHTML = `<span class="row-l">${fd.label}</span>`;
|
||
const v = document.createElement("div");
|
||
v.className = "row-v";
|
||
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);
|
||
const isSec = fd.type === "secret" || fd.key.includes("password") || fd.key === "webhook" || fd.key.includes("secret");
|
||
if (isSec || ["text","phone","select"].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 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");
|
||
sel.innerHTML = allTypes.map(t => `<option value="${t.id}">${t.label}</option>`).join("");
|
||
$("newUser").value = data.username || "";
|
||
}
|
||
|
||
$("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;
|
||
$("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);
|
||
});
|
||
};
|
||
|
||
$("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?type_id=${encodeURIComponent(type_id)}`;
|
||
if (q) url += `&q=${encodeURIComponent(q)}`;
|
||
if (type_id === "exchange") url += `&exchange=${encodeURIComponent($("queryExchange").value)}`;
|
||
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="url">链接</option><option value="email">邮箱</option><option value="phone">手机</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>${t.label}</strong> <code style="color:var(--muted)">${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;
|
||
|
||
async function initApp() {
|
||
await loadTypes();
|
||
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
|
||
if (!$("fieldRows").children.length) addFieldRow();
|
||
}
|
||
|
||
async function boot() {
|
||
const { data } = await api("/api/auth/status");
|
||
if (data.logged_in) {
|
||
await initApp();
|
||
showApp();
|
||
} else showLogin();
|
||
}
|
||
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|