| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554 |
- 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();
- }
|