app.js 164 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554
  1. const STORE_KEY = "bindvault:mvp:v1";
  2. const API_STATE_URL = "/api/state";
  3. const ONBOARDING_SEEN_KEY = "bindvault:onboarding:seen:v1";
  4. const AUTH_SESSION_KEY = "bindvault:auth:v1";
  5. const LICENSE_KEY = "bindvault:license:v1";
  6. const DEVICE_ID_KEY = "bindvault:device_id:v1";
  7. const LICENSE_API = "http://localhost:8080/api";
  8. const LICENSE_PROJECT = "BindVault";
  9. const DEFAULT_ACCOUNT_ID = "110";
  10. const DEFAULT_USER_ID = "1";
  11. const FREE_LIMITS = { assets: 6, accounts: 3, bindings: 8 };
  12. const LOCALE_KEY = "bindvault:locale:v1";
  13. const locales = globalThis.BindVaultLocales || {};
  14. const platformCatalog = globalThis.BindVaultPlatformCatalog || [];
  15. const platformAssets = globalThis.BindVaultPlatformAssets || {};
  16. const currentLocale = normalizeLocale(localStorage.getItem(LOCALE_KEY) || navigator.language || "zh");
  17. function normalizeLocale(value) {
  18. return String(value || "").toLowerCase().startsWith("en") ? "en" : "zh";
  19. }
  20. function i18n(path, fallback = "", vars = {}) {
  21. const parts = path.split(".");
  22. let value = locales[currentLocale];
  23. for (const part of parts) value = value?.[part];
  24. if (typeof value !== "string") {
  25. value = locales.zh;
  26. for (const part of parts) value = value?.[part];
  27. }
  28. const template = typeof value === "string" ? value : fallback;
  29. return Object.entries(vars).reduce((text, [key, val]) => text.replaceAll(`{${key}}`, String(val)), template);
  30. }
  31. const enums = {
  32. phoneStatus: ["available", "inactive", "cannot_receive_sms", "released", "high_risk", "unknown"],
  33. simType: ["physical", "esim", "virtual"],
  34. emailType: ["gmail", "outlook", "qq", "custom_domain", "cloudflare_routing", "alias"],
  35. emailStatus: ["available", "cannot_receive", "inactive", "high_risk"],
  36. domainStatus: ["active", "expired", "transferring", "high_risk"],
  37. accountStatus: ["normal", "pending_verify", "locked", "suspended", "appealing", "recovered", "deleted", "unusable", "unknown"],
  38. twoFactor: ["none", "sms", "email", "authenticator", "passkey", "hardware_key"],
  39. assetType: ["phone", "email", "domain", "account"],
  40. bindingRole: ["login", "recovery", "trusted_phone", "two_factor", "notification", "payment", "owner", "alias", "unknown"],
  41. bindingStatus: ["active", "removed", "unknown", "risky"],
  42. riskLevel: ["low", "medium", "high"],
  43. incidentType: ["locked", "suspended", "verification_failed", "payment_failed", "login_failed", "appeal", "recovered"],
  44. severity: ["low", "medium", "high", "critical"],
  45. incidentStatus: ["open", "processing", "resolved", "abandoned"],
  46. };
  47. const labels = {
  48. dashboard: "Dashboard",
  49. phones: "手机号",
  50. emails: "邮箱",
  51. domains: "域名",
  52. accounts: "账号",
  53. bindings: "绑定关系",
  54. incidents: "风险事件",
  55. available: "可用",
  56. inactive: "停用",
  57. cannot_receive_sms: "不可收码",
  58. released: "已释放",
  59. high_risk: "高风险",
  60. unknown: "未知",
  61. cannot_receive: "不可收信",
  62. active: "活跃",
  63. expired: "已过期",
  64. transferring: "转移中",
  65. normal: "正常",
  66. pending_verify: "待验证",
  67. locked: "已锁定",
  68. suspended: "已冻结",
  69. appealing: "申诉中",
  70. recovered: "已恢复",
  71. deleted: "已注销",
  72. unusable: "不可用",
  73. open: "待处理",
  74. processing: "处理中",
  75. resolved: "已解决",
  76. abandoned: "放弃",
  77. low: "低",
  78. medium: "中",
  79. high: "高",
  80. critical: "严重",
  81. phone: "手机号",
  82. email: "邮箱",
  83. domain: "域名",
  84. account: "账号",
  85. login: "登录",
  86. recovery: "恢复",
  87. trusted_phone: "受信任手机号",
  88. two_factor: "二次验证",
  89. notification: "通知",
  90. payment: "支付",
  91. owner: "实名/所有者",
  92. alias: "别名",
  93. removed: "已解绑",
  94. risky: "有风险",
  95. };
  96. Object.keys(labels).forEach((key) => {
  97. labels[key] = i18n(`labels.${key}`, labels[key]);
  98. });
  99. const modules = [
  100. { id: "dashboard", label: labels.dashboard, group: i18n("groups.overview", "总览"), icon: "grid" },
  101. { id: "phones", label: labels.phones, group: i18n("groups.assets", "基础资产"), icon: "phone" },
  102. { id: "emails", label: labels.emails, group: i18n("groups.assets", "基础资产"), icon: "mail" },
  103. { id: "domains", label: labels.domains, group: i18n("groups.assets", "基础资产"), icon: "domain" },
  104. { id: "accounts", label: labels.accounts, group: i18n("groups.relations", "关系管理"), icon: "user" },
  105. { id: "bindings", label: labels.bindings, group: i18n("groups.relations", "关系管理"), icon: "link" },
  106. { id: "incidents", label: labels.incidents, group: i18n("groups.risk", "风险"), icon: "alert" },
  107. ];
  108. const schemas = {
  109. phones: {
  110. title: "手机号",
  111. fields: [
  112. ["country_code", "手机号", "text", { required: true, placeholder: "+86" }],
  113. ["phone_local_number", "本地号码", "text", { required: true, placeholder: "131xxxx0000" }],
  114. ["country_region", "国家/地区", "text", { placeholder: "CN / US / TR" }],
  115. ["carrier", "运营商", "text"],
  116. ["owner", "实名人", "text", { placeholder: "self / family / company" }],
  117. ["sim_type", "SIM 类型", "select", { options: enums.simType }],
  118. ["status", "状态", "select", { options: enums.phoneStatus, required: true }],
  119. ["purpose", "用途", "text"],
  120. ["is_primary", "主力号码", "checkbox"],
  121. ["can_receive_sms", "可收短信", "checkbox"],
  122. ["can_receive_call", "可接电话", "checkbox"],
  123. ["last_verified_at", "最近验证", "datetime-local"],
  124. ["expires_at", "到期时间", "datetime-local"],
  125. ["tags", "标签", "text", { placeholder: "主力, 可收码" }],
  126. ["notes", "备注", "textarea"],
  127. ],
  128. columns: ["phone_number", "country_region", "carrier", "status", "is_primary", "can_receive_sms", "last_verified_at"],
  129. search: ["phone_number", "country_code", "phone_local_number", "country_region", "carrier", "owner", "purpose", "tags", "notes"],
  130. },
  131. emails: {
  132. title: "邮箱",
  133. fields: [
  134. ["email", "邮箱地址", "email", { required: true }],
  135. ["email_type", "邮箱类型", "select", { options: enums.emailType }],
  136. ["provider", "服务商", "text"],
  137. ["domain", "所属域名", "text"],
  138. ["forward_to", "转发目标", "email"],
  139. ["status", "状态", "select", { options: enums.emailStatus, required: true }],
  140. ["can_receive_email", "可收信", "checkbox"],
  141. ["can_send_email", "可发信", "checkbox"],
  142. ["purpose", "用途", "text"],
  143. ["is_primary", "主邮箱", "checkbox"],
  144. ["last_verified_at", "最近验证", "datetime-local"],
  145. ["tags", "标签", "text"],
  146. ["notes", "备注", "textarea"],
  147. ],
  148. columns: ["email", "email_type", "provider", "domain", "status", "can_receive_email", "forward_to"],
  149. search: ["email", "email_type", "provider", "domain", "forward_to", "purpose", "tags", "notes"],
  150. },
  151. domains: {
  152. title: "域名",
  153. fields: [
  154. ["domain", "域名", "text", { required: true, placeholder: "example.com" }],
  155. ["registrar", "注册商", "text"],
  156. ["dns_provider", "DNS 服务商", "text"],
  157. ["status", "状态", "select", { options: enums.domainStatus, required: true }],
  158. ["expires_at", "到期时间", "datetime-local"],
  159. ["auto_renew", "自动续费", "checkbox"],
  160. ["email_routing_enabled", "邮件路由", "checkbox"],
  161. ["tags", "标签", "text"],
  162. ["notes", "备注", "textarea"],
  163. ],
  164. columns: ["domain", "registrar", "dns_provider", "status", "expires_at", "auto_renew", "email_routing_enabled"],
  165. search: ["domain", "registrar", "dns_provider", "tags", "notes"],
  166. },
  167. accounts: {
  168. title: "账号",
  169. fields: [
  170. ["platform", "平台", "text", { required: true, placeholder: "Apple / Google / OpenAI" }],
  171. ["platform_logo", "平台 Logo", "text", { placeholder: "https://.../logo.svg 或 data:image/svg+xml;base64,..." }],
  172. ["account_identifier", "登录标识", "text", { required: true }],
  173. ["display_name", "展示名", "text"],
  174. ["region", "注册地区", "text"],
  175. ["status", "状态", "select", { options: enums.accountStatus, required: true }],
  176. ["login_email_id", "登录邮箱", "relation", { source: "emails" }],
  177. ["login_phone_id", "登录手机号", "relation", { source: "phones" }],
  178. ["recovery_email_id", "恢复邮箱", "relation", { source: "emails" }],
  179. ["recovery_phone_id", "恢复手机号", "relation", { source: "phones" }],
  180. ["two_factor_type", "2FA 类型", "select", { options: enums.twoFactor }],
  181. ["credential_ref", "凭据引用", "text", { placeholder: "Vaultwarden: Apple/apple01" }],
  182. ["recovery_ref", "恢复引用", "text"],
  183. ["registered_at", "注册时间", "datetime-local"],
  184. ["last_login_at", "最近登录", "datetime-local"],
  185. ["last_verified_at", "最近验证", "datetime-local"],
  186. ["tags", "标签", "text"],
  187. ["risk_notes", "风险提示", "textarea"],
  188. ["notes", "备注", "textarea"],
  189. ],
  190. columns: ["platform", "account_identifier", "region", "status", "two_factor_type", "credential_ref", "last_login_at"],
  191. search: ["platform", "platform_logo", "account_identifier", "display_name", "region", "credential_ref", "recovery_ref", "tags", "risk_notes", "notes"],
  192. },
  193. bindings: {
  194. title: "绑定关系",
  195. fields: [
  196. ["asset_type", "资产类型", "select", { options: enums.assetType, required: true }],
  197. ["asset_id", "资产", "asset-relation", { required: true }],
  198. ["account_id", "账号", "relation", { source: "accounts", required: true }],
  199. ["binding_role", "绑定角色", "select", { options: enums.bindingRole, required: true }],
  200. ["status", "状态", "select", { options: enums.bindingStatus, required: true }],
  201. ["bound_at", "绑定时间", "datetime-local"],
  202. ["unbound_at", "解绑时间", "datetime-local"],
  203. ["can_unbind", "可解绑", "checkbox"],
  204. ["risk_level", "风险等级", "select", { options: enums.riskLevel }],
  205. ["tags", "标签", "text"],
  206. ["notes", "备注", "textarea"],
  207. ],
  208. columns: ["asset_type", "asset_id", "account_id", "binding_role", "status", "risk_level", "bound_at"],
  209. search: ["platform", "binding_role", "status", "risk_level", "tags", "notes"],
  210. },
  211. incidents: {
  212. title: "风险事件",
  213. fields: [
  214. ["account_id", "关联账号", "relation", { source: "accounts" }],
  215. ["platform", "平台", "text"],
  216. ["incident_type", "事件类型", "select", { options: enums.incidentType, required: true }],
  217. ["severity", "严重等级", "select", { options: enums.severity, required: true }],
  218. ["status", "状态", "select", { options: enums.incidentStatus, required: true }],
  219. ["occurred_at", "发生时间", "datetime-local"],
  220. ["resolved_at", "解决时间", "datetime-local"],
  221. ["description", "描述", "textarea"],
  222. ["action_taken", "已采取动作", "textarea"],
  223. ["next_action", "下一步动作", "textarea"],
  224. ["evidence_ref", "证据引用", "text"],
  225. ["tags", "标签", "text"],
  226. ["notes", "备注", "textarea"],
  227. ],
  228. columns: ["platform", "account_id", "incident_type", "severity", "status", "occurred_at", "next_action"],
  229. search: ["platform", "incident_type", "severity", "status", "description", "action_taken", "next_action", "evidence_ref", "tags", "notes"],
  230. },
  231. };
  232. const defaults = {
  233. phones: { country_code: "+86", status: "available", sim_type: "physical", can_receive_sms: true, can_receive_call: true, is_primary: false },
  234. emails: { status: "available", email_type: "gmail", can_receive_email: true, can_send_email: true, is_primary: false },
  235. domains: { status: "active", auto_renew: true, email_routing_enabled: false },
  236. accounts: { status: "normal", two_factor_type: "none" },
  237. bindings: { asset_type: "phone", status: "active", risk_level: "low", can_unbind: true },
  238. incidents: { status: "open", severity: "medium", incident_type: "locked" },
  239. };
  240. localizeSchemas();
  241. function localizeSchemas() {
  242. Object.entries(schemas).forEach(([module, schema]) => {
  243. schema.title = i18n(`schemas.${module}.title`, schema.title);
  244. schema.fields = schema.fields.map(([key, label, ...rest]) => [
  245. key,
  246. i18n(`schemas.${module}.fields.${key}`, label),
  247. ...rest,
  248. ]);
  249. });
  250. }
  251. let allState = emptyState();
  252. let state = emptyState();
  253. let route = getRouteFromHash();
  254. let filters = {};
  255. let editing = null;
  256. let selected = null;
  257. let graphFocus = null;
  258. let toastTimer = null;
  259. let sqliteAvailable = false;
  260. let guidedTour = { active: false, index: 0 };
  261. let currentIdentity = null;
  262. let globalActionsBound = false;
  263. let authActionsBound = false;
  264. let hashChangeBound = false;
  265. let licenseGateBound = false;
  266. const el = {
  267. authScreen: document.querySelector("#auth-screen"),
  268. appShell: document.querySelector(".app-shell"),
  269. authForm: document.querySelector("#auth-form"),
  270. authIdentifier: document.querySelector("#auth-identifier"),
  271. authPassword: document.querySelector("#auth-password"),
  272. authRemember: document.querySelector("#auth-remember"),
  273. authPreview: document.querySelector("#auth-preview"),
  274. accountMenu: document.querySelector("#account-menu"),
  275. language: document.querySelector("#language-select"),
  276. nav: document.querySelector("#nav"),
  277. title: document.querySelector("#page-title"),
  278. content: document.querySelector("#content"),
  279. search: document.querySelector("#global-search"),
  280. dialog: document.querySelector("#record-dialog"),
  281. form: document.querySelector("#record-form"),
  282. fields: document.querySelector("#form-fields"),
  283. dialogTitle: document.querySelector("#dialog-title"),
  284. dialogKicker: document.querySelector("#dialog-kicker"),
  285. saveRecord: document.querySelector("#save-record"),
  286. toast: document.querySelector("#toast"),
  287. toastIcon: document.querySelector("#toast-icon"),
  288. toastMessage: document.querySelector("#toast-message"),
  289. onboarding: document.querySelector("#onboarding-dialog"),
  290. };
  291. init();
  292. // ── License ──────────────────────────────────────────────────────────────────
  293. function getOrCreateDeviceId() {
  294. let id = localStorage.getItem(DEVICE_ID_KEY);
  295. if (!id) {
  296. id = createDeviceId();
  297. localStorage.setItem(DEVICE_ID_KEY, id);
  298. }
  299. return id;
  300. }
  301. function createDeviceId() {
  302. if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
  303. const bytes = new Uint8Array(16);
  304. if (globalThis.crypto?.getRandomValues) {
  305. globalThis.crypto.getRandomValues(bytes);
  306. } else {
  307. for (let index = 0; index < bytes.length; index += 1) {
  308. bytes[index] = Math.floor(Math.random() * 256);
  309. }
  310. }
  311. bytes[6] = (bytes[6] & 0x0f) | 0x40;
  312. bytes[8] = (bytes[8] & 0x3f) | 0x80;
  313. const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
  314. return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
  315. }
  316. function getSavedLicense() {
  317. try { return JSON.parse(localStorage.getItem(LICENSE_KEY) || "null"); } catch { return null; }
  318. }
  319. function saveLicense(key, expiresAt, email, tier) {
  320. const prev = getSavedLicense() || {};
  321. localStorage.setItem(LICENSE_KEY, JSON.stringify({
  322. key,
  323. expires_at: "",
  324. email: email || prev.email || "",
  325. tier: tier || "pro",
  326. }));
  327. updateLicenseMenu();
  328. updateWorkspaceIdentity();
  329. }
  330. function currentPlan() {
  331. const license = getSavedLicense();
  332. return license?.key && license.tier !== "free" ? "pro" : "free";
  333. }
  334. function isProPlan() {
  335. return currentPlan() === "pro";
  336. }
  337. async function readLicenseJson(response) {
  338. const text = await response.text();
  339. try {
  340. return JSON.parse(text || "{}");
  341. } catch {
  342. throw new Error(`接口返回 ${response.status},但不是 JSON`);
  343. }
  344. }
  345. function licenseRequestError(error) {
  346. return `无法连接激活服务器:${error?.message || "请稍后重试"}`;
  347. }
  348. async function checkLicense() {
  349. const saved = getSavedLicense();
  350. if (!saved?.key) return false;
  351. try {
  352. const res = await fetch(`${LICENSE_API}/verify`, {
  353. method: "POST",
  354. headers: { "Content-Type": "application/json" },
  355. body: JSON.stringify({ key: saved.key, device_id: getOrCreateDeviceId(), project: LICENSE_PROJECT }),
  356. });
  357. const data = await readLicenseJson(res);
  358. if (data.code === 0 && data.data.valid) {
  359. saveLicense(saved.key, "", data.data.email, data.data.tier);
  360. return true;
  361. }
  362. if (data.code === 400) return false;
  363. } catch {}
  364. return true;
  365. }
  366. function showLicenseGate(reason = "") {
  367. const gate = document.querySelector("#license-gate");
  368. if (gate) gate.hidden = false;
  369. updateLicenseGateCopy(reason);
  370. if (licenseGateBound) return;
  371. licenseGateBound = true;
  372. document.querySelectorAll(".license-tab").forEach((tab) => {
  373. tab.addEventListener("click", () => {
  374. document.querySelectorAll(".license-tab").forEach((t) => t.classList.remove("active"));
  375. document.querySelectorAll(".license-panel").forEach((p) => (p.hidden = true));
  376. tab.classList.add("active");
  377. document.querySelector(`#license-panel-${tab.dataset.tab}`).hidden = false;
  378. });
  379. });
  380. document.querySelector("#license-register-form")?.addEventListener("submit", async (e) => {
  381. e.preventDefault();
  382. const btn = document.querySelector("#license-register-btn");
  383. const msg = document.querySelector("#license-register-msg");
  384. const email = document.querySelector("#license-email").value.trim();
  385. btn.disabled = true;
  386. btn.textContent = i18n("ui.sending", "发送中...");
  387. msg.hidden = true;
  388. try {
  389. const res = await fetch(`${LICENSE_API}/register`, {
  390. method: "POST",
  391. headers: { "Content-Type": "application/json" },
  392. body: JSON.stringify({ email, project: LICENSE_PROJECT }),
  393. });
  394. const data = await readLicenseJson(res);
  395. msg.textContent = data.code === 0 ? "✓ " + data.msg : "✗ " + (data.msg || "发送失败,请重试");
  396. msg.className = `license-msg ${data.code === 0 ? "success" : "error"}`;
  397. } catch (error) {
  398. msg.textContent = "✗ " + licenseRequestError(error);
  399. msg.className = "license-msg error";
  400. }
  401. msg.hidden = false;
  402. btn.disabled = false;
  403. btn.textContent = i18n("ui.sendCode", "发送激活码");
  404. });
  405. const activateForm = document.querySelector("#license-activate-form");
  406. const activateButton = document.querySelector("#license-activate-btn");
  407. activateForm?.addEventListener("submit", activateLicense);
  408. activateButton?.addEventListener("click", activateLicense);
  409. document.querySelectorAll("[data-close-license]").forEach((button) => {
  410. button.addEventListener("click", hideLicenseGate);
  411. });
  412. }
  413. function hideLicenseGate() {
  414. const gate = document.querySelector("#license-gate");
  415. if (gate) gate.hidden = true;
  416. }
  417. function updateLicenseGateCopy(reason = "") {
  418. const desc = document.querySelector("#license-panel-register .license-desc");
  419. if (!desc) return;
  420. desc.innerHTML = reason
  421. ? i18n("ui.limitUpgrade", "{reason}<br>升级 Pro 后可解除数量限制,开启完整功能。", { reason: escapeHtml(reason) })
  422. : i18n("ui.registerDesc", "免费版可长期使用:基础资产 {assets} 个、账号 {accounts} 个、绑定关系 {bindings} 条。升级 Pro 后开启完整功能。", FREE_LIMITS);
  423. }
  424. async function activateLicense(e) {
  425. e.preventDefault();
  426. const btn = document.querySelector("#license-activate-btn");
  427. const msg = document.querySelector("#license-activate-msg");
  428. const key = document.querySelector("#license-key").value.trim();
  429. if (!key) {
  430. msg.textContent = "✗ " + i18n("ui.enterCode", "请输入激活码");
  431. msg.className = "license-msg error";
  432. msg.hidden = false;
  433. return;
  434. }
  435. if (btn.disabled) return;
  436. btn.disabled = true;
  437. btn.textContent = i18n("ui.verifying", "验证中...");
  438. msg.hidden = true;
  439. try {
  440. const res = await fetch(`${LICENSE_API}/verify`, {
  441. method: "POST",
  442. headers: { "Content-Type": "application/json" },
  443. body: JSON.stringify({ key, device_id: getOrCreateDeviceId(), project: LICENSE_PROJECT }),
  444. });
  445. const data = await readLicenseJson(res);
  446. if (data.code === 0 && data.data.valid) {
  447. saveLicense(key, "", data.data.email, data.data.tier);
  448. hideLicenseGate();
  449. currentIdentity = loadIdentity() || createIdentity("local");
  450. await startWorkspace();
  451. return;
  452. }
  453. msg.textContent = "✗ " + (data.msg || "激活失败");
  454. msg.className = "license-msg error";
  455. } catch (error) {
  456. msg.textContent = "✗ " + licenseRequestError(error);
  457. msg.className = "license-msg error";
  458. }
  459. msg.hidden = false;
  460. btn.disabled = false;
  461. btn.textContent = i18n("ui.activate", "激活");
  462. }
  463. // ─────────────────────────────────────────────────────────────────────────────
  464. async function init() {
  465. applyStaticI18n();
  466. const licensed = await checkLicense();
  467. currentIdentity = loadIdentity() || createIdentity("local");
  468. if (!licensed) {
  469. showLicenseGate();
  470. return;
  471. }
  472. await startWorkspace();
  473. }
  474. function applyStaticI18n() {
  475. document.documentElement.lang = currentLocale === "en" ? "en" : "zh-CN";
  476. document.querySelectorAll(".brand p, .license-brand p").forEach((node) => {
  477. node.textContent = i18n("app.subtitle", "个人数字资产台账");
  478. });
  479. document.querySelector(".topbar-heading .eyebrow") && (document.querySelector(".topbar-heading .eyebrow").textContent = i18n("app.workspace", "MVP workspace"));
  480. document.querySelector("#global-search")?.setAttribute("placeholder", i18n("ui.searchGlobal", "搜索资源、账号或关系..."));
  481. document.querySelector("#topbar-refresh")?.setAttribute("title", i18n("ui.refresh", "刷新"));
  482. document.querySelector("#topbar-refresh")?.setAttribute("aria-label", i18n("ui.refresh", "刷新"));
  483. document.querySelector("#topbar-new span") && (document.querySelector("#topbar-new span").textContent = i18n("ui.newBinding", "新建绑定"));
  484. document.querySelector("#toast-message") && (document.querySelector("#toast-message").textContent = i18n("ui.saved", "已保存"));
  485. document.querySelector("[data-account-action='upgrade']") && (document.querySelector("[data-account-action='upgrade']").textContent = i18n("ui.upgrade", "升级套餐"));
  486. document.querySelector("[data-account-action='profile']") && (document.querySelector("[data-account-action='profile']").textContent = i18n("ui.profile", "个人中心"));
  487. document.querySelector("[data-account-action='guide']") && (document.querySelector("[data-account-action='guide']").textContent = i18n("ui.guide", "新手指引"));
  488. document.querySelector("[data-account-action='signout']") && (document.querySelector("[data-account-action='signout']").textContent = i18n("ui.signOut", "退出登录"));
  489. document.querySelector(".license-tab[data-tab='register']") && (document.querySelector(".license-tab[data-tab='register']").textContent = i18n("ui.getCode", "获取激活码"));
  490. document.querySelector(".license-tab[data-tab='activate']") && (document.querySelector(".license-tab[data-tab='activate']").textContent = i18n("ui.haveCode", "已有激活码"));
  491. document.querySelector("#license-register-btn") && (document.querySelector("#license-register-btn").textContent = i18n("ui.sendCode", "发送激活码"));
  492. document.querySelector("#license-activate-btn") && (document.querySelector("#license-activate-btn").textContent = i18n("ui.activate", "激活"));
  493. document.querySelector("#save-record") && (document.querySelector("#save-record").textContent = i18n("ui.save", "保存"));
  494. document.querySelectorAll("[data-close-dialog], .dialog-actions .ghost-button").forEach((node) => {
  495. if (node.textContent.trim() === "取消") node.textContent = i18n("ui.cancel", "取消");
  496. });
  497. applyOnboardingI18n();
  498. if (el.language) el.language.value = currentLocale;
  499. }
  500. function applyOnboardingI18n() {
  501. const dialog = document.querySelector("#onboarding-dialog");
  502. if (!dialog) return;
  503. const set = (selector, text) => {
  504. const node = dialog.querySelector(selector);
  505. if (node) node.textContent = text;
  506. };
  507. set(".onboarding-head .eyebrow", i18n("onboarding.eyebrow", "Getting Started"));
  508. set(".onboarding-head h3", i18n("onboarding.title", "快速建立第一条账号链路"));
  509. dialog.querySelector("[data-close-onboarding]")?.setAttribute("aria-label", i18n("ui.close", "关闭"));
  510. dialog.querySelectorAll(".onboarding-step").forEach((step, index) => {
  511. const title = step.querySelector("h4");
  512. const body = step.querySelector("p");
  513. const button = step.querySelector("button");
  514. if (title) title.textContent = i18n(`onboarding.steps.${index}.title`, title.textContent);
  515. if (body) body.textContent = i18n(`onboarding.steps.${index}.body`, body.textContent);
  516. if (button) button.textContent = i18n(`onboarding.steps.${index}.cta`, button.textContent);
  517. });
  518. const actionButtons = dialog.querySelectorAll(".onboarding-actions button");
  519. if (actionButtons[0]) actionButtons[0].textContent = i18n("onboarding.later", "稍后再说");
  520. if (actionButtons[1]) actionButtons[1].textContent = i18n("onboarding.start", "开始录入");
  521. }
  522. async function startWorkspace() {
  523. showWorkspace();
  524. allState = await loadState();
  525. state = scopeState(allState);
  526. await migrateBrowserStorage();
  527. renderNav();
  528. bindGlobalActions();
  529. bindHashChange();
  530. render();
  531. maybeShowOnboarding();
  532. }
  533. function bindAuthActions() {
  534. if (authActionsBound || !el.authForm) return;
  535. authActionsBound = true;
  536. el.authForm.addEventListener("submit", handleLogin);
  537. el.authIdentifier?.addEventListener("input", updateAuthPreview);
  538. updateAuthPreview();
  539. }
  540. function handleLogin(event) {
  541. event.preventDefault();
  542. const identifier = el.authIdentifier.value.trim();
  543. const password = el.authPassword.value.trim();
  544. if (!identifier || !password) return;
  545. currentIdentity = createIdentity(identifier);
  546. const targetStorage = el.authRemember.checked ? localStorage : sessionStorage;
  547. localStorage.removeItem(AUTH_SESSION_KEY);
  548. sessionStorage.removeItem(AUTH_SESSION_KEY);
  549. targetStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(currentIdentity));
  550. el.authPassword.value = "";
  551. startWorkspace();
  552. }
  553. function loadIdentity() {
  554. try {
  555. const saved = sessionStorage.getItem(AUTH_SESSION_KEY) || localStorage.getItem(AUTH_SESSION_KEY);
  556. return saved ? { ...JSON.parse(saved), accountid: DEFAULT_ACCOUNT_ID, userid: DEFAULT_USER_ID } : null;
  557. } catch {
  558. return null;
  559. }
  560. }
  561. function createIdentity(identifier) {
  562. return {
  563. identifier,
  564. accountid: DEFAULT_ACCOUNT_ID,
  565. userid: DEFAULT_USER_ID,
  566. signed_at: nowIso(),
  567. };
  568. }
  569. function updateAuthPreview() {
  570. if (!el.authPreview) return;
  571. const identifier = el.authIdentifier?.value.trim();
  572. if (!identifier) {
  573. el.authPreview.textContent = `当前工作区会使用 accountid: ${DEFAULT_ACCOUNT_ID} · userid: ${DEFAULT_USER_ID}`;
  574. return;
  575. }
  576. const identity = createIdentity(identifier);
  577. el.authPreview.textContent = `accountid: ${identity.accountid} · userid: ${identity.userid}`;
  578. }
  579. function showWorkspace() {
  580. if (el.authScreen) el.authScreen.hidden = true;
  581. el.appShell.hidden = false;
  582. updateWorkspaceIdentity();
  583. }
  584. function tierLabel(tier) {
  585. return tier === "pro" ? "Pro" : "Free";
  586. }
  587. function updateWorkspaceIdentity() {
  588. const license = getSavedLicense();
  589. const tier = currentPlan();
  590. const seed = license?.email || "B";
  591. const initial = seed.trim().slice(0, 1).toUpperCase();
  592. const avatarEl = document.querySelector("#sidebar-user-avatar");
  593. const emailEl = document.querySelector("#sidebar-user-email");
  594. const tierEl = document.querySelector("#sidebar-user-tier");
  595. const userBtn = document.querySelector("#topbar-avatar");
  596. if (avatarEl) {
  597. avatarEl.textContent = initial;
  598. avatarEl.dataset.tier = tier;
  599. }
  600. if (emailEl) emailEl.textContent = license?.email || i18n("ui.notActivated", "未激活");
  601. if (tierEl) {
  602. tierEl.textContent = tierLabel(tier);
  603. tierEl.dataset.tier = tier;
  604. }
  605. if (userBtn) {
  606. userBtn.dataset.tier = tier;
  607. userBtn.title = license?.email || i18n("ui.profile", "个人中心");
  608. }
  609. }
  610. function updateLicenseMenu() {
  611. const license = getSavedLicense();
  612. const tierLabelEl = document.querySelector("#account-menu-tier-label");
  613. const labelEl = document.querySelector("#account-menu-license-key");
  614. const statusEl = document.querySelector("#account-menu-license-status");
  615. if (!labelEl || !statusEl) return;
  616. const tier = currentPlan();
  617. if (tierLabelEl) {
  618. tierLabelEl.textContent = tier === "pro" ? i18n("ui.proPlan", "Pro 套餐") : i18n("ui.freePlan", "Free 套餐");
  619. tierLabelEl.dataset.tier = tier;
  620. }
  621. if (!license?.key) {
  622. labelEl.textContent = i18n("ui.notActivated", "未激活");
  623. statusEl.textContent = i18n("ui.freeUnlocked", "Free 永久可用,受数量限制");
  624. return;
  625. }
  626. labelEl.textContent = license.email || (currentLocale === "en" ? "(unknown email)" : "(未知邮箱)");
  627. statusEl.textContent = i18n("ui.proUnlocked", "已解锁完整功能");
  628. }
  629. function openProfile() {
  630. const dialog = document.querySelector("#profile-dialog");
  631. if (!dialog) return;
  632. const license = getSavedLicense() || {};
  633. const setText = (id, text) => {
  634. const el = document.querySelector(`#${id}`);
  635. if (el) el.textContent = text;
  636. };
  637. setText("profile-email", license.email || i18n("ui.notActivated", "未激活"));
  638. setText("profile-device-id", getOrCreateDeviceId());
  639. setText("profile-key", license.key || "-");
  640. setText("profile-plan", tierLabel(currentPlan()));
  641. setText("profile-license-status", license.key ? i18n("ui.activated", "已激活") : i18n("ui.notActivated", "未激活"));
  642. const totalAssets = (state.phones?.length || 0) + (state.emails?.length || 0) + (state.domains?.length || 0);
  643. setText("profile-asset-count", String(totalAssets));
  644. setText("profile-account-count", String(state.accounts?.length || 0));
  645. setText("profile-binding-count", String(state.bindings?.length || 0));
  646. dialog.showModal();
  647. }
  648. function openPricing() {
  649. const dialog = document.querySelector("#pricing-dialog");
  650. if (!dialog) return;
  651. applyPricingI18n();
  652. refreshPricingState();
  653. dialog.showModal();
  654. }
  655. function applyPricingI18n() {
  656. const setText = (sel, text) => {
  657. const node = document.querySelector(sel);
  658. if (node) node.textContent = text;
  659. };
  660. const setList = (sel, items) => {
  661. const ul = document.querySelector(sel);
  662. if (!ul) return;
  663. ul.innerHTML = items.map((t) => `<li>${escapeHtml(t)}</li>`).join("");
  664. };
  665. setText("#pricing-dialog .eyebrow", i18n("pricing.eyebrow", "Upgrade"));
  666. setText("#pricing-dialog .pricing-head h3", i18n("pricing.title", "选择适合你的套餐"));
  667. setText("#pricing-dialog .pricing-sub", i18n("pricing.subtitle", "从基础台账到全功能解锁,按需升级。"));
  668. setText("#pricing-dialog .pricing-badge", i18n("pricing.recommended", "推荐"));
  669. setText('[data-tier-card="free"] header h4', "Free");
  670. setText('[data-tier-card="free"] .pricing-period', i18n("pricing.monthSuffix", "/ 月"));
  671. setText('[data-tier-card="free"] .pricing-desc', i18n("pricing.free.desc", "本地管理你的数字资产"));
  672. const freeFeatures = (locales[currentLocale]?.pricing?.free?.features) || (locales.zh?.pricing?.free?.features) || [];
  673. setList('[data-tier-card="free"] .pricing-features', freeFeatures);
  674. setText('[data-tier-card="pro"] header h4', "Pro");
  675. setText('[data-tier-card="pro"] .pricing-period', i18n("pricing.monthSuffix", "/ 月"));
  676. setText('[data-tier-card="pro"] .pricing-desc', i18n("pricing.pro.desc", "解锁全部高级能力"));
  677. setText('[data-tier-card="pro"] .pricing-includes', i18n("pricing.includesAll", "包含 Free 所有功能,并解锁:"));
  678. const proFeatures = (locales[currentLocale]?.pricing?.pro?.features) || (locales.zh?.pricing?.pro?.features) || [];
  679. setList('[data-tier-card="pro"] .pricing-features', proFeatures);
  680. }
  681. function refreshPricingState() {
  682. const license = getSavedLicense();
  683. const tier = license?.tier === "pro" ? "pro" : "free";
  684. const freeCard = document.querySelector('[data-tier-card="free"]');
  685. const proCard = document.querySelector('[data-tier-card="pro"]');
  686. const freeCta = document.querySelector('[data-pricing-action="free"]');
  687. const proCta = document.querySelector('[data-pricing-action="pro"]');
  688. if (!freeCard || !proCard || !freeCta || !proCta) return;
  689. freeCard.classList.toggle("is-current", tier === "free");
  690. proCard.classList.toggle("is-current", tier === "pro");
  691. const currentText = i18n("pricing.currentPlan", "当前套餐");
  692. const upgradeText = i18n("pricing.upgradeToPro", "升级至 Pro");
  693. const downgradeText = i18n("pricing.switchToFree", "切换至 Free");
  694. if (tier === "pro") {
  695. freeCta.textContent = downgradeText;
  696. freeCta.className = "pricing-cta pricing-cta-downgrade";
  697. freeCta.disabled = false;
  698. proCta.textContent = currentText;
  699. proCta.className = "pricing-cta pricing-cta-current";
  700. proCta.disabled = true;
  701. } else {
  702. freeCta.textContent = currentText;
  703. freeCta.className = "pricing-cta pricing-cta-current";
  704. freeCta.disabled = true;
  705. proCta.textContent = upgradeText;
  706. proCta.className = "pricing-cta pricing-cta-upgrade";
  707. proCta.disabled = false;
  708. }
  709. }
  710. function handlePricingAction(action) {
  711. const license = getSavedLicense();
  712. const tier = license?.tier === "pro" ? "pro" : "free";
  713. if (action === tier) return;
  714. if (action === "pro") {
  715. toast(i18n("pricing.payPending", "Pro 升级支付通道开通中,敬请期待"), "warning");
  716. } else {
  717. toast(i18n("pricing.downgradePending", "切换至 Free 即将上线"), "warning");
  718. }
  719. }
  720. function signOut() {
  721. localStorage.removeItem(AUTH_SESSION_KEY);
  722. sessionStorage.removeItem(AUTH_SESSION_KEY);
  723. localStorage.removeItem(LICENSE_KEY);
  724. closeAccountMenu();
  725. window.location.reload();
  726. }
  727. function toggleAccountMenu() {
  728. if (!el.accountMenu) return;
  729. const willOpen = el.accountMenu.hidden;
  730. if (willOpen) updateLicenseMenu();
  731. el.accountMenu.hidden = !willOpen;
  732. document.querySelector("#topbar-avatar")?.setAttribute("aria-expanded", String(willOpen));
  733. }
  734. function closeAccountMenu() {
  735. if (!el.accountMenu) return;
  736. el.accountMenu.hidden = true;
  737. document.querySelector("#topbar-avatar")?.setAttribute("aria-expanded", "false");
  738. }
  739. function bindHashChange() {
  740. if (hashChangeBound) return;
  741. hashChangeBound = true;
  742. window.addEventListener("hashchange", () => {
  743. route = getRouteFromHash();
  744. selected = null;
  745. graphFocus = null;
  746. render();
  747. });
  748. }
  749. function getRouteFromHash() {
  750. const hash = window.location.hash.replace(/^#\/?/, "");
  751. return modules.some((item) => item.id === hash) ? hash : "dashboard";
  752. }
  753. function navigateTo(nextRoute) {
  754. if (route === nextRoute) return;
  755. window.location.hash = `/${nextRoute}`;
  756. }
  757. function emptyState() {
  758. return { phones: [], emails: [], domains: [], accounts: [], bindings: [], incidents: [] };
  759. }
  760. function normalizeState(nextState) {
  761. return {
  762. ...emptyState(),
  763. ...nextState,
  764. phones: (nextState.phones || []).map(normalizePhoneRecord),
  765. };
  766. }
  767. function normalizePhoneRecord(record) {
  768. const phone = { ...record };
  769. const split = splitPhoneNumber(phone.phone_number, phone.country_code, phone.phone_local_number);
  770. phone.country_code = split.countryCode;
  771. phone.phone_local_number = split.localNumber;
  772. phone.phone_number = `${split.countryCode}${split.localNumber}`;
  773. return phone;
  774. }
  775. function stampRecord(record) {
  776. if (!currentIdentity) return { ...record };
  777. return {
  778. ...record,
  779. accountid: currentIdentity.accountid,
  780. userid: currentIdentity.userid,
  781. };
  782. }
  783. function stampState(nextState) {
  784. const stamped = emptyState();
  785. Object.keys(stamped).forEach((collection) => {
  786. stamped[collection] = (nextState[collection] || []).map(stampRecord);
  787. });
  788. return normalizeState(stamped);
  789. }
  790. function isCurrentIdentityRecord(record) {
  791. if (!currentIdentity) return true;
  792. return record.accountid === currentIdentity.accountid && record.userid === currentIdentity.userid;
  793. }
  794. function isLegacyIdentityRecord(record) {
  795. return !record.accountid && !record.userid;
  796. }
  797. function isVisibleIdentityRecord(record) {
  798. return isCurrentIdentityRecord(record) || isLegacyIdentityRecord(record);
  799. }
  800. function scopeState(nextState) {
  801. if (!currentIdentity) return normalizeState(nextState);
  802. const scoped = emptyState();
  803. Object.keys(scoped).forEach((collection) => {
  804. scoped[collection] = (nextState[collection] || []).filter(isVisibleIdentityRecord);
  805. });
  806. return normalizeState(scoped);
  807. }
  808. function mergeScopedState() {
  809. const stamped = stampState(state);
  810. const merged = normalizeState(allState);
  811. Object.keys(merged).forEach((collection) => {
  812. const retained = (merged[collection] || []).filter((record) => !isVisibleIdentityRecord(record));
  813. merged[collection] = [...retained, ...(stamped[collection] || [])];
  814. });
  815. return normalizeState(merged);
  816. }
  817. async function loadState() {
  818. try {
  819. const response = await fetch(API_STATE_URL);
  820. if (!response.ok) throw new Error(`HTTP ${response.status}`);
  821. const payload = await response.json();
  822. sqliteAvailable = true;
  823. return normalizeState(payload.data || payload);
  824. } catch (error) {
  825. sqliteAvailable = false;
  826. console.warn("SQLite API unavailable, falling back to browser storage.", error);
  827. try {
  828. return normalizeState(JSON.parse(localStorage.getItem(scopedStoreKey()) || localStorage.getItem(STORE_KEY) || "{}"));
  829. } catch {
  830. return emptyState();
  831. }
  832. }
  833. }
  834. async function saveState() {
  835. const nextAllState = mergeScopedState();
  836. try {
  837. const response = await fetch(API_STATE_URL, {
  838. method: "PUT",
  839. headers: { "Content-Type": "application/json" },
  840. body: JSON.stringify({ data: nextAllState }),
  841. });
  842. if (!response.ok) throw new Error(`HTTP ${response.status}`);
  843. const payload = await response.json();
  844. sqliteAvailable = true;
  845. allState = normalizeState(payload.data || nextAllState);
  846. state = scopeState(allState);
  847. } catch (error) {
  848. sqliteAvailable = false;
  849. console.warn("SQLite API unavailable, falling back to browser storage.", error);
  850. state = stampState(state);
  851. localStorage.setItem(scopedStoreKey(), JSON.stringify(state));
  852. toast("SQLite 服务不可用,已临时保存到当前浏览器", "warning");
  853. }
  854. }
  855. async function migrateBrowserStorage() {
  856. if (!sqliteAvailable) return;
  857. try {
  858. const saved = JSON.parse(localStorage.getItem(scopedStoreKey()) || localStorage.getItem(STORE_KEY) || "{}");
  859. if (!Object.values(saved).some((value) => Array.isArray(value) && value.length)) return;
  860. if (Object.values(state).some((value) => Array.isArray(value) && value.length)) return;
  861. state = { ...emptyState(), ...saved };
  862. await saveState();
  863. localStorage.removeItem(scopedStoreKey());
  864. localStorage.removeItem(STORE_KEY);
  865. toast("已把浏览器旧数据迁移到 SQLite", "success");
  866. } catch {
  867. return;
  868. }
  869. }
  870. async function refreshState() {
  871. allState = await loadState();
  872. state = scopeState(allState);
  873. render();
  874. }
  875. function scopedStoreKey() {
  876. return currentIdentity ? `${STORE_KEY}:${currentIdentity.accountid}:${currentIdentity.userid}` : STORE_KEY;
  877. }
  878. function uid(prefix) {
  879. return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
  880. }
  881. function nowIso() {
  882. return new Date().toISOString();
  883. }
  884. function toIsoFromInput(value) {
  885. return value ? new Date(value).toISOString() : "";
  886. }
  887. function toInputDate(value) {
  888. if (!value) return "";
  889. const date = new Date(value);
  890. if (Number.isNaN(date.getTime())) return "";
  891. return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
  892. }
  893. function t(value) {
  894. if (value === true) return i18n("ui.yes", "是");
  895. if (value === false) return i18n("ui.no", "否");
  896. if (value === "") return "-";
  897. return labels[value] || value || "-";
  898. }
  899. function flagForRegion(value) {
  900. const text = String(value || "").trim().toLowerCase();
  901. if (!text) return "";
  902. const chinaRegions = ["中国", "cn", "china", "北京", "廊坊", "长春", "河北", "吉林"];
  903. const usRegions = ["美国", "us", "usa", "united states", "america"];
  904. const turkeyRegions = ["土耳其", "tr", "turkey", "türkiye", "turkiye"];
  905. if (chinaRegions.includes(text)) return "🇨🇳";
  906. if (usRegions.includes(text)) return "🇺🇸";
  907. if (turkeyRegions.includes(text)) return "🇹🇷";
  908. return "🌐";
  909. }
  910. function renderRegion(value) {
  911. if (!value) return "-";
  912. return `<span class="flag-chip"><span class="flag-chip-icon">${flagForRegion(value)}</span><span>${escapeHtml(value)}</span></span>`;
  913. }
  914. function countryList() {
  915. return Array.isArray(globalThis.countries) ? globalThis.countries : [];
  916. }
  917. function normalizeDialCode(value) {
  918. const digits = String(value || "").replace(/\D/g, "");
  919. return digits ? `+${digits}` : "";
  920. }
  921. function matchCountry(value, dialCode = "") {
  922. const text = String(value || "").trim().toLowerCase();
  923. const dial = normalizeDialCode(dialCode);
  924. const aliases = {
  925. 中国: "CN", china: "CN", cn: "CN", 北京: "CN", 廊坊: "CN", 长春: "CN", 河北: "CN", 吉林: "CN",
  926. 美国: "US", "united states": "US", usa: "US", us: "US", america: "US",
  927. 土耳其: "TR", turkey: "TR", turkiye: "TR", türkiye: "TR", tr: "TR",
  928. };
  929. const aliasCode = aliases[text];
  930. return countryList().find((item) =>
  931. item.code === value ||
  932. item.code === aliasCode ||
  933. item.name.toLowerCase() === text ||
  934. (dial && normalizeDialCode(item.dialCode) === dial)
  935. );
  936. }
  937. function countryOptionLabel(country) {
  938. return `${country.flag} ${country.name} (${country.dialCode})`;
  939. }
  940. function platformMeta(value, customLogo = "") {
  941. const name = typeof value === "object" && value
  942. ? String(value.platform || value.name || "").trim()
  943. : String(value || "").trim();
  944. const logo = typeof value === "object" && value ? value.platform_logo : customLogo;
  945. const safeLogo = normalizeLogoSource(logo);
  946. if (safeLogo) {
  947. return {
  948. className: "custom",
  949. mark: platformFallbackMark(name),
  950. src: safeLogo,
  951. name,
  952. custom: true,
  953. };
  954. }
  955. const key = name.toLowerCase().replace(/\s+/g, "");
  956. const entry = platformCatalog.find((item) => [item.key, item.name, ...(item.aliases || [])].some((alias) => normalizePlatformKey(alias) === key));
  957. if (entry) {
  958. const icon = entry.file || `${entry.simpleIcon || entry.key}.svg`;
  959. return {
  960. className: entry.className || entry.key,
  961. mark: entry.mark || platformFallbackMark(name),
  962. src: platformAssets[icon] ? `assets/platforms/${icon}` : "",
  963. name,
  964. };
  965. }
  966. return { className: "generic", mark: platformFallbackMark(name), src: "", name };
  967. }
  968. function platformFallbackMark(name) {
  969. return String(name || "?").trim().slice(0, 2).toUpperCase() || "?";
  970. }
  971. function normalizePlatformKey(value) {
  972. return String(value || "").trim().toLowerCase().replace(/\s+/g, "");
  973. }
  974. function normalizeLogoSource(value) {
  975. const src = String(value || "").trim();
  976. if (!src) return "";
  977. if (/^https?:\/\//i.test(src)) return src;
  978. if (/^data:image\/(svg\+xml|png|jpe?g|webp|gif);base64,/i.test(src)) return src;
  979. if (/^assets\/platforms\/[\w.-]+\.svg$/i.test(src)) return src;
  980. return "";
  981. }
  982. function renderPlatform(value, account = null) {
  983. if (!value && !account?.platform) return "-";
  984. const meta = platformMeta(account || value);
  985. const logo = meta.src
  986. ? `<img src="${escapeHtml(meta.src)}" alt="" loading="lazy" />`
  987. : escapeHtml(meta.mark);
  988. return `<span class="platform-chip"><span class="platform-logo ${meta.className}">${logo}</span><span>${escapeHtml(meta.name)}</span></span>`;
  989. }
  990. function splitPhoneNumber(phoneNumber, countryCode, localNumber) {
  991. const code = String(countryCode || "").trim();
  992. const local = String(localNumber || "").trim();
  993. if (code && local) {
  994. return {
  995. countryCode: code.startsWith("+") ? code : `+${code}`,
  996. localNumber: local.replace(/\D/g, ""),
  997. };
  998. }
  999. const text = String(phoneNumber || "").trim();
  1000. if (text.startsWith("+86") && text.length > 3) {
  1001. return { countryCode: "+86", localNumber: text.slice(3).replace(/\D/g, "") };
  1002. }
  1003. const match = text.match(/^\+(\d{1,3})(\d+)$/);
  1004. if (match) return { countryCode: `+${match[1]}`, localNumber: match[2] };
  1005. return { countryCode: code || "+86", localNumber: local || text.replace(/\D/g, "") };
  1006. }
  1007. function formatPhoneNumber(value, countryCode, localNumber) {
  1008. const split = splitPhoneNumber(value, countryCode, localNumber);
  1009. if (!split.localNumber) return value || "-";
  1010. return `(${split.countryCode}) ${split.localNumber}`;
  1011. }
  1012. function escapeHtml(value) {
  1013. return String(value ?? "")
  1014. .replaceAll("&", "&amp;")
  1015. .replaceAll("<", "&lt;")
  1016. .replaceAll(">", "&gt;")
  1017. .replaceAll('"', "&quot;");
  1018. }
  1019. function toast(message, type = "success") {
  1020. el.toast.className = `toast ${type}`;
  1021. el.toastIcon.textContent = type === "error" ? "!" : type === "warning" ? "i" : "OK";
  1022. el.toastMessage.textContent = message;
  1023. try { el.toast.showPopover?.(); } catch {}
  1024. requestAnimationFrame(() => el.toast.classList.add("show"));
  1025. clearTimeout(toastTimer);
  1026. toastTimer = setTimeout(() => {
  1027. el.toast.classList.remove("show");
  1028. setTimeout(() => { try { el.toast.hidePopover?.(); } catch {} }, 220);
  1029. }, type === "error" ? 3800 : 2600);
  1030. }
  1031. function setSaving(isSaving) {
  1032. el.saveRecord.disabled = isSaving;
  1033. el.saveRecord.textContent = isSaving ? i18n("ui.saving", "保存中...") : i18n("ui.save", "保存");
  1034. }
  1035. function renderNav() {
  1036. let currentGroup = "";
  1037. el.nav.innerHTML = modules
  1038. .map((item) => {
  1039. const group = item.group !== currentGroup ? `<div class="nav-group">${item.group}</div>` : "";
  1040. currentGroup = item.group;
  1041. return `${group}<button type="button" data-route="${item.id}" class="${route === item.id ? "active" : ""}">
  1042. <span class="nav-icon ${item.icon}" aria-hidden="true"></span>
  1043. <span>${item.label}</span>
  1044. </button>`;
  1045. })
  1046. .join("");
  1047. el.nav.querySelectorAll("button").forEach((button) => {
  1048. button.addEventListener("click", () => {
  1049. selected = null;
  1050. graphFocus = null;
  1051. navigateTo(button.dataset.route);
  1052. });
  1053. });
  1054. }
  1055. function bindGlobalActions() {
  1056. if (globalActionsBound) return;
  1057. globalActionsBound = true;
  1058. el.search.addEventListener("input", () => render());
  1059. document.querySelector("#seed-demo").addEventListener("click", seedDemo);
  1060. document.querySelector("#export-json").addEventListener("click", exportJson);
  1061. document.querySelector("#import-json").addEventListener("change", importJson);
  1062. el.language?.addEventListener("change", () => {
  1063. localStorage.setItem(LOCALE_KEY, el.language.value);
  1064. window.location.reload();
  1065. });
  1066. document.querySelector("#topbar-refresh")?.addEventListener("click", async () => {
  1067. await refreshState();
  1068. render();
  1069. toast("已刷新");
  1070. });
  1071. document.querySelector("#topbar-new")?.addEventListener("click", () => {
  1072. openEditor("bindings");
  1073. });
  1074. document.querySelector("#topbar-avatar")?.addEventListener("click", (event) => {
  1075. event.stopPropagation();
  1076. toggleAccountMenu();
  1077. });
  1078. el.accountMenu?.querySelectorAll("[data-account-action]").forEach((button) => {
  1079. button.addEventListener("click", () => {
  1080. closeAccountMenu();
  1081. const action = button.dataset.accountAction;
  1082. if (action === "upgrade") openPricing();
  1083. if (action === "profile") openProfile();
  1084. if (action === "guide") startGuidedTour();
  1085. if (action === "signout") signOut();
  1086. });
  1087. });
  1088. document.querySelectorAll("[data-close-profile]").forEach((btn) => {
  1089. btn.addEventListener("click", () => document.querySelector("#profile-dialog")?.close());
  1090. });
  1091. document.querySelectorAll("[data-close-pricing]").forEach((btn) => {
  1092. btn.addEventListener("click", () => document.querySelector("#pricing-dialog")?.close());
  1093. });
  1094. document.querySelectorAll("[data-pricing-action]").forEach((btn) => {
  1095. btn.addEventListener("click", () => handlePricingAction(btn.dataset.pricingAction));
  1096. });
  1097. document.addEventListener("click", (event) => {
  1098. if (!event.target.closest(".account-menu-wrap")) closeAccountMenu();
  1099. });
  1100. document.querySelectorAll("[data-close-onboarding]").forEach((button) => {
  1101. button.addEventListener("click", closeOnboarding);
  1102. });
  1103. document.querySelectorAll("[data-guide-action]").forEach((button) => {
  1104. button.addEventListener("click", () => runGuideAction(button.dataset.guideAction));
  1105. });
  1106. document.querySelectorAll("[data-close-dialog]").forEach((button) => {
  1107. button.addEventListener("click", closeEditor);
  1108. });
  1109. el.form.addEventListener("submit", handleFormSubmit);
  1110. el.form.addEventListener(
  1111. "invalid",
  1112. () => {
  1113. el.fields.classList.add("was-validated");
  1114. toast("请先补全高亮字段", "error");
  1115. },
  1116. true,
  1117. );
  1118. }
  1119. function maybeShowOnboarding() {
  1120. const hasAnyRecord = Object.values(state).some((value) => Array.isArray(value) && value.length);
  1121. if (hasAnyRecord || localStorage.getItem(ONBOARDING_SEEN_KEY) === "1") return;
  1122. startGuidedTour();
  1123. }
  1124. function showOnboarding(markSeen) {
  1125. if (!el.onboarding) return;
  1126. if (markSeen) localStorage.setItem(ONBOARDING_SEEN_KEY, "1");
  1127. el.onboarding.showModal();
  1128. }
  1129. function closeOnboarding() {
  1130. localStorage.setItem(ONBOARDING_SEEN_KEY, "1");
  1131. el.onboarding?.close();
  1132. }
  1133. function runGuideAction(action) {
  1134. closeOnboarding();
  1135. if (action === "phone" || action === "email") {
  1136. startGuidedTour();
  1137. return;
  1138. }
  1139. if (action === "account") {
  1140. navigateTo("accounts");
  1141. window.setTimeout(() => openEditor("accounts"), 0);
  1142. } else if (action === "email") {
  1143. navigateTo("emails");
  1144. window.setTimeout(() => openEditor("emails"), 0);
  1145. } else if (action === "binding") {
  1146. navigateTo("bindings");
  1147. window.setTimeout(() => openEditor("bindings"), 0);
  1148. }
  1149. }
  1150. function openGuidedAccountEditor() {
  1151. openEditor("accounts");
  1152. window.setTimeout(() => {
  1153. const latestEmail = state.emails[0];
  1154. const platformInput = el.fields.querySelector("#platform");
  1155. const identifierInput = el.fields.querySelector("#account_identifier");
  1156. const loginEmailSelect = el.fields.querySelector("#login_email_id");
  1157. if (platformInput && !platformInput.value) platformInput.value = "BindVault";
  1158. if (identifierInput && latestEmail?.email && !identifierInput.value) identifierInput.value = latestEmail.email;
  1159. if (loginEmailSelect && latestEmail?.id) loginEmailSelect.value = latestEmail.id;
  1160. }, 0);
  1161. }
  1162. const TOUR_STEPS = [
  1163. {
  1164. selector: `[data-route="emails"]`,
  1165. action: () => { navigateTo("emails"); nextTourStep(); },
  1166. },
  1167. {
  1168. route: "emails",
  1169. selector: `[data-new="emails"]`,
  1170. action: () => { openEditor("emails"); nextTourStep(); },
  1171. },
  1172. {
  1173. dialog: true,
  1174. selector: "#email",
  1175. waitModule: "emails",
  1176. },
  1177. {
  1178. selector: `[data-route="accounts"]`,
  1179. action: () => { navigateTo("accounts"); nextTourStep(); },
  1180. },
  1181. {
  1182. route: "accounts",
  1183. selector: `[data-new="accounts"]`,
  1184. action: () => { openGuidedAccountEditor(); nextTourStep(); },
  1185. },
  1186. {
  1187. dialog: true,
  1188. selector: "#login_email_id",
  1189. waitModule: "accounts",
  1190. },
  1191. {
  1192. route: "bindings",
  1193. selector: ".relationship-board",
  1194. action: () => stopGuidedTour(true),
  1195. },
  1196. ];
  1197. function tourText(index, key, fallback = "") {
  1198. return i18n(`tour.steps.${index}.${key}`, fallback);
  1199. }
  1200. function startGuidedTour() {
  1201. closeOnboarding();
  1202. localStorage.setItem(ONBOARDING_SEEN_KEY, "1");
  1203. guidedTour = { active: true, index: 0 };
  1204. render();
  1205. }
  1206. function stopGuidedTour(done = false) {
  1207. guidedTour = { active: false, index: 0 };
  1208. document.querySelectorAll(".guided-tour-layer").forEach((node) => node.remove());
  1209. if (done) toast(i18n("tour.completed", "第一条资产链路引导完成"), "success");
  1210. }
  1211. function nextTourStep() {
  1212. if (!guidedTour.active) return;
  1213. guidedTour.index = Math.min(guidedTour.index + 1, TOUR_STEPS.length - 1);
  1214. scheduleTour();
  1215. }
  1216. function handleTourRecordSaved(module) {
  1217. const step = TOUR_STEPS[guidedTour.index];
  1218. if (!guidedTour.active || step?.waitModule !== module) return;
  1219. guidedTour.index = Math.min(guidedTour.index + 1, TOUR_STEPS.length - 1);
  1220. }
  1221. function scheduleTour() {
  1222. if (!guidedTour.active) return;
  1223. window.setTimeout(renderGuidedTour, 40);
  1224. }
  1225. function renderGuidedTour() {
  1226. document.querySelectorAll(".guided-tour-layer").forEach((node) => node.remove());
  1227. if (!guidedTour.active) return;
  1228. const step = TOUR_STEPS[guidedTour.index];
  1229. if (!step) return stopGuidedTour();
  1230. if (step.route && route !== step.route) {
  1231. navigateTo(step.route);
  1232. return;
  1233. }
  1234. const target = document.querySelector(step.selector);
  1235. const rect = target?.getBoundingClientRect();
  1236. const layerHost = step.dialog && el.dialog.open ? el.dialog : document.body;
  1237. const layer = document.createElement("div");
  1238. layer.className = `guided-tour-layer ${step.dialog ? "dialog-tour" : ""}`;
  1239. 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 };
  1240. const popover = tourPopoverPosition(safeRect);
  1241. const stepIndex = guidedTour.index;
  1242. layer.innerHTML = `
  1243. <div class="guided-tour-scrim"></div>
  1244. <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>
  1245. <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>
  1246. <section class="guided-tour-card" style="left:${popover.left}px;top:${popover.top}px">
  1247. <div class="guided-tour-progress">${escapeHtml(i18n("tour.progress", "步骤 {current} / {total}", { current: stepIndex + 1, total: TOUR_STEPS.length }))}</div>
  1248. <h3>${escapeHtml(tourText(stepIndex, "title"))}</h3>
  1249. <p>${escapeHtml(tourText(stepIndex, "body"))}</p>
  1250. <div class="guided-tour-actions">
  1251. <button class="ghost-button" type="button" data-tour-skip>${escapeHtml(i18n("tour.skip", "退出引导"))}</button>
  1252. ${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>`}
  1253. </div>
  1254. </section>
  1255. `;
  1256. layerHost.appendChild(layer);
  1257. target?.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
  1258. if (target && !step.waitModule) {
  1259. target.addEventListener("click", () => {
  1260. const currentIndex = guidedTour.index;
  1261. window.setTimeout(() => {
  1262. if (guidedTour.active && guidedTour.index === currentIndex) nextTourStep();
  1263. }, 0);
  1264. }, { once: true });
  1265. }
  1266. layer.querySelector("[data-tour-skip]")?.addEventListener("click", () => stopGuidedTour());
  1267. layer.querySelector("[data-tour-next]")?.addEventListener("click", () => step.action ? step.action() : nextTourStep());
  1268. }
  1269. function tourPopoverPosition(rect) {
  1270. const width = 320;
  1271. const margin = 18;
  1272. let left = rect.right + 18;
  1273. let top = rect.top;
  1274. if (left + width > window.innerWidth - margin) left = Math.max(margin, rect.left - width - 18);
  1275. if (left < margin) left = margin;
  1276. if (top + 190 > window.innerHeight - margin) top = Math.max(margin, window.innerHeight - 208);
  1277. return { left, top };
  1278. }
  1279. function closeEditor() {
  1280. editing = null;
  1281. el.fields.classList.remove("was-validated");
  1282. setSaving(false);
  1283. el.dialog.close();
  1284. }
  1285. function render() {
  1286. renderNav();
  1287. el.title.textContent = modules.find((item) => item.id === route)?.label || "BindVault";
  1288. if (route === "dashboard") renderDashboard();
  1289. else renderModule(route);
  1290. scheduleTour();
  1291. }
  1292. function renderDashboard() {
  1293. const risks = computeRisks();
  1294. const totalAssets = state.phones.length + state.emails.length + state.domains.length;
  1295. const activeBindings = state.bindings.filter((binding) => binding.status === "active");
  1296. const normalAccounts = state.accounts.filter((account) => account.status === "normal").length;
  1297. const lockedAccounts = state.accounts.filter((account) => ["locked", "suspended", "unusable"].includes(account.status)).length;
  1298. const appealingAccounts = state.accounts.filter((account) => account.status === "appealing").length;
  1299. const twoFactorAccounts = state.accounts.filter((account) => account.two_factor_type && account.two_factor_type !== "none").length;
  1300. const paymentBindings = activeBindings.filter((binding) => binding.binding_role === "payment").length;
  1301. const recoveryBindings = activeBindings.filter((binding) => ["recovery", "trusted_phone", "two_factor"].includes(binding.binding_role)).length;
  1302. const highRiskBindings = risks.filter((risk) => risk.level === "high").length;
  1303. const openIncidents = state.incidents.filter((incident) => incident.status !== "resolved").length;
  1304. const platformRows = countBy(state.accounts, "platform");
  1305. const activeStatusRows = countBy(state.accounts, "status");
  1306. const latestIncident = [...state.incidents]
  1307. .sort((a, b) => new Date(b.occurred_at || b.updated_at || 0).getTime() - new Date(a.occurred_at || a.updated_at || 0).getTime())[0];
  1308. const recentAccounts = [...state.accounts]
  1309. .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())
  1310. .slice(0, 3);
  1311. el.content.innerHTML = `
  1312. <section class="dashboard-mainpanel">
  1313. <section class="dashboard-hero dashboard-hero--full">
  1314. <div>
  1315. <p class="eyebrow">${escapeHtml(i18n("ui.workspaceOverview", "Workspace Overview"))}</p>
  1316. <h1>${escapeHtml(i18n("ui.assetAccountSecurity", "资产与账号安全"))}</h1>
  1317. <p>${escapeHtml(i18n("ui.dashboardHeroDesc", "用更轻的方式看清当前台账状态、恢复链路和支付依赖。重点问题会直接浮到台前,不用再翻列表找。"))}</p>
  1318. </div>
  1319. <div class="dashboard-hero-note">
  1320. <span class="dashboard-note-label">${escapeHtml(i18n("ui.recentEvent", "最近事件"))}</span>
  1321. <strong>${latestIncident ? escapeHtml(t(latestIncident.incident_type)) : escapeHtml(i18n("ui.allClear", "一切平稳"))}</strong>
  1322. <span>${latestIncident ? `${escapeHtml(t(latestIncident.status))} · ${escapeHtml(formatDate(latestIncident.occurred_at || latestIncident.updated_at))}` : escapeHtml(i18n("ui.noOpenIncidents", "当前没有待处理事件"))}</span>
  1323. </div>
  1324. </section>
  1325. <section class="dashboard-settings-grid">
  1326. ${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")}
  1327. ${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 }))}
  1328. ${renderDashboardMetricCard(i18n("ui.recoveryPayment", "恢复与支付"), recoveryBindings + paymentBindings, i18n("ui.recoveryPaymentMeta", "恢复链路 {recovery} · 支付关系 {payment}", { recovery: recoveryBindings, payment: paymentBindings }), "recovery", i18n("ui.viewBindings", "查看绑定"), "bindings")}
  1329. ${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", "当前没有高风险"))}
  1330. </section>
  1331. <section class="dashboard-insights-grid">
  1332. <article class="dashboard-panel-card">
  1333. <div class="dashboard-panel-head">
  1334. <div>
  1335. <p class="eyebrow">${escapeHtml(i18n("ui.accountsPanel", "Accounts"))}</p>
  1336. <h3>${escapeHtml(i18n("ui.platformStatus", "平台与状态"))}</h3>
  1337. </div>
  1338. <button class="ghost-button" type="button" data-dashboard-route="accounts">${escapeHtml(i18n("ui.viewAll", "查看全部"))}</button>
  1339. </div>
  1340. <div class="dashboard-stack">
  1341. <div class="dashboard-subsection">
  1342. <span class="dashboard-subtitle">${escapeHtml(i18n("ui.platformDistribution", "平台分布"))}</span>
  1343. <div class="chart-list chart-list--soft">${renderDashboardPlatforms(platformRows)}</div>
  1344. </div>
  1345. <div class="dashboard-subsection">
  1346. <span class="dashboard-subtitle">${escapeHtml(i18n("ui.accountStatus", "账号状态"))}</span>
  1347. <div class="chart-list chart-list--soft">${renderBars(activeStatusRows, i18n("ui.noAccountData", "暂无账号数据"))}</div>
  1348. </div>
  1349. </div>
  1350. </article>
  1351. <article class="dashboard-panel-card">
  1352. <div class="dashboard-panel-head">
  1353. <div>
  1354. <p class="eyebrow">${escapeHtml(i18n("ui.monitoringPanel", "Monitoring"))}</p>
  1355. <h3>${escapeHtml(i18n("ui.riskRecentChanges", "风险与最近变更"))}</h3>
  1356. </div>
  1357. <button class="ghost-button" type="button" data-dashboard-route="incidents">${escapeHtml(i18n("ui.viewAll", "查看全部"))}</button>
  1358. </div>
  1359. <div class="dashboard-stack">
  1360. <div class="dashboard-subsection">
  1361. <span class="dashboard-subtitle">${escapeHtml(i18n("ui.riskTips", "风险提示"))}</span>
  1362. <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>
  1363. </div>
  1364. <div class="dashboard-subsection">
  1365. <span class="dashboard-subtitle">${escapeHtml(i18n("ui.recentChangedAccounts", "最近变更账号"))}</span>
  1366. <div class="dashboard-recent-list">
  1367. ${recentAccounts.length ? recentAccounts.map(renderDashboardRecentAccount).join("") : `<p class="muted">${escapeHtml(i18n("ui.noAccountsYet", "还没有账号记录。"))}</p>`}
  1368. </div>
  1369. </div>
  1370. </div>
  1371. </article>
  1372. </section>
  1373. </section>
  1374. `;
  1375. el.content.querySelectorAll("[data-dashboard-route]").forEach((button) => {
  1376. button.addEventListener("click", () => navigateTo(button.dataset.dashboardRoute));
  1377. });
  1378. }
  1379. function renderBars(rows, emptyText) {
  1380. if (!rows.length) return `<p class="muted">${emptyText}</p>`;
  1381. const max = Math.max(...rows.map((row) => row.count), 1);
  1382. return rows
  1383. .map(
  1384. (row) => `
  1385. <div class="bar-row">
  1386. <span>${escapeHtml(t(row.name))}</span>
  1387. <div class="bar-track"><div class="bar-fill" style="width:${(row.count / max) * 100}%"></div></div>
  1388. <strong>${row.count}</strong>
  1389. </div>
  1390. `,
  1391. )
  1392. .join("");
  1393. }
  1394. function renderDashboardMetricCard(title, value, detail, icon, ctaLabel, routeId, note = "") {
  1395. return `
  1396. <article class="dashboard-setting-card">
  1397. <div class="dashboard-setting-head">
  1398. <div>
  1399. <span class="dashboard-card-label">${escapeHtml(title)}</span>
  1400. <div class="dashboard-card-value">${value}</div>
  1401. </div>
  1402. <span class="dashboard-card-icon ${escapeHtml(icon)}" aria-hidden="true">${renderDashboardIcon(icon)}</span>
  1403. </div>
  1404. <div class="dashboard-card-copy">
  1405. <p>${escapeHtml(detail)}</p>
  1406. ${note ? `<span>${escapeHtml(note)}</span>` : ""}
  1407. </div>
  1408. <button class="dashboard-card-link" type="button" data-dashboard-route="${escapeHtml(routeId)}">${escapeHtml(ctaLabel)}</button>
  1409. </article>
  1410. `;
  1411. }
  1412. function renderDashboardIcon(icon) {
  1413. if (icon === "asset") {
  1414. 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>`;
  1415. }
  1416. if (icon === "security") {
  1417. 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>`;
  1418. }
  1419. if (icon === "recovery") {
  1420. 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>`;
  1421. }
  1422. if (icon === "risk") {
  1423. 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>`;
  1424. }
  1425. return `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>`;
  1426. }
  1427. function renderDashboardPlatforms(rows) {
  1428. if (!rows.length) return `<p class="muted">${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}</p>`;
  1429. const max = Math.max(...rows.map((row) => row.count), 1);
  1430. return rows.slice(0, 5).map((row) => {
  1431. const meta = platformMeta(row.name);
  1432. return `
  1433. <div class="dashboard-platform-row">
  1434. <div class="dashboard-platform-main">
  1435. ${renderPlatformLogo(meta.name)}
  1436. <strong>${escapeHtml(meta.name)}</strong>
  1437. </div>
  1438. <div class="dashboard-platform-meter">
  1439. <div class="dashboard-platform-track"><div class="dashboard-platform-fill" style="width:${(row.count / max) * 100}%"></div></div>
  1440. <span>${row.count}</span>
  1441. </div>
  1442. </div>
  1443. `;
  1444. }).join("");
  1445. }
  1446. function renderDashboardRecentAccount(account) {
  1447. return `
  1448. <button class="dashboard-recent-item" type="button" data-dashboard-route="accounts">
  1449. <div class="dashboard-recent-main">
  1450. ${renderPlatformLogo(account)}
  1451. <div>
  1452. <strong>${escapeHtml(account.platform || labels.account)}</strong>
  1453. <span>${escapeHtml(account.account_identifier || "-")}</span>
  1454. </div>
  1455. </div>
  1456. <span class="pill ${escapeHtml(account.status || "unknown")}">${t(account.status)}</span>
  1457. </button>
  1458. `;
  1459. }
  1460. function renderRisk(risk) {
  1461. return `
  1462. <div class="risk-item">
  1463. <div class="risk-title">
  1464. <strong>${escapeHtml(risk.title)}</strong>
  1465. <span class="pill ${risk.level}">${t(risk.level)}</span>
  1466. </div>
  1467. <span class="muted">${escapeHtml(risk.detail)}</span>
  1468. </div>
  1469. `;
  1470. }
  1471. function riskNotesToItems(value) {
  1472. return String(value || "")
  1473. .split(/\n+/)
  1474. .map((item) => item.trim())
  1475. .filter(Boolean);
  1476. }
  1477. function renderManualRiskNote(note) {
  1478. return `
  1479. <div class="risk-item">
  1480. <div class="risk-title">
  1481. <strong>${escapeHtml(i18n("ui.manualTip", "手动提示"))}</strong>
  1482. <span class="pill medium">${escapeHtml(i18n("ui.attentionNeeded", "需关注"))}</span>
  1483. </div>
  1484. <span class="muted">${escapeHtml(note)}</span>
  1485. </div>
  1486. `;
  1487. }
  1488. function countBy(items, key) {
  1489. const map = new Map();
  1490. items.forEach((item) => {
  1491. const name = item[key] || "unknown";
  1492. map.set(name, (map.get(name) || 0) + 1);
  1493. });
  1494. return [...map.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
  1495. }
  1496. function renderModule(module) {
  1497. const schema = schemas[module];
  1498. const rows = filterRows(module);
  1499. const statusOptions = getStatusOptions(module);
  1500. const drawerModules = ["phones", "emails", "domains", "accounts"];
  1501. const autoSelect = !drawerModules.includes(module);
  1502. const selectedRecord = selected ? state[module].find((row) => row.id === selected) : autoSelect ? rows[0] : null;
  1503. if (autoSelect && !selected && selectedRecord) selected = selectedRecord.id;
  1504. if (module === "bindings") {
  1505. renderBindingsWorkspace(rows, schema, statusOptions, selectedRecord);
  1506. return;
  1507. }
  1508. if (module === "accounts") {
  1509. renderAccountsPage(rows, schema, statusOptions, selectedRecord);
  1510. return;
  1511. }
  1512. if (["phones", "emails", "domains"].includes(module)) {
  1513. renderAssetPage(module, rows, schema, statusOptions, selectedRecord);
  1514. return;
  1515. }
  1516. el.content.innerHTML = `
  1517. <div class="toolbar">
  1518. <div class="filter-row">
  1519. <input type="search" data-filter="q" placeholder="${escapeHtml(i18n("ui.searchCurrent", "搜索当前列表"))}" value="${escapeHtml(filters[module]?.q || "")}" />
  1520. ${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>` : ""}
  1521. </div>
  1522. <div class="inline-actions">
  1523. <button class="primary-button" type="button" data-new="${module}">${escapeHtml(i18n("ui.addRecord", "新增{name}", { name: schema.title }))}</button>
  1524. </div>
  1525. </div>
  1526. ${module === "bindings" ? renderTopology(rows) : ""}
  1527. ${rows.length ? renderTable(module, rows, schema) : renderEmpty(module)}
  1528. ${selectedRecord ? renderDetail(module, selectedRecord) : ""}
  1529. `;
  1530. el.content.querySelectorAll("[data-filter]").forEach((input) => {
  1531. input.addEventListener("input", () => {
  1532. filters[module] = { ...(filters[module] || {}), [input.dataset.filter]: input.value };
  1533. selected = null;
  1534. render();
  1535. });
  1536. });
  1537. el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor(module));
  1538. el.content.querySelector("[data-export-csv]")?.addEventListener("click", () => exportCsv(module));
  1539. el.content.querySelector("[data-import-csv]")?.addEventListener("change", (event) => importCsv(module, event));
  1540. el.content.querySelectorAll("[data-select-row]").forEach((button) => button.addEventListener("click", () => {
  1541. selected = button.dataset.selectRow;
  1542. render();
  1543. }));
  1544. el.content.querySelectorAll("[data-edit]").forEach((button) => button.addEventListener("click", () => openEditor(module, button.dataset.edit)));
  1545. el.content.querySelectorAll("[data-delete]").forEach((button) => button.addEventListener("click", () => removeRecord(module, button.dataset.delete)));
  1546. }
  1547. function renderAccountsListView(rows, selectedId) {
  1548. const platformCounts = new Map();
  1549. rows.forEach((a) => { const p = a.platform || i18n("ui.other", "其他"); platformCounts.set(p, (platformCounts.get(p) || 0) + 1); });
  1550. const platforms = [...platformCounts.keys()].filter(Boolean).sort();
  1551. const activePlatform = filters.accounts?.platform || "";
  1552. const tabs =
  1553. `<button type="button" class="platform-tab ${activePlatform === "" ? "active" : ""}" data-platform="">${escapeHtml(i18n("ui.all", "全部"))} (${rows.length})</button>` +
  1554. platforms.map((p) => `<button type="button" class="platform-tab ${activePlatform === p ? "active" : ""}" data-platform="${escapeHtml(p)}">${escapeHtml(p)} (${platformCounts.get(p)})</button>`).join("");
  1555. const items = [...rows]
  1556. .sort((a, b) => (a.platform || "").localeCompare(b.platform || "") || (a.account_identifier || "").localeCompare(b.account_identifier || ""))
  1557. .map((row) => {
  1558. const meta = platformMeta(row);
  1559. const logo = meta.src ? `<img src="${escapeHtml(meta.src)}" alt="" />` : escapeHtml(meta.mark || "?");
  1560. const search = `${row.platform || ""} ${row.account_identifier || ""} ${row.display_name || ""}`.toLowerCase();
  1561. 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)}">
  1562. <span class="platform-logo ${escapeHtml(meta.className)}">${logo}</span>
  1563. <span class="account-list-item-info">
  1564. <span class="account-list-item-name">${escapeHtml(row.platform || i18n("ui.unknownName", "未知"))}</span>
  1565. <span class="account-list-item-id">${escapeHtml(row.account_identifier || row.display_name || "")}</span>
  1566. </span>
  1567. <div class="account-list-item-actions">
  1568. <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>
  1569. <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>
  1570. </div>
  1571. </div>`;
  1572. }).join("") || `<div class="account-list-empty">${escapeHtml(i18n("ui.noAccountData", "暂无账号数据"))}</div>`;
  1573. return `<div class="accounts-list-view">
  1574. <div class="accounts-list-header">
  1575. <div class="accounts-list-search">
  1576. <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>
  1577. <input type="text" id="accounts-q" placeholder="${escapeHtml(i18n("ui.searchModule", "搜索{name}…", { name: labels.account }))}" autocomplete="off" spellcheck="false">
  1578. </div>
  1579. <div class="accounts-platform-tabs" id="accounts-platform-tabs">${tabs}</div>
  1580. </div>
  1581. <div class="accounts-list-items" id="accounts-list-items">${items}</div>
  1582. </div>`;
  1583. }
  1584. function renderAccountsPage(rows, schema, statusOptions, selectedRecord) {
  1585. el.content.innerHTML = `
  1586. <div class="toolbar">
  1587. <div class="filter-row">
  1588. ${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>` : ""}
  1589. </div>
  1590. <div class="inline-actions">
  1591. <button class="primary-button" type="button" data-new="accounts">${escapeHtml(i18n("ui.addRecord", "新增{name}", { name: schema.title }))}</button>
  1592. </div>
  1593. </div>
  1594. ${renderAccountsListView(rows, selectedRecord?.id)}
  1595. ${renderRecordDrawer("accounts", selectedRecord)}
  1596. `;
  1597. el.content.querySelectorAll("[data-filter]").forEach((input) => {
  1598. input.addEventListener("input", () => {
  1599. filters.accounts = { ...(filters.accounts || {}), [input.dataset.filter]: input.value };
  1600. selected = null;
  1601. render();
  1602. });
  1603. });
  1604. el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor("accounts"));
  1605. el.content.querySelectorAll("[data-select-row]").forEach((btn) => {
  1606. btn.addEventListener("click", () => { selected = btn.dataset.selectRow; render(); });
  1607. });
  1608. el.content.querySelectorAll("[data-edit]").forEach((btn) => {
  1609. btn.addEventListener("click", (e) => { e.stopPropagation(); openEditor("accounts", btn.dataset.edit); });
  1610. });
  1611. el.content.querySelectorAll("[data-delete]").forEach((btn) => {
  1612. btn.addEventListener("click", (e) => { e.stopPropagation(); removeRecord("accounts", btn.dataset.delete); });
  1613. });
  1614. const qInput = el.content.querySelector("#accounts-q");
  1615. const tabsEl = el.content.querySelector("#accounts-platform-tabs");
  1616. const listEl = el.content.querySelector("#accounts-list-items");
  1617. function filterList() {
  1618. const q = (qInput?.value || "").toLowerCase();
  1619. const platform = filters.accounts?.platform || "";
  1620. listEl?.querySelectorAll(".account-list-item").forEach((item) => {
  1621. item.hidden = !((!platform || item.dataset.platform === platform) && (!q || item.dataset.search.includes(q)));
  1622. });
  1623. }
  1624. qInput?.addEventListener("input", filterList);
  1625. tabsEl?.addEventListener("click", (e) => {
  1626. const tab = e.target.closest(".platform-tab");
  1627. if (!tab) return;
  1628. filters.accounts = { ...(filters.accounts || {}), platform: tab.dataset.platform || "" };
  1629. tabsEl.querySelectorAll(".platform-tab").forEach((t) => t.classList.remove("active"));
  1630. tab.classList.add("active");
  1631. filterList();
  1632. });
  1633. filterList();
  1634. bindRecordDrawerClose();
  1635. }
  1636. function assetCardIcon(module, row) {
  1637. const emailTypeColor = {
  1638. gmail: "#EA4335", outlook: "#0078D4", qq: "#1D6FA4",
  1639. custom_domain: "#6B48FF", cloudflare_routing: "#F48024", alias: "#8E8E93",
  1640. };
  1641. const domainRegistrarLogo = {
  1642. cloudflare: { bg: "#F48024", src: "assets/platforms/cloudflare.svg" },
  1643. };
  1644. let bg, svgInner, logoSrc;
  1645. if (module === "phones") {
  1646. bg = "#34C759";
  1647. 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"/>`;
  1648. } else if (module === "emails") {
  1649. bg = emailTypeColor[row.email_type] || "#6B48FF";
  1650. 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"/>`;
  1651. } else {
  1652. const registrarKey = (row.registrar || "").toLowerCase().replace(/\s+/g, "");
  1653. const registrar = domainRegistrarLogo[registrarKey];
  1654. if (registrar) {
  1655. bg = registrar.bg;
  1656. logoSrc = registrar.src;
  1657. } else {
  1658. bg = "#5856D6";
  1659. 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"/>`;
  1660. }
  1661. }
  1662. if (logoSrc) {
  1663. 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>`;
  1664. }
  1665. 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>`;
  1666. }
  1667. function renderAssetListView(module, rows, selectedId) {
  1668. const tabField = module === "phones" ? "carrier" : module === "emails" ? "email_type" : "registrar";
  1669. const tabCounts = new Map();
  1670. rows.forEach((r) => { const v = r[tabField] || i18n("ui.other", "其他"); tabCounts.set(v, (tabCounts.get(v) || 0) + 1); });
  1671. const tabValues = [...tabCounts.keys()].sort();
  1672. const activeTab = filters[module]?.tabVal || "";
  1673. const tabs =
  1674. `<button type="button" class="platform-tab ${activeTab === "" ? "active" : ""}" data-tab-val="">${escapeHtml(i18n("ui.all", "全部"))} (${rows.length})</button>` +
  1675. tabValues.map((v) => {
  1676. const label = module === "emails" ? t(v) : escapeHtml(v);
  1677. return `<button type="button" class="platform-tab ${activeTab === v ? "active" : ""}" data-tab-val="${escapeHtml(v)}">${label} (${tabCounts.get(v)})</button>`;
  1678. }).join("");
  1679. 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>`;
  1680. 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>`;
  1681. const sorted = [...rows].sort((a, b) =>
  1682. (a[tabField] || "").localeCompare(b[tabField] || "") || primaryName(module, a).localeCompare(primaryName(module, b))
  1683. );
  1684. const cards = sorted.map((row) => {
  1685. const title = primaryName(module, row);
  1686. let sub = "";
  1687. if (module === "phones") sub = [row.carrier, row.country_region].filter(Boolean).join(" · ");
  1688. else if (module === "emails") sub = [t(row.email_type), row.provider].filter(Boolean).join(" · ");
  1689. else sub = [row.registrar, row.dns_provider].filter(Boolean).join(" · ");
  1690. const tabVal = row[tabField] || i18n("ui.other", "其他");
  1691. const searchStr = `${title} ${sub} ${row.status || ""}`.toLowerCase();
  1692. const status = row.status || "";
  1693. return `<div class="asset-card${row.id === selectedId ? " active" : ""}" data-select-row="${escapeHtml(row.id)}" data-tab-val="${escapeHtml(tabVal)}" data-search="${escapeHtml(searchStr)}">
  1694. ${assetCardIcon(module, row)}
  1695. <div class="asset-card-body">
  1696. <div class="asset-card-title">${escapeHtml(title)}</div>
  1697. ${sub ? `<div class="asset-card-sub">${escapeHtml(sub)}</div>` : ""}
  1698. ${status ? `<div class="asset-card-status"><span class="pill ${escapeHtml(status)}">${t(status)}</span></div>` : ""}
  1699. </div>
  1700. <div class="asset-card-actions">
  1701. <button class="acct-action-btn" type="button" data-edit="${escapeHtml(row.id)}" title="${escapeHtml(i18n("ui.edit", "编辑"))}">${editSvg}</button>
  1702. <button class="acct-action-btn acct-action-del" type="button" data-delete="${escapeHtml(row.id)}" title="${escapeHtml(i18n("ui.delete", "删除"))}">${trashSvg}</button>
  1703. </div>
  1704. </div>`;
  1705. }).join("") || `<div class="asset-card-empty">${escapeHtml(i18n("ui.noModuleData", "暂无{name}数据", { name: schemas[module].title }))}</div>`;
  1706. return `<div class="assets-card-view">
  1707. <div class="assets-card-header">
  1708. <div class="accounts-list-search">
  1709. <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>
  1710. <input type="text" id="${module}-q" placeholder="${escapeHtml(i18n("ui.searchModule", "搜索{name}…", { name: schemas[module].title }))}" autocomplete="off" spellcheck="false">
  1711. </div>
  1712. <div class="accounts-platform-tabs" id="${module}-tabs">${tabs}</div>
  1713. </div>
  1714. <div class="assets-card-grid" id="${module}-grid">${cards}</div>
  1715. </div>`;
  1716. }
  1717. function renderAssetPage(module, rows, schema, statusOptions, selectedRecord) {
  1718. el.content.innerHTML = `
  1719. <div class="toolbar">
  1720. <div class="filter-row">
  1721. ${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>` : ""}
  1722. </div>
  1723. <div class="inline-actions">
  1724. <button class="primary-button" type="button" data-new="${module}">${escapeHtml(i18n("ui.addRecord", "新增{name}", { name: schema.title }))}</button>
  1725. </div>
  1726. </div>
  1727. ${renderAssetListView(module, rows, selectedRecord?.id)}
  1728. ${renderRecordDrawer(module, selectedRecord)}
  1729. `;
  1730. el.content.querySelectorAll("[data-filter]").forEach((input) => {
  1731. input.addEventListener("input", () => {
  1732. filters[module] = { ...(filters[module] || {}), [input.dataset.filter]: input.value };
  1733. selected = null;
  1734. render();
  1735. });
  1736. });
  1737. el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor(module));
  1738. el.content.querySelectorAll("[data-select-row]").forEach((btn) => {
  1739. btn.addEventListener("click", () => { selected = btn.dataset.selectRow; render(); });
  1740. });
  1741. el.content.querySelectorAll("[data-edit]").forEach((btn) => {
  1742. btn.addEventListener("click", (e) => { e.stopPropagation(); openEditor(module, btn.dataset.edit); });
  1743. });
  1744. el.content.querySelectorAll("[data-delete]").forEach((btn) => {
  1745. btn.addEventListener("click", (e) => { e.stopPropagation(); removeRecord(module, btn.dataset.delete); });
  1746. });
  1747. const qInput = el.content.querySelector(`#${module}-q`);
  1748. const tabsEl = el.content.querySelector(`#${module}-tabs`);
  1749. const gridEl = el.content.querySelector(`#${module}-grid`);
  1750. function filterCards() {
  1751. const q = (qInput?.value || "").toLowerCase();
  1752. const tabVal = filters[module]?.tabVal || "";
  1753. gridEl?.querySelectorAll(".asset-card").forEach((card) => {
  1754. card.hidden = !((!tabVal || card.dataset.tabVal === tabVal) && (!q || card.dataset.search.includes(q)));
  1755. });
  1756. }
  1757. qInput?.addEventListener("input", filterCards);
  1758. tabsEl?.addEventListener("click", (e) => {
  1759. const tab = e.target.closest(".platform-tab");
  1760. if (!tab) return;
  1761. filters[module] = { ...(filters[module] || {}), tabVal: tab.dataset.tabVal || "" };
  1762. tabsEl.querySelectorAll(".platform-tab").forEach((tb) => tb.classList.remove("active"));
  1763. tab.classList.add("active");
  1764. filterCards();
  1765. });
  1766. filterCards();
  1767. bindRecordDrawerClose();
  1768. }
  1769. function bindRecordDrawerClose() {
  1770. el.content.querySelector("[data-close-record-drawer]")?.addEventListener("click", () => {
  1771. closeRecordDrawer();
  1772. });
  1773. el.content.querySelector(".record-drawer-backdrop")?.addEventListener("click", () => {
  1774. closeRecordDrawer();
  1775. });
  1776. }
  1777. function closeRecordDrawer() {
  1778. const drawer = el.content.querySelector(".asset-drawer");
  1779. const backdrop = el.content.querySelector(".record-drawer-backdrop");
  1780. if (!drawer) {
  1781. selected = null;
  1782. render();
  1783. return;
  1784. }
  1785. drawer.classList.add("closing");
  1786. backdrop?.classList.add("closing");
  1787. window.setTimeout(() => {
  1788. selected = null;
  1789. render();
  1790. }, 220);
  1791. }
  1792. function renderRecordDrawer(module, record) {
  1793. if (!record) return "";
  1794. return `
  1795. <div class="record-drawer-backdrop asset-drawer-backdrop"></div>
  1796. <aside class="asset-drawer" aria-label="${escapeHtml(`${schemas[module].title} ${i18n("ui.detailSuffix", "详情")}`)}">
  1797. <div class="asset-drawer-shell">
  1798. <button class="asset-drawer-close" type="button" data-close-record-drawer aria-label="${escapeHtml(i18n("ui.closeDetail", "关闭详情"))}">×</button>
  1799. ${renderDetail(module, record, { drawer: true })}
  1800. </div>
  1801. </aside>
  1802. `;
  1803. }
  1804. function renderBindingsWorkspace(rows, schema, statusOptions, selectedRecord) {
  1805. const activeRows = rows.filter((binding) => binding.status === "active");
  1806. const focusedRows = filterGraphRows(activeRows);
  1807. const visibleRows = graphFocus ? rows.filter((binding) => focusedRows.some((item) => item.id === binding.id)) : rows;
  1808. const selectedBinding = selectedRecord || focusedRows[0] || activeRows[0] || rows[0];
  1809. const selectedAccount = selectedBinding ? state.accounts.find((account) => account.id === selectedBinding.account_id) : null;
  1810. const highRisk = focusedRows.filter((binding) => binding.risk_level === "high").length;
  1811. const uniqueAssets = new Set(focusedRows.map((binding) => `${binding.asset_type}:${binding.asset_id}`));
  1812. const uniqueAccounts = new Set(focusedRows.map((binding) => binding.account_id));
  1813. el.content.innerHTML = `
  1814. <section class="relation-hero">
  1815. <div>
  1816. <p class="eyebrow">${escapeHtml(i18n("ui.relationshipMap", "Relationship Map"))}</p>
  1817. <h3>${escapeHtml(i18n("ui.bindingTopology", "绑定拓扑图"))}</h3>
  1818. <p>${escapeHtml(i18n("ui.bindingTopologyDesc", "清晰查看基础资产、账号与绑定角色之间的关系。"))}</p>
  1819. </div>
  1820. <div class="filter-row relation-filter">
  1821. ${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>` : ""}
  1822. ${graphFocus ? `<button class="ghost-button" type="button" data-clear-focus>${escapeHtml(i18n("ui.showAll", "显示全部"))}</button>` : ""}
  1823. </div>
  1824. </section>
  1825. <div class="relation-layout">
  1826. <div class="relation-main">
  1827. <div class="relation-stats">
  1828. ${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")}
  1829. ${renderRelationStat(i18n("ui.accountStats", "账号总数"), uniqueAccounts.size, i18n("ui.activeBindingsMeta", "活跃绑定 {count}", { count: focusedRows.length }), "account")}
  1830. ${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")}
  1831. ${renderRelationStat(i18n("ui.highRiskBindings", "高风险绑定"), highRisk, highRisk ? i18n("ui.needsPriority", "需要优先处理") : i18n("ui.noHighRisk", "当前没有高风险"), "risk")}
  1832. </div>
  1833. ${renderRelationshipBoard(focusedRows)}
  1834. </div>
  1835. ${renderRelationInspector(selectedAccount, selectedBinding)}
  1836. </div>
  1837. <section class="relation-table-card relation-table-card-full">
  1838. <div class="toolbar">
  1839. <div>
  1840. <p class="eyebrow">${escapeHtml(i18n("ui.details", "Details"))}</p>
  1841. <h3>${escapeHtml(i18n("ui.bindingDetails", "绑定明细"))} <span class="count-badge">${visibleRows.length}</span></h3>
  1842. </div>
  1843. </div>
  1844. ${visibleRows.length ? renderBindingDetailsTable(visibleRows) : renderEmpty("bindings")}
  1845. </section>
  1846. `;
  1847. el.content.querySelectorAll("[data-filter]").forEach((input) => {
  1848. input.addEventListener("input", () => {
  1849. filters.bindings = { ...(filters.bindings || {}), [input.dataset.filter]: input.value };
  1850. selected = null;
  1851. graphFocus = null;
  1852. render();
  1853. });
  1854. });
  1855. el.content.querySelector("[data-new]")?.addEventListener("click", () => openEditor("bindings"));
  1856. el.content.querySelector("[data-clear-focus]")?.addEventListener("click", () => {
  1857. graphFocus = null;
  1858. selected = null;
  1859. render();
  1860. });
  1861. el.content.querySelectorAll("[data-focus-type]").forEach((node) => node.addEventListener("click", () => {
  1862. graphFocus = { type: node.dataset.focusType, id: node.dataset.focusId };
  1863. selected = node.dataset.bindingId || null;
  1864. render();
  1865. }));
  1866. el.content.querySelectorAll("[data-select-row]").forEach((button) => button.addEventListener("click", () => {
  1867. selected = button.dataset.selectRow;
  1868. const binding = state.bindings.find((item) => item.id === selected);
  1869. graphFocus = binding ? { type: "account", id: binding.account_id } : null;
  1870. render();
  1871. }));
  1872. el.content.querySelectorAll("[data-edit]").forEach((button) => button.addEventListener("click", () => openEditor("bindings", button.dataset.edit)));
  1873. el.content.querySelectorAll("[data-delete]").forEach((button) => button.addEventListener("click", () => removeRecord("bindings", button.dataset.delete)));
  1874. }
  1875. function filterGraphRows(bindings) {
  1876. if (!graphFocus) return bindings;
  1877. if (graphFocus.type === "asset") {
  1878. return bindings.filter((binding) => `${binding.asset_type}:${binding.asset_id}` === graphFocus.id);
  1879. }
  1880. if (graphFocus.type === "account") {
  1881. return bindings.filter((binding) => binding.account_id === graphFocus.id);
  1882. }
  1883. if (graphFocus.type === "role") {
  1884. return bindings.filter((binding) => (binding.binding_role || "unknown") === graphFocus.id);
  1885. }
  1886. return bindings;
  1887. }
  1888. function renderRelationStat(title, value, meta, kind) {
  1889. return `
  1890. <article class="relation-stat ${kind}">
  1891. <span class="relation-stat-icon">${renderRelationStatIcon(kind)}</span>
  1892. <div>
  1893. <span>${title}</span>
  1894. <strong>${value}</strong>
  1895. <p>${escapeHtml(meta)}</p>
  1896. </div>
  1897. </article>
  1898. `;
  1899. }
  1900. function renderRelationStatIcon(kind) {
  1901. if (kind === "asset") {
  1902. 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>`;
  1903. }
  1904. if (kind === "account") {
  1905. 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>`;
  1906. }
  1907. if (kind === "binding") {
  1908. 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>`;
  1909. }
  1910. if (kind === "risk") {
  1911. 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>`;
  1912. }
  1913. return "";
  1914. }
  1915. function countBindingsByType(bindings, type) {
  1916. return new Set(bindings.filter((binding) => binding.asset_type === type).map((binding) => binding.asset_id)).size;
  1917. }
  1918. function renderRelationshipBoard(bindings) {
  1919. if (!bindings.length) {
  1920. return `
  1921. <section class="relationship-board">
  1922. <div class="topology-empty">${escapeHtml(i18n("ui.noActiveBindings", "暂无活跃绑定关系"))}</div>
  1923. </section>
  1924. `;
  1925. }
  1926. const assets = summarizeAssets(bindings);
  1927. const accounts = summarizeAccounts(bindings);
  1928. const roles = summarizeRoles(bindings);
  1929. const assetLayout = makeGroupedAssetLayout(assets);
  1930. const height = Math.max(430, assetLayout.height, Math.max(accounts.length, roles.length) * 72 + 110);
  1931. const assetY = assetLayout.yMap;
  1932. const accountY = makeYMap(accounts, height);
  1933. const roleY = makeYMap(roles, height);
  1934. const lines = bindings.map((binding) => {
  1935. const assetKey = `${binding.asset_type}:${binding.asset_id}`;
  1936. const roleKey = binding.binding_role || "unknown";
  1937. const y1 = assetY.get(assetKey);
  1938. const y2 = accountY.get(binding.account_id);
  1939. const y3 = roleY.get(roleKey);
  1940. if (!y1 || !y2 || !y3) return "";
  1941. return `
  1942. <path class="relation-line ${escapeHtml(binding.asset_type)} ${escapeHtml(binding.risk_level || "low")}" d="M 292 ${y1} C 360 ${y1}, 400 ${y2}, 485 ${y2}" />
  1943. <path class="relation-line role ${escapeHtml(binding.risk_level || "low")}" d="M 705 ${y2} C 790 ${y2}, 815 ${y3}, 900 ${y3}" />
  1944. <circle class="relation-dot ${escapeHtml(binding.risk_level || "low")}" cx="485" cy="${y2}" r="4" />
  1945. `;
  1946. }).join("");
  1947. const colTop = 14;
  1948. const colBottom = height - 14;
  1949. const colHeight = colBottom - colTop;
  1950. return `
  1951. <section class="relationship-board">
  1952. <div class="relationship-board-head">
  1953. <div>
  1954. <p class="eyebrow">${escapeHtml(i18n("ui.graph", "Graph"))}</p>
  1955. <h3>${escapeHtml(i18n("ui.coreBindingGraph", "核心绑定关系"))}</h3>
  1956. </div>
  1957. </div>
  1958. <div class="relation-canvas">
  1959. <svg viewBox="0 0 1120 ${height}" role="img" aria-label="${escapeHtml(i18n("ui.bindingTopology", "绑定拓扑图"))}">
  1960. <defs>
  1961. <filter id="relationShadow" x="-20%" y="-20%" width="140%" height="140%">
  1962. <feDropShadow dx="0" dy="5" stdDeviation="5" flood-opacity="0.08"/>
  1963. </filter>
  1964. </defs>
  1965. <rect class="relation-column asset" x="14" y="${colTop}" width="306" height="${colHeight}" rx="14" />
  1966. <rect class="relation-column account" x="470" y="${colTop}" width="250" height="${colHeight}" rx="14" />
  1967. <rect class="relation-column role" x="888" y="${colTop}" width="204" height="${colHeight}" rx="14" />
  1968. <text class="relation-axis" x="40" y="38">${escapeHtml(i18n("ui.baseResources", "基础资源"))}</text>
  1969. <text class="relation-axis account" x="495" y="38">${escapeHtml(labels.accounts)}</text>
  1970. <text class="relation-axis role" x="910" y="38">${escapeHtml(i18n("ui.bindingRoleLegend", "绑定角色"))}</text>
  1971. ${assetLayout.groups.map(renderAssetGroupBox).join("")}
  1972. ${lines}
  1973. ${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("")}
  1974. ${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("")}
  1975. ${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("")}
  1976. </svg>
  1977. </div>
  1978. <div class="topology-legend">
  1979. <span><i class="phone"></i>${escapeHtml(labels.phone)}</span>
  1980. <span><i class="email"></i>${escapeHtml(labels.email)}</span>
  1981. <span><i class="domain"></i>${escapeHtml(labels.domain)}</span>
  1982. <span><i class="role"></i>${escapeHtml(i18n("ui.bindingRoleLegend", "绑定角色"))}</span>
  1983. <span><i class="risk"></i>${escapeHtml(i18n("ui.riskLegend", "风险"))}</span>
  1984. </div>
  1985. </section>
  1986. `;
  1987. }
  1988. function makeGroupedAssetLayout(assets) {
  1989. const order = ["phone", "email", "domain", "account", "payment", "device", "subscription"];
  1990. const grouped = new Map();
  1991. assets.forEach((asset) => {
  1992. if (!grouped.has(asset.type)) grouped.set(asset.type, []);
  1993. grouped.get(asset.type).push(asset);
  1994. });
  1995. const yMap = new Map();
  1996. const groups = [];
  1997. let cursor = 68;
  1998. order
  1999. .filter((type) => grouped.has(type))
  2000. .concat([...grouped.keys()].filter((type) => !order.includes(type)))
  2001. .forEach((type) => {
  2002. const items = grouped.get(type);
  2003. const headerHeight = 30;
  2004. const rowGap = 62;
  2005. const padding = 16;
  2006. const groupTop = cursor;
  2007. items.forEach((item, index) => {
  2008. yMap.set(item.key, groupTop + headerHeight + padding + index * rowGap);
  2009. });
  2010. const height = headerHeight + padding * 2 + Math.max(items.length - 1, 0) * rowGap + 54;
  2011. groups.push({ type, count: items.length, y: groupTop - 18, height });
  2012. cursor += height + 12;
  2013. });
  2014. return { yMap, groups, height: cursor + 24 };
  2015. }
  2016. function renderAssetGroupBox(group) {
  2017. const meta = assetGroupMeta(group.type);
  2018. return `
  2019. <g class="asset-group ${escapeHtml(group.type)}">
  2020. <rect x="24" y="${group.y}" width="286" height="${group.height}" rx="14" />
  2021. <text class="asset-group-title" x="44" y="${group.y + 24}">${escapeHtml(meta.label)} (${group.count})</text>
  2022. </g>
  2023. `;
  2024. }
  2025. function assetGroupMeta(type) {
  2026. return {
  2027. phone: { label: labels.phone },
  2028. email: { label: labels.email },
  2029. domain: { label: labels.domain },
  2030. account: { label: labels.account },
  2031. payment: { label: i18n("ui.paymentMethod", "支付方式") },
  2032. device: { label: i18n("ui.device", "设备") },
  2033. subscription: { label: i18n("ui.subscription", "订阅") },
  2034. }[type] || { label: t(type) };
  2035. }
  2036. function summarizeAssets(bindings) {
  2037. const map = new Map();
  2038. bindings.forEach((binding) => {
  2039. const key = `${binding.asset_type}:${binding.asset_id}`;
  2040. if (!map.has(key)) {
  2041. const assetPlatform = binding.asset_type === "account"
  2042. ? (state.accounts.find((a) => a.id === binding.asset_id)?.platform || null)
  2043. : null;
  2044. const assetAccount = binding.asset_type === "account"
  2045. ? state.accounts.find((a) => a.id === binding.asset_id)
  2046. : null;
  2047. 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 || "" });
  2048. }
  2049. map.get(key).count += 1;
  2050. });
  2051. return [...map.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
  2052. }
  2053. function summarizeAccounts(bindings) {
  2054. const map = new Map();
  2055. bindings.forEach((binding) => {
  2056. const account = state.accounts.find((item) => item.id === binding.account_id);
  2057. if (!map.has(binding.account_id)) {
  2058. map.set(binding.account_id, {
  2059. id: binding.account_id,
  2060. platform: account?.platform || binding.platform || "Account",
  2061. platform_logo: account?.platform_logo || "",
  2062. name: account?.account_identifier || resolveName("accounts", binding.account_id),
  2063. bindingId: binding.id,
  2064. count: 0,
  2065. });
  2066. }
  2067. map.get(binding.account_id).count += 1;
  2068. });
  2069. return [...map.values()].sort((a, b) => b.count - a.count || a.platform.localeCompare(b.platform));
  2070. }
  2071. function summarizeRoles(bindings) {
  2072. const map = new Map();
  2073. bindings.forEach((binding) => {
  2074. const key = binding.binding_role || "unknown";
  2075. if (!map.has(key)) map.set(key, { key, count: 0 });
  2076. map.get(key).count += 1;
  2077. });
  2078. return [...map.values()].sort((a, b) => b.count - a.count || t(a.key).localeCompare(t(b.key)));
  2079. }
  2080. function renderRelationSvgNode({ x, y, width, title, meta, kind, platform, platformLogo, active, focusType, focusId, bindingId }) {
  2081. const nodeHeight = 58;
  2082. const platformInfo = platform ? platformMeta(platform, platformLogo) : null;
  2083. const iconText = platformInfo?.mark || nodeIconText(kind);
  2084. const iconClass = platformInfo?.className || kind;
  2085. const isAssetIcon = !platformInfo && ["phone", "email", "domain"].includes(kind);
  2086. const markBackground = isAssetIcon
  2087. ? `<rect class="asset-node-icon-bg ${escapeHtml(kind)}" x="${x + 13}" y="${y - 15}" width="30" height="30" rx="9" />`
  2088. : `<circle class="node-mark ${escapeHtml(iconClass)}" cx="${x + 28}" cy="${y}" r="15" />`;
  2089. const iconSvg = platformInfo?.className === "apple"
  2090. ? renderAppleNodeIcon(x + 28, y)
  2091. : platformInfo?.src
  2092. ? `<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" />`
  2093. : renderNodeIcon(kind, x + 28, y, iconText);
  2094. return `
  2095. <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 || "")}">
  2096. <rect x="${x}" y="${y - nodeHeight / 2}" width="${width}" height="${nodeHeight}" rx="14" />
  2097. ${markBackground}
  2098. ${iconSvg}
  2099. <text class="relation-node-title" x="${x + 54}" y="${y - 5}">${escapeHtml(truncate(title, 22))}</text>
  2100. <text class="relation-node-meta" x="${x + 54}" y="${y + 15}">${escapeHtml(truncate(meta, 26))}</text>
  2101. </g>
  2102. `;
  2103. }
  2104. function renderNodeIcon(kind, cx, cy, fallbackText) {
  2105. if (kind === "phone") {
  2106. return `
  2107. <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" />
  2108. `;
  2109. }
  2110. if (kind === "email") {
  2111. return `
  2112. <rect class="asset-line-icon email" x="${cx - 9}" y="${cy - 6}" width="18" height="12" rx="2" />
  2113. <path class="asset-line-icon email" d="M ${cx - 8} ${cy - 5} L ${cx} ${cy + 1} L ${cx + 8} ${cy - 5}" />
  2114. `;
  2115. }
  2116. if (kind === "domain") {
  2117. return `
  2118. <circle class="asset-line-icon domain" cx="${cx}" cy="${cy}" r="8" />
  2119. <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}" />
  2120. `;
  2121. }
  2122. return `<text class="node-mark-text" x="${cx}" y="${cy + 4}">${escapeHtml(fallbackText)}</text>`;
  2123. }
  2124. function renderAppleNodeIcon(cx, cy) {
  2125. const size = 20, x = cx - size / 2, y = cy - size / 2;
  2126. 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>`;
  2127. }
  2128. function nodeIconText(kind) {
  2129. if (kind === "role") return "R";
  2130. return "•";
  2131. }
  2132. function renderRelationInspector(account, binding) {
  2133. if (!account) {
  2134. return `
  2135. <aside class="relation-inspector">
  2136. <div class="topology-empty">${escapeHtml(i18n("ui.selectBindingDetails", "选择一条绑定查看详情"))}</div>
  2137. </aside>
  2138. `;
  2139. }
  2140. const accountBindings = state.bindings.filter((item) => item.account_id === account.id && item.status === "active");
  2141. const risks = computeRisks().filter((risk) => risk.accountId === account.id);
  2142. const riskNotes = riskNotesToItems(account.risk_notes);
  2143. const hasRiskTips = riskNotes.length || risks.length;
  2144. return `
  2145. <aside class="relation-inspector">
  2146. <section class="inspector-profile">
  2147. <div class="inspector-avatar">${renderPlatform(account.platform, account)}</div>
  2148. <div>
  2149. <h3>${escapeHtml(account.platform)}</h3>
  2150. <p>${escapeHtml(account.account_identifier || "-")}</p>
  2151. <span class="pill ${escapeHtml(account.status)}">${t(account.status)}</span>
  2152. </div>
  2153. </section>
  2154. <section class="inspector-section">
  2155. <h4>${escapeHtml(i18n("ui.boundResources", "绑定资源"))}</h4>
  2156. <div class="inspector-list">
  2157. ${accountBindings.map((item) => `
  2158. <div class="inspector-item ${binding?.id === item.id ? "active" : ""}">
  2159. <span>${escapeHtml(resolveName(item.asset_type, item.asset_id))}</span>
  2160. <strong>${t(item.binding_role)}</strong>
  2161. </div>
  2162. `).join("") || `<p class="muted">${escapeHtml(i18n("ui.noActiveAccountBindings", "暂无活跃绑定。"))}</p>`}
  2163. </div>
  2164. </section>
  2165. <section class="inspector-section">
  2166. <h4>${escapeHtml(i18n("ui.accountState", "账号状态"))}</h4>
  2167. <dl class="inspector-meta">
  2168. <dt>${escapeHtml(i18n("ui.status", "状态"))}</dt><dd><span class="pill ${escapeHtml(account.status)}">${t(account.status)}</span></dd>
  2169. <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>
  2170. <dt>${escapeHtml(i18n("ui.region", "地区"))}</dt><dd>${renderRegion(account.region)}</dd>
  2171. <dt>2FA</dt><dd>${escapeHtml(t(account.two_factor_type))}</dd>
  2172. </dl>
  2173. </section>
  2174. <section class="inspector-section">
  2175. <h4>${escapeHtml(i18n("ui.riskTips", "风险提示"))}</h4>
  2176. <div class="risk-list compact">
  2177. ${hasRiskTips ? `${riskNotes.map(renderManualRiskNote).join("")}${risks.map(renderRisk).join("")}` : `<p class="muted">${escapeHtml(i18n("ui.noRiskTips", "暂无风险提示。"))}</p>`}
  2178. </div>
  2179. </section>
  2180. </aside>
  2181. `;
  2182. }
  2183. function renderTable(module, rows, schema) {
  2184. return `
  2185. <div class="table-wrap">
  2186. <table>
  2187. <thead>
  2188. <tr>
  2189. ${schema.columns.map((col) => `<th>${fieldLabel(module, col)}</th>`).join("")}
  2190. <th>${escapeHtml(i18n("ui.actions", "操作"))}</th>
  2191. </tr>
  2192. </thead>
  2193. <tbody>
  2194. ${rows
  2195. .map(
  2196. (row) => `
  2197. <tr>
  2198. ${schema.columns.map((col) => `<td>${renderCell(module, row, col)}</td>`).join("")}
  2199. <td>
  2200. <div class="inline-actions">
  2201. <button class="ghost-button" type="button" data-select-row="${row.id}">${escapeHtml(i18n("ui.view", "详情"))}</button>
  2202. <button class="ghost-button" type="button" data-edit="${row.id}">${escapeHtml(i18n("ui.edit", "编辑"))}</button>
  2203. <button class="danger-button" type="button" data-delete="${row.id}">${escapeHtml(i18n("ui.delete", "删除"))}</button>
  2204. </div>
  2205. </td>
  2206. </tr>
  2207. `,
  2208. )
  2209. .join("")}
  2210. </tbody>
  2211. </table>
  2212. </div>
  2213. `;
  2214. }
  2215. function renderBindingDetailsTable(rows) {
  2216. return `
  2217. <div class="binding-details-list">
  2218. ${rows.map((binding) => `
  2219. <article class="binding-detail-row">
  2220. <div class="binding-detail-resource">
  2221. <span class="binding-detail-label">${escapeHtml(i18n("ui.resource", "资源"))}</span>
  2222. ${renderBindingResourceCell(binding)}
  2223. </div>
  2224. <div class="binding-detail-account">
  2225. <span class="binding-detail-label">${escapeHtml(labels.account)}</span>
  2226. ${renderBindingAccountCell(binding)}
  2227. </div>
  2228. <div class="binding-detail-meta">
  2229. <div>
  2230. <span class="binding-detail-label">${escapeHtml(i18n("ui.role", "角色"))}</span>
  2231. <span class="role-badge ${escapeHtml(binding.binding_role)}">${t(binding.binding_role)}</span>
  2232. </div>
  2233. <div>
  2234. <span class="binding-detail-label">${escapeHtml(i18n("ui.status", "状态"))}</span>
  2235. <span class="pill ${escapeHtml(binding.status)}">${t(binding.status)}</span>
  2236. </div>
  2237. <div>
  2238. <span class="binding-detail-label">${escapeHtml(i18n("ui.risk", "风险"))}</span>
  2239. <span class="pill ${escapeHtml(binding.risk_level)}">${t(binding.risk_level)}</span>
  2240. </div>
  2241. <div>
  2242. <span class="binding-detail-label">${escapeHtml(i18n("ui.boundAt", "绑定时间"))}</span>
  2243. <strong>${escapeHtml(formatDate(binding.bound_at))}</strong>
  2244. </div>
  2245. </div>
  2246. <div class="table-icon-actions binding-detail-actions">
  2247. <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>
  2248. <button class="table-icon-button edit" type="button" data-edit="${binding.id}" title="${escapeHtml(i18n("ui.edit", "编辑"))}" aria-label="${escapeHtml(i18n("ui.edit", "编辑"))}"></button>
  2249. <button class="table-icon-button delete" type="button" data-delete="${binding.id}" title="${escapeHtml(i18n("ui.delete", "删除"))}" aria-label="${escapeHtml(i18n("ui.delete", "删除"))}"></button>
  2250. </div>
  2251. </article>
  2252. `).join("")}
  2253. </div>
  2254. `;
  2255. }
  2256. function renderBindingResourceCell(binding) {
  2257. if (binding.asset_type === "account") {
  2258. const account = state.accounts.find((item) => item.id === binding.asset_id);
  2259. return renderBindingIdentityCell({
  2260. iconHtml: renderPlatformLogo(account || "Account"),
  2261. title: account?.platform || "Account",
  2262. subtitle: account?.account_identifier || resolveName("accounts", binding.asset_id),
  2263. extraClass: "resource-account",
  2264. });
  2265. }
  2266. return `
  2267. <div class="binding-cell-main">
  2268. <span class="resource-mini-icon ${escapeHtml(binding.asset_type)}">${renderInlineAssetIcon(binding.asset_type)}</span>
  2269. <div>
  2270. <strong>${escapeHtml(resolveName(binding.asset_type, binding.asset_id))}</strong>
  2271. <span>${t(binding.asset_type)}</span>
  2272. </div>
  2273. </div>
  2274. `;
  2275. }
  2276. function renderBindingAccountCell(binding) {
  2277. const account = state.accounts.find((item) => item.id === binding.account_id);
  2278. const platform = account?.platform || binding.platform || "Account";
  2279. const identifier = account?.account_identifier || resolveName("accounts", binding.account_id);
  2280. return renderBindingIdentityCell({
  2281. iconHtml: renderPlatformLogo(account || platform),
  2282. title: platform,
  2283. subtitle: identifier,
  2284. extraClass: "account",
  2285. });
  2286. }
  2287. function renderBindingIdentityCell({ iconHtml, title, subtitle, extraClass = "" }) {
  2288. return `
  2289. <div class="binding-cell-main ${escapeHtml(extraClass)}">
  2290. ${iconHtml}
  2291. <div>
  2292. <strong>${escapeHtml(title)}</strong>
  2293. <span>${escapeHtml(subtitle)}</span>
  2294. </div>
  2295. </div>
  2296. `;
  2297. }
  2298. function renderPlatformLogo(platform) {
  2299. const meta = platformMeta(platform);
  2300. const logo = meta.src ? `<img src="${escapeHtml(meta.src)}" alt="" loading="lazy" />` : escapeHtml(meta.mark);
  2301. return `<span class="platform-logo ${meta.className}">${logo}</span>`;
  2302. }
  2303. function renderInlineAssetIcon(type) {
  2304. 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>`;
  2305. 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>`;
  2306. 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>`;
  2307. 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>`;
  2308. return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12M12 6v12"/></svg>`;
  2309. }
  2310. function renderCell(module, row, col) {
  2311. const value = row[col];
  2312. if (col === "platform") return renderPlatform(value, row);
  2313. if (col === "phone_number") return escapeHtml(formatPhoneNumber(value, row.country_code, row.phone_local_number));
  2314. if (["country_region", "region"].includes(col)) return renderRegion(value);
  2315. if (["status", "risk_level", "severity"].includes(col)) return `<span class="pill ${escapeHtml(value)}">${t(value)}</span>`;
  2316. if (typeof value === "boolean") return t(value);
  2317. if (col.endsWith("_at")) return formatDate(value);
  2318. if (col.endsWith("_id") || col === "asset_id") return escapeHtml(resolveName(col === "asset_id" ? row.asset_type : relationSource(col), value));
  2319. return escapeHtml(t(value));
  2320. }
  2321. function renderTopology(bindings) {
  2322. const activeBindings = bindings.filter((binding) => binding.status === "active");
  2323. if (!activeBindings.length) {
  2324. return `
  2325. <section class="topology-card">
  2326. <div class="toolbar">
  2327. <div>
  2328. <p class="eyebrow">${escapeHtml(i18n("ui.topology", "Topology"))}</p>
  2329. <h3>${escapeHtml(i18n("ui.assetBindingTopology", "资产绑定拓扑"))}</h3>
  2330. </div>
  2331. </div>
  2332. <div class="topology-empty">${escapeHtml(i18n("ui.noActiveBindings", "暂无活跃绑定关系"))}</div>
  2333. </section>
  2334. `;
  2335. }
  2336. const assetMap = new Map();
  2337. const accountMap = new Map();
  2338. activeBindings.forEach((binding) => {
  2339. const assetKey = `${binding.asset_type}:${binding.asset_id}`;
  2340. if (!assetMap.has(assetKey)) {
  2341. assetMap.set(assetKey, {
  2342. key: assetKey,
  2343. id: binding.asset_id,
  2344. type: binding.asset_type,
  2345. name: resolveName(binding.asset_type, binding.asset_id),
  2346. count: 0,
  2347. });
  2348. }
  2349. assetMap.get(assetKey).count += 1;
  2350. if (!accountMap.has(binding.account_id)) {
  2351. const account = state.accounts.find((item) => item.id === binding.account_id);
  2352. accountMap.set(binding.account_id, {
  2353. id: binding.account_id,
  2354. platform: account?.platform || binding.platform || "Account",
  2355. platform_logo: account?.platform_logo || "",
  2356. name: account?.account_identifier || resolveName("accounts", binding.account_id),
  2357. count: 0,
  2358. });
  2359. }
  2360. accountMap.get(binding.account_id).count += 1;
  2361. });
  2362. const assets = [...assetMap.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
  2363. const accounts = [...accountMap.values()].sort((a, b) => b.count - a.count || a.platform.localeCompare(b.platform));
  2364. const height = Math.max(340, Math.max(assets.length, accounts.length) * 82 + 70);
  2365. const assetY = makeYMap(assets, height);
  2366. const accountY = makeYMap(accounts, height);
  2367. const accountById = new Map(accounts.map((account) => [account.id, account]));
  2368. const lines = activeBindings
  2369. .map((binding) => {
  2370. const assetKey = `${binding.asset_type}:${binding.asset_id}`;
  2371. const fromY = assetY.get(assetKey);
  2372. const toY = accountY.get(binding.account_id);
  2373. if (!fromY || !toY) return "";
  2374. return `
  2375. <path class="topology-edge ${escapeHtml(binding.risk_level || "low")}" d="M 255 ${fromY} C 410 ${fromY}, 560 ${toY}, 715 ${toY}" />
  2376. <text class="topology-edge-label" x="485" y="${(fromY + toY) / 2 - 5}">${escapeHtml(t(binding.binding_role))}</text>
  2377. `;
  2378. })
  2379. .join("");
  2380. const assetNodes = assets.map((asset) => renderTopologyNode({
  2381. x: 55,
  2382. y: assetY.get(asset.key),
  2383. width: 200,
  2384. title: asset.name,
  2385. meta: `${t(asset.type)} · ${i18n("ui.bindingCount", "{count} 个绑定", { count: asset.count })}`,
  2386. kind: asset.type,
  2387. })).join("");
  2388. const accountNodes = accounts.map((account) => renderTopologyNode({
  2389. x: 715,
  2390. y: accountY.get(account.id),
  2391. width: 230,
  2392. title: account.platform,
  2393. meta: `${account.name} · ${i18n("ui.bindingCount", "{count} 个绑定", { count: account.count })}`,
  2394. kind: "account",
  2395. platform: account.platform,
  2396. platformLogo: account.platform_logo,
  2397. })).join("");
  2398. return `
  2399. <section class="topology-card">
  2400. <div class="toolbar">
  2401. <div>
  2402. <p class="eyebrow">${escapeHtml(i18n("ui.topology", "Topology"))}</p>
  2403. <h3>${escapeHtml(i18n("ui.assetBindingTopology", "资产绑定拓扑"))}</h3>
  2404. </div>
  2405. <div class="topology-legend">
  2406. <span><i class="phone"></i>${escapeHtml(labels.phone)}</span>
  2407. <span><i class="email"></i>${escapeHtml(labels.email)}</span>
  2408. <span><i class="domain"></i>${escapeHtml(labels.domain)}</span>
  2409. <span><i class="risk"></i>${escapeHtml(i18n("ui.riskBinding", "风险绑定"))}</span>
  2410. </div>
  2411. </div>
  2412. <div class="topology-stage">
  2413. <svg viewBox="0 0 1000 ${height}" role="img" aria-label="${escapeHtml(i18n("ui.assetBindingTopology", "资产绑定拓扑"))}">
  2414. <defs>
  2415. <filter id="nodeShadow" x="-20%" y="-20%" width="140%" height="140%">
  2416. <feDropShadow dx="0" dy="5" stdDeviation="5" flood-opacity="0.09"/>
  2417. </filter>
  2418. </defs>
  2419. <text class="topology-axis" x="55" y="32">${escapeHtml(i18n("ui.baseResources", "基础资源"))}</text>
  2420. <text class="topology-axis" x="715" y="32">${escapeHtml(i18n("ui.platformAccounts", "平台账号"))}</text>
  2421. ${lines}
  2422. ${assetNodes}
  2423. ${accountNodes}
  2424. </svg>
  2425. </div>
  2426. </section>
  2427. `;
  2428. }
  2429. function makeYMap(items, height) {
  2430. const map = new Map();
  2431. const top = 72;
  2432. const bottom = height - 42;
  2433. const step = items.length > 1 ? (bottom - top) / (items.length - 1) : 0;
  2434. items.forEach((item, index) => {
  2435. map.set(item.key || item.id, Math.round(top + step * index));
  2436. });
  2437. return map;
  2438. }
  2439. function renderTopologyNode({ x, y, width, title, meta, kind, platform, platformLogo }) {
  2440. const nodeHeight = 54;
  2441. const platformInfo = platform ? platformMeta(platform, platformLogo) : null;
  2442. const icon = platformInfo?.src
  2443. ? `<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" />`
  2444. : `<text class="node-mark-text" x="${x + 26}" y="${y + 4}">${escapeHtml(platformInfo?.mark || "")}</text>`;
  2445. return `
  2446. <g class="topology-node ${escapeHtml(kind)}" filter="url(#nodeShadow)">
  2447. <rect x="${x}" y="${y - nodeHeight / 2}" width="${width}" height="${nodeHeight}" rx="14" />
  2448. <circle class="${escapeHtml(platformInfo?.className || "")}" cx="${x + 26}" cy="${y}" r="12" />
  2449. ${kind === "account" ? icon : ""}
  2450. <text class="topology-node-title" x="${x + 48}" y="${y - 5}">${escapeHtml(truncate(title, 24))}</text>
  2451. <text class="topology-node-meta" x="${x + 48}" y="${y + 14}">${escapeHtml(truncate(meta, 30))}</text>
  2452. </g>
  2453. `;
  2454. }
  2455. function truncate(value, maxLength) {
  2456. const text = String(value || "");
  2457. return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text;
  2458. }
  2459. function renderEmpty(module) {
  2460. return `
  2461. <div class="empty">
  2462. <h3>${escapeHtml(i18n("ui.emptyRecord", "还没有{name}记录", { name: schemas[module].title }))}</h3>
  2463. <p>${escapeHtml(i18n("ui.emptyRecordDesc", "点击右上角新增,先把关键手机号、邮箱、账号和绑定关系录入起来,风险检测就能开始工作。"))}</p>
  2464. </div>
  2465. `;
  2466. }
  2467. function renderDetail(module, record, options = {}) {
  2468. const drawer = options.drawer === true;
  2469. const fields = schemas[module].fields.filter(([key]) => !["notes", "risk_notes", "description", "action_taken", "next_action"].includes(key));
  2470. const bindings = relatedBindings(module, record);
  2471. const incidents = module === "accounts" ? state.incidents.filter((i) => i.account_id === record.id) : [];
  2472. const risks = computeRisks().filter((risk) => risk.ref === record.id || risk.accountId === record.id);
  2473. const riskNotes = riskNotesToItems(record.risk_notes);
  2474. return `
  2475. <section class="detail-panel ${drawer ? "detail-panel-drawer" : ""}">
  2476. <div class="toolbar">
  2477. <div>
  2478. <p class="eyebrow">${escapeHtml(drawer ? i18n("ui.quickView", "Quick View") : i18n("ui.details", "Details"))}</p>
  2479. <h3>${escapeHtml(primaryName(module, record))}</h3>
  2480. </div>
  2481. <div class="inline-actions">
  2482. <button class="ghost-button" type="button" data-edit="${record.id}">${escapeHtml(i18n("ui.edit", "编辑"))}</button>
  2483. <button class="danger-button" type="button" data-delete="${record.id}">${escapeHtml(i18n("ui.delete", "删除"))}</button>
  2484. </div>
  2485. </div>
  2486. <div class="detail-grid">
  2487. ${fields.map(([key, label]) => `<div class="detail-item"><span>${label}</span><strong>${renderDetailValue(module, record, key)}</strong></div>`).join("")}
  2488. </div>
  2489. ${record.notes ? `<div class="detail-item"><span>${escapeHtml(i18n("ui.notes", "备注"))}</span><strong>${escapeHtml(record.notes)}</strong></div>` : ""}
  2490. ${riskNotes.length ? `<div><h3>${escapeHtml(i18n("ui.riskTips", "风险提示"))}</h3><div class="risk-list">${riskNotes.map(renderManualRiskNote).join("")}</div></div>` : ""}
  2491. <div>
  2492. <h3>${escapeHtml(i18n("ui.relatedBindings", "关联绑定"))}</h3>
  2493. <div class="bound-list">${bindings.length ? bindings.map(renderBindingCard).join("") : `<p class="muted">${escapeHtml(i18n("ui.noRelatedBindings", "暂无关联绑定。"))}</p>`}</div>
  2494. </div>
  2495. ${incidents.length ? `<div><h3>${escapeHtml(i18n("ui.eventTimeline", "事件时间线"))}</h3><div class="bound-list">${incidents.map(renderIncidentCard).join("")}</div></div>` : ""}
  2496. ${risks.length ? `<div><h3>${escapeHtml(i18n("ui.riskTips", "风险提示"))}</h3><div class="risk-list">${risks.map(renderRisk).join("")}</div></div>` : ""}
  2497. </section>
  2498. `;
  2499. }
  2500. function renderDetailValue(module, record, key) {
  2501. const value = record[key];
  2502. if (key === "platform") return renderPlatform(value);
  2503. if (key === "phone_number") return escapeHtml(formatPhoneNumber(value, record.country_code, record.phone_local_number));
  2504. if (["country_region", "region"].includes(key)) return renderRegion(value);
  2505. if (key.endsWith("_at")) return escapeHtml(formatDate(value));
  2506. if (typeof value === "boolean") return t(value);
  2507. if (key.endsWith("_id")) return escapeHtml(resolveName(relationSource(key), value));
  2508. if (["status", "risk_level", "severity"].includes(key)) return `<span class="pill ${escapeHtml(value)}">${t(value)}</span>`;
  2509. return escapeHtml(t(value));
  2510. }
  2511. function renderBindingCard(binding) {
  2512. return `
  2513. <div class="bound-item">
  2514. <strong>${escapeHtml(resolveName(binding.asset_type, binding.asset_id))} -> ${escapeHtml(resolveName("accounts", binding.account_id))}</strong>
  2515. <span class="muted">${t(binding.asset_type)} / ${t(binding.binding_role)} / ${t(binding.status)} / ${t(binding.risk_level)}</span>
  2516. ${binding.notes ? `<span>${escapeHtml(binding.notes)}</span>` : ""}
  2517. </div>
  2518. `;
  2519. }
  2520. function renderIncidentCard(incident) {
  2521. return `
  2522. <div class="bound-item">
  2523. <strong>${escapeHtml(t(incident.incident_type))} <span class="pill ${incident.severity}">${t(incident.severity)}</span></strong>
  2524. <span class="muted">${formatDate(incident.occurred_at)} / ${t(incident.status)}</span>
  2525. ${incident.next_action ? `<span>${escapeHtml(incident.next_action)}</span>` : ""}
  2526. </div>
  2527. `;
  2528. }
  2529. function filterRows(module) {
  2530. const globalQ = el.search.value.trim().toLowerCase();
  2531. const local = filters[module] || {};
  2532. return state[module].filter((row) => {
  2533. if (local.status && row.status !== local.status) return false;
  2534. const text = searchableText(module, row);
  2535. if (local.q && !text.includes(local.q.toLowerCase())) return false;
  2536. if (globalQ && !text.includes(globalQ)) return false;
  2537. return true;
  2538. });
  2539. }
  2540. function searchableText(module, row) {
  2541. const identityText = `${row.accountid || ""} ${row.userid || ""}`;
  2542. const base = `${schemas[module].search.flatMap((key) => [row[key]]).join(" ")} ${identityText}`;
  2543. if (module === "bindings") {
  2544. return `${base} ${resolveName(row.asset_type, row.asset_id)} ${resolveName("accounts", row.account_id)}`.toLowerCase();
  2545. }
  2546. return base.toLowerCase();
  2547. }
  2548. function getStatusOptions(module) {
  2549. return schemas[module].fields.find(([key]) => key === "status")?.[3]?.options || [];
  2550. }
  2551. function fieldLabel(module, key) {
  2552. return schemas[module].fields.find(([field]) => field === key)?.[1] || key;
  2553. }
  2554. function relationSource(key) {
  2555. if (key.includes("phone")) return "phones";
  2556. if (key.includes("email")) return "emails";
  2557. if (key.includes("account")) return "accounts";
  2558. if (key.includes("domain")) return "domains";
  2559. return key;
  2560. }
  2561. function primaryName(module, record) {
  2562. if (!record) return "-";
  2563. if (module === "phones" || module === "phone") return formatPhoneNumber(record.phone_number, record.country_code, record.phone_local_number);
  2564. if (module === "emails" || module === "email") return record.email;
  2565. if (module === "domains" || module === "domain") return record.domain;
  2566. if (module === "accounts") return `${record.platform || ""} ${record.account_identifier || ""}`.trim();
  2567. if (module === "bindings") return `${resolveName(record.asset_type, record.asset_id)} -> ${resolveName("accounts", record.account_id)}`;
  2568. if (module === "incidents") return `${record.platform || resolveName("accounts", record.account_id)} ${t(record.incident_type)}`.trim();
  2569. return record.id;
  2570. }
  2571. function resolveName(source, id) {
  2572. if (!id) return "-";
  2573. const collection = source.endsWith("s") ? source : `${source}s`;
  2574. const record = state[collection]?.find((item) => item.id === id);
  2575. return record ? primaryName(collection, record) : id;
  2576. }
  2577. function relatedBindings(module, record) {
  2578. if (module === "bindings") return [record];
  2579. if (module === "accounts") return state.bindings.filter((binding) => binding.account_id === record.id);
  2580. const assetType = module.replace(/s$/, "");
  2581. return state.bindings.filter((binding) => binding.asset_type === assetType && binding.asset_id === record.id);
  2582. }
  2583. function freeUsage() {
  2584. return {
  2585. assets: (state.phones?.length || 0) + (state.emails?.length || 0) + (state.domains?.length || 0),
  2586. accounts: state.accounts?.length || 0,
  2587. bindings: state.bindings?.length || 0,
  2588. };
  2589. }
  2590. function quotaKeyForModule(module) {
  2591. if (["phones", "emails", "domains"].includes(module)) return "assets";
  2592. if (module === "accounts") return "accounts";
  2593. if (module === "bindings") return "bindings";
  2594. return "";
  2595. }
  2596. function quotaLabel(key) {
  2597. return { assets: i18n("groups.assets", "基础资产"), accounts: labels.accounts, bindings: labels.bindings }[key] || (currentLocale === "en" ? "records" : "记录");
  2598. }
  2599. function canCreateInFreePlan(module) {
  2600. if (isProPlan()) return true;
  2601. const key = quotaKeyForModule(module);
  2602. if (!key) return true;
  2603. return freeUsage()[key] < FREE_LIMITS[key];
  2604. }
  2605. function showLimitGate(module) {
  2606. const key = quotaKeyForModule(module);
  2607. const used = freeUsage()[key] || 0;
  2608. const limit = FREE_LIMITS[key];
  2609. const reason = i18n("ui.limitReason", "免费版最多可创建 {limit} 个{name},当前已使用 {used}/{limit}。", { limit, name: quotaLabel(key), used });
  2610. toast(reason, "warning");
  2611. openPricing();
  2612. }
  2613. function openEditor(module, id) {
  2614. if (!id && !canCreateInFreePlan(module)) {
  2615. showLimitGate(module);
  2616. return;
  2617. }
  2618. if (module === "bindings") { openBindingEditor(id); return; }
  2619. editing = { module, id };
  2620. const record = id ? state[module].find((row) => row.id === id) : { ...defaults[module] };
  2621. el.dialogKicker.textContent = schemas[module].title;
  2622. el.dialogTitle.textContent = id
  2623. ? i18n("ui.editRecord", "编辑{name}", { name: schemas[module].title })
  2624. : i18n("ui.addRecord", "新增{name}", { name: schemas[module].title });
  2625. el.fields.classList.remove("was-validated");
  2626. setSaving(false);
  2627. el.fields.innerHTML = schemas[module].fields.map(([key, label, type, opts = {}]) => renderField(module, record, key, label, type, opts)).join("");
  2628. wireDynamicAssetField(record);
  2629. wirePhoneCountryFields();
  2630. wireLogoUpload();
  2631. el.dialog.showModal();
  2632. scheduleTour();
  2633. }
  2634. function wireLogoUpload() {
  2635. const input = el.fields.querySelector("#platform_logo");
  2636. const file = el.fields.querySelector("[data-logo-upload]");
  2637. if (!input || !file) return;
  2638. file.addEventListener("change", () => {
  2639. const selected = file.files?.[0];
  2640. if (!selected) return;
  2641. const reader = new FileReader();
  2642. reader.addEventListener("load", () => {
  2643. input.value = String(reader.result || "");
  2644. });
  2645. reader.readAsDataURL(selected);
  2646. });
  2647. }
  2648. const bindingRolesByAssetType = {
  2649. phone: ["trusted_phone", "two_factor", "recovery", "notification", "payment", "owner", "unknown"],
  2650. email: ["login", "recovery", "notification", "alias", "owner", "unknown"],
  2651. domain: ["alias", "owner", "notification", "unknown"],
  2652. account: ["login", "owner", "unknown"],
  2653. };
  2654. function bindingRoleLabel(role) {
  2655. const descs = {
  2656. login: "登录凭据",
  2657. recovery: "账号恢复",
  2658. trusted_phone: "受信任手机号",
  2659. two_factor: "两步验证 (2FA)",
  2660. notification: "通知接收",
  2661. payment: "支付方式",
  2662. owner: "实名 / 所有人",
  2663. alias: "别名",
  2664. unknown: "其他",
  2665. };
  2666. return descs[role] || t(role);
  2667. }
  2668. function openBindingEditor(id) {
  2669. if (!id && !canCreateInFreePlan("bindings")) {
  2670. showLimitGate("bindings");
  2671. return;
  2672. }
  2673. editing = { module: "bindings", id };
  2674. const record = id ? state.bindings.find((r) => r.id === id) : { ...defaults.bindings };
  2675. el.dialogKicker.textContent = schemas.bindings.title;
  2676. el.dialogTitle.textContent = id
  2677. ? i18n("ui.editRecord", "编辑{name}", { name: schemas.bindings.title })
  2678. : i18n("ui.addRecord", "新增{name}", { name: schemas.bindings.title });
  2679. el.fields.classList.remove("was-validated");
  2680. setSaving(false);
  2681. el.fields.innerHTML = renderBindingForm(record);
  2682. wireBindingForm();
  2683. el.dialog.showModal();
  2684. scheduleTour();
  2685. }
  2686. const assetTypeIcon = {
  2687. 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>`,
  2688. 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>`,
  2689. 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>`,
  2690. 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>`,
  2691. };
  2692. function renderAssetItems(assetType, selectedId) {
  2693. const collection = `${assetType}s`;
  2694. const items = state[collection] || [];
  2695. if (!items.length) return `<div class="asset-item-empty">${escapeHtml(i18n("ui.noModuleData", "暂无{name}数据", { name: t(assetType) }))}</div>`;
  2696. return items.map((item) => {
  2697. const name = primaryName(collection, item);
  2698. return `<div class="asset-item${item.id === selectedId ? " selected" : ""}" data-id="${escapeHtml(item.id)}"><span>${escapeHtml(name)}</span></div>`;
  2699. }).join("");
  2700. }
  2701. function renderBindingForm(record) {
  2702. const assetType = record.asset_type || "phone";
  2703. const roles = bindingRolesByAssetType[assetType] || enums.bindingRole;
  2704. const roleOpts = roles.map((r) => `<option value="${r}" ${r === record.binding_role ? "selected" : ""}>${escapeHtml(bindingRoleLabel(r))}</option>`).join("");
  2705. const statusOpts = enums.bindingStatus.map((s) => `<option value="${s}" ${s === (record.status || "active") ? "selected" : ""}>${t(s)}</option>`).join("");
  2706. const assetTypeTabs = enums.assetType.map((tp) =>
  2707. `<button type="button" class="asset-type-tab${tp === assetType ? " active" : ""}" data-type="${tp}">${assetTypeIcon[tp] || ""}<span>${t(tp)}</span></button>`
  2708. ).join("");
  2709. const platformCounts = new Map();
  2710. state.accounts.forEach((a) => { const p = a.platform || ""; platformCounts.set(p, (platformCounts.get(p) || 0) + 1); });
  2711. const platforms = [...platformCounts.keys()].filter(Boolean).sort();
  2712. const platformTabs = `<button type="button" class="platform-tab active" data-platform="">${escapeHtml(i18n("ui.all", "全部"))} (${state.accounts.length})</button>` +
  2713. platforms.map((p) => `<button type="button" class="platform-tab" data-platform="${escapeHtml(p)}">${escapeHtml(p)} (${platformCounts.get(p)})</button>`).join("");
  2714. const accountItems = [...state.accounts]
  2715. .sort((a, b) => (a.platform || "").localeCompare(b.platform || "") || (a.account_identifier || "").localeCompare(b.account_identifier || ""))
  2716. .map((a) => {
  2717. const meta = platformMeta(a);
  2718. const logo = meta.src ? `<img src="${escapeHtml(meta.src)}" alt="" />` : escapeHtml(meta.mark || "?");
  2719. const search = `${a.platform || ""} ${a.account_identifier || ""} ${a.display_name || ""}`.toLowerCase();
  2720. 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)}">
  2721. <span class="account-item-logo platform-logo ${escapeHtml(meta.className)}">${logo}</span>
  2722. <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>
  2723. </div>`;
  2724. }).join("");
  2725. return `
  2726. <div class="binding-sentence" id="binding-sentence">
  2727. <span class="bs-text">${escapeHtml(i18n("ui.put", "把"))}</span>
  2728. <span class="bs-chip" id="bs-asset">—</span>
  2729. <span class="bs-text">${escapeHtml(i18n("ui.as", "作为"))}</span>
  2730. <span class="bs-chip" id="bs-account">—</span>
  2731. <span class="bs-text">${escapeHtml(i18n("ui.possessive", "的"))}</span>
  2732. <span class="bs-chip" id="bs-role">—</span>
  2733. </div>
  2734. <div class="binding-section-label">${escapeHtml(i18n("ui.selectAsset", "选择资产"))} <span class="required-mark">${escapeHtml(i18n("ui.required", "必填"))}</span></div>
  2735. <div class="form-field full">
  2736. <input type="hidden" id="asset_type" name="asset_type" value="${escapeHtml(assetType)}">
  2737. <input type="hidden" id="asset_id" name="asset_id" value="${escapeHtml(record.asset_id || "")}">
  2738. <div class="asset-type-tabs" id="asset-type-tabs">${assetTypeTabs}</div>
  2739. <div class="asset-picker-list" id="asset-picker-list">${renderAssetItems(assetType, record.asset_id || "")}</div>
  2740. </div>
  2741. <div class="binding-section-label">${escapeHtml(i18n("ui.selectAccount", "选择账号"))} <span class="required-mark">${escapeHtml(i18n("ui.required", "必填"))}</span></div>
  2742. <div class="form-field full">
  2743. <input type="hidden" id="account_id" name="account_id" value="${escapeHtml(record.account_id || "")}">
  2744. <div class="account-picker" id="account-picker">
  2745. <div class="account-picker-search">
  2746. <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>
  2747. <input type="text" id="account-search" placeholder="${escapeHtml(i18n("ui.searchModule", "搜索{name}…", { name: labels.account }))}" autocomplete="off" spellcheck="false">
  2748. </div>
  2749. <div class="account-filter-tabs" id="account-filter-tabs">${platformTabs}</div>
  2750. <div class="account-picker-list" id="account-picker-list">${accountItems}</div>
  2751. </div>
  2752. </div>
  2753. <div class="binding-section-label">${escapeHtml(i18n("ui.usageRole", "用途 / 绑定角色"))} <span class="required-mark">${escapeHtml(i18n("ui.required", "必填"))}</span></div>
  2754. <div class="form-field full">
  2755. <label for="binding_role" class="sr-only">${escapeHtml(fieldLabel("bindings", "binding_role"))}</label>
  2756. <select id="binding_role" name="binding_role" required>
  2757. <option value="">${escapeHtml(i18n("ui.selectUsage", "选择用途"))}</option>
  2758. ${roleOpts}
  2759. </select>
  2760. </div>
  2761. <div class="binding-section-label">${escapeHtml(fieldLabel("bindings", "status"))}</div>
  2762. <div class="form-field full">
  2763. <label for="status" class="sr-only">${escapeHtml(fieldLabel("bindings", "status"))}</label>
  2764. <select id="status" name="status" required>
  2765. ${statusOpts}
  2766. </select>
  2767. </div>
  2768. <details class="binding-more">
  2769. <summary>${escapeHtml(i18n("ui.moreOptions", "更多选项"))}</summary>
  2770. <div class="binding-more-grid">
  2771. ${renderField("bindings", record, "bound_at", fieldLabel("bindings", "bound_at"), "datetime-local")}
  2772. ${renderField("bindings", record, "unbound_at", fieldLabel("bindings", "unbound_at"), "datetime-local")}
  2773. ${renderField("bindings", record, "risk_level", fieldLabel("bindings", "risk_level"), "select", { options: enums.riskLevel })}
  2774. <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>
  2775. ${renderField("bindings", record, "tags", fieldLabel("bindings", "tags"), "text", { placeholder: i18n("ui.optionalTags", "可选标签") })}
  2776. ${renderField("bindings", record, "notes", fieldLabel("bindings", "notes"), "textarea")}
  2777. </div>
  2778. </details>
  2779. `;
  2780. }
  2781. function wireBindingForm() {
  2782. const assetTypeInput = el.fields.querySelector("#asset_type");
  2783. const assetIdInput = el.fields.querySelector("#asset_id");
  2784. const assetTypeTabs = el.fields.querySelector("#asset-type-tabs");
  2785. const assetPickerList = el.fields.querySelector("#asset-picker-list");
  2786. const accountInput = el.fields.querySelector("#account_id");
  2787. const roleSel = el.fields.querySelector("[name='binding_role']");
  2788. const searchInput = el.fields.querySelector("#account-search");
  2789. const filterTabs = el.fields.querySelector("#account-filter-tabs");
  2790. const accountPickerList = el.fields.querySelector("#account-picker-list");
  2791. function updateSentence() {
  2792. const assetName = assetIdInput.value
  2793. ? resolveName(assetTypeInput.value, assetIdInput.value)
  2794. : "—";
  2795. const acct = state.accounts.find((a) => a.id === accountInput.value);
  2796. const accountText = acct ? `${acct.platform} ${acct.account_identifier || acct.display_name || ""}`.trim() : "—";
  2797. const roleText = roleSel.value ? roleSel.options[roleSel.selectedIndex].text : "—";
  2798. el.fields.querySelector("#bs-asset").textContent = assetName;
  2799. el.fields.querySelector("#bs-account").textContent = accountText;
  2800. el.fields.querySelector("#bs-role").textContent = roleText;
  2801. }
  2802. // Asset type tab click
  2803. assetTypeTabs.addEventListener("click", (e) => {
  2804. const tab = e.target.closest(".asset-type-tab");
  2805. if (!tab) return;
  2806. const newType = tab.dataset.type;
  2807. assetTypeTabs.querySelectorAll(".asset-type-tab").forEach((t) => t.classList.remove("active"));
  2808. tab.classList.add("active");
  2809. assetTypeInput.value = newType;
  2810. assetIdInput.value = "";
  2811. assetPickerList.innerHTML = renderAssetItems(newType, "");
  2812. const roles = bindingRolesByAssetType[newType] || enums.bindingRole;
  2813. roleSel.innerHTML = `<option value="">${escapeHtml(i18n("ui.selectUsage", "选择用途"))}</option>` + roles.map((r) => `<option value="${r}">${escapeHtml(bindingRoleLabel(r))}</option>`).join("");
  2814. updateSentence();
  2815. });
  2816. // Asset item click
  2817. assetPickerList.addEventListener("click", (e) => {
  2818. const item = e.target.closest(".asset-item");
  2819. if (!item || !item.dataset.id) return;
  2820. assetPickerList.querySelectorAll(".asset-item").forEach((i) => i.classList.remove("selected"));
  2821. item.classList.add("selected");
  2822. assetIdInput.value = item.dataset.id;
  2823. updateSentence();
  2824. });
  2825. // Account search/filter
  2826. function filterAccounts() {
  2827. const query = (searchInput.value || "").toLowerCase();
  2828. const activePlatform = filterTabs.querySelector(".platform-tab.active")?.dataset.platform || "";
  2829. accountPickerList.querySelectorAll(".account-item").forEach((item) => {
  2830. const platformOk = !activePlatform || item.dataset.platform === activePlatform;
  2831. const searchOk = !query || item.dataset.search.includes(query);
  2832. item.hidden = !(platformOk && searchOk);
  2833. });
  2834. }
  2835. accountPickerList.addEventListener("click", (e) => {
  2836. const item = e.target.closest(".account-item");
  2837. if (!item) return;
  2838. accountPickerList.querySelectorAll(".account-item").forEach((i) => i.classList.remove("selected"));
  2839. item.classList.add("selected");
  2840. accountInput.value = item.dataset.id;
  2841. el.fields.querySelector("#account-picker").classList.remove("picker-error");
  2842. updateSentence();
  2843. });
  2844. filterTabs.addEventListener("click", (e) => {
  2845. const tab = e.target.closest(".platform-tab");
  2846. if (!tab) return;
  2847. filterTabs.querySelectorAll(".platform-tab").forEach((t) => t.classList.remove("active"));
  2848. tab.classList.add("active");
  2849. filterAccounts();
  2850. });
  2851. searchInput.addEventListener("input", filterAccounts);
  2852. if (accountInput.value) {
  2853. accountPickerList.querySelector(`.account-item[data-id="${CSS.escape(accountInput.value)}"]`)?.scrollIntoView({ block: "nearest" });
  2854. }
  2855. roleSel.addEventListener("change", updateSentence);
  2856. updateSentence();
  2857. }
  2858. function renderField(module, record, key, label, type, opts = {}) {
  2859. const value = record[key] ?? "";
  2860. const required = opts.required ? "required" : "";
  2861. const requiredMark = opts.required ? `<span class="required-mark">必填</span>` : "";
  2862. const labelHtml = `<span>${label}</span>${requiredMark}`;
  2863. const hint = key === "credential_ref" || key === "recovery_ref"
  2864. ? `<small class="field-hint">只保存密码管理器引用,不保存明文密码、2FA Secret 或恢复码。</small>`
  2865. : key === "platform_logo"
  2866. ? `<small class="field-hint">${escapeHtml(i18n("ui.logoHint", "可选。支持 https 图片地址、data:image... 或 assets/platforms/*.svg;不填则使用内置品牌或首字母。"))}</small>`
  2867. : "";
  2868. if (module === "phones" && key === "country_region") {
  2869. const selected = matchCountry(value, record.country_code) || matchCountry("CN");
  2870. return `<input type="hidden" id="${key}" name="${key}" value="${escapeHtml(selected?.code || value || "")}">`;
  2871. }
  2872. if (module === "phones" && key === "country_code") {
  2873. const selected = matchCountry(record.country_region, value) || matchCountry("CN");
  2874. const options = countryList().map((country) =>
  2875. `<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>`
  2876. ).join("");
  2877. const localNumber = record.phone_local_number || splitPhoneNumber(record.phone_number, value, record.phone_local_number).localNumber;
  2878. return `
  2879. <div class="form-field full phone-combo-field">
  2880. <label for="phone_local_number">${labelHtml}</label>
  2881. <div class="phone-combo">
  2882. <select id="${key}" name="${key}" ${required}>${options}</select>
  2883. <input id="phone_local_number" name="phone_local_number" type="text" value="${escapeHtml(localNumber || "")}" placeholder="${escapeHtml(opts.placeholder || "131xxxx0000")}" required />
  2884. </div>
  2885. ${hint}
  2886. </div>
  2887. `;
  2888. }
  2889. if (module === "phones" && key === "phone_local_number") {
  2890. return "";
  2891. }
  2892. if (module === "accounts" && key === "region") {
  2893. const selected = matchCountry(value) || null;
  2894. const options = countryList().map((country) =>
  2895. `<option value="${escapeHtml(country.code)}" ${country.code === selected?.code ? "selected" : ""}>${escapeHtml(`${country.flag} ${country.name}`)}</option>`
  2896. ).join("");
  2897. 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>`;
  2898. }
  2899. if (module === "accounts" && key === "platform_logo") {
  2900. return `
  2901. <div class="form-field full logo-field">
  2902. <label for="${key}">${labelHtml}</label>
  2903. <div class="logo-input-row">
  2904. <input id="${key}" name="${key}" type="text" value="${escapeHtml(value)}" placeholder="${escapeHtml(opts.placeholder || "")}" />
  2905. <label class="ghost-button logo-upload-button">
  2906. ${escapeHtml(i18n("ui.uploadLogo", "上传图片"))}
  2907. <input type="file" data-logo-upload accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif" hidden>
  2908. </label>
  2909. </div>
  2910. ${hint}
  2911. </div>
  2912. `;
  2913. }
  2914. if (type === "textarea") {
  2915. return `<div class="form-field full"><label for="${key}">${labelHtml}</label><textarea id="${key}" name="${key}" ${required}>${escapeHtml(value)}</textarea>${hint}</div>`;
  2916. }
  2917. if (type === "select") {
  2918. 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>`;
  2919. }
  2920. if (type === "relation") {
  2921. 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>`;
  2922. }
  2923. if (type === "asset-relation") {
  2924. const assetType = record.asset_type || "phone";
  2925. return `<div class="form-field"><label for="${key}">${labelHtml}</label><select id="${key}" name="${key}" ${required}>${assetOptions(assetType, value)}</select>${hint}</div>`;
  2926. }
  2927. if (type === "checkbox") {
  2928. 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>`;
  2929. }
  2930. const inputValue = type === "datetime-local" ? toInputDate(value) : value;
  2931. 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>`;
  2932. }
  2933. function wireDynamicAssetField(record) {
  2934. const typeSelect = el.fields.querySelector("[name='asset_type']");
  2935. const assetSelect = el.fields.querySelector("[name='asset_id']");
  2936. if (!typeSelect || !assetSelect) return;
  2937. typeSelect.addEventListener("change", () => {
  2938. assetSelect.innerHTML = assetOptions(typeSelect.value, record.asset_id);
  2939. });
  2940. }
  2941. function wirePhoneCountryFields() {
  2942. const regionSelect = el.fields.querySelector("#country_region");
  2943. const codeSelect = el.fields.querySelector("#country_code");
  2944. if (!regionSelect || !codeSelect) return;
  2945. if (regionSelect.type === "hidden") {
  2946. codeSelect.addEventListener("change", () => {
  2947. const countryCode = codeSelect.selectedOptions[0]?.dataset.countryCode;
  2948. if (countryCode) regionSelect.value = countryCode;
  2949. });
  2950. return;
  2951. }
  2952. regionSelect.addEventListener("change", () => {
  2953. const dialCode = regionSelect.selectedOptions[0]?.dataset.dialCode;
  2954. if (dialCode) codeSelect.value = dialCode;
  2955. });
  2956. codeSelect.addEventListener("change", () => {
  2957. const countryCode = codeSelect.selectedOptions[0]?.dataset.countryCode;
  2958. if (countryCode) regionSelect.value = countryCode;
  2959. });
  2960. }
  2961. function assetOptions(assetType, selectedId) {
  2962. const collection = `${assetType}s`;
  2963. const items = state[collection] || [];
  2964. 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("")}`;
  2965. }
  2966. async function handleFormSubmit(event) {
  2967. if (event.submitter?.value === "cancel") return;
  2968. event.preventDefault();
  2969. const { module, id } = editing || {};
  2970. if (!module) return;
  2971. const formData = new FormData(el.form);
  2972. const record = id ? { ...state[module].find((row) => row.id === id) } : { id: uid(module.slice(0, -1)), created_at: nowIso() };
  2973. schemas[module].fields.forEach(([key, , type]) => {
  2974. if (type === "checkbox") record[key] = formData.has(key);
  2975. else if (type === "datetime-local") record[key] = toIsoFromInput(formData.get(key));
  2976. else record[key] = String(formData.get(key) || "").trim();
  2977. });
  2978. if (module === "phones") {
  2979. const split = splitPhoneNumber(record.phone_number, record.country_code, record.phone_local_number);
  2980. record.country_code = split.countryCode;
  2981. record.phone_local_number = split.localNumber;
  2982. record.phone_number = `${split.countryCode}${split.localNumber}`;
  2983. }
  2984. record.updated_at = nowIso();
  2985. const error = validateRecord(module, record);
  2986. if (error) {
  2987. el.fields.classList.add("was-validated");
  2988. if (module === "bindings" && !record.account_id) el.fields.querySelector("#account-picker")?.classList.add("picker-error");
  2989. toast(error, "error");
  2990. return;
  2991. }
  2992. if (!id && module === "accounts" && !isProPlan()) {
  2993. const autoBindingCount = [record.login_email_id, record.login_phone_id].filter(Boolean).length;
  2994. if (freeUsage().bindings + autoBindingCount > FREE_LIMITS.bindings) {
  2995. showLimitGate("bindings");
  2996. return;
  2997. }
  2998. }
  2999. setSaving(true);
  3000. if (module === "bindings") {
  3001. const account = state.accounts.find((item) => item.id === record.account_id);
  3002. record.platform = account?.platform || record.platform || "";
  3003. }
  3004. if (module === "incidents" && !record.platform) {
  3005. const account = state.accounts.find((item) => item.id === record.account_id);
  3006. record.platform = account?.platform || "";
  3007. }
  3008. Object.assign(record, stampRecord(record));
  3009. if (id) state[module] = state[module].map((row) => (row.id === id ? record : row));
  3010. else state[module].unshift(record);
  3011. if (module === "accounts") syncAccountLoginBindings(record);
  3012. try {
  3013. await saveState();
  3014. selected = record.id;
  3015. el.dialog.close();
  3016. handleTourRecordSaved(module);
  3017. toast(id ? "修改已保存" : "新记录已创建", "success");
  3018. render();
  3019. } finally {
  3020. setSaving(false);
  3021. }
  3022. }
  3023. const AUTO_ACCOUNT_LOGIN_TAG = "auto:account-login";
  3024. function syncAccountLoginBindings(account) {
  3025. const desired = [
  3026. { field: "login_email_id", assetType: "email", assetId: account.login_email_id },
  3027. { field: "login_phone_id", assetType: "phone", assetId: account.login_phone_id },
  3028. ].filter((item) => item.assetId);
  3029. const desiredKeys = new Set(desired.map((item) => `${item.assetType}:${item.assetId}`));
  3030. state.bindings = state.bindings.filter((binding) => {
  3031. const isAutoLogin = binding.account_id === account.id &&
  3032. binding.binding_role === "login" &&
  3033. String(binding.tags || "").split(",").map((tag) => tag.trim()).includes(AUTO_ACCOUNT_LOGIN_TAG);
  3034. if (!isAutoLogin) return true;
  3035. return desiredKeys.has(`${binding.asset_type}:${binding.asset_id}`);
  3036. });
  3037. desired.forEach((item) => {
  3038. const existing = state.bindings.find((binding) =>
  3039. binding.account_id === account.id &&
  3040. binding.asset_type === item.assetType &&
  3041. binding.asset_id === item.assetId &&
  3042. binding.binding_role === "login"
  3043. );
  3044. if (existing) {
  3045. existing.platform = account.platform || existing.platform || "";
  3046. existing.status = existing.status || "active";
  3047. existing.risk_level = existing.risk_level || "low";
  3048. existing.updated_at = nowIso();
  3049. Object.assign(existing, stampRecord(existing));
  3050. return;
  3051. }
  3052. state.bindings.unshift({
  3053. ...stampRecord({
  3054. id: uid("binding"),
  3055. asset_type: item.assetType,
  3056. asset_id: item.assetId,
  3057. account_id: account.id,
  3058. platform: account.platform || "",
  3059. binding_role: "login",
  3060. status: "active",
  3061. bound_at: nowIso(),
  3062. unbound_at: "",
  3063. can_unbind: true,
  3064. risk_level: "low",
  3065. tags: AUTO_ACCOUNT_LOGIN_TAG,
  3066. notes: "由账号登录字段自动创建",
  3067. created_at: nowIso(),
  3068. updated_at: nowIso(),
  3069. }),
  3070. });
  3071. });
  3072. }
  3073. function validateRecord(module, record) {
  3074. if (module === "phones" && !/^\+[1-9][0-9]{0,3}$/.test(record.country_code)) return "国码格式应类似 +86";
  3075. if (module === "phones" && !/^[0-9]{5,18}$/.test(record.phone_local_number)) return "本地号码只填写数字,例如 18533752119";
  3076. if (module === "phones" && !/^\+[1-9][0-9]{5,18}$/.test(record.phone_number)) return "手机号组合后格式不正确";
  3077. if (module === "emails" && !isEmail(record.email)) return "请输入有效邮箱地址";
  3078. if (module === "emails" && record.forward_to && !isEmail(record.forward_to)) return "转发目标必须是有效邮箱地址";
  3079. if (module === "accounts" && /(明文密码|短信验证码|邮箱验证码|2fa secret|secret=|cvv|完整卡号|恢复码[::]\s*\S+)/i.test(`${record.notes} ${record.credential_ref} ${record.recovery_ref}`)) {
  3080. return "请只保存凭据引用,不要记录明文密码、验证码、2FA Secret 或恢复码";
  3081. }
  3082. if (module === "bindings" && !state.accounts.some((a) => a.id === record.account_id)) return "请选择账号";
  3083. if (module === "bindings" && !state[`${record.asset_type}s`]?.some((item) => item.id === record.asset_id)) return "请选择有效资产";
  3084. return "";
  3085. }
  3086. function isEmail(value) {
  3087. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value || "");
  3088. }
  3089. async function removeRecord(module, id) {
  3090. const record = state[module].find((row) => row.id === id);
  3091. if (!record) return;
  3092. if (!confirm(`确认删除「${primaryName(module, record)}」?`)) return;
  3093. state[module] = state[module].filter((row) => row.id !== id);
  3094. if (module === "accounts") {
  3095. state.bindings = state.bindings.filter((binding) => binding.account_id !== id);
  3096. state.incidents = state.incidents.filter((incident) => incident.account_id !== id);
  3097. }
  3098. if (["phones", "emails", "domains"].includes(module)) {
  3099. const assetType = module.replace(/s$/, "");
  3100. state.bindings = state.bindings.filter((binding) => !(binding.asset_type === assetType && binding.asset_id === id));
  3101. }
  3102. await saveState();
  3103. selected = null;
  3104. toast("已删除", "success");
  3105. render();
  3106. }
  3107. function computeRisks() {
  3108. const risks = [];
  3109. const activeBindings = state.bindings.filter((binding) => binding.status === "active");
  3110. activeBindings.forEach((binding) => {
  3111. const account = state.accounts.find((item) => item.id === binding.account_id);
  3112. if (binding.asset_type === "phone") {
  3113. const phone = state.phones.find((item) => item.id === binding.asset_id);
  3114. if (phone && ["cannot_receive_sms", "inactive", "released"].includes(phone.status) && ["two_factor", "trusted_phone", "recovery"].includes(binding.binding_role)) {
  3115. risks.push({ level: "high", ref: phone.id, accountId: account?.id, title: "不可收码手机号仍用于验证/恢复", detail: `${phone.phone_number} 仍绑定 ${account?.platform || "账号"} 的 ${t(binding.binding_role)}` });
  3116. }
  3117. if (phone?.is_primary && account && ["locked", "suspended", "unusable"].includes(account.status)) {
  3118. risks.push({ level: "medium", ref: phone.id, accountId: account.id, title: "异常账号仍绑定主力手机号", detail: `${account.platform} ${account.account_identifier} 当前为 ${t(account.status)}` });
  3119. }
  3120. }
  3121. if (binding.asset_type === "email") {
  3122. const email = state.emails.find((item) => item.id === binding.asset_id);
  3123. if (email && ["cannot_receive", "inactive"].includes(email.status) && ["login", "recovery"].includes(binding.binding_role)) {
  3124. risks.push({ level: "high", ref: email.id, accountId: account?.id, title: "不可收信邮箱仍用于登录/恢复", detail: `${email.email} 仍绑定 ${account?.platform || "账号"} 的 ${t(binding.binding_role)}` });
  3125. }
  3126. }
  3127. });
  3128. state.phones.forEach((phone) => {
  3129. const count = activeBindings.filter((binding) => binding.asset_type === "phone" && binding.asset_id === phone.id).length;
  3130. if (count >= 5) {
  3131. risks.push({ level: "medium", ref: phone.id, title: "手机号绑定账号过多", detail: `${phone.phone_number} 当前有 ${count} 个活跃绑定,建议拆分风险。` });
  3132. }
  3133. });
  3134. const deadline = Date.now() + 30 * 24 * 60 * 60 * 1000;
  3135. state.domains.forEach((domain) => {
  3136. const expires = domain.expires_at ? new Date(domain.expires_at).getTime() : Infinity;
  3137. const activeAliases = state.emails.filter((email) => email.domain === domain.domain && email.status === "available");
  3138. if (expires <= deadline && activeAliases.length) {
  3139. risks.push({ level: "high", ref: domain.id, title: "域名 30 天内到期且承载邮箱", detail: `${domain.domain} 将于 ${formatDate(domain.expires_at)} 到期,仍有 ${activeAliases.length} 个可用邮箱。` });
  3140. }
  3141. });
  3142. return risks;
  3143. }
  3144. function exportJson() {
  3145. download("bindvault-backup.json", "application/json", JSON.stringify({ exported_at: nowIso(), data: state }, null, 2));
  3146. }
  3147. function importJson(event) {
  3148. const file = event.target.files?.[0];
  3149. if (!file) return;
  3150. const reader = new FileReader();
  3151. reader.onload = async () => {
  3152. try {
  3153. const parsed = JSON.parse(reader.result);
  3154. state = { ...emptyState(), ...(parsed.data || parsed) };
  3155. await saveState();
  3156. selected = null;
  3157. toast("JSON 已导入", "success");
  3158. render();
  3159. } catch {
  3160. toast("JSON 解析失败", "error");
  3161. }
  3162. };
  3163. reader.readAsText(file);
  3164. event.target.value = "";
  3165. }
  3166. function exportCsv(module) {
  3167. const rows = module === "risks" ? computeRisks() : state[module];
  3168. const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
  3169. const csv = [headers.join(","), ...rows.map((row) => headers.map((key) => csvValue(row[key])).join(","))].join("\n");
  3170. download(`bindvault-${module}.csv`, "text/csv;charset=utf-8", csv);
  3171. }
  3172. function importCsv(module, event) {
  3173. const file = event.target.files?.[0];
  3174. if (!file) return;
  3175. const reader = new FileReader();
  3176. reader.onload = async () => {
  3177. const records = parseCsv(reader.result).map((row) => ({
  3178. ...defaults[module],
  3179. ...row,
  3180. id: row.id || uid(module.slice(0, -1)),
  3181. created_at: row.created_at || nowIso(),
  3182. updated_at: nowIso(),
  3183. }));
  3184. state[module] = [...records, ...state[module]];
  3185. await saveState();
  3186. toast(`已导入 ${records.length} 条 CSV`, "success");
  3187. render();
  3188. };
  3189. reader.readAsText(file);
  3190. event.target.value = "";
  3191. }
  3192. function csvValue(value) {
  3193. const text = String(value ?? "");
  3194. return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
  3195. }
  3196. function parseCsv(text) {
  3197. const lines = text.trim().split(/\r?\n/);
  3198. const headers = splitCsvLine(lines.shift() || "");
  3199. return lines.filter(Boolean).map((line) => {
  3200. const values = splitCsvLine(line);
  3201. return Object.fromEntries(headers.map((header, index) => [header, values[index] || ""]));
  3202. });
  3203. }
  3204. function splitCsvLine(line) {
  3205. const result = [];
  3206. let cell = "";
  3207. let quoted = false;
  3208. for (let i = 0; i < line.length; i += 1) {
  3209. const char = line[i];
  3210. const next = line[i + 1];
  3211. if (char === '"' && quoted && next === '"') {
  3212. cell += '"';
  3213. i += 1;
  3214. } else if (char === '"') {
  3215. quoted = !quoted;
  3216. } else if (char === "," && !quoted) {
  3217. result.push(cell);
  3218. cell = "";
  3219. } else {
  3220. cell += char;
  3221. }
  3222. }
  3223. result.push(cell);
  3224. return result;
  3225. }
  3226. function download(filename, type, content) {
  3227. const blob = new Blob([content], { type });
  3228. const url = URL.createObjectURL(blob);
  3229. const anchor = document.createElement("a");
  3230. anchor.href = url;
  3231. anchor.download = filename;
  3232. anchor.click();
  3233. URL.revokeObjectURL(url);
  3234. }
  3235. function formatDate(value) {
  3236. if (!value) return "-";
  3237. const date = new Date(value);
  3238. if (Number.isNaN(date.getTime())) return value;
  3239. return date.toLocaleString("zh-CN", { hour12: false });
  3240. }
  3241. async function seedDemo() {
  3242. 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() };
  3243. 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() };
  3244. 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() };
  3245. 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() };
  3246. 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() };
  3247. 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() };
  3248. 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() };
  3249. const bindings = [
  3250. { asset_type: "email", asset_id: email.id, account_id: account.id, binding_role: "login", status: "active", risk_level: "low", notes: "Apple ID 登录邮箱" },
  3251. { asset_type: "phone", asset_id: badPhone.id, account_id: account.id, binding_role: "two_factor", status: "active", risk_level: "high", notes: "旧虚拟号仍作为 2FA" },
  3252. { asset_type: "email", asset_id: badEmail.id, account_id: locked.id, binding_role: "recovery", status: "active", risk_level: "high", notes: "不可收信恢复邮箱" },
  3253. { asset_type: "phone", asset_id: phone.id, account_id: locked.id, binding_role: "trusted_phone", status: "active", risk_level: "medium", notes: "锁定账号仍绑定主力手机号" },
  3254. ].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 }));
  3255. bindings.forEach((binding) => {
  3256. const accountRef = [account, locked].find((item) => item.id === binding.account_id);
  3257. binding.platform = accountRef?.platform || "";
  3258. });
  3259. 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() };
  3260. state = {
  3261. phones: [phone, badPhone, ...state.phones],
  3262. emails: [email, badEmail, ...state.emails],
  3263. domains: [domain, ...state.domains],
  3264. accounts: [account, locked, ...state.accounts],
  3265. bindings: [...bindings, ...state.bindings],
  3266. incidents: [incident, ...state.incidents],
  3267. };
  3268. await saveState();
  3269. selected = null;
  3270. toast("示例数据已生成", "success");
  3271. render();
  3272. }