|
@@ -0,0 +1,3554 @@
|
|
|
|
|
+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}<br>升级 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) => `<li>${escapeHtml(t)}</li>`).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 `<span class="flag-chip"><span class="flag-chip-icon">${flagForRegion(value)}</span><span>${escapeHtml(value)}</span></span>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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
|
|
|
|
|
+ ? `<img src="${escapeHtml(meta.src)}" alt="" loading="lazy" />`
|
|
|
|
|
+ : escapeHtml(meta.mark);
|
|
|
|
|
+ return `<span class="platform-chip"><span class="platform-logo ${meta.className}">${logo}</span><span>${escapeHtml(meta.name)}</span></span>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 ? `<div class="nav-group">${item.group}</div>` : "";
|
|
|
|
|
+ currentGroup = item.group;
|
|
|
|
|
+ return `${group}<button type="button" data-route="${item.id}" class="${route === item.id ? "active" : ""}">
|
|
|
|
|
+ <span class="nav-icon ${item.icon}" aria-hidden="true"></span>
|
|
|
|
|
+ <span>${item.label}</span>
|
|
|
|
|
+ </button>`;
|
|
|
|
|
+ })
|
|
|
|
|
+ .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 = `
|
|
|
|
|
+ <div class="guided-tour-scrim"></div>
|
|
|
|
|
+ <div class="guided-tour-spotlight" style="left:${safeRect.left - 8}px;top:${safeRect.top - 8}px;width:${safeRect.width + 16}px;height:${safeRect.height + 16}px"></div>
|
|
|
|
|
+ <div class="guided-tour-cursor" style="left:${safeRect.left + Math.min(safeRect.width * 0.72, safeRect.width - 8)}px;top:${safeRect.top + Math.min(safeRect.height * 0.64, safeRect.height - 8)}px"></div>
|
|
|
|
|
+ <section class="guided-tour-card" style="left:${popover.left}px;top:${popover.top}px">
|
|
|
|
|
+ <div class="guided-tour-progress">${escapeHtml(i18n("tour.progress", "步骤 {current} / {total}", { current: stepIndex + 1, total: TOUR_STEPS.length }))}</div>
|
|
|
|
|
+ <h3>${escapeHtml(tourText(stepIndex, "title"))}</h3>
|
|
|
|
|
+ <p>${escapeHtml(tourText(stepIndex, "body"))}</p>
|
|
|
|
|
+ <div class="guided-tour-actions">
|
|
|
|
|
+ <button class="ghost-button" type="button" data-tour-skip>${escapeHtml(i18n("tour.skip", "退出引导"))}</button>
|
|
|
|
|
+ ${step.waitModule ? `<span class="guided-tour-wait">${escapeHtml(i18n("tour.waitingSave", "等待保存..."))}</span>` : `<button class="primary-button" type="button" data-tour-next>${escapeHtml(tourText(stepIndex, "cta", i18n("tour.next", "下一步")))}</button>`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+ 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 = `
|
|
|
|
|
+ <section class="dashboard-mainpanel">
|
|
|
|
|
+ <section class="dashboard-hero dashboard-hero--full">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.workspaceOverview", "Workspace Overview"))}</p>
|
|
|
|
|
+ <h1>${escapeHtml(i18n("ui.assetAccountSecurity", "资产与账号安全"))}</h1>
|
|
|
|
|
+ <p>${escapeHtml(i18n("ui.dashboardHeroDesc", "用更轻的方式看清当前台账状态、恢复链路和支付依赖。重点问题会直接浮到台前,不用再翻列表找。"))}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-hero-note">
|
|
|
|
|
+ <span class="dashboard-note-label">${escapeHtml(i18n("ui.recentEvent", "最近事件"))}</span>
|
|
|
|
|
+ <strong>${latestIncident ? escapeHtml(t(latestIncident.incident_type)) : escapeHtml(i18n("ui.allClear", "一切平稳"))}</strong>
|
|
|
|
|
+ <span>${latestIncident ? `${escapeHtml(t(latestIncident.status))} · ${escapeHtml(formatDate(latestIncident.occurred_at || latestIncident.updated_at))}` : escapeHtml(i18n("ui.noOpenIncidents", "当前没有待处理事件"))}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="dashboard-settings-grid">
|
|
|
|
|
+ ${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", "当前没有高风险"))}
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="dashboard-insights-grid">
|
|
|
|
|
+ <article class="dashboard-panel-card">
|
|
|
|
|
+ <div class="dashboard-panel-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.accountsPanel", "Accounts"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.platformStatus", "平台与状态"))}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="ghost-button" type="button" data-dashboard-route="accounts">${escapeHtml(i18n("ui.viewAll", "查看全部"))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-stack">
|
|
|
|
|
+ <div class="dashboard-subsection">
|
|
|
|
|
+ <span class="dashboard-subtitle">${escapeHtml(i18n("ui.platformDistribution", "平台分布"))}</span>
|
|
|
|
|
+ <div class="chart-list chart-list--soft">${renderDashboardPlatforms(platformRows)}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-subsection">
|
|
|
|
|
+ <span class="dashboard-subtitle">${escapeHtml(i18n("ui.accountStatus", "账号状态"))}</span>
|
|
|
|
|
+ <div class="chart-list chart-list--soft">${renderBars(activeStatusRows, i18n("ui.noAccountData", "暂无账号数据"))}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+
|
|
|
|
|
+ <article class="dashboard-panel-card">
|
|
|
|
|
+ <div class="dashboard-panel-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.monitoringPanel", "Monitoring"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.riskRecentChanges", "风险与最近变更"))}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="ghost-button" type="button" data-dashboard-route="incidents">${escapeHtml(i18n("ui.viewAll", "查看全部"))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-stack">
|
|
|
|
|
+ <div class="dashboard-subsection">
|
|
|
|
|
+ <span class="dashboard-subtitle">${escapeHtml(i18n("ui.riskTips", "风险提示"))}</span>
|
|
|
|
|
+ <div class="risk-list">${risks.length ? risks.slice(0, 4).map(renderRisk).join("") : `<div class="empty empty-compact"><h3>${escapeHtml(i18n("ui.noRisk", "暂无风险"))}</h3><p>${escapeHtml(i18n("ui.noRiskDesc", "当手机号、邮箱、域名和恢复方式出现异常时,这里会优先提醒你。"))}</p></div>`}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-subsection">
|
|
|
|
|
+ <span class="dashboard-subtitle">${escapeHtml(i18n("ui.recentChangedAccounts", "最近变更账号"))}</span>
|
|
|
|
|
+ <div class="dashboard-recent-list">
|
|
|
|
|
+ ${recentAccounts.length ? recentAccounts.map(renderDashboardRecentAccount).join("") : `<p class="muted">${escapeHtml(i18n("ui.noAccountsYet", "还没有账号记录。"))}</p>`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ el.content.querySelectorAll("[data-dashboard-route]").forEach((button) => {
|
|
|
|
|
+ button.addEventListener("click", () => navigateTo(button.dataset.dashboardRoute));
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderBars(rows, emptyText) {
|
|
|
|
|
+ if (!rows.length) return `<p class="muted">${emptyText}</p>`;
|
|
|
|
|
+ const max = Math.max(...rows.map((row) => row.count), 1);
|
|
|
|
|
+ return rows
|
|
|
|
|
+ .map(
|
|
|
|
|
+ (row) => `
|
|
|
|
|
+ <div class="bar-row">
|
|
|
|
|
+ <span>${escapeHtml(t(row.name))}</span>
|
|
|
|
|
+ <div class="bar-track"><div class="bar-fill" style="width:${(row.count / max) * 100}%"></div></div>
|
|
|
|
|
+ <strong>${row.count}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `,
|
|
|
|
|
+ )
|
|
|
|
|
+ .join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderDashboardMetricCard(title, value, detail, icon, ctaLabel, routeId, note = "") {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <article class="dashboard-setting-card">
|
|
|
|
|
+ <div class="dashboard-setting-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="dashboard-card-label">${escapeHtml(title)}</span>
|
|
|
|
|
+ <div class="dashboard-card-value">${value}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="dashboard-card-icon ${escapeHtml(icon)}" aria-hidden="true">${renderDashboardIcon(icon)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-card-copy">
|
|
|
|
|
+ <p>${escapeHtml(detail)}</p>
|
|
|
|
|
+ ${note ? `<span>${escapeHtml(note)}</span>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="dashboard-card-link" type="button" data-dashboard-route="${escapeHtml(routeId)}">${escapeHtml(ctaLabel)}</button>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderDashboardIcon(icon) {
|
|
|
|
|
+ if (icon === "asset") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4.5" y="5" width="6" height="14" rx="2"/><rect x="13.5" y="7" width="6" height="10" rx="2"/><path d="M7.5 19.5h0M16.5 17.5h0"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (icon === "security") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.8 18 6v5.4c0 4.1-2.6 7.8-6 8.8-3.4-1-6-4.7-6-8.8V6l6-2.2Z"/><path d="M12 8v5"/><path d="M12 16h0"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (icon === "recovery") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8a3 3 0 0 1 0-6h9"/><path d="M7 7V4"/><path d="m4.5 6 2.5 2.5L9.5 6"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (icon === "risk") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 4 8 14H4l8-14Z"/><path d="M12 9v4"/><path d="M12 16h0"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderDashboardPlatforms(rows) {
|
|
|
|
|
+ if (!rows.length) return `<p class="muted">${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}</p>`;
|
|
|
|
|
+ const max = Math.max(...rows.map((row) => row.count), 1);
|
|
|
|
|
+ return rows.slice(0, 5).map((row) => {
|
|
|
|
|
+ const meta = platformMeta(row.name);
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="dashboard-platform-row">
|
|
|
|
|
+ <div class="dashboard-platform-main">
|
|
|
|
|
+ ${renderPlatformLogo(meta.name)}
|
|
|
|
|
+ <strong>${escapeHtml(meta.name)}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dashboard-platform-meter">
|
|
|
|
|
+ <div class="dashboard-platform-track"><div class="dashboard-platform-fill" style="width:${(row.count / max) * 100}%"></div></div>
|
|
|
|
|
+ <span>${row.count}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderDashboardRecentAccount(account) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <button class="dashboard-recent-item" type="button" data-dashboard-route="accounts">
|
|
|
|
|
+ <div class="dashboard-recent-main">
|
|
|
|
|
+ ${renderPlatformLogo(account)}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <strong>${escapeHtml(account.platform || labels.account)}</strong>
|
|
|
|
|
+ <span>${escapeHtml(account.account_identifier || "-")}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="pill ${escapeHtml(account.status || "unknown")}">${t(account.status)}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRisk(risk) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="risk-item">
|
|
|
|
|
+ <div class="risk-title">
|
|
|
|
|
+ <strong>${escapeHtml(risk.title)}</strong>
|
|
|
|
|
+ <span class="pill ${risk.level}">${t(risk.level)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="muted">${escapeHtml(risk.detail)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function riskNotesToItems(value) {
|
|
|
|
|
+ return String(value || "")
|
|
|
|
|
+ .split(/\n+/)
|
|
|
|
|
+ .map((item) => item.trim())
|
|
|
|
|
+ .filter(Boolean);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderManualRiskNote(note) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="risk-item">
|
|
|
|
|
+ <div class="risk-title">
|
|
|
|
|
+ <strong>${escapeHtml(i18n("ui.manualTip", "手动提示"))}</strong>
|
|
|
|
|
+ <span class="pill medium">${escapeHtml(i18n("ui.attentionNeeded", "需关注"))}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="muted">${escapeHtml(note)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 = `
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div class="filter-row">
|
|
|
|
|
+ <input type="search" data-filter="q" placeholder="${escapeHtml(i18n("ui.searchCurrent", "搜索当前列表"))}" value="${escapeHtml(filters[module]?.q || "")}" />
|
|
|
|
|
+ ${statusOptions.length ? `<select data-filter="status"><option value="">${escapeHtml(i18n("ui.allStatus", "全部状态"))}</option>${statusOptions.map((s) => `<option value="${s}" ${filters[module]?.status === s ? "selected" : ""}>${t(s)}</option>`).join("")}</select>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="inline-actions">
|
|
|
|
|
+ <button class="primary-button" type="button" data-new="${module}">${escapeHtml(i18n("ui.addRecord", "新增{name}", { name: schema.title }))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${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 =
|
|
|
|
|
+ `<button type="button" class="platform-tab ${activePlatform === "" ? "active" : ""}" data-platform="">${escapeHtml(i18n("ui.all", "全部"))} (${rows.length})</button>` +
|
|
|
|
|
+ platforms.map((p) => `<button type="button" class="platform-tab ${activePlatform === p ? "active" : ""}" data-platform="${escapeHtml(p)}">${escapeHtml(p)} (${platformCounts.get(p)})</button>`).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 ? `<img src="${escapeHtml(meta.src)}" alt="" />` : escapeHtml(meta.mark || "?");
|
|
|
|
|
+ const search = `${row.platform || ""} ${row.account_identifier || ""} ${row.display_name || ""}`.toLowerCase();
|
|
|
|
|
+ return `<div class="account-list-item${row.id === selectedId ? " active" : ""}" data-select-row="${escapeHtml(row.id)}" data-platform="${escapeHtml(row.platform || "")}" data-search="${escapeHtml(search)}">
|
|
|
|
|
+ <span class="platform-logo ${escapeHtml(meta.className)}">${logo}</span>
|
|
|
|
|
+ <span class="account-list-item-info">
|
|
|
|
|
+ <span class="account-list-item-name">${escapeHtml(row.platform || i18n("ui.unknownName", "未知"))}</span>
|
|
|
|
|
+ <span class="account-list-item-id">${escapeHtml(row.account_identifier || row.display_name || "")}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div class="account-list-item-actions">
|
|
|
|
|
+ <button class="acct-action-btn" type="button" data-edit="${escapeHtml(row.id)}" title="${escapeHtml(i18n("ui.edit", "编辑"))}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5Z"/></svg></button>
|
|
|
|
|
+ <button class="acct-action-btn acct-action-del" type="button" data-delete="${escapeHtml(row.id)}" title="${escapeHtml(i18n("ui.delete", "删除"))}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ }).join("") || `<div class="account-list-empty">${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}</div>`;
|
|
|
|
|
+
|
|
|
|
|
+ return `<div class="accounts-list-view">
|
|
|
|
|
+ <div class="accounts-list-header">
|
|
|
|
|
+ <div class="accounts-list-search">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6"/><path d="M16 16l4 4"/></svg>
|
|
|
|
|
+ <input type="text" id="accounts-q" placeholder="${escapeHtml(i18n("ui.searchModule", "搜索{name}…", { name: labels.account }))}" autocomplete="off" spellcheck="false">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="accounts-platform-tabs" id="accounts-platform-tabs">${tabs}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="accounts-list-items" id="accounts-list-items">${items}</div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAccountsPage(rows, schema, statusOptions, selectedRecord) {
|
|
|
|
|
+ el.content.innerHTML = `
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div class="filter-row">
|
|
|
|
|
+ ${statusOptions.length ? `<select data-filter="status"><option value="">${escapeHtml(i18n("ui.allStatus", "全部状态"))}</option>${statusOptions.map((s) => `<option value="${s}" ${filters.accounts?.status === s ? "selected" : ""}>${t(s)}</option>`).join("")}</select>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="inline-actions">
|
|
|
|
|
+ <button class="primary-button" type="button" data-new="accounts">${escapeHtml(i18n("ui.addRecord", "新增{name}", { name: schema.title }))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${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 = `<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.4 2 2 0 0 1 3.6 1h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 8.6a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.1-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 21.5 16z"/>`;
|
|
|
|
|
+ } else if (module === "emails") {
|
|
|
|
|
+ bg = emailTypeColor[row.email_type] || "#6B48FF";
|
|
|
|
|
+ svgInner = `<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>`;
|
|
|
|
|
+ } 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 = `<circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><path d="M2 12h20"/>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (logoSrc) {
|
|
|
|
|
+ return `<div class="asset-card-icon" style="background:${bg}"><img src="${escapeHtml(logoSrc)}" alt="" style="width:60%;height:60%;object-fit:contain" loading="lazy" /></div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `<div class="asset-card-icon" style="background:${bg}"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svgInner}</svg></div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 =
|
|
|
|
|
+ `<button type="button" class="platform-tab ${activeTab === "" ? "active" : ""}" data-tab-val="">${escapeHtml(i18n("ui.all", "全部"))} (${rows.length})</button>` +
|
|
|
|
|
+ tabValues.map((v) => {
|
|
|
|
|
+ const label = module === "emails" ? t(v) : escapeHtml(v);
|
|
|
|
|
+ return `<button type="button" class="platform-tab ${activeTab === v ? "active" : ""}" data-tab-val="${escapeHtml(v)}">${label} (${tabCounts.get(v)})</button>`;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+
|
|
|
|
|
+ const editSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5Z"/></svg>`;
|
|
|
|
|
+ const trashSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
|
|
|
|
|
+
|
|
|
|
|
+ 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 `<div class="asset-card${row.id === selectedId ? " active" : ""}" data-select-row="${escapeHtml(row.id)}" data-tab-val="${escapeHtml(tabVal)}" data-search="${escapeHtml(searchStr)}">
|
|
|
|
|
+ ${assetCardIcon(module, row)}
|
|
|
|
|
+ <div class="asset-card-body">
|
|
|
|
|
+ <div class="asset-card-title">${escapeHtml(title)}</div>
|
|
|
|
|
+ ${sub ? `<div class="asset-card-sub">${escapeHtml(sub)}</div>` : ""}
|
|
|
|
|
+ ${status ? `<div class="asset-card-status"><span class="pill ${escapeHtml(status)}">${t(status)}</span></div>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="asset-card-actions">
|
|
|
|
|
+ <button class="acct-action-btn" type="button" data-edit="${escapeHtml(row.id)}" title="${escapeHtml(i18n("ui.edit", "编辑"))}">${editSvg}</button>
|
|
|
|
|
+ <button class="acct-action-btn acct-action-del" type="button" data-delete="${escapeHtml(row.id)}" title="${escapeHtml(i18n("ui.delete", "删除"))}">${trashSvg}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ }).join("") || `<div class="asset-card-empty">${escapeHtml(i18n("ui.noModuleData", "暂无{name}数据", { name: schemas[module].title }))}</div>`;
|
|
|
|
|
+
|
|
|
|
|
+ return `<div class="assets-card-view">
|
|
|
|
|
+ <div class="assets-card-header">
|
|
|
|
|
+ <div class="accounts-list-search">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6"/><path d="M16 16l4 4"/></svg>
|
|
|
|
|
+ <input type="text" id="${module}-q" placeholder="${escapeHtml(i18n("ui.searchModule", "搜索{name}…", { name: schemas[module].title }))}" autocomplete="off" spellcheck="false">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="accounts-platform-tabs" id="${module}-tabs">${tabs}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="assets-card-grid" id="${module}-grid">${cards}</div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAssetPage(module, rows, schema, statusOptions, selectedRecord) {
|
|
|
|
|
+ el.content.innerHTML = `
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div class="filter-row">
|
|
|
|
|
+ ${statusOptions.length ? `<select data-filter="status"><option value="">${escapeHtml(i18n("ui.allStatus", "全部状态"))}</option>${statusOptions.map((s) => `<option value="${s}" ${filters[module]?.status === s ? "selected" : ""}>${t(s)}</option>`).join("")}</select>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="inline-actions">
|
|
|
|
|
+ <button class="primary-button" type="button" data-new="${module}">${escapeHtml(i18n("ui.addRecord", "新增{name}", { name: schema.title }))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${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 `
|
|
|
|
|
+ <div class="record-drawer-backdrop asset-drawer-backdrop"></div>
|
|
|
|
|
+ <aside class="asset-drawer" aria-label="${escapeHtml(`${schemas[module].title} ${i18n("ui.detailSuffix", "详情")}`)}">
|
|
|
|
|
+ <div class="asset-drawer-shell">
|
|
|
|
|
+ <button class="asset-drawer-close" type="button" data-close-record-drawer aria-label="${escapeHtml(i18n("ui.closeDetail", "关闭详情"))}">×</button>
|
|
|
|
|
+ ${renderDetail(module, record, { drawer: true })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 = `
|
|
|
|
|
+ <section class="relation-hero">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.relationshipMap", "Relationship Map"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.bindingTopology", "绑定拓扑图"))}</h3>
|
|
|
|
|
+ <p>${escapeHtml(i18n("ui.bindingTopologyDesc", "清晰查看基础资产、账号与绑定角色之间的关系。"))}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="filter-row relation-filter">
|
|
|
|
|
+ ${statusOptions.length ? `<select data-filter="status"><option value="">${escapeHtml(i18n("ui.allRelations", "全部关系"))}</option>${statusOptions.map((s) => `<option value="${s}" ${filters.bindings?.status === s ? "selected" : ""}>${t(s)}</option>`).join("")}</select>` : ""}
|
|
|
|
|
+ ${graphFocus ? `<button class="ghost-button" type="button" data-clear-focus>${escapeHtml(i18n("ui.showAll", "显示全部"))}</button>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="relation-layout">
|
|
|
|
|
+ <div class="relation-main">
|
|
|
|
|
+ <div class="relation-stats">
|
|
|
|
|
+ ${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")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderRelationshipBoard(focusedRows)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${renderRelationInspector(selectedAccount, selectedBinding)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <section class="relation-table-card relation-table-card-full">
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.details", "Details"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.bindingDetails", "绑定明细"))} <span class="count-badge">${visibleRows.length}</span></h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${visibleRows.length ? renderBindingDetailsTable(visibleRows) : renderEmpty("bindings")}
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+
|
|
|
|
|
+ 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 `
|
|
|
|
|
+ <article class="relation-stat ${kind}">
|
|
|
|
|
+ <span class="relation-stat-icon">${renderRelationStatIcon(kind)}</span>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span>${title}</span>
|
|
|
|
|
+ <strong>${value}</strong>
|
|
|
|
|
+ <p>${escapeHtml(meta)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRelationStatIcon(kind) {
|
|
|
|
|
+ if (kind === "asset") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 7v6c0 4.5 3.4 7.7 8 8 4.6-.3 8-3.5 8-8V7l-8-4Z"/><path d="M9 12l2 2 4-4"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (kind === "account") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.5"/><path d="M5 20c1.4-4 12.2-4 14 0"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (kind === "binding") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="3"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (kind === "risk") {
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4 3 20h18L12 4Z"/><path d="M12 10v5M12 17.5v.5"/></svg>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 `
|
|
|
|
|
+ <section class="relationship-board">
|
|
|
|
|
+ <div class="topology-empty">${escapeHtml(i18n("ui.noActiveBindings", "暂无活跃绑定关系"))}</div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 `
|
|
|
|
|
+ <path class="relation-line ${escapeHtml(binding.asset_type)} ${escapeHtml(binding.risk_level || "low")}" d="M 292 ${y1} C 360 ${y1}, 400 ${y2}, 485 ${y2}" />
|
|
|
|
|
+ <path class="relation-line role ${escapeHtml(binding.risk_level || "low")}" d="M 705 ${y2} C 790 ${y2}, 815 ${y3}, 900 ${y3}" />
|
|
|
|
|
+ <circle class="relation-dot ${escapeHtml(binding.risk_level || "low")}" cx="485" cy="${y2}" r="4" />
|
|
|
|
|
+ `;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+
|
|
|
|
|
+ const colTop = 14;
|
|
|
|
|
+ const colBottom = height - 14;
|
|
|
|
|
+ const colHeight = colBottom - colTop;
|
|
|
|
|
+ return `
|
|
|
|
|
+ <section class="relationship-board">
|
|
|
|
|
+ <div class="relationship-board-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.graph", "Graph"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.coreBindingGraph", "核心绑定关系"))}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="relation-canvas">
|
|
|
|
|
+ <svg viewBox="0 0 1120 ${height}" role="img" aria-label="${escapeHtml(i18n("ui.bindingTopology", "绑定拓扑图"))}">
|
|
|
|
|
+ <defs>
|
|
|
|
|
+ <filter id="relationShadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
|
|
|
+ <feDropShadow dx="0" dy="5" stdDeviation="5" flood-opacity="0.08"/>
|
|
|
|
|
+ </filter>
|
|
|
|
|
+ </defs>
|
|
|
|
|
+ <rect class="relation-column asset" x="14" y="${colTop}" width="306" height="${colHeight}" rx="14" />
|
|
|
|
|
+ <rect class="relation-column account" x="470" y="${colTop}" width="250" height="${colHeight}" rx="14" />
|
|
|
|
|
+ <rect class="relation-column role" x="888" y="${colTop}" width="204" height="${colHeight}" rx="14" />
|
|
|
|
|
+ <text class="relation-axis" x="40" y="38">${escapeHtml(i18n("ui.baseResources", "基础资源"))}</text>
|
|
|
|
|
+ <text class="relation-axis account" x="495" y="38">${escapeHtml(labels.accounts)}</text>
|
|
|
|
|
+ <text class="relation-axis role" x="910" y="38">${escapeHtml(i18n("ui.bindingRoleLegend", "绑定角色"))}</text>
|
|
|
|
|
+ ${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("")}
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="topology-legend">
|
|
|
|
|
+ <span><i class="phone"></i>${escapeHtml(labels.phone)}</span>
|
|
|
|
|
+ <span><i class="email"></i>${escapeHtml(labels.email)}</span>
|
|
|
|
|
+ <span><i class="domain"></i>${escapeHtml(labels.domain)}</span>
|
|
|
|
|
+ <span><i class="role"></i>${escapeHtml(i18n("ui.bindingRoleLegend", "绑定角色"))}</span>
|
|
|
|
|
+ <span><i class="risk"></i>${escapeHtml(i18n("ui.riskLegend", "风险"))}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `
|
|
|
|
|
+ <g class="asset-group ${escapeHtml(group.type)}">
|
|
|
|
|
+ <rect x="24" y="${group.y}" width="286" height="${group.height}" rx="14" />
|
|
|
|
|
+ <text class="asset-group-title" x="44" y="${group.y + 24}">${escapeHtml(meta.label)} (${group.count})</text>
|
|
|
|
|
+ </g>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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
|
|
|
|
|
+ ? `<rect class="asset-node-icon-bg ${escapeHtml(kind)}" x="${x + 13}" y="${y - 15}" width="30" height="30" rx="9" />`
|
|
|
|
|
+ : `<circle class="node-mark ${escapeHtml(iconClass)}" cx="${x + 28}" cy="${y}" r="15" />`;
|
|
|
|
|
+ const iconSvg = platformInfo?.className === "apple"
|
|
|
|
|
+ ? renderAppleNodeIcon(x + 28, y)
|
|
|
|
|
+ : platformInfo?.src
|
|
|
|
|
+ ? `<image class="node-mark-image ${platformInfo.custom ? "custom" : ""}" href="${escapeHtml(platformInfo.src)}" x="${x + 17}" y="${y - 11}" width="22" height="22" preserveAspectRatio="xMidYMid meet" />`
|
|
|
|
|
+ : renderNodeIcon(kind, x + 28, y, iconText);
|
|
|
|
|
+ return `
|
|
|
|
|
+ <g class="relation-node ${escapeHtml(kind)} ${active ? "selected" : ""}" filter="url(#relationShadow)" data-focus-type="${escapeHtml(focusType || "")}" data-focus-id="${escapeHtml(focusId || "")}" data-binding-id="${escapeHtml(bindingId || "")}">
|
|
|
|
|
+ <rect x="${x}" y="${y - nodeHeight / 2}" width="${width}" height="${nodeHeight}" rx="14" />
|
|
|
|
|
+ ${markBackground}
|
|
|
|
|
+ ${iconSvg}
|
|
|
|
|
+ <text class="relation-node-title" x="${x + 54}" y="${y - 5}">${escapeHtml(truncate(title, 22))}</text>
|
|
|
|
|
+ <text class="relation-node-meta" x="${x + 54}" y="${y + 15}">${escapeHtml(truncate(meta, 26))}</text>
|
|
|
|
|
+ </g>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderNodeIcon(kind, cx, cy, fallbackText) {
|
|
|
|
|
+ if (kind === "phone") {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <path class="asset-line-icon phone" d="M ${cx - 6} ${cy - 7} C ${cx - 3} ${cy + 1}, ${cx - 1} ${cy + 4}, ${cx + 7} ${cy + 7} L ${cx + 9} ${cy + 3} L ${cx + 4} ${cy} L ${cx + 1} ${cy + 3} C ${cx - 1} ${cy + 1}, ${cx - 2} ${cy - 1}, ${cx - 3} ${cy - 3} L ${cx} ${cy - 6} L ${cx - 4} ${cy - 9} Z" />
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (kind === "email") {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <rect class="asset-line-icon email" x="${cx - 9}" y="${cy - 6}" width="18" height="12" rx="2" />
|
|
|
|
|
+ <path class="asset-line-icon email" d="M ${cx - 8} ${cy - 5} L ${cx} ${cy + 1} L ${cx + 8} ${cy - 5}" />
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (kind === "domain") {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <circle class="asset-line-icon domain" cx="${cx}" cy="${cy}" r="8" />
|
|
|
|
|
+ <path class="asset-line-icon domain" d="M ${cx - 8} ${cy} H ${cx + 8} M ${cx} ${cy - 8} C ${cx - 4} ${cy - 4}, ${cx - 4} ${cy + 4}, ${cx} ${cy + 8} M ${cx} ${cy - 8} C ${cx + 4} ${cy - 4}, ${cx + 4} ${cy + 4}, ${cx} ${cy + 8}" />
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `<text class="node-mark-text" x="${cx}" y="${cy + 4}">${escapeHtml(fallbackText)}</text>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAppleNodeIcon(cx, cy) {
|
|
|
|
|
+ const size = 20, x = cx - size / 2, y = cy - size / 2;
|
|
|
|
|
+ return `<foreignObject x="${x}" y="${y}" width="${size}" height="${size}"><img xmlns="http://www.w3.org/1999/xhtml" src="assets/platforms/apple.svg" width="${size}" height="${size}" style="display:block;filter:brightness(0) invert(1)"/></foreignObject>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function nodeIconText(kind) {
|
|
|
|
|
+ if (kind === "role") return "R";
|
|
|
|
|
+ return "•";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRelationInspector(account, binding) {
|
|
|
|
|
+ if (!account) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <aside class="relation-inspector">
|
|
|
|
|
+ <div class="topology-empty">${escapeHtml(i18n("ui.selectBindingDetails", "选择一条绑定查看详情"))}</div>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 `
|
|
|
|
|
+ <aside class="relation-inspector">
|
|
|
|
|
+ <section class="inspector-profile">
|
|
|
|
|
+ <div class="inspector-avatar">${renderPlatform(account.platform, account)}</div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3>${escapeHtml(account.platform)}</h3>
|
|
|
|
|
+ <p>${escapeHtml(account.account_identifier || "-")}</p>
|
|
|
|
|
+ <span class="pill ${escapeHtml(account.status)}">${t(account.status)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ <section class="inspector-section">
|
|
|
|
|
+ <h4>${escapeHtml(i18n("ui.boundResources", "绑定资源"))}</h4>
|
|
|
|
|
+ <div class="inspector-list">
|
|
|
|
|
+ ${accountBindings.map((item) => `
|
|
|
|
|
+ <div class="inspector-item ${binding?.id === item.id ? "active" : ""}">
|
|
|
|
|
+ <span>${escapeHtml(resolveName(item.asset_type, item.asset_id))}</span>
|
|
|
|
|
+ <strong>${t(item.binding_role)}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join("") || `<p class="muted">${escapeHtml(i18n("ui.noActiveAccountBindings", "暂无活跃绑定。"))}</p>`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ <section class="inspector-section">
|
|
|
|
|
+ <h4>${escapeHtml(i18n("ui.accountState", "账号状态"))}</h4>
|
|
|
|
|
+ <dl class="inspector-meta">
|
|
|
|
|
+ <dt>${escapeHtml(i18n("ui.status", "状态"))}</dt><dd><span class="pill ${escapeHtml(account.status)}">${t(account.status)}</span></dd>
|
|
|
|
|
+ <dt>${escapeHtml(i18n("ui.risk", "风险"))}</dt><dd><span class="pill ${risks.length ? "medium" : "low"}">${escapeHtml(risks.length ? i18n("ui.attentionNeeded", "需关注") : i18n("ui.healthy", "正常"))}</span></dd>
|
|
|
|
|
+ <dt>${escapeHtml(i18n("ui.region", "地区"))}</dt><dd>${renderRegion(account.region)}</dd>
|
|
|
|
|
+ <dt>2FA</dt><dd>${escapeHtml(t(account.two_factor_type))}</dd>
|
|
|
|
|
+ </dl>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ <section class="inspector-section">
|
|
|
|
|
+ <h4>${escapeHtml(i18n("ui.riskTips", "风险提示"))}</h4>
|
|
|
|
|
+ <div class="risk-list compact">
|
|
|
|
|
+ ${hasRiskTips ? `${riskNotes.map(renderManualRiskNote).join("")}${risks.map(renderRisk).join("")}` : `<p class="muted">${escapeHtml(i18n("ui.noRiskTips", "暂无风险提示。"))}</p>`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderTable(module, rows, schema) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="table-wrap">
|
|
|
|
|
+ <table>
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ ${schema.columns.map((col) => `<th>${fieldLabel(module, col)}</th>`).join("")}
|
|
|
|
|
+ <th>${escapeHtml(i18n("ui.actions", "操作"))}</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ ${rows
|
|
|
|
|
+ .map(
|
|
|
|
|
+ (row) => `
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ ${schema.columns.map((col) => `<td>${renderCell(module, row, col)}</td>`).join("")}
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <div class="inline-actions">
|
|
|
|
|
+ <button class="ghost-button" type="button" data-select-row="${row.id}">${escapeHtml(i18n("ui.view", "详情"))}</button>
|
|
|
|
|
+ <button class="ghost-button" type="button" data-edit="${row.id}">${escapeHtml(i18n("ui.edit", "编辑"))}</button>
|
|
|
|
|
+ <button class="danger-button" type="button" data-delete="${row.id}">${escapeHtml(i18n("ui.delete", "删除"))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ `,
|
|
|
|
|
+ )
|
|
|
|
|
+ .join("")}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+function renderBindingDetailsTable(rows) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="binding-details-list">
|
|
|
|
|
+ ${rows.map((binding) => `
|
|
|
|
|
+ <article class="binding-detail-row">
|
|
|
|
|
+ <div class="binding-detail-resource">
|
|
|
|
|
+ <span class="binding-detail-label">${escapeHtml(i18n("ui.resource", "资源"))}</span>
|
|
|
|
|
+ ${renderBindingResourceCell(binding)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="binding-detail-account">
|
|
|
|
|
+ <span class="binding-detail-label">${escapeHtml(labels.account)}</span>
|
|
|
|
|
+ ${renderBindingAccountCell(binding)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="binding-detail-meta">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="binding-detail-label">${escapeHtml(i18n("ui.role", "角色"))}</span>
|
|
|
|
|
+ <span class="role-badge ${escapeHtml(binding.binding_role)}">${t(binding.binding_role)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="binding-detail-label">${escapeHtml(i18n("ui.status", "状态"))}</span>
|
|
|
|
|
+ <span class="pill ${escapeHtml(binding.status)}">${t(binding.status)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="binding-detail-label">${escapeHtml(i18n("ui.risk", "风险"))}</span>
|
|
|
|
|
+ <span class="pill ${escapeHtml(binding.risk_level)}">${t(binding.risk_level)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <span class="binding-detail-label">${escapeHtml(i18n("ui.boundAt", "绑定时间"))}</span>
|
|
|
|
|
+ <strong>${escapeHtml(formatDate(binding.bound_at))}</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="table-icon-actions binding-detail-actions">
|
|
|
|
|
+ <button class="table-icon-button view" type="button" data-select-row="${binding.id}" title="${escapeHtml(i18n("ui.view", "详情"))}" aria-label="${escapeHtml(i18n("ui.view", "详情"))}"></button>
|
|
|
|
|
+ <button class="table-icon-button edit" type="button" data-edit="${binding.id}" title="${escapeHtml(i18n("ui.edit", "编辑"))}" aria-label="${escapeHtml(i18n("ui.edit", "编辑"))}"></button>
|
|
|
|
|
+ <button class="table-icon-button delete" type="button" data-delete="${binding.id}" title="${escapeHtml(i18n("ui.delete", "删除"))}" aria-label="${escapeHtml(i18n("ui.delete", "删除"))}"></button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ `).join("")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `
|
|
|
|
|
+ <div class="binding-cell-main">
|
|
|
|
|
+ <span class="resource-mini-icon ${escapeHtml(binding.asset_type)}">${renderInlineAssetIcon(binding.asset_type)}</span>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <strong>${escapeHtml(resolveName(binding.asset_type, binding.asset_id))}</strong>
|
|
|
|
|
+ <span>${t(binding.asset_type)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `
|
|
|
|
|
+ <div class="binding-cell-main ${escapeHtml(extraClass)}">
|
|
|
|
|
+ ${iconHtml}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <strong>${escapeHtml(title)}</strong>
|
|
|
|
|
+ <span>${escapeHtml(subtitle)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderPlatformLogo(platform) {
|
|
|
|
|
+ const meta = platformMeta(platform);
|
|
|
|
|
+ const logo = meta.src ? `<img src="${escapeHtml(meta.src)}" alt="" loading="lazy" />` : escapeHtml(meta.mark);
|
|
|
|
|
+ return `<span class="platform-logo ${meta.className}">${logo}</span>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderInlineAssetIcon(type) {
|
|
|
|
|
+ if (type === "phone") return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7.4 4.8 10 7.4 8.3 9.5c1.2 2.4 3.8 5 6.2 6.2l2.1-1.7 2.6 2.6-1.3 3.1c-.3.7-1.1 1-1.8.8C10 18.8 5.2 14 3.5 7.9c-.2-.7.1-1.5.8-1.8l3.1-1.3Z"/></svg>`;
|
|
|
|
|
+ if (type === "email") return `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4.5" y="6.5" width="15" height="11" rx="2"/><path d="M5.5 8 12 13l6.5-5"/></svg>`;
|
|
|
|
|
+ if (type === "domain") return `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c-3 4-3 12 0 16M12 4c3 4 3 12 0 16"/></svg>`;
|
|
|
|
|
+ if (type === "account") return `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M5 20c1.4-4 12.6-4 14 0"/></svg>`;
|
|
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12M12 6v12"/></svg>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `<span class="pill ${escapeHtml(value)}">${t(value)}</span>`;
|
|
|
|
|
+ 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 `
|
|
|
|
|
+ <section class="topology-card">
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.topology", "Topology"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.assetBindingTopology", "资产绑定拓扑"))}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="topology-empty">${escapeHtml(i18n("ui.noActiveBindings", "暂无活跃绑定关系"))}</div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 `
|
|
|
|
|
+ <path class="topology-edge ${escapeHtml(binding.risk_level || "low")}" d="M 255 ${fromY} C 410 ${fromY}, 560 ${toY}, 715 ${toY}" />
|
|
|
|
|
+ <text class="topology-edge-label" x="485" y="${(fromY + toY) / 2 - 5}">${escapeHtml(t(binding.binding_role))}</text>
|
|
|
|
|
+ `;
|
|
|
|
|
+ })
|
|
|
|
|
+ .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 `
|
|
|
|
|
+ <section class="topology-card">
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(i18n("ui.topology", "Topology"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.assetBindingTopology", "资产绑定拓扑"))}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="topology-legend">
|
|
|
|
|
+ <span><i class="phone"></i>${escapeHtml(labels.phone)}</span>
|
|
|
|
|
+ <span><i class="email"></i>${escapeHtml(labels.email)}</span>
|
|
|
|
|
+ <span><i class="domain"></i>${escapeHtml(labels.domain)}</span>
|
|
|
|
|
+ <span><i class="risk"></i>${escapeHtml(i18n("ui.riskBinding", "风险绑定"))}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="topology-stage">
|
|
|
|
|
+ <svg viewBox="0 0 1000 ${height}" role="img" aria-label="${escapeHtml(i18n("ui.assetBindingTopology", "资产绑定拓扑"))}">
|
|
|
|
|
+ <defs>
|
|
|
|
|
+ <filter id="nodeShadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
|
|
|
+ <feDropShadow dx="0" dy="5" stdDeviation="5" flood-opacity="0.09"/>
|
|
|
|
|
+ </filter>
|
|
|
|
|
+ </defs>
|
|
|
|
|
+ <text class="topology-axis" x="55" y="32">${escapeHtml(i18n("ui.baseResources", "基础资源"))}</text>
|
|
|
|
|
+ <text class="topology-axis" x="715" y="32">${escapeHtml(i18n("ui.platformAccounts", "平台账号"))}</text>
|
|
|
|
|
+ ${lines}
|
|
|
|
|
+ ${assetNodes}
|
|
|
|
|
+ ${accountNodes}
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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
|
|
|
|
|
+ ? `<image class="node-mark-image ${platformInfo.custom ? "custom" : ""}" href="${escapeHtml(platformInfo.src)}" x="${x + 16}" y="${y - 10}" width="20" height="20" preserveAspectRatio="xMidYMid meet" />`
|
|
|
|
|
+ : `<text class="node-mark-text" x="${x + 26}" y="${y + 4}">${escapeHtml(platformInfo?.mark || "")}</text>`;
|
|
|
|
|
+ return `
|
|
|
|
|
+ <g class="topology-node ${escapeHtml(kind)}" filter="url(#nodeShadow)">
|
|
|
|
|
+ <rect x="${x}" y="${y - nodeHeight / 2}" width="${width}" height="${nodeHeight}" rx="14" />
|
|
|
|
|
+ <circle class="${escapeHtml(platformInfo?.className || "")}" cx="${x + 26}" cy="${y}" r="12" />
|
|
|
|
|
+ ${kind === "account" ? icon : ""}
|
|
|
|
|
+ <text class="topology-node-title" x="${x + 48}" y="${y - 5}">${escapeHtml(truncate(title, 24))}</text>
|
|
|
|
|
+ <text class="topology-node-meta" x="${x + 48}" y="${y + 14}">${escapeHtml(truncate(meta, 30))}</text>
|
|
|
|
|
+ </g>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function truncate(value, maxLength) {
|
|
|
|
|
+ const text = String(value || "");
|
|
|
|
|
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderEmpty(module) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="empty">
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.emptyRecord", "还没有{name}记录", { name: schemas[module].title }))}</h3>
|
|
|
|
|
+ <p>${escapeHtml(i18n("ui.emptyRecordDesc", "点击右上角新增,先把关键手机号、邮箱、账号和绑定关系录入起来,风险检测就能开始工作。"))}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `
|
|
|
|
|
+ <section class="detail-panel ${drawer ? "detail-panel-drawer" : ""}">
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="eyebrow">${escapeHtml(drawer ? i18n("ui.quickView", "Quick View") : i18n("ui.details", "Details"))}</p>
|
|
|
|
|
+ <h3>${escapeHtml(primaryName(module, record))}</h3>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="inline-actions">
|
|
|
|
|
+ <button class="ghost-button" type="button" data-edit="${record.id}">${escapeHtml(i18n("ui.edit", "编辑"))}</button>
|
|
|
|
|
+ <button class="danger-button" type="button" data-delete="${record.id}">${escapeHtml(i18n("ui.delete", "删除"))}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="detail-grid">
|
|
|
|
|
+ ${fields.map(([key, label]) => `<div class="detail-item"><span>${label}</span><strong>${renderDetailValue(module, record, key)}</strong></div>`).join("")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${record.notes ? `<div class="detail-item"><span>${escapeHtml(i18n("ui.notes", "备注"))}</span><strong>${escapeHtml(record.notes)}</strong></div>` : ""}
|
|
|
|
|
+ ${riskNotes.length ? `<div><h3>${escapeHtml(i18n("ui.riskTips", "风险提示"))}</h3><div class="risk-list">${riskNotes.map(renderManualRiskNote).join("")}</div></div>` : ""}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h3>${escapeHtml(i18n("ui.relatedBindings", "关联绑定"))}</h3>
|
|
|
|
|
+ <div class="bound-list">${bindings.length ? bindings.map(renderBindingCard).join("") : `<p class="muted">${escapeHtml(i18n("ui.noRelatedBindings", "暂无关联绑定。"))}</p>`}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${incidents.length ? `<div><h3>${escapeHtml(i18n("ui.eventTimeline", "事件时间线"))}</h3><div class="bound-list">${incidents.map(renderIncidentCard).join("")}</div></div>` : ""}
|
|
|
|
|
+ ${risks.length ? `<div><h3>${escapeHtml(i18n("ui.riskTips", "风险提示"))}</h3><div class="risk-list">${risks.map(renderRisk).join("")}</div></div>` : ""}
|
|
|
|
|
+ </section>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `<span class="pill ${escapeHtml(value)}">${t(value)}</span>`;
|
|
|
|
|
+ return escapeHtml(t(value));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderBindingCard(binding) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="bound-item">
|
|
|
|
|
+ <strong>${escapeHtml(resolveName(binding.asset_type, binding.asset_id))} -> ${escapeHtml(resolveName("accounts", binding.account_id))}</strong>
|
|
|
|
|
+ <span class="muted">${t(binding.asset_type)} / ${t(binding.binding_role)} / ${t(binding.status)} / ${t(binding.risk_level)}</span>
|
|
|
|
|
+ ${binding.notes ? `<span>${escapeHtml(binding.notes)}</span>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderIncidentCard(incident) {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="bound-item">
|
|
|
|
|
+ <strong>${escapeHtml(t(incident.incident_type))} <span class="pill ${incident.severity}">${t(incident.severity)}</span></strong>
|
|
|
|
|
+ <span class="muted">${formatDate(incident.occurred_at)} / ${t(incident.status)}</span>
|
|
|
|
|
+ ${incident.next_action ? `<span>${escapeHtml(incident.next_action)}</span>` : ""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.4 4.8 10 7.4 8.3 9.5c1.2 2.4 3.8 5 6.2 6.2l2.1-1.7 2.6 2.6-1.3 3.1c-.3.7-1.1 1-1.8.8C10 18.8 5.2 14 3.5 7.9c-.2-.7.1-1.5.8-1.8l3.1-1.3Z"/></svg>`,
|
|
|
|
|
+ email: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4.5" y="6.5" width="15" height="11" rx="2"/><path d="M5.5 8 12 13l6.5-5"/></svg>`,
|
|
|
|
|
+ domain: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c-3 4-3 12 0 16M12 4c3 4 3 12 0 16"/></svg>`,
|
|
|
|
|
+ account: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M5 20c1.4-4 12.6-4 14 0"/></svg>`,
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function renderAssetItems(assetType, selectedId) {
|
|
|
|
|
+ const collection = `${assetType}s`;
|
|
|
|
|
+ const items = state[collection] || [];
|
|
|
|
|
+ if (!items.length) return `<div class="asset-item-empty">${escapeHtml(i18n("ui.noModuleData", "暂无{name}数据", { name: t(assetType) }))}</div>`;
|
|
|
|
|
+ return items.map((item) => {
|
|
|
|
|
+ const name = primaryName(collection, item);
|
|
|
|
|
+ return `<div class="asset-item${item.id === selectedId ? " selected" : ""}" data-id="${escapeHtml(item.id)}"><span>${escapeHtml(name)}</span></div>`;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderBindingForm(record) {
|
|
|
|
|
+ const assetType = record.asset_type || "phone";
|
|
|
|
|
+ const roles = bindingRolesByAssetType[assetType] || enums.bindingRole;
|
|
|
|
|
+ const roleOpts = roles.map((r) => `<option value="${r}" ${r === record.binding_role ? "selected" : ""}>${escapeHtml(bindingRoleLabel(r))}</option>`).join("");
|
|
|
|
|
+ const statusOpts = enums.bindingStatus.map((s) => `<option value="${s}" ${s === (record.status || "active") ? "selected" : ""}>${t(s)}</option>`).join("");
|
|
|
|
|
+
|
|
|
|
|
+ const assetTypeTabs = enums.assetType.map((tp) =>
|
|
|
|
|
+ `<button type="button" class="asset-type-tab${tp === assetType ? " active" : ""}" data-type="${tp}">${assetTypeIcon[tp] || ""}<span>${t(tp)}</span></button>`
|
|
|
|
|
+ ).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 = `<button type="button" class="platform-tab active" data-platform="">${escapeHtml(i18n("ui.all", "全部"))} (${state.accounts.length})</button>` +
|
|
|
|
|
+ platforms.map((p) => `<button type="button" class="platform-tab" data-platform="${escapeHtml(p)}">${escapeHtml(p)} (${platformCounts.get(p)})</button>`).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 ? `<img src="${escapeHtml(meta.src)}" alt="" />` : escapeHtml(meta.mark || "?");
|
|
|
|
|
+ const search = `${a.platform || ""} ${a.account_identifier || ""} ${a.display_name || ""}`.toLowerCase();
|
|
|
|
|
+ return `<div class="account-item${a.id === record.account_id ? " selected" : ""}" data-id="${escapeHtml(a.id)}" data-platform="${escapeHtml(a.platform || "")}" data-search="${escapeHtml(search)}">
|
|
|
|
|
+ <span class="account-item-logo platform-logo ${escapeHtml(meta.className)}">${logo}</span>
|
|
|
|
|
+ <span class="account-item-info"><span class="account-item-platform">${escapeHtml(a.platform || i18n("ui.unknownName", "未知"))}</span><span class="account-item-id">${escapeHtml(a.account_identifier || a.display_name || "")}</span></span>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="binding-sentence" id="binding-sentence">
|
|
|
|
|
+ <span class="bs-text">${escapeHtml(i18n("ui.put", "把"))}</span>
|
|
|
|
|
+ <span class="bs-chip" id="bs-asset">—</span>
|
|
|
|
|
+ <span class="bs-text">${escapeHtml(i18n("ui.as", "作为"))}</span>
|
|
|
|
|
+ <span class="bs-chip" id="bs-account">—</span>
|
|
|
|
|
+ <span class="bs-text">${escapeHtml(i18n("ui.possessive", "的"))}</span>
|
|
|
|
|
+ <span class="bs-chip" id="bs-role">—</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="binding-section-label">${escapeHtml(i18n("ui.selectAsset", "选择资产"))} <span class="required-mark">${escapeHtml(i18n("ui.required", "必填"))}</span></div>
|
|
|
|
|
+ <div class="form-field full">
|
|
|
|
|
+ <input type="hidden" id="asset_type" name="asset_type" value="${escapeHtml(assetType)}">
|
|
|
|
|
+ <input type="hidden" id="asset_id" name="asset_id" value="${escapeHtml(record.asset_id || "")}">
|
|
|
|
|
+ <div class="asset-type-tabs" id="asset-type-tabs">${assetTypeTabs}</div>
|
|
|
|
|
+ <div class="asset-picker-list" id="asset-picker-list">${renderAssetItems(assetType, record.asset_id || "")}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="binding-section-label">${escapeHtml(i18n("ui.selectAccount", "选择账号"))} <span class="required-mark">${escapeHtml(i18n("ui.required", "必填"))}</span></div>
|
|
|
|
|
+ <div class="form-field full">
|
|
|
|
|
+ <input type="hidden" id="account_id" name="account_id" value="${escapeHtml(record.account_id || "")}">
|
|
|
|
|
+ <div class="account-picker" id="account-picker">
|
|
|
|
|
+ <div class="account-picker-search">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="6"/><path d="M16 16l4 4"/></svg>
|
|
|
|
|
+ <input type="text" id="account-search" placeholder="${escapeHtml(i18n("ui.searchModule", "搜索{name}…", { name: labels.account }))}" autocomplete="off" spellcheck="false">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="account-filter-tabs" id="account-filter-tabs">${platformTabs}</div>
|
|
|
|
|
+ <div class="account-picker-list" id="account-picker-list">${accountItems}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="binding-section-label">${escapeHtml(i18n("ui.usageRole", "用途 / 绑定角色"))} <span class="required-mark">${escapeHtml(i18n("ui.required", "必填"))}</span></div>
|
|
|
|
|
+ <div class="form-field full">
|
|
|
|
|
+ <label for="binding_role" class="sr-only">${escapeHtml(fieldLabel("bindings", "binding_role"))}</label>
|
|
|
|
|
+ <select id="binding_role" name="binding_role" required>
|
|
|
|
|
+ <option value="">${escapeHtml(i18n("ui.selectUsage", "选择用途"))}</option>
|
|
|
|
|
+ ${roleOpts}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="binding-section-label">${escapeHtml(fieldLabel("bindings", "status"))}</div>
|
|
|
|
|
+ <div class="form-field full">
|
|
|
|
|
+ <label for="status" class="sr-only">${escapeHtml(fieldLabel("bindings", "status"))}</label>
|
|
|
|
|
+ <select id="status" name="status" required>
|
|
|
|
|
+ ${statusOpts}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <details class="binding-more">
|
|
|
|
|
+ <summary>${escapeHtml(i18n("ui.moreOptions", "更多选项"))}</summary>
|
|
|
|
|
+ <div class="binding-more-grid">
|
|
|
|
|
+ ${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 })}
|
|
|
|
|
+ <label class="switch-row"><input type="checkbox" name="can_unbind" ${record.can_unbind !== false ? "checked" : ""} /><span class="switch-track"><span class="switch-thumb"></span></span><span>${escapeHtml(i18n("ui.canUnbind", "可解绑"))}</span></label>
|
|
|
|
|
+ ${renderField("bindings", record, "tags", fieldLabel("bindings", "tags"), "text", { placeholder: i18n("ui.optionalTags", "可选标签") })}
|
|
|
|
|
+ ${renderField("bindings", record, "notes", fieldLabel("bindings", "notes"), "textarea")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </details>
|
|
|
|
|
+ `;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 = `<option value="">${escapeHtml(i18n("ui.selectUsage", "选择用途"))}</option>` + roles.map((r) => `<option value="${r}">${escapeHtml(bindingRoleLabel(r))}</option>`).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 ? `<span class="required-mark">必填</span>` : "";
|
|
|
|
|
+ const labelHtml = `<span>${label}</span>${requiredMark}`;
|
|
|
|
|
+ const hint = key === "credential_ref" || key === "recovery_ref"
|
|
|
|
|
+ ? `<small class="field-hint">只保存密码管理器引用,不保存明文密码、2FA Secret 或恢复码。</small>`
|
|
|
|
|
+ : key === "platform_logo"
|
|
|
|
|
+ ? `<small class="field-hint">${escapeHtml(i18n("ui.logoHint", "可选。支持 https 图片地址、data:image... 或 assets/platforms/*.svg;不填则使用内置品牌或首字母。"))}</small>`
|
|
|
|
|
+ : "";
|
|
|
|
|
+ if (module === "phones" && key === "country_region") {
|
|
|
|
|
+ const selected = matchCountry(value, record.country_code) || matchCountry("CN");
|
|
|
|
|
+ return `<input type="hidden" id="${key}" name="${key}" value="${escapeHtml(selected?.code || value || "")}">`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (module === "phones" && key === "country_code") {
|
|
|
|
|
+ const selected = matchCountry(record.country_region, value) || matchCountry("CN");
|
|
|
|
|
+ const options = countryList().map((country) =>
|
|
|
|
|
+ `<option value="${escapeHtml(normalizeDialCode(country.dialCode))}" data-country-code="${escapeHtml(country.code)}" ${normalizeDialCode(country.dialCode) === normalizeDialCode(selected?.dialCode) ? "selected" : ""}>${escapeHtml(`${country.flag} ${country.dialCode} ${country.name}`)}</option>`
|
|
|
|
|
+ ).join("");
|
|
|
|
|
+ const localNumber = record.phone_local_number || splitPhoneNumber(record.phone_number, value, record.phone_local_number).localNumber;
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="form-field full phone-combo-field">
|
|
|
|
|
+ <label for="phone_local_number">${labelHtml}</label>
|
|
|
|
|
+ <div class="phone-combo">
|
|
|
|
|
+ <select id="${key}" name="${key}" ${required}>${options}</select>
|
|
|
|
|
+ <input id="phone_local_number" name="phone_local_number" type="text" value="${escapeHtml(localNumber || "")}" placeholder="${escapeHtml(opts.placeholder || "131xxxx0000")}" required />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${hint}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (module === "phones" && key === "phone_local_number") {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (module === "accounts" && key === "region") {
|
|
|
|
|
+ const selected = matchCountry(value) || null;
|
|
|
|
|
+ const options = countryList().map((country) =>
|
|
|
|
|
+ `<option value="${escapeHtml(country.code)}" ${country.code === selected?.code ? "selected" : ""}>${escapeHtml(`${country.flag} ${country.name}`)}</option>`
|
|
|
|
|
+ ).join("");
|
|
|
|
|
+ return `<div class="form-field"><label for="${key}">${labelHtml}</label><select id="${key}" name="${key}" ${required}><option value="">${escapeHtml(i18n("ui.noSelection", "未选择"))}</option>${options}</select>${hint}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (module === "accounts" && key === "platform_logo") {
|
|
|
|
|
+ return `
|
|
|
|
|
+ <div class="form-field full logo-field">
|
|
|
|
|
+ <label for="${key}">${labelHtml}</label>
|
|
|
|
|
+ <div class="logo-input-row">
|
|
|
|
|
+ <input id="${key}" name="${key}" type="text" value="${escapeHtml(value)}" placeholder="${escapeHtml(opts.placeholder || "")}" />
|
|
|
|
|
+ <label class="ghost-button logo-upload-button">
|
|
|
|
|
+ ${escapeHtml(i18n("ui.uploadLogo", "上传图片"))}
|
|
|
|
|
+ <input type="file" data-logo-upload accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif" hidden>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ${hint}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === "textarea") {
|
|
|
|
|
+ return `<div class="form-field full"><label for="${key}">${labelHtml}</label><textarea id="${key}" name="${key}" ${required}>${escapeHtml(value)}</textarea>${hint}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === "select") {
|
|
|
|
|
+ return `<div class="form-field"><label for="${key}">${labelHtml}</label><select id="${key}" name="${key}" ${required}>${opts.options.map((o) => `<option value="${o}" ${value === o ? "selected" : ""}>${t(o)}</option>`).join("")}</select>${hint}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === "relation") {
|
|
|
|
|
+ return `<div class="form-field"><label for="${key}">${labelHtml}</label><select id="${key}" name="${key}" ${required}><option value="">${escapeHtml(i18n("ui.noSelection", "未选择"))}</option>${state[opts.source].map((item) => `<option value="${item.id}" ${value === item.id ? "selected" : ""}>${escapeHtml(primaryName(opts.source, item))}</option>`).join("")}</select>${hint}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === "asset-relation") {
|
|
|
|
|
+ const assetType = record.asset_type || "phone";
|
|
|
|
|
+ return `<div class="form-field"><label for="${key}">${labelHtml}</label><select id="${key}" name="${key}" ${required}>${assetOptions(assetType, value)}</select>${hint}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === "checkbox") {
|
|
|
|
|
+ return `<label class="switch-row"><input type="checkbox" name="${key}" ${value ? "checked" : ""} /><span class="switch-track"><span class="switch-thumb"></span></span><span>${label}</span></label>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ const inputValue = type === "datetime-local" ? toInputDate(value) : value;
|
|
|
|
|
+ return `<div class="form-field"><label for="${key}">${labelHtml}</label><input id="${key}" name="${key}" type="${type}" value="${escapeHtml(inputValue)}" placeholder="${escapeHtml(opts.placeholder || "")}" ${required} />${hint}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 `<option value="">${escapeHtml(i18n("ui.noSelection", "未选择"))}</option>${items.map((item) => `<option value="${item.id}" ${item.id === selectedId ? "selected" : ""}>${escapeHtml(primaryName(collection, item))}</option>`).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: "[email protected]", email_type: "cloudflare_routing", provider: "Cloudflare", domain: "yunzhihui.ltd", forward_to: "[email protected]", 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: "[email protected]", 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: "[email protected]", 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: "[email protected]", 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();
|
|
|
|
|
+}
|