const STORE_KEY = "bindvault:mvp:v1";
const API_STATE_URL = "/api/state";
const ONBOARDING_SEEN_KEY = "bindvault:onboarding:seen:v1";
const AUTH_SESSION_KEY = "bindvault:auth:v1";
const LICENSE_KEY = "bindvault:license:v1";
const DEVICE_ID_KEY = "bindvault:device_id:v1";
const LICENSE_API = "http://localhost:8080/api";
const LICENSE_PROJECT = "BindVault";
const DEFAULT_ACCOUNT_ID = "110";
const DEFAULT_USER_ID = "1";
const FREE_LIMITS = { assets: 6, accounts: 3, bindings: 8 };
const LOCALE_KEY = "bindvault:locale:v1";
const locales = globalThis.BindVaultLocales || {};
const platformCatalog = globalThis.BindVaultPlatformCatalog || [];
const platformAssets = globalThis.BindVaultPlatformAssets || {};
const currentLocale = normalizeLocale(localStorage.getItem(LOCALE_KEY) || navigator.language || "zh");
function normalizeLocale(value) {
return String(value || "").toLowerCase().startsWith("en") ? "en" : "zh";
}
function i18n(path, fallback = "", vars = {}) {
const parts = path.split(".");
let value = locales[currentLocale];
for (const part of parts) value = value?.[part];
if (typeof value !== "string") {
value = locales.zh;
for (const part of parts) value = value?.[part];
}
const template = typeof value === "string" ? value : fallback;
return Object.entries(vars).reduce((text, [key, val]) => text.replaceAll(`{${key}}`, String(val)), template);
}
const enums = {
phoneStatus: ["available", "inactive", "cannot_receive_sms", "released", "high_risk", "unknown"],
simType: ["physical", "esim", "virtual"],
emailType: ["gmail", "outlook", "qq", "custom_domain", "cloudflare_routing", "alias"],
emailStatus: ["available", "cannot_receive", "inactive", "high_risk"],
domainStatus: ["active", "expired", "transferring", "high_risk"],
accountStatus: ["normal", "pending_verify", "locked", "suspended", "appealing", "recovered", "deleted", "unusable", "unknown"],
twoFactor: ["none", "sms", "email", "authenticator", "passkey", "hardware_key"],
assetType: ["phone", "email", "domain", "account"],
bindingRole: ["login", "recovery", "trusted_phone", "two_factor", "notification", "payment", "owner", "alias", "unknown"],
bindingStatus: ["active", "removed", "unknown", "risky"],
riskLevel: ["low", "medium", "high"],
incidentType: ["locked", "suspended", "verification_failed", "payment_failed", "login_failed", "appeal", "recovered"],
severity: ["low", "medium", "high", "critical"],
incidentStatus: ["open", "processing", "resolved", "abandoned"],
};
const labels = {
dashboard: "Dashboard",
phones: "手机号",
emails: "邮箱",
domains: "域名",
accounts: "账号",
bindings: "绑定关系",
incidents: "风险事件",
available: "可用",
inactive: "停用",
cannot_receive_sms: "不可收码",
released: "已释放",
high_risk: "高风险",
unknown: "未知",
cannot_receive: "不可收信",
active: "活跃",
expired: "已过期",
transferring: "转移中",
normal: "正常",
pending_verify: "待验证",
locked: "已锁定",
suspended: "已冻结",
appealing: "申诉中",
recovered: "已恢复",
deleted: "已注销",
unusable: "不可用",
open: "待处理",
processing: "处理中",
resolved: "已解决",
abandoned: "放弃",
low: "低",
medium: "中",
high: "高",
critical: "严重",
phone: "手机号",
email: "邮箱",
domain: "域名",
account: "账号",
login: "登录",
recovery: "恢复",
trusted_phone: "受信任手机号",
two_factor: "二次验证",
notification: "通知",
payment: "支付",
owner: "实名/所有者",
alias: "别名",
removed: "已解绑",
risky: "有风险",
};
Object.keys(labels).forEach((key) => {
labels[key] = i18n(`labels.${key}`, labels[key]);
});
const modules = [
{ id: "dashboard", label: labels.dashboard, group: i18n("groups.overview", "总览"), icon: "grid" },
{ id: "phones", label: labels.phones, group: i18n("groups.assets", "基础资产"), icon: "phone" },
{ id: "emails", label: labels.emails, group: i18n("groups.assets", "基础资产"), icon: "mail" },
{ id: "domains", label: labels.domains, group: i18n("groups.assets", "基础资产"), icon: "domain" },
{ id: "accounts", label: labels.accounts, group: i18n("groups.relations", "关系管理"), icon: "user" },
{ id: "bindings", label: labels.bindings, group: i18n("groups.relations", "关系管理"), icon: "link" },
{ id: "incidents", label: labels.incidents, group: i18n("groups.risk", "风险"), icon: "alert" },
];
const schemas = {
phones: {
title: "手机号",
fields: [
["country_code", "手机号", "text", { required: true, placeholder: "+86" }],
["phone_local_number", "本地号码", "text", { required: true, placeholder: "131xxxx0000" }],
["country_region", "国家/地区", "text", { placeholder: "CN / US / TR" }],
["carrier", "运营商", "text"],
["owner", "实名人", "text", { placeholder: "self / family / company" }],
["sim_type", "SIM 类型", "select", { options: enums.simType }],
["status", "状态", "select", { options: enums.phoneStatus, required: true }],
["purpose", "用途", "text"],
["is_primary", "主力号码", "checkbox"],
["can_receive_sms", "可收短信", "checkbox"],
["can_receive_call", "可接电话", "checkbox"],
["last_verified_at", "最近验证", "datetime-local"],
["expires_at", "到期时间", "datetime-local"],
["tags", "标签", "text", { placeholder: "主力, 可收码" }],
["notes", "备注", "textarea"],
],
columns: ["phone_number", "country_region", "carrier", "status", "is_primary", "can_receive_sms", "last_verified_at"],
search: ["phone_number", "country_code", "phone_local_number", "country_region", "carrier", "owner", "purpose", "tags", "notes"],
},
emails: {
title: "邮箱",
fields: [
["email", "邮箱地址", "email", { required: true }],
["email_type", "邮箱类型", "select", { options: enums.emailType }],
["provider", "服务商", "text"],
["domain", "所属域名", "text"],
["forward_to", "转发目标", "email"],
["status", "状态", "select", { options: enums.emailStatus, required: true }],
["can_receive_email", "可收信", "checkbox"],
["can_send_email", "可发信", "checkbox"],
["purpose", "用途", "text"],
["is_primary", "主邮箱", "checkbox"],
["last_verified_at", "最近验证", "datetime-local"],
["tags", "标签", "text"],
["notes", "备注", "textarea"],
],
columns: ["email", "email_type", "provider", "domain", "status", "can_receive_email", "forward_to"],
search: ["email", "email_type", "provider", "domain", "forward_to", "purpose", "tags", "notes"],
},
domains: {
title: "域名",
fields: [
["domain", "域名", "text", { required: true, placeholder: "example.com" }],
["registrar", "注册商", "text"],
["dns_provider", "DNS 服务商", "text"],
["status", "状态", "select", { options: enums.domainStatus, required: true }],
["expires_at", "到期时间", "datetime-local"],
["auto_renew", "自动续费", "checkbox"],
["email_routing_enabled", "邮件路由", "checkbox"],
["tags", "标签", "text"],
["notes", "备注", "textarea"],
],
columns: ["domain", "registrar", "dns_provider", "status", "expires_at", "auto_renew", "email_routing_enabled"],
search: ["domain", "registrar", "dns_provider", "tags", "notes"],
},
accounts: {
title: "账号",
fields: [
["platform", "平台", "text", { required: true, placeholder: "Apple / Google / OpenAI" }],
["platform_logo", "平台 Logo", "text", { placeholder: "https://.../logo.svg 或 data:image/svg+xml;base64,..." }],
["account_identifier", "登录标识", "text", { required: true }],
["display_name", "展示名", "text"],
["region", "注册地区", "text"],
["status", "状态", "select", { options: enums.accountStatus, required: true }],
["login_email_id", "登录邮箱", "relation", { source: "emails" }],
["login_phone_id", "登录手机号", "relation", { source: "phones" }],
["recovery_email_id", "恢复邮箱", "relation", { source: "emails" }],
["recovery_phone_id", "恢复手机号", "relation", { source: "phones" }],
["two_factor_type", "2FA 类型", "select", { options: enums.twoFactor }],
["credential_ref", "凭据引用", "text", { placeholder: "Vaultwarden: Apple/apple01" }],
["recovery_ref", "恢复引用", "text"],
["registered_at", "注册时间", "datetime-local"],
["last_login_at", "最近登录", "datetime-local"],
["last_verified_at", "最近验证", "datetime-local"],
["tags", "标签", "text"],
["risk_notes", "风险提示", "textarea"],
["notes", "备注", "textarea"],
],
columns: ["platform", "account_identifier", "region", "status", "two_factor_type", "credential_ref", "last_login_at"],
search: ["platform", "platform_logo", "account_identifier", "display_name", "region", "credential_ref", "recovery_ref", "tags", "risk_notes", "notes"],
},
bindings: {
title: "绑定关系",
fields: [
["asset_type", "资产类型", "select", { options: enums.assetType, required: true }],
["asset_id", "资产", "asset-relation", { required: true }],
["account_id", "账号", "relation", { source: "accounts", required: true }],
["binding_role", "绑定角色", "select", { options: enums.bindingRole, required: true }],
["status", "状态", "select", { options: enums.bindingStatus, required: true }],
["bound_at", "绑定时间", "datetime-local"],
["unbound_at", "解绑时间", "datetime-local"],
["can_unbind", "可解绑", "checkbox"],
["risk_level", "风险等级", "select", { options: enums.riskLevel }],
["tags", "标签", "text"],
["notes", "备注", "textarea"],
],
columns: ["asset_type", "asset_id", "account_id", "binding_role", "status", "risk_level", "bound_at"],
search: ["platform", "binding_role", "status", "risk_level", "tags", "notes"],
},
incidents: {
title: "风险事件",
fields: [
["account_id", "关联账号", "relation", { source: "accounts" }],
["platform", "平台", "text"],
["incident_type", "事件类型", "select", { options: enums.incidentType, required: true }],
["severity", "严重等级", "select", { options: enums.severity, required: true }],
["status", "状态", "select", { options: enums.incidentStatus, required: true }],
["occurred_at", "发生时间", "datetime-local"],
["resolved_at", "解决时间", "datetime-local"],
["description", "描述", "textarea"],
["action_taken", "已采取动作", "textarea"],
["next_action", "下一步动作", "textarea"],
["evidence_ref", "证据引用", "text"],
["tags", "标签", "text"],
["notes", "备注", "textarea"],
],
columns: ["platform", "account_id", "incident_type", "severity", "status", "occurred_at", "next_action"],
search: ["platform", "incident_type", "severity", "status", "description", "action_taken", "next_action", "evidence_ref", "tags", "notes"],
},
};
const defaults = {
phones: { country_code: "+86", status: "available", sim_type: "physical", can_receive_sms: true, can_receive_call: true, is_primary: false },
emails: { status: "available", email_type: "gmail", can_receive_email: true, can_send_email: true, is_primary: false },
domains: { status: "active", auto_renew: true, email_routing_enabled: false },
accounts: { status: "normal", two_factor_type: "none" },
bindings: { asset_type: "phone", status: "active", risk_level: "low", can_unbind: true },
incidents: { status: "open", severity: "medium", incident_type: "locked" },
};
localizeSchemas();
function localizeSchemas() {
Object.entries(schemas).forEach(([module, schema]) => {
schema.title = i18n(`schemas.${module}.title`, schema.title);
schema.fields = schema.fields.map(([key, label, ...rest]) => [
key,
i18n(`schemas.${module}.fields.${key}`, label),
...rest,
]);
});
}
let allState = emptyState();
let state = emptyState();
let route = getRouteFromHash();
let filters = {};
let editing = null;
let selected = null;
let graphFocus = null;
let toastTimer = null;
let sqliteAvailable = false;
let guidedTour = { active: false, index: 0 };
let currentIdentity = null;
let globalActionsBound = false;
let authActionsBound = false;
let hashChangeBound = false;
let licenseGateBound = false;
const el = {
authScreen: document.querySelector("#auth-screen"),
appShell: document.querySelector(".app-shell"),
authForm: document.querySelector("#auth-form"),
authIdentifier: document.querySelector("#auth-identifier"),
authPassword: document.querySelector("#auth-password"),
authRemember: document.querySelector("#auth-remember"),
authPreview: document.querySelector("#auth-preview"),
accountMenu: document.querySelector("#account-menu"),
language: document.querySelector("#language-select"),
nav: document.querySelector("#nav"),
title: document.querySelector("#page-title"),
content: document.querySelector("#content"),
search: document.querySelector("#global-search"),
dialog: document.querySelector("#record-dialog"),
form: document.querySelector("#record-form"),
fields: document.querySelector("#form-fields"),
dialogTitle: document.querySelector("#dialog-title"),
dialogKicker: document.querySelector("#dialog-kicker"),
saveRecord: document.querySelector("#save-record"),
toast: document.querySelector("#toast"),
toastIcon: document.querySelector("#toast-icon"),
toastMessage: document.querySelector("#toast-message"),
onboarding: document.querySelector("#onboarding-dialog"),
};
init();
// ── License ──────────────────────────────────────────────────────────────────
function getOrCreateDeviceId() {
let id = localStorage.getItem(DEVICE_ID_KEY);
if (!id) {
id = createDeviceId();
localStorage.setItem(DEVICE_ID_KEY, id);
}
return id;
}
function createDeviceId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function getSavedLicense() {
try { return JSON.parse(localStorage.getItem(LICENSE_KEY) || "null"); } catch { return null; }
}
function saveLicense(key, expiresAt, email, tier) {
const prev = getSavedLicense() || {};
localStorage.setItem(LICENSE_KEY, JSON.stringify({
key,
expires_at: "",
email: email || prev.email || "",
tier: tier || "pro",
}));
updateLicenseMenu();
updateWorkspaceIdentity();
}
function currentPlan() {
const license = getSavedLicense();
return license?.key && license.tier !== "free" ? "pro" : "free";
}
function isProPlan() {
return currentPlan() === "pro";
}
async function readLicenseJson(response) {
const text = await response.text();
try {
return JSON.parse(text || "{}");
} catch {
throw new Error(`接口返回 ${response.status},但不是 JSON`);
}
}
function licenseRequestError(error) {
return `无法连接激活服务器:${error?.message || "请稍后重试"}`;
}
async function checkLicense() {
const saved = getSavedLicense();
if (!saved?.key) return false;
try {
const res = await fetch(`${LICENSE_API}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: saved.key, device_id: getOrCreateDeviceId(), project: LICENSE_PROJECT }),
});
const data = await readLicenseJson(res);
if (data.code === 0 && data.data.valid) {
saveLicense(saved.key, "", data.data.email, data.data.tier);
return true;
}
if (data.code === 400) return false;
} catch {}
return true;
}
function showLicenseGate(reason = "") {
const gate = document.querySelector("#license-gate");
if (gate) gate.hidden = false;
updateLicenseGateCopy(reason);
if (licenseGateBound) return;
licenseGateBound = true;
document.querySelectorAll(".license-tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".license-tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".license-panel").forEach((p) => (p.hidden = true));
tab.classList.add("active");
document.querySelector(`#license-panel-${tab.dataset.tab}`).hidden = false;
});
});
document.querySelector("#license-register-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const btn = document.querySelector("#license-register-btn");
const msg = document.querySelector("#license-register-msg");
const email = document.querySelector("#license-email").value.trim();
btn.disabled = true;
btn.textContent = i18n("ui.sending", "发送中...");
msg.hidden = true;
try {
const res = await fetch(`${LICENSE_API}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, project: LICENSE_PROJECT }),
});
const data = await readLicenseJson(res);
msg.textContent = data.code === 0 ? "✓ " + data.msg : "✗ " + (data.msg || "发送失败,请重试");
msg.className = `license-msg ${data.code === 0 ? "success" : "error"}`;
} catch (error) {
msg.textContent = "✗ " + licenseRequestError(error);
msg.className = "license-msg error";
}
msg.hidden = false;
btn.disabled = false;
btn.textContent = i18n("ui.sendCode", "发送激活码");
});
const activateForm = document.querySelector("#license-activate-form");
const activateButton = document.querySelector("#license-activate-btn");
activateForm?.addEventListener("submit", activateLicense);
activateButton?.addEventListener("click", activateLicense);
document.querySelectorAll("[data-close-license]").forEach((button) => {
button.addEventListener("click", hideLicenseGate);
});
}
function hideLicenseGate() {
const gate = document.querySelector("#license-gate");
if (gate) gate.hidden = true;
}
function updateLicenseGateCopy(reason = "") {
const desc = document.querySelector("#license-panel-register .license-desc");
if (!desc) return;
desc.innerHTML = reason
? i18n("ui.limitUpgrade", "{reason}
升级 Pro 后可解除数量限制,开启完整功能。", { reason: escapeHtml(reason) })
: i18n("ui.registerDesc", "免费版可长期使用:基础资产 {assets} 个、账号 {accounts} 个、绑定关系 {bindings} 条。升级 Pro 后开启完整功能。", FREE_LIMITS);
}
async function activateLicense(e) {
e.preventDefault();
const btn = document.querySelector("#license-activate-btn");
const msg = document.querySelector("#license-activate-msg");
const key = document.querySelector("#license-key").value.trim();
if (!key) {
msg.textContent = "✗ " + i18n("ui.enterCode", "请输入激活码");
msg.className = "license-msg error";
msg.hidden = false;
return;
}
if (btn.disabled) return;
btn.disabled = true;
btn.textContent = i18n("ui.verifying", "验证中...");
msg.hidden = true;
try {
const res = await fetch(`${LICENSE_API}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, device_id: getOrCreateDeviceId(), project: LICENSE_PROJECT }),
});
const data = await readLicenseJson(res);
if (data.code === 0 && data.data.valid) {
saveLicense(key, "", data.data.email, data.data.tier);
hideLicenseGate();
currentIdentity = loadIdentity() || createIdentity("local");
await startWorkspace();
return;
}
msg.textContent = "✗ " + (data.msg || "激活失败");
msg.className = "license-msg error";
} catch (error) {
msg.textContent = "✗ " + licenseRequestError(error);
msg.className = "license-msg error";
}
msg.hidden = false;
btn.disabled = false;
btn.textContent = i18n("ui.activate", "激活");
}
// ─────────────────────────────────────────────────────────────────────────────
async function init() {
applyStaticI18n();
const licensed = await checkLicense();
currentIdentity = loadIdentity() || createIdentity("local");
if (!licensed) {
showLicenseGate();
return;
}
await startWorkspace();
}
function applyStaticI18n() {
document.documentElement.lang = currentLocale === "en" ? "en" : "zh-CN";
document.querySelectorAll(".brand p, .license-brand p").forEach((node) => {
node.textContent = i18n("app.subtitle", "个人数字资产台账");
});
document.querySelector(".topbar-heading .eyebrow") && (document.querySelector(".topbar-heading .eyebrow").textContent = i18n("app.workspace", "MVP workspace"));
document.querySelector("#global-search")?.setAttribute("placeholder", i18n("ui.searchGlobal", "搜索资源、账号或关系..."));
document.querySelector("#topbar-refresh")?.setAttribute("title", i18n("ui.refresh", "刷新"));
document.querySelector("#topbar-refresh")?.setAttribute("aria-label", i18n("ui.refresh", "刷新"));
document.querySelector("#topbar-new span") && (document.querySelector("#topbar-new span").textContent = i18n("ui.newBinding", "新建绑定"));
document.querySelector("#toast-message") && (document.querySelector("#toast-message").textContent = i18n("ui.saved", "已保存"));
document.querySelector("[data-account-action='upgrade']") && (document.querySelector("[data-account-action='upgrade']").textContent = i18n("ui.upgrade", "升级套餐"));
document.querySelector("[data-account-action='profile']") && (document.querySelector("[data-account-action='profile']").textContent = i18n("ui.profile", "个人中心"));
document.querySelector("[data-account-action='guide']") && (document.querySelector("[data-account-action='guide']").textContent = i18n("ui.guide", "新手指引"));
document.querySelector("[data-account-action='signout']") && (document.querySelector("[data-account-action='signout']").textContent = i18n("ui.signOut", "退出登录"));
document.querySelector(".license-tab[data-tab='register']") && (document.querySelector(".license-tab[data-tab='register']").textContent = i18n("ui.getCode", "获取激活码"));
document.querySelector(".license-tab[data-tab='activate']") && (document.querySelector(".license-tab[data-tab='activate']").textContent = i18n("ui.haveCode", "已有激活码"));
document.querySelector("#license-register-btn") && (document.querySelector("#license-register-btn").textContent = i18n("ui.sendCode", "发送激活码"));
document.querySelector("#license-activate-btn") && (document.querySelector("#license-activate-btn").textContent = i18n("ui.activate", "激活"));
document.querySelector("#save-record") && (document.querySelector("#save-record").textContent = i18n("ui.save", "保存"));
document.querySelectorAll("[data-close-dialog], .dialog-actions .ghost-button").forEach((node) => {
if (node.textContent.trim() === "取消") node.textContent = i18n("ui.cancel", "取消");
});
applyOnboardingI18n();
if (el.language) el.language.value = currentLocale;
}
function applyOnboardingI18n() {
const dialog = document.querySelector("#onboarding-dialog");
if (!dialog) return;
const set = (selector, text) => {
const node = dialog.querySelector(selector);
if (node) node.textContent = text;
};
set(".onboarding-head .eyebrow", i18n("onboarding.eyebrow", "Getting Started"));
set(".onboarding-head h3", i18n("onboarding.title", "快速建立第一条账号链路"));
dialog.querySelector("[data-close-onboarding]")?.setAttribute("aria-label", i18n("ui.close", "关闭"));
dialog.querySelectorAll(".onboarding-step").forEach((step, index) => {
const title = step.querySelector("h4");
const body = step.querySelector("p");
const button = step.querySelector("button");
if (title) title.textContent = i18n(`onboarding.steps.${index}.title`, title.textContent);
if (body) body.textContent = i18n(`onboarding.steps.${index}.body`, body.textContent);
if (button) button.textContent = i18n(`onboarding.steps.${index}.cta`, button.textContent);
});
const actionButtons = dialog.querySelectorAll(".onboarding-actions button");
if (actionButtons[0]) actionButtons[0].textContent = i18n("onboarding.later", "稍后再说");
if (actionButtons[1]) actionButtons[1].textContent = i18n("onboarding.start", "开始录入");
}
async function startWorkspace() {
showWorkspace();
allState = await loadState();
state = scopeState(allState);
await migrateBrowserStorage();
renderNav();
bindGlobalActions();
bindHashChange();
render();
maybeShowOnboarding();
}
function bindAuthActions() {
if (authActionsBound || !el.authForm) return;
authActionsBound = true;
el.authForm.addEventListener("submit", handleLogin);
el.authIdentifier?.addEventListener("input", updateAuthPreview);
updateAuthPreview();
}
function handleLogin(event) {
event.preventDefault();
const identifier = el.authIdentifier.value.trim();
const password = el.authPassword.value.trim();
if (!identifier || !password) return;
currentIdentity = createIdentity(identifier);
const targetStorage = el.authRemember.checked ? localStorage : sessionStorage;
localStorage.removeItem(AUTH_SESSION_KEY);
sessionStorage.removeItem(AUTH_SESSION_KEY);
targetStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(currentIdentity));
el.authPassword.value = "";
startWorkspace();
}
function loadIdentity() {
try {
const saved = sessionStorage.getItem(AUTH_SESSION_KEY) || localStorage.getItem(AUTH_SESSION_KEY);
return saved ? { ...JSON.parse(saved), accountid: DEFAULT_ACCOUNT_ID, userid: DEFAULT_USER_ID } : null;
} catch {
return null;
}
}
function createIdentity(identifier) {
return {
identifier,
accountid: DEFAULT_ACCOUNT_ID,
userid: DEFAULT_USER_ID,
signed_at: nowIso(),
};
}
function updateAuthPreview() {
if (!el.authPreview) return;
const identifier = el.authIdentifier?.value.trim();
if (!identifier) {
el.authPreview.textContent = `当前工作区会使用 accountid: ${DEFAULT_ACCOUNT_ID} · userid: ${DEFAULT_USER_ID}`;
return;
}
const identity = createIdentity(identifier);
el.authPreview.textContent = `accountid: ${identity.accountid} · userid: ${identity.userid}`;
}
function showWorkspace() {
if (el.authScreen) el.authScreen.hidden = true;
el.appShell.hidden = false;
updateWorkspaceIdentity();
}
function tierLabel(tier) {
return tier === "pro" ? "Pro" : "Free";
}
function updateWorkspaceIdentity() {
const license = getSavedLicense();
const tier = currentPlan();
const seed = license?.email || "B";
const initial = seed.trim().slice(0, 1).toUpperCase();
const avatarEl = document.querySelector("#sidebar-user-avatar");
const emailEl = document.querySelector("#sidebar-user-email");
const tierEl = document.querySelector("#sidebar-user-tier");
const userBtn = document.querySelector("#topbar-avatar");
if (avatarEl) {
avatarEl.textContent = initial;
avatarEl.dataset.tier = tier;
}
if (emailEl) emailEl.textContent = license?.email || i18n("ui.notActivated", "未激活");
if (tierEl) {
tierEl.textContent = tierLabel(tier);
tierEl.dataset.tier = tier;
}
if (userBtn) {
userBtn.dataset.tier = tier;
userBtn.title = license?.email || i18n("ui.profile", "个人中心");
}
}
function updateLicenseMenu() {
const license = getSavedLicense();
const tierLabelEl = document.querySelector("#account-menu-tier-label");
const labelEl = document.querySelector("#account-menu-license-key");
const statusEl = document.querySelector("#account-menu-license-status");
if (!labelEl || !statusEl) return;
const tier = currentPlan();
if (tierLabelEl) {
tierLabelEl.textContent = tier === "pro" ? i18n("ui.proPlan", "Pro 套餐") : i18n("ui.freePlan", "Free 套餐");
tierLabelEl.dataset.tier = tier;
}
if (!license?.key) {
labelEl.textContent = i18n("ui.notActivated", "未激活");
statusEl.textContent = i18n("ui.freeUnlocked", "Free 永久可用,受数量限制");
return;
}
labelEl.textContent = license.email || (currentLocale === "en" ? "(unknown email)" : "(未知邮箱)");
statusEl.textContent = i18n("ui.proUnlocked", "已解锁完整功能");
}
function openProfile() {
const dialog = document.querySelector("#profile-dialog");
if (!dialog) return;
const license = getSavedLicense() || {};
const setText = (id, text) => {
const el = document.querySelector(`#${id}`);
if (el) el.textContent = text;
};
setText("profile-email", license.email || i18n("ui.notActivated", "未激活"));
setText("profile-device-id", getOrCreateDeviceId());
setText("profile-key", license.key || "-");
setText("profile-plan", tierLabel(currentPlan()));
setText("profile-license-status", license.key ? i18n("ui.activated", "已激活") : i18n("ui.notActivated", "未激活"));
const totalAssets = (state.phones?.length || 0) + (state.emails?.length || 0) + (state.domains?.length || 0);
setText("profile-asset-count", String(totalAssets));
setText("profile-account-count", String(state.accounts?.length || 0));
setText("profile-binding-count", String(state.bindings?.length || 0));
dialog.showModal();
}
function openPricing() {
const dialog = document.querySelector("#pricing-dialog");
if (!dialog) return;
applyPricingI18n();
refreshPricingState();
dialog.showModal();
}
function applyPricingI18n() {
const setText = (sel, text) => {
const node = document.querySelector(sel);
if (node) node.textContent = text;
};
const setList = (sel, items) => {
const ul = document.querySelector(sel);
if (!ul) return;
ul.innerHTML = items.map((t) => `
${escapeHtml(t)}`).join("");
};
setText("#pricing-dialog .eyebrow", i18n("pricing.eyebrow", "Upgrade"));
setText("#pricing-dialog .pricing-head h3", i18n("pricing.title", "选择适合你的套餐"));
setText("#pricing-dialog .pricing-sub", i18n("pricing.subtitle", "从基础台账到全功能解锁,按需升级。"));
setText("#pricing-dialog .pricing-badge", i18n("pricing.recommended", "推荐"));
setText('[data-tier-card="free"] header h4', "Free");
setText('[data-tier-card="free"] .pricing-period', i18n("pricing.monthSuffix", "/ 月"));
setText('[data-tier-card="free"] .pricing-desc', i18n("pricing.free.desc", "本地管理你的数字资产"));
const freeFeatures = (locales[currentLocale]?.pricing?.free?.features) || (locales.zh?.pricing?.free?.features) || [];
setList('[data-tier-card="free"] .pricing-features', freeFeatures);
setText('[data-tier-card="pro"] header h4', "Pro");
setText('[data-tier-card="pro"] .pricing-period', i18n("pricing.monthSuffix", "/ 月"));
setText('[data-tier-card="pro"] .pricing-desc', i18n("pricing.pro.desc", "解锁全部高级能力"));
setText('[data-tier-card="pro"] .pricing-includes', i18n("pricing.includesAll", "包含 Free 所有功能,并解锁:"));
const proFeatures = (locales[currentLocale]?.pricing?.pro?.features) || (locales.zh?.pricing?.pro?.features) || [];
setList('[data-tier-card="pro"] .pricing-features', proFeatures);
}
function refreshPricingState() {
const license = getSavedLicense();
const tier = license?.tier === "pro" ? "pro" : "free";
const freeCard = document.querySelector('[data-tier-card="free"]');
const proCard = document.querySelector('[data-tier-card="pro"]');
const freeCta = document.querySelector('[data-pricing-action="free"]');
const proCta = document.querySelector('[data-pricing-action="pro"]');
if (!freeCard || !proCard || !freeCta || !proCta) return;
freeCard.classList.toggle("is-current", tier === "free");
proCard.classList.toggle("is-current", tier === "pro");
const currentText = i18n("pricing.currentPlan", "当前套餐");
const upgradeText = i18n("pricing.upgradeToPro", "升级至 Pro");
const downgradeText = i18n("pricing.switchToFree", "切换至 Free");
if (tier === "pro") {
freeCta.textContent = downgradeText;
freeCta.className = "pricing-cta pricing-cta-downgrade";
freeCta.disabled = false;
proCta.textContent = currentText;
proCta.className = "pricing-cta pricing-cta-current";
proCta.disabled = true;
} else {
freeCta.textContent = currentText;
freeCta.className = "pricing-cta pricing-cta-current";
freeCta.disabled = true;
proCta.textContent = upgradeText;
proCta.className = "pricing-cta pricing-cta-upgrade";
proCta.disabled = false;
}
}
function handlePricingAction(action) {
const license = getSavedLicense();
const tier = license?.tier === "pro" ? "pro" : "free";
if (action === tier) return;
if (action === "pro") {
toast(i18n("pricing.payPending", "Pro 升级支付通道开通中,敬请期待"), "warning");
} else {
toast(i18n("pricing.downgradePending", "切换至 Free 即将上线"), "warning");
}
}
function signOut() {
localStorage.removeItem(AUTH_SESSION_KEY);
sessionStorage.removeItem(AUTH_SESSION_KEY);
localStorage.removeItem(LICENSE_KEY);
closeAccountMenu();
window.location.reload();
}
function toggleAccountMenu() {
if (!el.accountMenu) return;
const willOpen = el.accountMenu.hidden;
if (willOpen) updateLicenseMenu();
el.accountMenu.hidden = !willOpen;
document.querySelector("#topbar-avatar")?.setAttribute("aria-expanded", String(willOpen));
}
function closeAccountMenu() {
if (!el.accountMenu) return;
el.accountMenu.hidden = true;
document.querySelector("#topbar-avatar")?.setAttribute("aria-expanded", "false");
}
function bindHashChange() {
if (hashChangeBound) return;
hashChangeBound = true;
window.addEventListener("hashchange", () => {
route = getRouteFromHash();
selected = null;
graphFocus = null;
render();
});
}
function getRouteFromHash() {
const hash = window.location.hash.replace(/^#\/?/, "");
return modules.some((item) => item.id === hash) ? hash : "dashboard";
}
function navigateTo(nextRoute) {
if (route === nextRoute) return;
window.location.hash = `/${nextRoute}`;
}
function emptyState() {
return { phones: [], emails: [], domains: [], accounts: [], bindings: [], incidents: [] };
}
function normalizeState(nextState) {
return {
...emptyState(),
...nextState,
phones: (nextState.phones || []).map(normalizePhoneRecord),
};
}
function normalizePhoneRecord(record) {
const phone = { ...record };
const split = splitPhoneNumber(phone.phone_number, phone.country_code, phone.phone_local_number);
phone.country_code = split.countryCode;
phone.phone_local_number = split.localNumber;
phone.phone_number = `${split.countryCode}${split.localNumber}`;
return phone;
}
function stampRecord(record) {
if (!currentIdentity) return { ...record };
return {
...record,
accountid: currentIdentity.accountid,
userid: currentIdentity.userid,
};
}
function stampState(nextState) {
const stamped = emptyState();
Object.keys(stamped).forEach((collection) => {
stamped[collection] = (nextState[collection] || []).map(stampRecord);
});
return normalizeState(stamped);
}
function isCurrentIdentityRecord(record) {
if (!currentIdentity) return true;
return record.accountid === currentIdentity.accountid && record.userid === currentIdentity.userid;
}
function isLegacyIdentityRecord(record) {
return !record.accountid && !record.userid;
}
function isVisibleIdentityRecord(record) {
return isCurrentIdentityRecord(record) || isLegacyIdentityRecord(record);
}
function scopeState(nextState) {
if (!currentIdentity) return normalizeState(nextState);
const scoped = emptyState();
Object.keys(scoped).forEach((collection) => {
scoped[collection] = (nextState[collection] || []).filter(isVisibleIdentityRecord);
});
return normalizeState(scoped);
}
function mergeScopedState() {
const stamped = stampState(state);
const merged = normalizeState(allState);
Object.keys(merged).forEach((collection) => {
const retained = (merged[collection] || []).filter((record) => !isVisibleIdentityRecord(record));
merged[collection] = [...retained, ...(stamped[collection] || [])];
});
return normalizeState(merged);
}
async function loadState() {
try {
const response = await fetch(API_STATE_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
sqliteAvailable = true;
return normalizeState(payload.data || payload);
} catch (error) {
sqliteAvailable = false;
console.warn("SQLite API unavailable, falling back to browser storage.", error);
try {
return normalizeState(JSON.parse(localStorage.getItem(scopedStoreKey()) || localStorage.getItem(STORE_KEY) || "{}"));
} catch {
return emptyState();
}
}
}
async function saveState() {
const nextAllState = mergeScopedState();
try {
const response = await fetch(API_STATE_URL, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: nextAllState }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
sqliteAvailable = true;
allState = normalizeState(payload.data || nextAllState);
state = scopeState(allState);
} catch (error) {
sqliteAvailable = false;
console.warn("SQLite API unavailable, falling back to browser storage.", error);
state = stampState(state);
localStorage.setItem(scopedStoreKey(), JSON.stringify(state));
toast("SQLite 服务不可用,已临时保存到当前浏览器", "warning");
}
}
async function migrateBrowserStorage() {
if (!sqliteAvailable) return;
try {
const saved = JSON.parse(localStorage.getItem(scopedStoreKey()) || localStorage.getItem(STORE_KEY) || "{}");
if (!Object.values(saved).some((value) => Array.isArray(value) && value.length)) return;
if (Object.values(state).some((value) => Array.isArray(value) && value.length)) return;
state = { ...emptyState(), ...saved };
await saveState();
localStorage.removeItem(scopedStoreKey());
localStorage.removeItem(STORE_KEY);
toast("已把浏览器旧数据迁移到 SQLite", "success");
} catch {
return;
}
}
async function refreshState() {
allState = await loadState();
state = scopeState(allState);
render();
}
function scopedStoreKey() {
return currentIdentity ? `${STORE_KEY}:${currentIdentity.accountid}:${currentIdentity.userid}` : STORE_KEY;
}
function uid(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function nowIso() {
return new Date().toISOString();
}
function toIsoFromInput(value) {
return value ? new Date(value).toISOString() : "";
}
function toInputDate(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
function t(value) {
if (value === true) return i18n("ui.yes", "是");
if (value === false) return i18n("ui.no", "否");
if (value === "") return "-";
return labels[value] || value || "-";
}
function flagForRegion(value) {
const text = String(value || "").trim().toLowerCase();
if (!text) return "";
const chinaRegions = ["中国", "cn", "china", "北京", "廊坊", "长春", "河北", "吉林"];
const usRegions = ["美国", "us", "usa", "united states", "america"];
const turkeyRegions = ["土耳其", "tr", "turkey", "türkiye", "turkiye"];
if (chinaRegions.includes(text)) return "🇨🇳";
if (usRegions.includes(text)) return "🇺🇸";
if (turkeyRegions.includes(text)) return "🇹🇷";
return "🌐";
}
function renderRegion(value) {
if (!value) return "-";
return `${flagForRegion(value)}${escapeHtml(value)}`;
}
function countryList() {
return Array.isArray(globalThis.countries) ? globalThis.countries : [];
}
function normalizeDialCode(value) {
const digits = String(value || "").replace(/\D/g, "");
return digits ? `+${digits}` : "";
}
function matchCountry(value, dialCode = "") {
const text = String(value || "").trim().toLowerCase();
const dial = normalizeDialCode(dialCode);
const aliases = {
中国: "CN", china: "CN", cn: "CN", 北京: "CN", 廊坊: "CN", 长春: "CN", 河北: "CN", 吉林: "CN",
美国: "US", "united states": "US", usa: "US", us: "US", america: "US",
土耳其: "TR", turkey: "TR", turkiye: "TR", türkiye: "TR", tr: "TR",
};
const aliasCode = aliases[text];
return countryList().find((item) =>
item.code === value ||
item.code === aliasCode ||
item.name.toLowerCase() === text ||
(dial && normalizeDialCode(item.dialCode) === dial)
);
}
function countryOptionLabel(country) {
return `${country.flag} ${country.name} (${country.dialCode})`;
}
function platformMeta(value, customLogo = "") {
const name = typeof value === "object" && value
? String(value.platform || value.name || "").trim()
: String(value || "").trim();
const logo = typeof value === "object" && value ? value.platform_logo : customLogo;
const safeLogo = normalizeLogoSource(logo);
if (safeLogo) {
return {
className: "custom",
mark: platformFallbackMark(name),
src: safeLogo,
name,
custom: true,
};
}
const key = name.toLowerCase().replace(/\s+/g, "");
const entry = platformCatalog.find((item) => [item.key, item.name, ...(item.aliases || [])].some((alias) => normalizePlatformKey(alias) === key));
if (entry) {
const icon = entry.file || `${entry.simpleIcon || entry.key}.svg`;
return {
className: entry.className || entry.key,
mark: entry.mark || platformFallbackMark(name),
src: platformAssets[icon] ? `assets/platforms/${icon}` : "",
name,
};
}
return { className: "generic", mark: platformFallbackMark(name), src: "", name };
}
function platformFallbackMark(name) {
return String(name || "?").trim().slice(0, 2).toUpperCase() || "?";
}
function normalizePlatformKey(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, "");
}
function normalizeLogoSource(value) {
const src = String(value || "").trim();
if (!src) return "";
if (/^https?:\/\//i.test(src)) return src;
if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif);base64,/i.test(src)) return src;
if (/^assets\/platforms\/[\w.-]+\.svg$/i.test(src)) return src;
return "";
}
function renderPlatform(value, account = null) {
if (!value && !account?.platform) return "-";
const meta = platformMeta(account || value);
const logo = meta.src
? `
`
: escapeHtml(meta.mark);
return `${logo}${escapeHtml(meta.name)}`;
}
function splitPhoneNumber(phoneNumber, countryCode, localNumber) {
const code = String(countryCode || "").trim();
const local = String(localNumber || "").trim();
if (code && local) {
return {
countryCode: code.startsWith("+") ? code : `+${code}`,
localNumber: local.replace(/\D/g, ""),
};
}
const text = String(phoneNumber || "").trim();
if (text.startsWith("+86") && text.length > 3) {
return { countryCode: "+86", localNumber: text.slice(3).replace(/\D/g, "") };
}
const match = text.match(/^\+(\d{1,3})(\d+)$/);
if (match) return { countryCode: `+${match[1]}`, localNumber: match[2] };
return { countryCode: code || "+86", localNumber: local || text.replace(/\D/g, "") };
}
function formatPhoneNumber(value, countryCode, localNumber) {
const split = splitPhoneNumber(value, countryCode, localNumber);
if (!split.localNumber) return value || "-";
return `(${split.countryCode}) ${split.localNumber}`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function toast(message, type = "success") {
el.toast.className = `toast ${type}`;
el.toastIcon.textContent = type === "error" ? "!" : type === "warning" ? "i" : "OK";
el.toastMessage.textContent = message;
try { el.toast.showPopover?.(); } catch {}
requestAnimationFrame(() => el.toast.classList.add("show"));
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.toast.classList.remove("show");
setTimeout(() => { try { el.toast.hidePopover?.(); } catch {} }, 220);
}, type === "error" ? 3800 : 2600);
}
function setSaving(isSaving) {
el.saveRecord.disabled = isSaving;
el.saveRecord.textContent = isSaving ? i18n("ui.saving", "保存中...") : i18n("ui.save", "保存");
}
function renderNav() {
let currentGroup = "";
el.nav.innerHTML = modules
.map((item) => {
const group = item.group !== currentGroup ? `${item.group}
` : "";
currentGroup = item.group;
return `${group}`;
})
.join("");
el.nav.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", () => {
selected = null;
graphFocus = null;
navigateTo(button.dataset.route);
});
});
}
function bindGlobalActions() {
if (globalActionsBound) return;
globalActionsBound = true;
el.search.addEventListener("input", () => render());
document.querySelector("#seed-demo").addEventListener("click", seedDemo);
document.querySelector("#export-json").addEventListener("click", exportJson);
document.querySelector("#import-json").addEventListener("change", importJson);
el.language?.addEventListener("change", () => {
localStorage.setItem(LOCALE_KEY, el.language.value);
window.location.reload();
});
document.querySelector("#topbar-refresh")?.addEventListener("click", async () => {
await refreshState();
render();
toast("已刷新");
});
document.querySelector("#topbar-new")?.addEventListener("click", () => {
openEditor("bindings");
});
document.querySelector("#topbar-avatar")?.addEventListener("click", (event) => {
event.stopPropagation();
toggleAccountMenu();
});
el.accountMenu?.querySelectorAll("[data-account-action]").forEach((button) => {
button.addEventListener("click", () => {
closeAccountMenu();
const action = button.dataset.accountAction;
if (action === "upgrade") openPricing();
if (action === "profile") openProfile();
if (action === "guide") startGuidedTour();
if (action === "signout") signOut();
});
});
document.querySelectorAll("[data-close-profile]").forEach((btn) => {
btn.addEventListener("click", () => document.querySelector("#profile-dialog")?.close());
});
document.querySelectorAll("[data-close-pricing]").forEach((btn) => {
btn.addEventListener("click", () => document.querySelector("#pricing-dialog")?.close());
});
document.querySelectorAll("[data-pricing-action]").forEach((btn) => {
btn.addEventListener("click", () => handlePricingAction(btn.dataset.pricingAction));
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".account-menu-wrap")) closeAccountMenu();
});
document.querySelectorAll("[data-close-onboarding]").forEach((button) => {
button.addEventListener("click", closeOnboarding);
});
document.querySelectorAll("[data-guide-action]").forEach((button) => {
button.addEventListener("click", () => runGuideAction(button.dataset.guideAction));
});
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
button.addEventListener("click", closeEditor);
});
el.form.addEventListener("submit", handleFormSubmit);
el.form.addEventListener(
"invalid",
() => {
el.fields.classList.add("was-validated");
toast("请先补全高亮字段", "error");
},
true,
);
}
function maybeShowOnboarding() {
const hasAnyRecord = Object.values(state).some((value) => Array.isArray(value) && value.length);
if (hasAnyRecord || localStorage.getItem(ONBOARDING_SEEN_KEY) === "1") return;
startGuidedTour();
}
function showOnboarding(markSeen) {
if (!el.onboarding) return;
if (markSeen) localStorage.setItem(ONBOARDING_SEEN_KEY, "1");
el.onboarding.showModal();
}
function closeOnboarding() {
localStorage.setItem(ONBOARDING_SEEN_KEY, "1");
el.onboarding?.close();
}
function runGuideAction(action) {
closeOnboarding();
if (action === "phone" || action === "email") {
startGuidedTour();
return;
}
if (action === "account") {
navigateTo("accounts");
window.setTimeout(() => openEditor("accounts"), 0);
} else if (action === "email") {
navigateTo("emails");
window.setTimeout(() => openEditor("emails"), 0);
} else if (action === "binding") {
navigateTo("bindings");
window.setTimeout(() => openEditor("bindings"), 0);
}
}
function openGuidedAccountEditor() {
openEditor("accounts");
window.setTimeout(() => {
const latestEmail = state.emails[0];
const platformInput = el.fields.querySelector("#platform");
const identifierInput = el.fields.querySelector("#account_identifier");
const loginEmailSelect = el.fields.querySelector("#login_email_id");
if (platformInput && !platformInput.value) platformInput.value = "BindVault";
if (identifierInput && latestEmail?.email && !identifierInput.value) identifierInput.value = latestEmail.email;
if (loginEmailSelect && latestEmail?.id) loginEmailSelect.value = latestEmail.id;
}, 0);
}
const TOUR_STEPS = [
{
selector: `[data-route="emails"]`,
action: () => { navigateTo("emails"); nextTourStep(); },
},
{
route: "emails",
selector: `[data-new="emails"]`,
action: () => { openEditor("emails"); nextTourStep(); },
},
{
dialog: true,
selector: "#email",
waitModule: "emails",
},
{
selector: `[data-route="accounts"]`,
action: () => { navigateTo("accounts"); nextTourStep(); },
},
{
route: "accounts",
selector: `[data-new="accounts"]`,
action: () => { openGuidedAccountEditor(); nextTourStep(); },
},
{
dialog: true,
selector: "#login_email_id",
waitModule: "accounts",
},
{
route: "bindings",
selector: ".relationship-board",
action: () => stopGuidedTour(true),
},
];
function tourText(index, key, fallback = "") {
return i18n(`tour.steps.${index}.${key}`, fallback);
}
function startGuidedTour() {
closeOnboarding();
localStorage.setItem(ONBOARDING_SEEN_KEY, "1");
guidedTour = { active: true, index: 0 };
render();
}
function stopGuidedTour(done = false) {
guidedTour = { active: false, index: 0 };
document.querySelectorAll(".guided-tour-layer").forEach((node) => node.remove());
if (done) toast(i18n("tour.completed", "第一条资产链路引导完成"), "success");
}
function nextTourStep() {
if (!guidedTour.active) return;
guidedTour.index = Math.min(guidedTour.index + 1, TOUR_STEPS.length - 1);
scheduleTour();
}
function handleTourRecordSaved(module) {
const step = TOUR_STEPS[guidedTour.index];
if (!guidedTour.active || step?.waitModule !== module) return;
guidedTour.index = Math.min(guidedTour.index + 1, TOUR_STEPS.length - 1);
}
function scheduleTour() {
if (!guidedTour.active) return;
window.setTimeout(renderGuidedTour, 40);
}
function renderGuidedTour() {
document.querySelectorAll(".guided-tour-layer").forEach((node) => node.remove());
if (!guidedTour.active) return;
const step = TOUR_STEPS[guidedTour.index];
if (!step) return stopGuidedTour();
if (step.route && route !== step.route) {
navigateTo(step.route);
return;
}
const target = document.querySelector(step.selector);
const rect = target?.getBoundingClientRect();
const layerHost = step.dialog && el.dialog.open ? el.dialog : document.body;
const layer = document.createElement("div");
layer.className = `guided-tour-layer ${step.dialog ? "dialog-tour" : ""}`;
const safeRect = rect || { left: window.innerWidth / 2 - 120, top: window.innerHeight / 2 - 40, width: 240, height: 80, right: window.innerWidth / 2 + 120, bottom: window.innerHeight / 2 + 40 };
const popover = tourPopoverPosition(safeRect);
const stepIndex = guidedTour.index;
layer.innerHTML = `
${escapeHtml(i18n("tour.progress", "步骤 {current} / {total}", { current: stepIndex + 1, total: TOUR_STEPS.length }))}
${escapeHtml(tourText(stepIndex, "title"))}
${escapeHtml(tourText(stepIndex, "body"))}
${step.waitModule ? `${escapeHtml(i18n("tour.waitingSave", "等待保存..."))}` : ``}
`;
layerHost.appendChild(layer);
target?.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
if (target && !step.waitModule) {
target.addEventListener("click", () => {
const currentIndex = guidedTour.index;
window.setTimeout(() => {
if (guidedTour.active && guidedTour.index === currentIndex) nextTourStep();
}, 0);
}, { once: true });
}
layer.querySelector("[data-tour-skip]")?.addEventListener("click", () => stopGuidedTour());
layer.querySelector("[data-tour-next]")?.addEventListener("click", () => step.action ? step.action() : nextTourStep());
}
function tourPopoverPosition(rect) {
const width = 320;
const margin = 18;
let left = rect.right + 18;
let top = rect.top;
if (left + width > window.innerWidth - margin) left = Math.max(margin, rect.left - width - 18);
if (left < margin) left = margin;
if (top + 190 > window.innerHeight - margin) top = Math.max(margin, window.innerHeight - 208);
return { left, top };
}
function closeEditor() {
editing = null;
el.fields.classList.remove("was-validated");
setSaving(false);
el.dialog.close();
}
function render() {
renderNav();
el.title.textContent = modules.find((item) => item.id === route)?.label || "BindVault";
if (route === "dashboard") renderDashboard();
else renderModule(route);
scheduleTour();
}
function renderDashboard() {
const risks = computeRisks();
const totalAssets = state.phones.length + state.emails.length + state.domains.length;
const activeBindings = state.bindings.filter((binding) => binding.status === "active");
const normalAccounts = state.accounts.filter((account) => account.status === "normal").length;
const lockedAccounts = state.accounts.filter((account) => ["locked", "suspended", "unusable"].includes(account.status)).length;
const appealingAccounts = state.accounts.filter((account) => account.status === "appealing").length;
const twoFactorAccounts = state.accounts.filter((account) => account.two_factor_type && account.two_factor_type !== "none").length;
const paymentBindings = activeBindings.filter((binding) => binding.binding_role === "payment").length;
const recoveryBindings = activeBindings.filter((binding) => ["recovery", "trusted_phone", "two_factor"].includes(binding.binding_role)).length;
const highRiskBindings = risks.filter((risk) => risk.level === "high").length;
const openIncidents = state.incidents.filter((incident) => incident.status !== "resolved").length;
const platformRows = countBy(state.accounts, "platform");
const activeStatusRows = countBy(state.accounts, "status");
const latestIncident = [...state.incidents]
.sort((a, b) => new Date(b.occurred_at || b.updated_at || 0).getTime() - new Date(a.occurred_at || a.updated_at || 0).getTime())[0];
const recentAccounts = [...state.accounts]
.sort((a, b) => new Date(b.updated_at || b.last_verified_at || b.created_at || 0).getTime() - new Date(a.updated_at || a.last_verified_at || a.created_at || 0).getTime())
.slice(0, 3);
el.content.innerHTML = `
${escapeHtml(i18n("ui.workspaceOverview", "Workspace Overview"))}
${escapeHtml(i18n("ui.assetAccountSecurity", "资产与账号安全"))}
${escapeHtml(i18n("ui.dashboardHeroDesc", "用更轻的方式看清当前台账状态、恢复链路和支付依赖。重点问题会直接浮到台前,不用再翻列表找。"))}
${escapeHtml(i18n("ui.recentEvent", "最近事件"))}
${latestIncident ? escapeHtml(t(latestIncident.incident_type)) : escapeHtml(i18n("ui.allClear", "一切平稳"))}
${latestIncident ? `${escapeHtml(t(latestIncident.status))} · ${escapeHtml(formatDate(latestIncident.occurred_at || latestIncident.updated_at))}` : escapeHtml(i18n("ui.noOpenIncidents", "当前没有待处理事件"))}
${renderDashboardMetricCard(i18n("ui.assetStats", "基础资产"), totalAssets, i18n("ui.dashboardAssetsMeta", "手机号 {phones} · 邮箱 {emails} · 域名 {domains}", { phones: state.phones.length, emails: state.emails.length, domains: state.domains.length }), "asset", i18n("ui.goMaintain", "前往维护"), "phones")}
${renderDashboardMetricCard(i18n("ui.accountSecurity", "账号安全"), normalAccounts, i18n("ui.accountSecurityMeta", "正常 {normal} · 异常 {locked} · 申诉中 {appealing}", { normal: normalAccounts, locked: lockedAccounts, appealing: appealingAccounts }), "security", i18n("ui.viewAccounts", "查看账号"), "accounts", i18n("ui.twoFactorEnabled", "2FA 已启用 {count}", { count: twoFactorAccounts }))}
${renderDashboardMetricCard(i18n("ui.recoveryPayment", "恢复与支付"), recoveryBindings + paymentBindings, i18n("ui.recoveryPaymentMeta", "恢复链路 {recovery} · 支付关系 {payment}", { recovery: recoveryBindings, payment: paymentBindings }), "recovery", i18n("ui.viewBindings", "查看绑定"), "bindings")}
${renderDashboardMetricCard(i18n("ui.riskStatus", "风险状态"), highRiskBindings + openIncidents, i18n("ui.riskStatusMeta", "高风险 {highRisk} · 待处理事件 {openIncidents}", { highRisk: highRiskBindings, openIncidents }), "risk", i18n("ui.viewRisks", "查看风险"), "incidents", highRiskBindings ? i18n("ui.needsPriority", "建议优先处理") : i18n("ui.noHighRisk", "当前没有高风险"))}
${escapeHtml(i18n("ui.accountsPanel", "Accounts"))}
${escapeHtml(i18n("ui.platformStatus", "平台与状态"))}
${escapeHtml(i18n("ui.platformDistribution", "平台分布"))}
${renderDashboardPlatforms(platformRows)}
${escapeHtml(i18n("ui.accountStatus", "账号状态"))}
${renderBars(activeStatusRows, i18n("ui.noAccountData", "暂无账号数据"))}
${escapeHtml(i18n("ui.monitoringPanel", "Monitoring"))}
${escapeHtml(i18n("ui.riskRecentChanges", "风险与最近变更"))}
${escapeHtml(i18n("ui.riskTips", "风险提示"))}
${risks.length ? risks.slice(0, 4).map(renderRisk).join("") : `
${escapeHtml(i18n("ui.noRisk", "暂无风险"))}
${escapeHtml(i18n("ui.noRiskDesc", "当手机号、邮箱、域名和恢复方式出现异常时,这里会优先提醒你。"))}
`}
${escapeHtml(i18n("ui.recentChangedAccounts", "最近变更账号"))}
${recentAccounts.length ? recentAccounts.map(renderDashboardRecentAccount).join("") : `
${escapeHtml(i18n("ui.noAccountsYet", "还没有账号记录。"))}
`}
`;
el.content.querySelectorAll("[data-dashboard-route]").forEach((button) => {
button.addEventListener("click", () => navigateTo(button.dataset.dashboardRoute));
});
}
function renderBars(rows, emptyText) {
if (!rows.length) return `${emptyText}
`;
const max = Math.max(...rows.map((row) => row.count), 1);
return rows
.map(
(row) => `
${escapeHtml(t(row.name))}
${row.count}
`,
)
.join("");
}
function renderDashboardMetricCard(title, value, detail, icon, ctaLabel, routeId, note = "") {
return `
${escapeHtml(title)}
${value}
${renderDashboardIcon(icon)}
${escapeHtml(detail)}
${note ? `
${escapeHtml(note)}` : ""}
`;
}
function renderDashboardIcon(icon) {
if (icon === "asset") {
return ``;
}
if (icon === "security") {
return ``;
}
if (icon === "recovery") {
return ``;
}
if (icon === "risk") {
return ``;
}
return ``;
}
function renderDashboardPlatforms(rows) {
if (!rows.length) return `${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}
`;
const max = Math.max(...rows.map((row) => row.count), 1);
return rows.slice(0, 5).map((row) => {
const meta = platformMeta(row.name);
return `
`;
}).join("");
}
function renderDashboardRecentAccount(account) {
return `
`;
}
function renderRisk(risk) {
return `
${escapeHtml(risk.title)}
${t(risk.level)}
${escapeHtml(risk.detail)}
`;
}
function riskNotesToItems(value) {
return String(value || "")
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}
function renderManualRiskNote(note) {
return `
${escapeHtml(i18n("ui.manualTip", "手动提示"))}
${escapeHtml(i18n("ui.attentionNeeded", "需关注"))}
${escapeHtml(note)}
`;
}
function countBy(items, key) {
const map = new Map();
items.forEach((item) => {
const name = item[key] || "unknown";
map.set(name, (map.get(name) || 0) + 1);
});
return [...map.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
}
function renderModule(module) {
const schema = schemas[module];
const rows = filterRows(module);
const statusOptions = getStatusOptions(module);
const drawerModules = ["phones", "emails", "domains", "accounts"];
const autoSelect = !drawerModules.includes(module);
const selectedRecord = selected ? state[module].find((row) => row.id === selected) : autoSelect ? rows[0] : null;
if (autoSelect && !selected && selectedRecord) selected = selectedRecord.id;
if (module === "bindings") {
renderBindingsWorkspace(rows, schema, statusOptions, selectedRecord);
return;
}
if (module === "accounts") {
renderAccountsPage(rows, schema, statusOptions, selectedRecord);
return;
}
if (["phones", "emails", "domains"].includes(module)) {
renderAssetPage(module, rows, schema, statusOptions, selectedRecord);
return;
}
el.content.innerHTML = `
${module === "bindings" ? renderTopology(rows) : ""}
${rows.length ? renderTable(module, rows, schema) : renderEmpty(module)}
${selectedRecord ? renderDetail(module, selectedRecord) : ""}
`;
el.content.querySelectorAll("[data-filter]").forEach((input) => {
input.addEventListener("input", () => {
filters[module] = { ...(filters[module] || {}), [input.dataset.filter]: input.value };
selected = null;
render();
});
});
el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor(module));
el.content.querySelector("[data-export-csv]")?.addEventListener("click", () => exportCsv(module));
el.content.querySelector("[data-import-csv]")?.addEventListener("change", (event) => importCsv(module, event));
el.content.querySelectorAll("[data-select-row]").forEach((button) => button.addEventListener("click", () => {
selected = button.dataset.selectRow;
render();
}));
el.content.querySelectorAll("[data-edit]").forEach((button) => button.addEventListener("click", () => openEditor(module, button.dataset.edit)));
el.content.querySelectorAll("[data-delete]").forEach((button) => button.addEventListener("click", () => removeRecord(module, button.dataset.delete)));
}
function renderAccountsListView(rows, selectedId) {
const platformCounts = new Map();
rows.forEach((a) => { const p = a.platform || i18n("ui.other", "其他"); platformCounts.set(p, (platformCounts.get(p) || 0) + 1); });
const platforms = [...platformCounts.keys()].filter(Boolean).sort();
const activePlatform = filters.accounts?.platform || "";
const tabs =
`` +
platforms.map((p) => ``).join("");
const items = [...rows]
.sort((a, b) => (a.platform || "").localeCompare(b.platform || "") || (a.account_identifier || "").localeCompare(b.account_identifier || ""))
.map((row) => {
const meta = platformMeta(row);
const logo = meta.src ? `
` : escapeHtml(meta.mark || "?");
const search = `${row.platform || ""} ${row.account_identifier || ""} ${row.display_name || ""}`.toLowerCase();
return `
${logo}
${escapeHtml(row.platform || i18n("ui.unknownName", "未知"))}
${escapeHtml(row.account_identifier || row.display_name || "")}
`;
}).join("") || `${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}
`;
return ``;
}
function renderAccountsPage(rows, schema, statusOptions, selectedRecord) {
el.content.innerHTML = `
${renderAccountsListView(rows, selectedRecord?.id)}
${renderRecordDrawer("accounts", selectedRecord)}
`;
el.content.querySelectorAll("[data-filter]").forEach((input) => {
input.addEventListener("input", () => {
filters.accounts = { ...(filters.accounts || {}), [input.dataset.filter]: input.value };
selected = null;
render();
});
});
el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor("accounts"));
el.content.querySelectorAll("[data-select-row]").forEach((btn) => {
btn.addEventListener("click", () => { selected = btn.dataset.selectRow; render(); });
});
el.content.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", (e) => { e.stopPropagation(); openEditor("accounts", btn.dataset.edit); });
});
el.content.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", (e) => { e.stopPropagation(); removeRecord("accounts", btn.dataset.delete); });
});
const qInput = el.content.querySelector("#accounts-q");
const tabsEl = el.content.querySelector("#accounts-platform-tabs");
const listEl = el.content.querySelector("#accounts-list-items");
function filterList() {
const q = (qInput?.value || "").toLowerCase();
const platform = filters.accounts?.platform || "";
listEl?.querySelectorAll(".account-list-item").forEach((item) => {
item.hidden = !((!platform || item.dataset.platform === platform) && (!q || item.dataset.search.includes(q)));
});
}
qInput?.addEventListener("input", filterList);
tabsEl?.addEventListener("click", (e) => {
const tab = e.target.closest(".platform-tab");
if (!tab) return;
filters.accounts = { ...(filters.accounts || {}), platform: tab.dataset.platform || "" };
tabsEl.querySelectorAll(".platform-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
filterList();
});
filterList();
bindRecordDrawerClose();
}
function assetCardIcon(module, row) {
const emailTypeColor = {
gmail: "#EA4335", outlook: "#0078D4", qq: "#1D6FA4",
custom_domain: "#6B48FF", cloudflare_routing: "#F48024", alias: "#8E8E93",
};
const domainRegistrarLogo = {
cloudflare: { bg: "#F48024", src: "assets/platforms/cloudflare.svg" },
};
let bg, svgInner, logoSrc;
if (module === "phones") {
bg = "#34C759";
svgInner = ``;
} else if (module === "emails") {
bg = emailTypeColor[row.email_type] || "#6B48FF";
svgInner = ``;
} else {
const registrarKey = (row.registrar || "").toLowerCase().replace(/\s+/g, "");
const registrar = domainRegistrarLogo[registrarKey];
if (registrar) {
bg = registrar.bg;
logoSrc = registrar.src;
} else {
bg = "#5856D6";
svgInner = ``;
}
}
if (logoSrc) {
return ``;
}
return ``;
}
function renderAssetListView(module, rows, selectedId) {
const tabField = module === "phones" ? "carrier" : module === "emails" ? "email_type" : "registrar";
const tabCounts = new Map();
rows.forEach((r) => { const v = r[tabField] || i18n("ui.other", "其他"); tabCounts.set(v, (tabCounts.get(v) || 0) + 1); });
const tabValues = [...tabCounts.keys()].sort();
const activeTab = filters[module]?.tabVal || "";
const tabs =
`` +
tabValues.map((v) => {
const label = module === "emails" ? t(v) : escapeHtml(v);
return ``;
}).join("");
const editSvg = ``;
const trashSvg = ``;
const sorted = [...rows].sort((a, b) =>
(a[tabField] || "").localeCompare(b[tabField] || "") || primaryName(module, a).localeCompare(primaryName(module, b))
);
const cards = sorted.map((row) => {
const title = primaryName(module, row);
let sub = "";
if (module === "phones") sub = [row.carrier, row.country_region].filter(Boolean).join(" · ");
else if (module === "emails") sub = [t(row.email_type), row.provider].filter(Boolean).join(" · ");
else sub = [row.registrar, row.dns_provider].filter(Boolean).join(" · ");
const tabVal = row[tabField] || i18n("ui.other", "其他");
const searchStr = `${title} ${sub} ${row.status || ""}`.toLowerCase();
const status = row.status || "";
return `
${assetCardIcon(module, row)}
${escapeHtml(title)}
${sub ? `
${escapeHtml(sub)}
` : ""}
${status ? `
${t(status)}
` : ""}
`;
}).join("") || `${escapeHtml(i18n("ui.noModuleData", "暂无{name}数据", { name: schemas[module].title }))}
`;
return ``;
}
function renderAssetPage(module, rows, schema, statusOptions, selectedRecord) {
el.content.innerHTML = `
${renderAssetListView(module, rows, selectedRecord?.id)}
${renderRecordDrawer(module, selectedRecord)}
`;
el.content.querySelectorAll("[data-filter]").forEach((input) => {
input.addEventListener("input", () => {
filters[module] = { ...(filters[module] || {}), [input.dataset.filter]: input.value };
selected = null;
render();
});
});
el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor(module));
el.content.querySelectorAll("[data-select-row]").forEach((btn) => {
btn.addEventListener("click", () => { selected = btn.dataset.selectRow; render(); });
});
el.content.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", (e) => { e.stopPropagation(); openEditor(module, btn.dataset.edit); });
});
el.content.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", (e) => { e.stopPropagation(); removeRecord(module, btn.dataset.delete); });
});
const qInput = el.content.querySelector(`#${module}-q`);
const tabsEl = el.content.querySelector(`#${module}-tabs`);
const gridEl = el.content.querySelector(`#${module}-grid`);
function filterCards() {
const q = (qInput?.value || "").toLowerCase();
const tabVal = filters[module]?.tabVal || "";
gridEl?.querySelectorAll(".asset-card").forEach((card) => {
card.hidden = !((!tabVal || card.dataset.tabVal === tabVal) && (!q || card.dataset.search.includes(q)));
});
}
qInput?.addEventListener("input", filterCards);
tabsEl?.addEventListener("click", (e) => {
const tab = e.target.closest(".platform-tab");
if (!tab) return;
filters[module] = { ...(filters[module] || {}), tabVal: tab.dataset.tabVal || "" };
tabsEl.querySelectorAll(".platform-tab").forEach((tb) => tb.classList.remove("active"));
tab.classList.add("active");
filterCards();
});
filterCards();
bindRecordDrawerClose();
}
function bindRecordDrawerClose() {
el.content.querySelector("[data-close-record-drawer]")?.addEventListener("click", () => {
closeRecordDrawer();
});
el.content.querySelector(".record-drawer-backdrop")?.addEventListener("click", () => {
closeRecordDrawer();
});
}
function closeRecordDrawer() {
const drawer = el.content.querySelector(".asset-drawer");
const backdrop = el.content.querySelector(".record-drawer-backdrop");
if (!drawer) {
selected = null;
render();
return;
}
drawer.classList.add("closing");
backdrop?.classList.add("closing");
window.setTimeout(() => {
selected = null;
render();
}, 220);
}
function renderRecordDrawer(module, record) {
if (!record) return "";
return `
`;
}
function renderBindingsWorkspace(rows, schema, statusOptions, selectedRecord) {
const activeRows = rows.filter((binding) => binding.status === "active");
const focusedRows = filterGraphRows(activeRows);
const visibleRows = graphFocus ? rows.filter((binding) => focusedRows.some((item) => item.id === binding.id)) : rows;
const selectedBinding = selectedRecord || focusedRows[0] || activeRows[0] || rows[0];
const selectedAccount = selectedBinding ? state.accounts.find((account) => account.id === selectedBinding.account_id) : null;
const highRisk = focusedRows.filter((binding) => binding.risk_level === "high").length;
const uniqueAssets = new Set(focusedRows.map((binding) => `${binding.asset_type}:${binding.asset_id}`));
const uniqueAccounts = new Set(focusedRows.map((binding) => binding.account_id));
el.content.innerHTML = `
${escapeHtml(i18n("ui.relationshipMap", "Relationship Map"))}
${escapeHtml(i18n("ui.bindingTopology", "绑定拓扑图"))}
${escapeHtml(i18n("ui.bindingTopologyDesc", "清晰查看基础资产、账号与绑定角色之间的关系。"))}
${statusOptions.length ? `` : ""}
${graphFocus ? `` : ""}
${renderRelationStat(i18n("ui.assetStats", "基础资产"), uniqueAssets.size, i18n("ui.assetStatsMeta", "手机号 {phones} · 邮箱 {emails} · 域名 {domains}", { phones: countBindingsByType(focusedRows, "phone"), emails: countBindingsByType(focusedRows, "email"), domains: countBindingsByType(focusedRows, "domain") }), "asset")}
${renderRelationStat(i18n("ui.accountStats", "账号总数"), uniqueAccounts.size, i18n("ui.activeBindingsMeta", "活跃绑定 {count}", { count: focusedRows.length }), "account")}
${renderRelationStat(i18n("ui.bindingStats", "绑定关系"), visibleRows.length, graphFocus ? i18n("ui.focusedView", "当前为聚焦视图") : `${i18n("ui.activeCount", "活跃 {count}", { count: activeRows.length })} · ${i18n("ui.inactiveCount", "非活跃 {count}", { count: Math.max(rows.length - activeRows.length, 0) })}`, "binding")}
${renderRelationStat(i18n("ui.highRiskBindings", "高风险绑定"), highRisk, highRisk ? i18n("ui.needsPriority", "需要优先处理") : i18n("ui.noHighRisk", "当前没有高风险"), "risk")}
${renderRelationshipBoard(focusedRows)}
${renderRelationInspector(selectedAccount, selectedBinding)}
${visibleRows.length ? renderBindingDetailsTable(visibleRows) : renderEmpty("bindings")}
`;
el.content.querySelectorAll("[data-filter]").forEach((input) => {
input.addEventListener("input", () => {
filters.bindings = { ...(filters.bindings || {}), [input.dataset.filter]: input.value };
selected = null;
graphFocus = null;
render();
});
});
el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor("bindings"));
el.content.querySelector("[data-clear-focus]")?.addEventListener("click", () => {
graphFocus = null;
selected = null;
render();
});
el.content.querySelectorAll("[data-focus-type]").forEach((node) => node.addEventListener("click", () => {
graphFocus = { type: node.dataset.focusType, id: node.dataset.focusId };
selected = node.dataset.bindingId || null;
render();
}));
el.content.querySelectorAll("[data-select-row]").forEach((button) => button.addEventListener("click", () => {
selected = button.dataset.selectRow;
const binding = state.bindings.find((item) => item.id === selected);
graphFocus = binding ? { type: "account", id: binding.account_id } : null;
render();
}));
el.content.querySelectorAll("[data-edit]").forEach((button) => button.addEventListener("click", () => openEditor("bindings", button.dataset.edit)));
el.content.querySelectorAll("[data-delete]").forEach((button) => button.addEventListener("click", () => removeRecord("bindings", button.dataset.delete)));
}
function filterGraphRows(bindings) {
if (!graphFocus) return bindings;
if (graphFocus.type === "asset") {
return bindings.filter((binding) => `${binding.asset_type}:${binding.asset_id}` === graphFocus.id);
}
if (graphFocus.type === "account") {
return bindings.filter((binding) => binding.account_id === graphFocus.id);
}
if (graphFocus.type === "role") {
return bindings.filter((binding) => (binding.binding_role || "unknown") === graphFocus.id);
}
return bindings;
}
function renderRelationStat(title, value, meta, kind) {
return `
${renderRelationStatIcon(kind)}
${title}
${value}
${escapeHtml(meta)}
`;
}
function renderRelationStatIcon(kind) {
if (kind === "asset") {
return ``;
}
if (kind === "account") {
return ``;
}
if (kind === "binding") {
return ``;
}
if (kind === "risk") {
return ``;
}
return "";
}
function countBindingsByType(bindings, type) {
return new Set(bindings.filter((binding) => binding.asset_type === type).map((binding) => binding.asset_id)).size;
}
function renderRelationshipBoard(bindings) {
if (!bindings.length) {
return `
${escapeHtml(i18n("ui.noActiveBindings", "暂无活跃绑定关系"))}
`;
}
const assets = summarizeAssets(bindings);
const accounts = summarizeAccounts(bindings);
const roles = summarizeRoles(bindings);
const assetLayout = makeGroupedAssetLayout(assets);
const height = Math.max(430, assetLayout.height, Math.max(accounts.length, roles.length) * 72 + 110);
const assetY = assetLayout.yMap;
const accountY = makeYMap(accounts, height);
const roleY = makeYMap(roles, height);
const lines = bindings.map((binding) => {
const assetKey = `${binding.asset_type}:${binding.asset_id}`;
const roleKey = binding.binding_role || "unknown";
const y1 = assetY.get(assetKey);
const y2 = accountY.get(binding.account_id);
const y3 = roleY.get(roleKey);
if (!y1 || !y2 || !y3) return "";
return `
`;
}).join("");
const colTop = 14;
const colBottom = height - 14;
const colHeight = colBottom - colTop;
return `
${escapeHtml(i18n("ui.graph", "Graph"))}
${escapeHtml(i18n("ui.coreBindingGraph", "核心绑定关系"))}
${escapeHtml(labels.phone)}
${escapeHtml(labels.email)}
${escapeHtml(labels.domain)}
${escapeHtml(i18n("ui.bindingRoleLegend", "绑定角色"))}
${escapeHtml(i18n("ui.riskLegend", "风险"))}
`;
}
function makeGroupedAssetLayout(assets) {
const order = ["phone", "email", "domain", "account", "payment", "device", "subscription"];
const grouped = new Map();
assets.forEach((asset) => {
if (!grouped.has(asset.type)) grouped.set(asset.type, []);
grouped.get(asset.type).push(asset);
});
const yMap = new Map();
const groups = [];
let cursor = 68;
order
.filter((type) => grouped.has(type))
.concat([...grouped.keys()].filter((type) => !order.includes(type)))
.forEach((type) => {
const items = grouped.get(type);
const headerHeight = 30;
const rowGap = 62;
const padding = 16;
const groupTop = cursor;
items.forEach((item, index) => {
yMap.set(item.key, groupTop + headerHeight + padding + index * rowGap);
});
const height = headerHeight + padding * 2 + Math.max(items.length - 1, 0) * rowGap + 54;
groups.push({ type, count: items.length, y: groupTop - 18, height });
cursor += height + 12;
});
return { yMap, groups, height: cursor + 24 };
}
function renderAssetGroupBox(group) {
const meta = assetGroupMeta(group.type);
return `
${escapeHtml(meta.label)} (${group.count})
`;
}
function assetGroupMeta(type) {
return {
phone: { label: labels.phone },
email: { label: labels.email },
domain: { label: labels.domain },
account: { label: labels.account },
payment: { label: i18n("ui.paymentMethod", "支付方式") },
device: { label: i18n("ui.device", "设备") },
subscription: { label: i18n("ui.subscription", "订阅") },
}[type] || { label: t(type) };
}
function summarizeAssets(bindings) {
const map = new Map();
bindings.forEach((binding) => {
const key = `${binding.asset_type}:${binding.asset_id}`;
if (!map.has(key)) {
const assetPlatform = binding.asset_type === "account"
? (state.accounts.find((a) => a.id === binding.asset_id)?.platform || null)
: null;
const assetAccount = binding.asset_type === "account"
? state.accounts.find((a) => a.id === binding.asset_id)
: null;
map.set(key, { key, id: binding.asset_id, type: binding.asset_type, name: resolveName(binding.asset_type, binding.asset_id), count: 0, platform: assetPlatform, platform_logo: assetAccount?.platform_logo || "" });
}
map.get(key).count += 1;
});
return [...map.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}
function summarizeAccounts(bindings) {
const map = new Map();
bindings.forEach((binding) => {
const account = state.accounts.find((item) => item.id === binding.account_id);
if (!map.has(binding.account_id)) {
map.set(binding.account_id, {
id: binding.account_id,
platform: account?.platform || binding.platform || "Account",
platform_logo: account?.platform_logo || "",
name: account?.account_identifier || resolveName("accounts", binding.account_id),
bindingId: binding.id,
count: 0,
});
}
map.get(binding.account_id).count += 1;
});
return [...map.values()].sort((a, b) => b.count - a.count || a.platform.localeCompare(b.platform));
}
function summarizeRoles(bindings) {
const map = new Map();
bindings.forEach((binding) => {
const key = binding.binding_role || "unknown";
if (!map.has(key)) map.set(key, { key, count: 0 });
map.get(key).count += 1;
});
return [...map.values()].sort((a, b) => b.count - a.count || t(a.key).localeCompare(t(b.key)));
}
function renderRelationSvgNode({ x, y, width, title, meta, kind, platform, platformLogo, active, focusType, focusId, bindingId }) {
const nodeHeight = 58;
const platformInfo = platform ? platformMeta(platform, platformLogo) : null;
const iconText = platformInfo?.mark || nodeIconText(kind);
const iconClass = platformInfo?.className || kind;
const isAssetIcon = !platformInfo && ["phone", "email", "domain"].includes(kind);
const markBackground = isAssetIcon
? ``
: ``;
const iconSvg = platformInfo?.className === "apple"
? renderAppleNodeIcon(x + 28, y)
: platformInfo?.src
? ``
: renderNodeIcon(kind, x + 28, y, iconText);
return `
${markBackground}
${iconSvg}
${escapeHtml(truncate(title, 22))}
${escapeHtml(truncate(meta, 26))}
`;
}
function renderNodeIcon(kind, cx, cy, fallbackText) {
if (kind === "phone") {
return `
`;
}
if (kind === "email") {
return `
`;
}
if (kind === "domain") {
return `
`;
}
return `${escapeHtml(fallbackText)}`;
}
function renderAppleNodeIcon(cx, cy) {
const size = 20, x = cx - size / 2, y = cy - size / 2;
return `
`;
}
function nodeIconText(kind) {
if (kind === "role") return "R";
return "•";
}
function renderRelationInspector(account, binding) {
if (!account) {
return `
`;
}
const accountBindings = state.bindings.filter((item) => item.account_id === account.id && item.status === "active");
const risks = computeRisks().filter((risk) => risk.accountId === account.id);
const riskNotes = riskNotesToItems(account.risk_notes);
const hasRiskTips = riskNotes.length || risks.length;
return `
`;
}
function renderTable(module, rows, schema) {
return `
${schema.columns.map((col) => `| ${fieldLabel(module, col)} | `).join("")}
${escapeHtml(i18n("ui.actions", "操作"))} |
${rows
.map(
(row) => `
${schema.columns.map((col) => `| ${renderCell(module, row, col)} | `).join("")}
|
`,
)
.join("")}
`;
}
function renderBindingDetailsTable(rows) {
return `
${rows.map((binding) => `
${escapeHtml(i18n("ui.resource", "资源"))}
${renderBindingResourceCell(binding)}
${escapeHtml(labels.account)}
${renderBindingAccountCell(binding)}
`).join("")}
`;
}
function renderBindingResourceCell(binding) {
if (binding.asset_type === "account") {
const account = state.accounts.find((item) => item.id === binding.asset_id);
return renderBindingIdentityCell({
iconHtml: renderPlatformLogo(account || "Account"),
title: account?.platform || "Account",
subtitle: account?.account_identifier || resolveName("accounts", binding.asset_id),
extraClass: "resource-account",
});
}
return `
${renderInlineAssetIcon(binding.asset_type)}
${escapeHtml(resolveName(binding.asset_type, binding.asset_id))}
${t(binding.asset_type)}
`;
}
function renderBindingAccountCell(binding) {
const account = state.accounts.find((item) => item.id === binding.account_id);
const platform = account?.platform || binding.platform || "Account";
const identifier = account?.account_identifier || resolveName("accounts", binding.account_id);
return renderBindingIdentityCell({
iconHtml: renderPlatformLogo(account || platform),
title: platform,
subtitle: identifier,
extraClass: "account",
});
}
function renderBindingIdentityCell({ iconHtml, title, subtitle, extraClass = "" }) {
return `
${iconHtml}
${escapeHtml(title)}
${escapeHtml(subtitle)}
`;
}
function renderPlatformLogo(platform) {
const meta = platformMeta(platform);
const logo = meta.src ? `
` : escapeHtml(meta.mark);
return `${logo}`;
}
function renderInlineAssetIcon(type) {
if (type === "phone") return ``;
if (type === "email") return ``;
if (type === "domain") return ``;
if (type === "account") return ``;
return ``;
}
function renderCell(module, row, col) {
const value = row[col];
if (col === "platform") return renderPlatform(value, row);
if (col === "phone_number") return escapeHtml(formatPhoneNumber(value, row.country_code, row.phone_local_number));
if (["country_region", "region"].includes(col)) return renderRegion(value);
if (["status", "risk_level", "severity"].includes(col)) return `${t(value)}`;
if (typeof value === "boolean") return t(value);
if (col.endsWith("_at")) return formatDate(value);
if (col.endsWith("_id") || col === "asset_id") return escapeHtml(resolveName(col === "asset_id" ? row.asset_type : relationSource(col), value));
return escapeHtml(t(value));
}
function renderTopology(bindings) {
const activeBindings = bindings.filter((binding) => binding.status === "active");
if (!activeBindings.length) {
return `
${escapeHtml(i18n("ui.noActiveBindings", "暂无活跃绑定关系"))}
`;
}
const assetMap = new Map();
const accountMap = new Map();
activeBindings.forEach((binding) => {
const assetKey = `${binding.asset_type}:${binding.asset_id}`;
if (!assetMap.has(assetKey)) {
assetMap.set(assetKey, {
key: assetKey,
id: binding.asset_id,
type: binding.asset_type,
name: resolveName(binding.asset_type, binding.asset_id),
count: 0,
});
}
assetMap.get(assetKey).count += 1;
if (!accountMap.has(binding.account_id)) {
const account = state.accounts.find((item) => item.id === binding.account_id);
accountMap.set(binding.account_id, {
id: binding.account_id,
platform: account?.platform || binding.platform || "Account",
platform_logo: account?.platform_logo || "",
name: account?.account_identifier || resolveName("accounts", binding.account_id),
count: 0,
});
}
accountMap.get(binding.account_id).count += 1;
});
const assets = [...assetMap.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
const accounts = [...accountMap.values()].sort((a, b) => b.count - a.count || a.platform.localeCompare(b.platform));
const height = Math.max(340, Math.max(assets.length, accounts.length) * 82 + 70);
const assetY = makeYMap(assets, height);
const accountY = makeYMap(accounts, height);
const accountById = new Map(accounts.map((account) => [account.id, account]));
const lines = activeBindings
.map((binding) => {
const assetKey = `${binding.asset_type}:${binding.asset_id}`;
const fromY = assetY.get(assetKey);
const toY = accountY.get(binding.account_id);
if (!fromY || !toY) return "";
return `
${escapeHtml(t(binding.binding_role))}
`;
})
.join("");
const assetNodes = assets.map((asset) => renderTopologyNode({
x: 55,
y: assetY.get(asset.key),
width: 200,
title: asset.name,
meta: `${t(asset.type)} · ${i18n("ui.bindingCount", "{count} 个绑定", { count: asset.count })}`,
kind: asset.type,
})).join("");
const accountNodes = accounts.map((account) => renderTopologyNode({
x: 715,
y: accountY.get(account.id),
width: 230,
title: account.platform,
meta: `${account.name} · ${i18n("ui.bindingCount", "{count} 个绑定", { count: account.count })}`,
kind: "account",
platform: account.platform,
platformLogo: account.platform_logo,
})).join("");
return `
`;
}
function makeYMap(items, height) {
const map = new Map();
const top = 72;
const bottom = height - 42;
const step = items.length > 1 ? (bottom - top) / (items.length - 1) : 0;
items.forEach((item, index) => {
map.set(item.key || item.id, Math.round(top + step * index));
});
return map;
}
function renderTopologyNode({ x, y, width, title, meta, kind, platform, platformLogo }) {
const nodeHeight = 54;
const platformInfo = platform ? platformMeta(platform, platformLogo) : null;
const icon = platformInfo?.src
? ``
: `${escapeHtml(platformInfo?.mark || "")}`;
return `
${kind === "account" ? icon : ""}
${escapeHtml(truncate(title, 24))}
${escapeHtml(truncate(meta, 30))}
`;
}
function truncate(value, maxLength) {
const text = String(value || "");
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text;
}
function renderEmpty(module) {
return `
${escapeHtml(i18n("ui.emptyRecord", "还没有{name}记录", { name: schemas[module].title }))}
${escapeHtml(i18n("ui.emptyRecordDesc", "点击右上角新增,先把关键手机号、邮箱、账号和绑定关系录入起来,风险检测就能开始工作。"))}
`;
}
function renderDetail(module, record, options = {}) {
const drawer = options.drawer === true;
const fields = schemas[module].fields.filter(([key]) => !["notes", "risk_notes", "description", "action_taken", "next_action"].includes(key));
const bindings = relatedBindings(module, record);
const incidents = module === "accounts" ? state.incidents.filter((i) => i.account_id === record.id) : [];
const risks = computeRisks().filter((risk) => risk.ref === record.id || risk.accountId === record.id);
const riskNotes = riskNotesToItems(record.risk_notes);
return `
${fields.map(([key, label]) => `
${label}${renderDetailValue(module, record, key)}
`).join("")}
${record.notes ? `${escapeHtml(i18n("ui.notes", "备注"))}${escapeHtml(record.notes)}
` : ""}
${riskNotes.length ? `${escapeHtml(i18n("ui.riskTips", "风险提示"))}
${riskNotes.map(renderManualRiskNote).join("")}
` : ""}
${escapeHtml(i18n("ui.relatedBindings", "关联绑定"))}
${bindings.length ? bindings.map(renderBindingCard).join("") : `
${escapeHtml(i18n("ui.noRelatedBindings", "暂无关联绑定。"))}
`}
${incidents.length ? `${escapeHtml(i18n("ui.eventTimeline", "事件时间线"))}
${incidents.map(renderIncidentCard).join("")}
` : ""}
${risks.length ? `${escapeHtml(i18n("ui.riskTips", "风险提示"))}
${risks.map(renderRisk).join("")}
` : ""}
`;
}
function renderDetailValue(module, record, key) {
const value = record[key];
if (key === "platform") return renderPlatform(value);
if (key === "phone_number") return escapeHtml(formatPhoneNumber(value, record.country_code, record.phone_local_number));
if (["country_region", "region"].includes(key)) return renderRegion(value);
if (key.endsWith("_at")) return escapeHtml(formatDate(value));
if (typeof value === "boolean") return t(value);
if (key.endsWith("_id")) return escapeHtml(resolveName(relationSource(key), value));
if (["status", "risk_level", "severity"].includes(key)) return `${t(value)}`;
return escapeHtml(t(value));
}
function renderBindingCard(binding) {
return `
${escapeHtml(resolveName(binding.asset_type, binding.asset_id))} -> ${escapeHtml(resolveName("accounts", binding.account_id))}
${t(binding.asset_type)} / ${t(binding.binding_role)} / ${t(binding.status)} / ${t(binding.risk_level)}
${binding.notes ? `${escapeHtml(binding.notes)}` : ""}
`;
}
function renderIncidentCard(incident) {
return `
${escapeHtml(t(incident.incident_type))} ${t(incident.severity)}
${formatDate(incident.occurred_at)} / ${t(incident.status)}
${incident.next_action ? `${escapeHtml(incident.next_action)}` : ""}
`;
}
function filterRows(module) {
const globalQ = el.search.value.trim().toLowerCase();
const local = filters[module] || {};
return state[module].filter((row) => {
if (local.status && row.status !== local.status) return false;
const text = searchableText(module, row);
if (local.q && !text.includes(local.q.toLowerCase())) return false;
if (globalQ && !text.includes(globalQ)) return false;
return true;
});
}
function searchableText(module, row) {
const identityText = `${row.accountid || ""} ${row.userid || ""}`;
const base = `${schemas[module].search.flatMap((key) => [row[key]]).join(" ")} ${identityText}`;
if (module === "bindings") {
return `${base} ${resolveName(row.asset_type, row.asset_id)} ${resolveName("accounts", row.account_id)}`.toLowerCase();
}
return base.toLowerCase();
}
function getStatusOptions(module) {
return schemas[module].fields.find(([key]) => key === "status")?.[3]?.options || [];
}
function fieldLabel(module, key) {
return schemas[module].fields.find(([field]) => field === key)?.[1] || key;
}
function relationSource(key) {
if (key.includes("phone")) return "phones";
if (key.includes("email")) return "emails";
if (key.includes("account")) return "accounts";
if (key.includes("domain")) return "domains";
return key;
}
function primaryName(module, record) {
if (!record) return "-";
if (module === "phones" || module === "phone") return formatPhoneNumber(record.phone_number, record.country_code, record.phone_local_number);
if (module === "emails" || module === "email") return record.email;
if (module === "domains" || module === "domain") return record.domain;
if (module === "accounts") return `${record.platform || ""} ${record.account_identifier || ""}`.trim();
if (module === "bindings") return `${resolveName(record.asset_type, record.asset_id)} -> ${resolveName("accounts", record.account_id)}`;
if (module === "incidents") return `${record.platform || resolveName("accounts", record.account_id)} ${t(record.incident_type)}`.trim();
return record.id;
}
function resolveName(source, id) {
if (!id) return "-";
const collection = source.endsWith("s") ? source : `${source}s`;
const record = state[collection]?.find((item) => item.id === id);
return record ? primaryName(collection, record) : id;
}
function relatedBindings(module, record) {
if (module === "bindings") return [record];
if (module === "accounts") return state.bindings.filter((binding) => binding.account_id === record.id);
const assetType = module.replace(/s$/, "");
return state.bindings.filter((binding) => binding.asset_type === assetType && binding.asset_id === record.id);
}
function freeUsage() {
return {
assets: (state.phones?.length || 0) + (state.emails?.length || 0) + (state.domains?.length || 0),
accounts: state.accounts?.length || 0,
bindings: state.bindings?.length || 0,
};
}
function quotaKeyForModule(module) {
if (["phones", "emails", "domains"].includes(module)) return "assets";
if (module === "accounts") return "accounts";
if (module === "bindings") return "bindings";
return "";
}
function quotaLabel(key) {
return { assets: i18n("groups.assets", "基础资产"), accounts: labels.accounts, bindings: labels.bindings }[key] || (currentLocale === "en" ? "records" : "记录");
}
function canCreateInFreePlan(module) {
if (isProPlan()) return true;
const key = quotaKeyForModule(module);
if (!key) return true;
return freeUsage()[key] < FREE_LIMITS[key];
}
function showLimitGate(module) {
const key = quotaKeyForModule(module);
const used = freeUsage()[key] || 0;
const limit = FREE_LIMITS[key];
const reason = i18n("ui.limitReason", "免费版最多可创建 {limit} 个{name},当前已使用 {used}/{limit}。", { limit, name: quotaLabel(key), used });
toast(reason, "warning");
openPricing();
}
function openEditor(module, id) {
if (!id && !canCreateInFreePlan(module)) {
showLimitGate(module);
return;
}
if (module === "bindings") { openBindingEditor(id); return; }
editing = { module, id };
const record = id ? state[module].find((row) => row.id === id) : { ...defaults[module] };
el.dialogKicker.textContent = schemas[module].title;
el.dialogTitle.textContent = id
? i18n("ui.editRecord", "编辑{name}", { name: schemas[module].title })
: i18n("ui.addRecord", "新增{name}", { name: schemas[module].title });
el.fields.classList.remove("was-validated");
setSaving(false);
el.fields.innerHTML = schemas[module].fields.map(([key, label, type, opts = {}]) => renderField(module, record, key, label, type, opts)).join("");
wireDynamicAssetField(record);
wirePhoneCountryFields();
wireLogoUpload();
el.dialog.showModal();
scheduleTour();
}
function wireLogoUpload() {
const input = el.fields.querySelector("#platform_logo");
const file = el.fields.querySelector("[data-logo-upload]");
if (!input || !file) return;
file.addEventListener("change", () => {
const selected = file.files?.[0];
if (!selected) return;
const reader = new FileReader();
reader.addEventListener("load", () => {
input.value = String(reader.result || "");
});
reader.readAsDataURL(selected);
});
}
const bindingRolesByAssetType = {
phone: ["trusted_phone", "two_factor", "recovery", "notification", "payment", "owner", "unknown"],
email: ["login", "recovery", "notification", "alias", "owner", "unknown"],
domain: ["alias", "owner", "notification", "unknown"],
account: ["login", "owner", "unknown"],
};
function bindingRoleLabel(role) {
const descs = {
login: "登录凭据",
recovery: "账号恢复",
trusted_phone: "受信任手机号",
two_factor: "两步验证 (2FA)",
notification: "通知接收",
payment: "支付方式",
owner: "实名 / 所有人",
alias: "别名",
unknown: "其他",
};
return descs[role] || t(role);
}
function openBindingEditor(id) {
if (!id && !canCreateInFreePlan("bindings")) {
showLimitGate("bindings");
return;
}
editing = { module: "bindings", id };
const record = id ? state.bindings.find((r) => r.id === id) : { ...defaults.bindings };
el.dialogKicker.textContent = schemas.bindings.title;
el.dialogTitle.textContent = id
? i18n("ui.editRecord", "编辑{name}", { name: schemas.bindings.title })
: i18n("ui.addRecord", "新增{name}", { name: schemas.bindings.title });
el.fields.classList.remove("was-validated");
setSaving(false);
el.fields.innerHTML = renderBindingForm(record);
wireBindingForm();
el.dialog.showModal();
scheduleTour();
}
const assetTypeIcon = {
phone: ``,
email: ``,
domain: ``,
account: ``,
};
function renderAssetItems(assetType, selectedId) {
const collection = `${assetType}s`;
const items = state[collection] || [];
if (!items.length) return `${escapeHtml(i18n("ui.noModuleData", "暂无{name}数据", { name: t(assetType) }))}
`;
return items.map((item) => {
const name = primaryName(collection, item);
return `${escapeHtml(name)}
`;
}).join("");
}
function renderBindingForm(record) {
const assetType = record.asset_type || "phone";
const roles = bindingRolesByAssetType[assetType] || enums.bindingRole;
const roleOpts = roles.map((r) => ``).join("");
const statusOpts = enums.bindingStatus.map((s) => ``).join("");
const assetTypeTabs = enums.assetType.map((tp) =>
``
).join("");
const platformCounts = new Map();
state.accounts.forEach((a) => { const p = a.platform || ""; platformCounts.set(p, (platformCounts.get(p) || 0) + 1); });
const platforms = [...platformCounts.keys()].filter(Boolean).sort();
const platformTabs = `` +
platforms.map((p) => ``).join("");
const accountItems = [...state.accounts]
.sort((a, b) => (a.platform || "").localeCompare(b.platform || "") || (a.account_identifier || "").localeCompare(b.account_identifier || ""))
.map((a) => {
const meta = platformMeta(a);
const logo = meta.src ? `
` : escapeHtml(meta.mark || "?");
const search = `${a.platform || ""} ${a.account_identifier || ""} ${a.display_name || ""}`.toLowerCase();
return `
${logo}
${escapeHtml(a.platform || i18n("ui.unknownName", "未知"))}${escapeHtml(a.account_identifier || a.display_name || "")}
`;
}).join("");
return `
${escapeHtml(i18n("ui.put", "把"))}
—
${escapeHtml(i18n("ui.as", "作为"))}
—
${escapeHtml(i18n("ui.possessive", "的"))}
—
${escapeHtml(i18n("ui.selectAsset", "选择资产"))} ${escapeHtml(i18n("ui.required", "必填"))}
${escapeHtml(i18n("ui.selectAccount", "选择账号"))} ${escapeHtml(i18n("ui.required", "必填"))}
${escapeHtml(i18n("ui.usageRole", "用途 / 绑定角色"))} ${escapeHtml(i18n("ui.required", "必填"))}
${escapeHtml(fieldLabel("bindings", "status"))}
${escapeHtml(i18n("ui.moreOptions", "更多选项"))}
${renderField("bindings", record, "bound_at", fieldLabel("bindings", "bound_at"), "datetime-local")}
${renderField("bindings", record, "unbound_at", fieldLabel("bindings", "unbound_at"), "datetime-local")}
${renderField("bindings", record, "risk_level", fieldLabel("bindings", "risk_level"), "select", { options: enums.riskLevel })}
${renderField("bindings", record, "tags", fieldLabel("bindings", "tags"), "text", { placeholder: i18n("ui.optionalTags", "可选标签") })}
${renderField("bindings", record, "notes", fieldLabel("bindings", "notes"), "textarea")}
`;
}
function wireBindingForm() {
const assetTypeInput = el.fields.querySelector("#asset_type");
const assetIdInput = el.fields.querySelector("#asset_id");
const assetTypeTabs = el.fields.querySelector("#asset-type-tabs");
const assetPickerList = el.fields.querySelector("#asset-picker-list");
const accountInput = el.fields.querySelector("#account_id");
const roleSel = el.fields.querySelector("[name='binding_role']");
const searchInput = el.fields.querySelector("#account-search");
const filterTabs = el.fields.querySelector("#account-filter-tabs");
const accountPickerList = el.fields.querySelector("#account-picker-list");
function updateSentence() {
const assetName = assetIdInput.value
? resolveName(assetTypeInput.value, assetIdInput.value)
: "—";
const acct = state.accounts.find((a) => a.id === accountInput.value);
const accountText = acct ? `${acct.platform} ${acct.account_identifier || acct.display_name || ""}`.trim() : "—";
const roleText = roleSel.value ? roleSel.options[roleSel.selectedIndex].text : "—";
el.fields.querySelector("#bs-asset").textContent = assetName;
el.fields.querySelector("#bs-account").textContent = accountText;
el.fields.querySelector("#bs-role").textContent = roleText;
}
// Asset type tab click
assetTypeTabs.addEventListener("click", (e) => {
const tab = e.target.closest(".asset-type-tab");
if (!tab) return;
const newType = tab.dataset.type;
assetTypeTabs.querySelectorAll(".asset-type-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
assetTypeInput.value = newType;
assetIdInput.value = "";
assetPickerList.innerHTML = renderAssetItems(newType, "");
const roles = bindingRolesByAssetType[newType] || enums.bindingRole;
roleSel.innerHTML = `` + roles.map((r) => ``).join("");
updateSentence();
});
// Asset item click
assetPickerList.addEventListener("click", (e) => {
const item = e.target.closest(".asset-item");
if (!item || !item.dataset.id) return;
assetPickerList.querySelectorAll(".asset-item").forEach((i) => i.classList.remove("selected"));
item.classList.add("selected");
assetIdInput.value = item.dataset.id;
updateSentence();
});
// Account search/filter
function filterAccounts() {
const query = (searchInput.value || "").toLowerCase();
const activePlatform = filterTabs.querySelector(".platform-tab.active")?.dataset.platform || "";
accountPickerList.querySelectorAll(".account-item").forEach((item) => {
const platformOk = !activePlatform || item.dataset.platform === activePlatform;
const searchOk = !query || item.dataset.search.includes(query);
item.hidden = !(platformOk && searchOk);
});
}
accountPickerList.addEventListener("click", (e) => {
const item = e.target.closest(".account-item");
if (!item) return;
accountPickerList.querySelectorAll(".account-item").forEach((i) => i.classList.remove("selected"));
item.classList.add("selected");
accountInput.value = item.dataset.id;
el.fields.querySelector("#account-picker").classList.remove("picker-error");
updateSentence();
});
filterTabs.addEventListener("click", (e) => {
const tab = e.target.closest(".platform-tab");
if (!tab) return;
filterTabs.querySelectorAll(".platform-tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
filterAccounts();
});
searchInput.addEventListener("input", filterAccounts);
if (accountInput.value) {
accountPickerList.querySelector(`.account-item[data-id="${CSS.escape(accountInput.value)}"]`)?.scrollIntoView({ block: "nearest" });
}
roleSel.addEventListener("change", updateSentence);
updateSentence();
}
function renderField(module, record, key, label, type, opts = {}) {
const value = record[key] ?? "";
const required = opts.required ? "required" : "";
const requiredMark = opts.required ? `必填` : "";
const labelHtml = `${label}${requiredMark}`;
const hint = key === "credential_ref" || key === "recovery_ref"
? `只保存密码管理器引用,不保存明文密码、2FA Secret 或恢复码。`
: key === "platform_logo"
? `${escapeHtml(i18n("ui.logoHint", "可选。支持 https 图片地址、data:image... 或 assets/platforms/*.svg;不填则使用内置品牌或首字母。"))}`
: "";
if (module === "phones" && key === "country_region") {
const selected = matchCountry(value, record.country_code) || matchCountry("CN");
return ``;
}
if (module === "phones" && key === "country_code") {
const selected = matchCountry(record.country_region, value) || matchCountry("CN");
const options = countryList().map((country) =>
``
).join("");
const localNumber = record.phone_local_number || splitPhoneNumber(record.phone_number, value, record.phone_local_number).localNumber;
return `
`;
}
if (module === "phones" && key === "phone_local_number") {
return "";
}
if (module === "accounts" && key === "region") {
const selected = matchCountry(value) || null;
const options = countryList().map((country) =>
``
).join("");
return `${hint}
`;
}
if (module === "accounts" && key === "platform_logo") {
return `
`;
}
if (type === "textarea") {
return `${hint}
`;
}
if (type === "select") {
return `${hint}
`;
}
if (type === "relation") {
return `${hint}
`;
}
if (type === "asset-relation") {
const assetType = record.asset_type || "phone";
return `${hint}
`;
}
if (type === "checkbox") {
return ``;
}
const inputValue = type === "datetime-local" ? toInputDate(value) : value;
return `${hint}
`;
}
function wireDynamicAssetField(record) {
const typeSelect = el.fields.querySelector("[name='asset_type']");
const assetSelect = el.fields.querySelector("[name='asset_id']");
if (!typeSelect || !assetSelect) return;
typeSelect.addEventListener("change", () => {
assetSelect.innerHTML = assetOptions(typeSelect.value, record.asset_id);
});
}
function wirePhoneCountryFields() {
const regionSelect = el.fields.querySelector("#country_region");
const codeSelect = el.fields.querySelector("#country_code");
if (!regionSelect || !codeSelect) return;
if (regionSelect.type === "hidden") {
codeSelect.addEventListener("change", () => {
const countryCode = codeSelect.selectedOptions[0]?.dataset.countryCode;
if (countryCode) regionSelect.value = countryCode;
});
return;
}
regionSelect.addEventListener("change", () => {
const dialCode = regionSelect.selectedOptions[0]?.dataset.dialCode;
if (dialCode) codeSelect.value = dialCode;
});
codeSelect.addEventListener("change", () => {
const countryCode = codeSelect.selectedOptions[0]?.dataset.countryCode;
if (countryCode) regionSelect.value = countryCode;
});
}
function assetOptions(assetType, selectedId) {
const collection = `${assetType}s`;
const items = state[collection] || [];
return `${items.map((item) => ``).join("")}`;
}
async function handleFormSubmit(event) {
if (event.submitter?.value === "cancel") return;
event.preventDefault();
const { module, id } = editing || {};
if (!module) return;
const formData = new FormData(el.form);
const record = id ? { ...state[module].find((row) => row.id === id) } : { id: uid(module.slice(0, -1)), created_at: nowIso() };
schemas[module].fields.forEach(([key, , type]) => {
if (type === "checkbox") record[key] = formData.has(key);
else if (type === "datetime-local") record[key] = toIsoFromInput(formData.get(key));
else record[key] = String(formData.get(key) || "").trim();
});
if (module === "phones") {
const split = splitPhoneNumber(record.phone_number, record.country_code, record.phone_local_number);
record.country_code = split.countryCode;
record.phone_local_number = split.localNumber;
record.phone_number = `${split.countryCode}${split.localNumber}`;
}
record.updated_at = nowIso();
const error = validateRecord(module, record);
if (error) {
el.fields.classList.add("was-validated");
if (module === "bindings" && !record.account_id) el.fields.querySelector("#account-picker")?.classList.add("picker-error");
toast(error, "error");
return;
}
if (!id && module === "accounts" && !isProPlan()) {
const autoBindingCount = [record.login_email_id, record.login_phone_id].filter(Boolean).length;
if (freeUsage().bindings + autoBindingCount > FREE_LIMITS.bindings) {
showLimitGate("bindings");
return;
}
}
setSaving(true);
if (module === "bindings") {
const account = state.accounts.find((item) => item.id === record.account_id);
record.platform = account?.platform || record.platform || "";
}
if (module === "incidents" && !record.platform) {
const account = state.accounts.find((item) => item.id === record.account_id);
record.platform = account?.platform || "";
}
Object.assign(record, stampRecord(record));
if (id) state[module] = state[module].map((row) => (row.id === id ? record : row));
else state[module].unshift(record);
if (module === "accounts") syncAccountLoginBindings(record);
try {
await saveState();
selected = record.id;
el.dialog.close();
handleTourRecordSaved(module);
toast(id ? "修改已保存" : "新记录已创建", "success");
render();
} finally {
setSaving(false);
}
}
const AUTO_ACCOUNT_LOGIN_TAG = "auto:account-login";
function syncAccountLoginBindings(account) {
const desired = [
{ field: "login_email_id", assetType: "email", assetId: account.login_email_id },
{ field: "login_phone_id", assetType: "phone", assetId: account.login_phone_id },
].filter((item) => item.assetId);
const desiredKeys = new Set(desired.map((item) => `${item.assetType}:${item.assetId}`));
state.bindings = state.bindings.filter((binding) => {
const isAutoLogin = binding.account_id === account.id &&
binding.binding_role === "login" &&
String(binding.tags || "").split(",").map((tag) => tag.trim()).includes(AUTO_ACCOUNT_LOGIN_TAG);
if (!isAutoLogin) return true;
return desiredKeys.has(`${binding.asset_type}:${binding.asset_id}`);
});
desired.forEach((item) => {
const existing = state.bindings.find((binding) =>
binding.account_id === account.id &&
binding.asset_type === item.assetType &&
binding.asset_id === item.assetId &&
binding.binding_role === "login"
);
if (existing) {
existing.platform = account.platform || existing.platform || "";
existing.status = existing.status || "active";
existing.risk_level = existing.risk_level || "low";
existing.updated_at = nowIso();
Object.assign(existing, stampRecord(existing));
return;
}
state.bindings.unshift({
...stampRecord({
id: uid("binding"),
asset_type: item.assetType,
asset_id: item.assetId,
account_id: account.id,
platform: account.platform || "",
binding_role: "login",
status: "active",
bound_at: nowIso(),
unbound_at: "",
can_unbind: true,
risk_level: "low",
tags: AUTO_ACCOUNT_LOGIN_TAG,
notes: "由账号登录字段自动创建",
created_at: nowIso(),
updated_at: nowIso(),
}),
});
});
}
function validateRecord(module, record) {
if (module === "phones" && !/^\+[1-9][0-9]{0,3}$/.test(record.country_code)) return "国码格式应类似 +86";
if (module === "phones" && !/^[0-9]{5,18}$/.test(record.phone_local_number)) return "本地号码只填写数字,例如 18533752119";
if (module === "phones" && !/^\+[1-9][0-9]{5,18}$/.test(record.phone_number)) return "手机号组合后格式不正确";
if (module === "emails" && !isEmail(record.email)) return "请输入有效邮箱地址";
if (module === "emails" && record.forward_to && !isEmail(record.forward_to)) return "转发目标必须是有效邮箱地址";
if (module === "accounts" && /(明文密码|短信验证码|邮箱验证码|2fa secret|secret=|cvv|完整卡号|恢复码[::]\s*\S+)/i.test(`${record.notes} ${record.credential_ref} ${record.recovery_ref}`)) {
return "请只保存凭据引用,不要记录明文密码、验证码、2FA Secret 或恢复码";
}
if (module === "bindings" && !state.accounts.some((a) => a.id === record.account_id)) return "请选择账号";
if (module === "bindings" && !state[`${record.asset_type}s`]?.some((item) => item.id === record.asset_id)) return "请选择有效资产";
return "";
}
function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value || "");
}
async function removeRecord(module, id) {
const record = state[module].find((row) => row.id === id);
if (!record) return;
if (!confirm(`确认删除「${primaryName(module, record)}」?`)) return;
state[module] = state[module].filter((row) => row.id !== id);
if (module === "accounts") {
state.bindings = state.bindings.filter((binding) => binding.account_id !== id);
state.incidents = state.incidents.filter((incident) => incident.account_id !== id);
}
if (["phones", "emails", "domains"].includes(module)) {
const assetType = module.replace(/s$/, "");
state.bindings = state.bindings.filter((binding) => !(binding.asset_type === assetType && binding.asset_id === id));
}
await saveState();
selected = null;
toast("已删除", "success");
render();
}
function computeRisks() {
const risks = [];
const activeBindings = state.bindings.filter((binding) => binding.status === "active");
activeBindings.forEach((binding) => {
const account = state.accounts.find((item) => item.id === binding.account_id);
if (binding.asset_type === "phone") {
const phone = state.phones.find((item) => item.id === binding.asset_id);
if (phone && ["cannot_receive_sms", "inactive", "released"].includes(phone.status) && ["two_factor", "trusted_phone", "recovery"].includes(binding.binding_role)) {
risks.push({ level: "high", ref: phone.id, accountId: account?.id, title: "不可收码手机号仍用于验证/恢复", detail: `${phone.phone_number} 仍绑定 ${account?.platform || "账号"} 的 ${t(binding.binding_role)}` });
}
if (phone?.is_primary && account && ["locked", "suspended", "unusable"].includes(account.status)) {
risks.push({ level: "medium", ref: phone.id, accountId: account.id, title: "异常账号仍绑定主力手机号", detail: `${account.platform} ${account.account_identifier} 当前为 ${t(account.status)}` });
}
}
if (binding.asset_type === "email") {
const email = state.emails.find((item) => item.id === binding.asset_id);
if (email && ["cannot_receive", "inactive"].includes(email.status) && ["login", "recovery"].includes(binding.binding_role)) {
risks.push({ level: "high", ref: email.id, accountId: account?.id, title: "不可收信邮箱仍用于登录/恢复", detail: `${email.email} 仍绑定 ${account?.platform || "账号"} 的 ${t(binding.binding_role)}` });
}
}
});
state.phones.forEach((phone) => {
const count = activeBindings.filter((binding) => binding.asset_type === "phone" && binding.asset_id === phone.id).length;
if (count >= 5) {
risks.push({ level: "medium", ref: phone.id, title: "手机号绑定账号过多", detail: `${phone.phone_number} 当前有 ${count} 个活跃绑定,建议拆分风险。` });
}
});
const deadline = Date.now() + 30 * 24 * 60 * 60 * 1000;
state.domains.forEach((domain) => {
const expires = domain.expires_at ? new Date(domain.expires_at).getTime() : Infinity;
const activeAliases = state.emails.filter((email) => email.domain === domain.domain && email.status === "available");
if (expires <= deadline && activeAliases.length) {
risks.push({ level: "high", ref: domain.id, title: "域名 30 天内到期且承载邮箱", detail: `${domain.domain} 将于 ${formatDate(domain.expires_at)} 到期,仍有 ${activeAliases.length} 个可用邮箱。` });
}
});
return risks;
}
function exportJson() {
download("bindvault-backup.json", "application/json", JSON.stringify({ exported_at: nowIso(), data: state }, null, 2));
}
function importJson(event) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
const parsed = JSON.parse(reader.result);
state = { ...emptyState(), ...(parsed.data || parsed) };
await saveState();
selected = null;
toast("JSON 已导入", "success");
render();
} catch {
toast("JSON 解析失败", "error");
}
};
reader.readAsText(file);
event.target.value = "";
}
function exportCsv(module) {
const rows = module === "risks" ? computeRisks() : state[module];
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
const csv = [headers.join(","), ...rows.map((row) => headers.map((key) => csvValue(row[key])).join(","))].join("\n");
download(`bindvault-${module}.csv`, "text/csv;charset=utf-8", csv);
}
function importCsv(module, event) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
const records = parseCsv(reader.result).map((row) => ({
...defaults[module],
...row,
id: row.id || uid(module.slice(0, -1)),
created_at: row.created_at || nowIso(),
updated_at: nowIso(),
}));
state[module] = [...records, ...state[module]];
await saveState();
toast(`已导入 ${records.length} 条 CSV`, "success");
render();
};
reader.readAsText(file);
event.target.value = "";
}
function csvValue(value) {
const text = String(value ?? "");
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function parseCsv(text) {
const lines = text.trim().split(/\r?\n/);
const headers = splitCsvLine(lines.shift() || "");
return lines.filter(Boolean).map((line) => {
const values = splitCsvLine(line);
return Object.fromEntries(headers.map((header, index) => [header, values[index] || ""]));
});
}
function splitCsvLine(line) {
const result = [];
let cell = "";
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
const next = line[i + 1];
if (char === '"' && quoted && next === '"') {
cell += '"';
i += 1;
} else if (char === '"') {
quoted = !quoted;
} else if (char === "," && !quoted) {
result.push(cell);
cell = "";
} else {
cell += char;
}
}
result.push(cell);
return result;
}
function download(filename, type, content) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
async function seedDemo() {
const phone = { id: uid("phone"), phone_number: "+8613111110000", country_region: "CN", carrier: "China Mobile", owner: "self", sim_type: "physical", status: "available", purpose: "主力手机号", is_primary: true, can_receive_sms: true, can_receive_call: true, last_verified_at: nowIso(), tags: "主力, 可收码", notes: "不建议绑定过多外区账号", created_at: nowIso(), updated_at: nowIso() };
const badPhone = { id: uid("phone"), phone_number: "+12025550199", country_region: "US", carrier: "Virtual", owner: "self", sim_type: "virtual", status: "cannot_receive_sms", purpose: "旧验证号", is_primary: false, can_receive_sms: false, can_receive_call: false, tags: "不可收码", notes: "需要尽快解绑", created_at: nowIso(), updated_at: nowIso() };
const domain = { id: uid("domain"), domain: "yunzhihui.ltd", registrar: "Cloudflare", dns_provider: "Cloudflare", status: "active", expires_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), auto_renew: false, email_routing_enabled: true, tags: "Cloudflare Routing", notes: "承载邮箱别名", created_at: nowIso(), updated_at: nowIso() };
const email = { id: uid("email"), email: "apple01@yunzhihui.ltd", email_type: "cloudflare_routing", provider: "Cloudflare", domain: "yunzhihui.ltd", forward_to: "example@gmail.com", status: "available", can_receive_email: true, can_send_email: false, purpose: "Apple ID 注册", is_primary: false, tags: "Apple ID", notes: "只收信不发信", created_at: nowIso(), updated_at: nowIso() };
const badEmail = { id: uid("email"), email: "old-backup@example.com", email_type: "alias", provider: "Unknown", domain: "example.com", status: "cannot_receive", can_receive_email: false, can_send_email: false, purpose: "旧恢复邮箱", is_primary: false, tags: "不可收信", notes: "需要替换", created_at: nowIso(), updated_at: nowIso() };
const account = { id: uid("account"), platform: "Apple", account_identifier: "apple01@yunzhihui.ltd", display_name: "Apple US", region: "US", status: "normal", login_email_id: email.id, login_phone_id: phone.id, recovery_email_id: badEmail.id, recovery_phone_id: badPhone.id, two_factor_type: "sms", credential_ref: "Vaultwarden: Apple/apple01", recovery_ref: "Vaultwarden: Apple/apple01-recovery", registered_at: nowIso(), last_login_at: nowIso(), last_verified_at: nowIso(), tags: "Apple ID, 外区", notes: "", created_at: nowIso(), updated_at: nowIso() };
const locked = { id: uid("account"), platform: "OpenAI", account_identifier: "openai@example.com", display_name: "OpenAI", region: "US", status: "locked", login_email_id: badEmail.id, login_phone_id: phone.id, recovery_email_id: badEmail.id, recovery_phone_id: phone.id, two_factor_type: "email", credential_ref: "Vaultwarden: OpenAI/main", recovery_ref: "", registered_at: nowIso(), last_login_at: "", last_verified_at: "", tags: "高风险", notes: "正在申诉", created_at: nowIso(), updated_at: nowIso() };
const bindings = [
{ asset_type: "email", asset_id: email.id, account_id: account.id, binding_role: "login", status: "active", risk_level: "low", notes: "Apple ID 登录邮箱" },
{ asset_type: "phone", asset_id: badPhone.id, account_id: account.id, binding_role: "two_factor", status: "active", risk_level: "high", notes: "旧虚拟号仍作为 2FA" },
{ asset_type: "email", asset_id: badEmail.id, account_id: locked.id, binding_role: "recovery", status: "active", risk_level: "high", notes: "不可收信恢复邮箱" },
{ asset_type: "phone", asset_id: phone.id, account_id: locked.id, binding_role: "trusted_phone", status: "active", risk_level: "medium", notes: "锁定账号仍绑定主力手机号" },
].map((binding) => ({ id: uid("binding"), platform: state.accounts.find((a) => a.id === binding.account_id)?.platform || "", bound_at: nowIso(), unbound_at: "", can_unbind: true, tags: "", created_at: nowIso(), updated_at: nowIso(), ...binding }));
bindings.forEach((binding) => {
const accountRef = [account, locked].find((item) => item.id === binding.account_id);
binding.platform = accountRef?.platform || "";
});
const incident = { id: uid("incident"), account_id: locked.id, platform: "OpenAI", incident_type: "locked", severity: "high", status: "open", occurred_at: nowIso(), resolved_at: "", description: "登录触发风控锁定", action_taken: "已提交申诉", next_action: "等待回复并准备恢复邮箱替换", evidence_ref: "LocalFile: encrypted/screenshots/openai-lock.png", tags: "申诉中", notes: "", created_at: nowIso(), updated_at: nowIso() };
state = {
phones: [phone, badPhone, ...state.phones],
emails: [email, badEmail, ...state.emails],
domains: [domain, ...state.domains],
accounts: [account, locked, ...state.accounts],
bindings: [...bindings, ...state.bindings],
incidents: [incident, ...state.incidents],
};
await saveState();
selected = null;
toast("示例数据已生成", "success");
render();
}