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 `${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 ? `` : ""; 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}

    ${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 `
    ${renderPlatformLogo(meta.name)} ${escapeHtml(meta.name)}
    ${row.count}
    `; }).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 = `
    ${statusOptions.length ? `` : ""}
    ${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 `
    `; }).join("") || `
    ${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}
    `; return `
    ${tabs}
    ${items}
    `; } function renderAccountsPage(rows, schema, statusOptions, selectedRecord) { el.content.innerHTML = `
    ${statusOptions.length ? `` : ""}
    ${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 `
    ${svgInner}
    `; } 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 `
    ${tabs}
    ${cards}
    `; } function renderAssetPage(module, rows, schema, statusOptions, selectedRecord) { el.content.innerHTML = `
    ${statusOptions.length ? `` : ""}
    ${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)}

    ${escapeHtml(i18n("ui.details", "Details"))}

    ${escapeHtml(i18n("ui.bindingDetails", "绑定明细"))} ${visibleRows.length}

    ${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(i18n("ui.baseResources", "基础资源"))} ${escapeHtml(i18n("ui.bindingRoleLegend", "绑定角色"))} ${assetLayout.groups.map(renderAssetGroupBox).join("")} ${lines} ${assets.map((asset) => renderRelationSvgNode({ x: 58, y: assetY.get(asset.key), width: 234, title: asset.name, meta: `${t(asset.type)} · ${asset.count}`, kind: asset.type, platform: asset.platform, platformLogo: asset.platform_logo, focusType: "asset", focusId: asset.key, active: graphFocus?.type === "asset" && graphFocus.id === asset.key })).join("")} ${accounts.map((account) => renderRelationSvgNode({ x: 485, y: accountY.get(account.id), width: 220, title: account.platform, meta: account.name, kind: "account", platform: account.platform, platformLogo: account.platform_logo, focusType: "account", focusId: account.id, bindingId: account.bindingId, active: selected === account.bindingId || (graphFocus?.type === "account" && graphFocus.id === account.id) })).join("")} ${roles.map((role) => renderRelationSvgNode({ x: 900, y: roleY.get(role.key), width: 180, title: t(role.key), meta: i18n("ui.bindingCount", "{count} 个绑定", { count: role.count }), kind: "role", focusType: "role", focusId: role.key, active: graphFocus?.type === "role" && graphFocus.id === role.key })).join("")}
    ${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 `