@l1yp/file-viewer 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +55 -6
  2. package/dist/assets/step.worker-CfDTYOf_.js.map +1 -0
  3. package/dist/components/CertViewer.js +1 -1
  4. package/dist/components/CertViewer.js.map +1 -1
  5. package/dist/components/CertViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  6. package/dist/components/DocxNavigationPane.js +1 -1
  7. package/dist/components/DocxNavigationPane.js.map +1 -1
  8. package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/DocxViewer.js +1 -1
  10. package/dist/components/DocxViewer.js.map +1 -1
  11. package/dist/components/DocxViewer.vue.d.ts +1 -1
  12. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +80 -78
  13. package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  14. package/dist/components/EplViewer.js +1 -1
  15. package/dist/components/EplViewer.js.map +1 -1
  16. package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  17. package/dist/components/FileViewerBody.js +1 -1
  18. package/dist/components/FileViewerBody.js.map +1 -1
  19. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +265 -257
  20. package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -1
  21. package/dist/components/FileViewerPanel.js +1 -1
  22. package/dist/components/FileViewerPanel.js.map +1 -1
  23. package/dist/components/FileViewerPanel.vue.d.ts +6 -1
  24. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +42 -40
  25. package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -1
  26. package/dist/components/HarViewer.js +1 -1
  27. package/dist/components/HarViewer.js.map +1 -1
  28. package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  29. package/dist/components/JksViewer.js +1 -1
  30. package/dist/components/JksViewer.js.map +1 -1
  31. package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  32. package/dist/components/ModelViewModes.js +9 -0
  33. package/dist/components/ModelViewModes.js.map +1 -0
  34. package/dist/components/ModelViewModes.vue.d.ts +11 -0
  35. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js +49 -0
  36. package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js.map +1 -0
  37. package/dist/components/ModelViewer.js +1 -1
  38. package/dist/components/ModelViewer.js.map +1 -1
  39. package/dist/components/ModelViewer.vue.d.ts +2 -1
  40. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js +183 -96
  41. package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  42. package/dist/components/PdfNavigationPane.js +1 -1
  43. package/dist/components/PdfNavigationPane.js.map +1 -1
  44. package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  45. package/dist/components/PdfViewer.js +1 -1
  46. package/dist/components/PdfViewer.js.map +1 -1
  47. package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  48. package/dist/components/PptxNavigationPane.js +1 -1
  49. package/dist/components/PptxNavigationPane.js.map +1 -1
  50. package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
  51. package/dist/components/PsdViewer.js +1 -1
  52. package/dist/components/PsdViewer.js.map +1 -1
  53. package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  54. package/dist/components/SqliteViewer.js +1 -1
  55. package/dist/components/SqliteViewer.js.map +1 -1
  56. package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  57. package/dist/components/ViewerLoadError.js +9 -0
  58. package/dist/components/ViewerLoadError.js.map +1 -0
  59. package/dist/components/ViewerLoadError.vue.d.ts +17 -0
  60. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js +58 -0
  61. package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js.map +1 -0
  62. package/dist/components/XlsxChart.js +1 -1
  63. package/dist/components/XlsxChart.js.map +1 -1
  64. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js +9 -9
  65. package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js.map +1 -1
  66. package/dist/components/XlsxImageLayer.js +9 -0
  67. package/dist/components/XlsxImageLayer.js.map +1 -0
  68. package/dist/components/XlsxImageLayer.vue.d.ts +9 -0
  69. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js +46 -0
  70. package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js.map +1 -0
  71. package/dist/components/XlsxViewer.js +1 -1
  72. package/dist/components/XlsxViewer.js.map +1 -1
  73. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js +230 -216
  74. package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  75. package/dist/components/epl/EplResource.js +1 -1
  76. package/dist/components/epl/EplResource.js.map +1 -1
  77. package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js.map +1 -1
  78. package/dist/components/optionalViewer.d.ts +3 -2
  79. package/dist/components/optionalViewer.js +6 -3
  80. package/dist/components/optionalViewer.js.map +1 -1
  81. package/dist/composables/useFileViewer.d.ts +11 -3
  82. package/dist/composables/useFileViewer.js.map +1 -1
  83. package/dist/composables/useTeleportTheme.d.ts +7 -0
  84. package/dist/composables/useTeleportTheme.js +42 -0
  85. package/dist/composables/useTeleportTheme.js.map +1 -0
  86. package/dist/index.d.ts +1 -0
  87. package/dist/index.js +29 -28
  88. package/dist/lib/docxTheme.d.ts +6 -0
  89. package/dist/lib/docxTheme.js +64 -0
  90. package/dist/lib/docxTheme.js.map +1 -0
  91. package/dist/lib/epl/code/structures.js +82 -0
  92. package/dist/lib/epl/code/structures.js.map +1 -0
  93. package/dist/lib/epl/code/variable.js +16 -0
  94. package/dist/lib/epl/code/variable.js.map +1 -0
  95. package/dist/lib/epl/eplDocument.js +28 -22
  96. package/dist/lib/epl/eplDocument.js.map +1 -1
  97. package/dist/lib/epl/index.d.ts +2 -1
  98. package/dist/lib/epl/index.js.map +1 -1
  99. package/dist/lib/epl/sections/code.js +37 -0
  100. package/dist/lib/epl/sections/code.js.map +1 -0
  101. package/dist/lib/epl/sections/esystemInfo.js +29 -0
  102. package/dist/lib/epl/sections/esystemInfo.js.map +1 -0
  103. package/dist/lib/epl/sections/losable.js +33 -0
  104. package/dist/lib/epl/sections/losable.js.map +1 -0
  105. package/dist/lib/epl/sections/projectConfig.js +39 -0
  106. package/dist/lib/epl/sections/projectConfig.js.map +1 -0
  107. package/dist/lib/epl/sections/resource.js +84 -0
  108. package/dist/lib/epl/sections/resource.js.map +1 -0
  109. package/dist/lib/fileType.d.ts +3 -1
  110. package/dist/lib/fileType.js +150 -137
  111. package/dist/lib/fileType.js.map +1 -1
  112. package/dist/lib/modelAppearance.d.ts +7 -0
  113. package/dist/lib/modelAppearance.js +41 -0
  114. package/dist/lib/modelAppearance.js.map +1 -0
  115. package/dist/lib/step.d.ts +10 -0
  116. package/dist/lib/step.js +35 -0
  117. package/dist/lib/step.js.map +1 -0
  118. package/dist/lib/step.worker.d.ts +1 -0
  119. package/dist/lib/step.worker.js +18 -0
  120. package/dist/lib/step.worker.js.map +1 -0
  121. package/dist/lib/stepObject.d.ts +4 -0
  122. package/dist/lib/stepObject.js +52 -0
  123. package/dist/lib/stepObject.js.map +1 -0
  124. package/dist/lib/stepTypes.d.ts +60 -0
  125. package/dist/lib/viewerKind.js +10 -5
  126. package/dist/lib/viewerKind.js.map +1 -1
  127. package/dist/lib/xlsx.d.ts +1 -1
  128. package/dist/lib/xlsx.js +106 -89
  129. package/dist/lib/xlsx.js.map +1 -1
  130. package/dist/lib/xlsxCharts.js +71 -119
  131. package/dist/lib/xlsxCharts.js.map +1 -1
  132. package/dist/lib/xlsxImages.d.ts +43 -0
  133. package/dist/lib/xlsxImages.js +114 -0
  134. package/dist/lib/xlsxImages.js.map +1 -0
  135. package/dist/lib/xlsxParts.d.ts +17 -0
  136. package/dist/lib/xlsxParts.js +86 -0
  137. package/dist/lib/xlsxParts.js.map +1 -0
  138. package/dist/style.css +1 -1
  139. package/package.json +8 -3
@@ -1 +1 @@
1
- {"version":3,"file":"JksViewer.js","names":[],"sources":["../../src/components/JksViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'\nimport {\n decryptPrivateKey,\n JCEKS_MAGIC,\n JKS_MAGIC,\n JksError,\n readJks,\n verifyStorePassword,\n type JksEntry,\n type JksStore,\n type KeyDecryptResult,\n} from '@/lib/jks'\nimport { looksLikePkcs12, readPkcs12, type Pkcs12Result } from '@/lib/pkcs12'\nimport { parseCertificate, sha256Hex, type CertSummary } from '@/lib/x509'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for keystores. Fetches the BYTES itself (binary-safe) and sniffs the format:\n * - JKS / JCEKS (magic 0xFEEDFEED / 0xCECECECE): the Sun binary format (see lib/jks.ts). Certificates are\n * plaintext, so the whole inventory shows immediately; a password verifies the store MAC and decrypts JKS\n * private keys.\n * - PKCS#12 / PFX (`.p12`/`.pfx`, and the very common `.jks`-that-is-actually-PKCS#12 — keytool defaults to\n * PKCS12 since JDK 9): everything is encrypted (see lib/pkcs12.ts), so nothing shows until the password\n * unlocks it; then certs + private keys appear and the integrity MAC is verified.\n *\n * Both render the same cards: per entry the alias / kind / (JKS) creation date, the private-key algorithm once\n * decrypted, and each certificate's X.509 fields (subject/issuer, validity + expiry badge, key + signature\n * algorithm, SANs, SHA-256 fingerprint — reusing lib/x509.ts). All parsing/decryption is in-browser; the\n * password and key material never leave the page.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Format = 'jks' | 'jceks' | 'pkcs12'\n\ninterface CertView {\n summary: CertSummary\n id: string\n}\ninterface EntryView {\n entry: JksEntry\n index: number\n certs: CertView[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst format = ref<Format | null>(null)\nconst rawBuf = shallowRef<ArrayBuffer | null>(null) // kept for PKCS#12 (decrypted on password submit)\nconst jksStore = shallowRef<JksStore | null>(null)\nconst entryViews = shallowRef<EntryView[]>([])\nconst fingerprints = reactive<Record<string, string>>({})\n\n// Password state: one field drives store-MAC verification and per-key decryption (keytool defaults the store\n// password equal to each key password — the common case).\nconst password = ref('')\nconst pwTried = ref(false)\nconst integrity = ref<'unknown' | 'ok' | 'bad'>('unknown')\nconst unlocked = ref(false) // PKCS#12: bags decrypted\nconst keyResults = reactive<Record<number, KeyDecryptResult>>({})\nconst p12meta = shallowRef<Pkcs12Result | null>(null)\nconst p12error = ref('') // unsupported / malformed (distinct from wrong-password)\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nconst isPkcs12 = computed(() => format.value === 'pkcs12')\nconst storeTypeLabel = computed(() =>\n format.value === 'pkcs12' ? 'PKCS#12' : format.value === 'jceks' ? 'JCEKS' : 'JKS',\n)\nconst hasPrivateKeys = computed(() => entryViews.value.some((e) => e.entry.type === 'privateKey'))\nconst storeMeta = computed(() => {\n const s = jksStore.value\n if (format.value === 'pkcs12') {\n const m = p12meta.value\n if (m) return [...m.encryptionAlgos, m.macAlgorithm].filter(Boolean).join(' · ')\n return '证书与私钥均加密,输入密码解密'\n }\n return s ? `版本 ${s.version} · ${s.entries.length} 个条目` : ''\n})\n\nfunction fingerprintAll(): void {\n for (const ev of entryViews.value) {\n for (const cv of ev.certs) {\n if (cv.summary.ok) {\n void sha256Hex(cv.summary.der)\n .then((hex) => {\n fingerprints[cv.id] = hex\n })\n .catch(() => {})\n }\n }\n }\n}\n\nfunction buildJksEntryViews(store: JksStore): void {\n entryViews.value = store.entries.map((entry, index) => ({\n entry,\n index,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${index}.${ci}` })),\n }))\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const magic =\n bytes.length >= 4 ? ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0 : 0\n if (magic === JKS_MAGIC || magic === JCEKS_MAGIC) {\n const store = readJks(buf)\n format.value = store.storeType\n jksStore.value = store\n buildJksEntryViews(store)\n fingerprintAll()\n } else if (looksLikePkcs12(bytes)) {\n format.value = 'pkcs12'\n rawBuf.value = buf // wait for the password before decrypting\n } else {\n throw new JksError(\n `文件头不匹配(magic=0x${magic.toString(16).padStart(8, '0')})—— 不是 JKS/JCEKS/PKCS#12 密钥库`,\n )\n }\n status.value = 'ready'\n}\n\nfunction resetState(): void {\n errorMsg.value = ''\n format.value = null\n rawBuf.value = null\n jksStore.value = null\n entryViews.value = []\n password.value = ''\n pwTried.value = false\n integrity.value = 'unknown'\n unlocked.value = false\n p12meta.value = null\n p12error.value = ''\n for (const k of Object.keys(fingerprints)) delete fingerprints[k]\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n resetState()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof JksError || e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nfunction submitJksPassword(store: JksStore): void {\n integrity.value = verifyStorePassword(store, password.value) ? 'ok' : 'bad'\n for (const ev of entryViews.value) {\n if (ev.entry.type === 'privateKey' && ev.entry.encryptedKey) {\n keyResults[ev.index] = decryptPrivateKey(ev.entry.encryptedKey, password.value)\n }\n }\n}\n\nfunction buildPkcs12EntryViews(result: Pkcs12Result): void {\n const views: EntryView[] = []\n const usedCerts = new Set<number>()\n let idx = 0\n for (const k of result.keys) {\n const chain = result.certs.filter((c, ci) => {\n const match = !!k.localKeyId && c.localKeyId === k.localKeyId\n if (match) usedCerts.add(ci)\n return match\n })\n const entry: JksEntry = {\n type: 'privateKey',\n alias: k.friendlyName || k.localKeyId || `私钥 ${idx + 1}`,\n createdMs: 0,\n certs: chain.map((c) => ({ type: 'X.509', der: c.der })),\n }\n views.push({\n entry,\n index: idx,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${idx}.${ci}` })),\n })\n keyResults[idx] = {\n ok: true,\n key: { pkcs8: k.pkcs8, algorithm: k.algorithm, bits: k.bits, curve: k.curve },\n }\n idx++\n }\n result.certs.forEach((c, ci) => {\n if (usedCerts.has(ci)) return\n const entry: JksEntry = {\n type: 'trustedCert',\n alias: c.friendlyName || `证书 ${idx + 1}`,\n createdMs: 0,\n certs: [{ type: 'X.509', der: c.der }],\n }\n views.push({ entry, index: idx, certs: [{ summary: parseCertificate(c.der), id: `${idx}.0` }] })\n idx++\n })\n entryViews.value = views\n}\n\nfunction submitPkcs12Password(buf: ArrayBuffer): void {\n const r = readPkcs12(buf, password.value)\n if (!r.ok) {\n unlocked.value = false\n entryViews.value = []\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n if (r.reason === 'wrong-password') {\n integrity.value = 'bad'\n p12error.value = ''\n } else {\n integrity.value = 'unknown'\n p12error.value =\n (r.reason === 'unsupported' ? '暂不支持的加密方式:' : '解析失败:') + (r.detail ?? '')\n }\n return\n }\n p12error.value = ''\n p12meta.value = r.result\n buildPkcs12EntryViews(r.result)\n integrity.value = r.result.macVerified === true ? 'ok' : 'unknown'\n unlocked.value = true\n fingerprintAll()\n}\n\n// Apply the password to whichever format this keystore is.\nfunction submitPassword(): void {\n if (!password.value) return\n pwTried.value = true\n if (format.value === 'pkcs12') {\n if (rawBuf.value) submitPkcs12Password(rawBuf.value)\n } else if (jksStore.value) {\n submitJksPassword(jksStore.value)\n }\n}\n\n// ── friendly-field formatting (shared with the cert cards) ─────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst ENTRY_LABEL: Record<JksEntry['type'], string> = {\n privateKey: '私钥条目',\n trustedCert: '受信任证书',\n secretKey: '密钥条目',\n}\nconst ENTRY_CLASS: Record<JksEntry['type'], string> = {\n privateKey: 'jks__badge--key',\n trustedCert: 'jks__badge--cert',\n secretKey: 'jks__badge--secret',\n}\n\nfunction formatDate(ms: number): string {\n if (!ms) return ''\n try {\n return new Date(ms).toLocaleString('zh-CN', { hour12: false })\n } catch {\n return ''\n }\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'jks__cbadge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'jks__cbadge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'jks__cbadge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'jks__cbadge--ok' }\n}\n\n// The private-key row's decrypted/locked/error state for one entry.\ntype KeyState =\n | { kind: 'locked' }\n | { kind: 'ok'; text: string }\n | { kind: 'wrong' }\n | { kind: 'unsupported'; detail?: string }\n | { kind: 'malformed' }\n\nfunction keyState(index: number): KeyState {\n const r = keyResults[index]\n if (!r) return { kind: 'locked' }\n if (r.ok) {\n const parts = [r.key.algorithm]\n if (r.key.bits) parts.push(`${r.key.bits} 位`)\n if (r.key.curve && r.key.curve !== r.key.algorithm) parts.push(r.key.curve)\n return { kind: 'ok', text: parts.join(' · ') }\n }\n if (r.reason === 'wrong-password') return { kind: 'wrong' }\n if (r.reason === 'unsupported') return { kind: 'unsupported', detail: r.detail }\n return { kind: 'malformed' }\n}\n\nconst pwPlaceholder = computed(() => {\n if (isPkcs12.value)\n return unlocked.value ? '已解锁(可重新输入)' : '输入 PKCS#12 密码解密证书与私钥'\n return hasPrivateKeys.value ? '输入密码:解密私钥并校验完整性' : '输入库密码校验完整性(可选)'\n})\n\n// Integrity/unlock status line after a password attempt (format-aware).\nconst integrityText = computed(() => {\n if (integrity.value === 'bad') {\n return isPkcs12.value ? '密码错误 —— 无法解密' : '完整性校验未通过 —— 密码错误或文件已被修改'\n }\n if (integrity.value === 'ok') {\n if (isPkcs12.value) return `密码正确,完整性校验通过(${p12meta.value?.macAlgorithm ?? 'MAC'})`\n return '完整性校验通过 —— 密码正确,keystore 未被篡改'\n }\n // PKCS#12 unlocked but MAC absent/unsupported: still a successful decrypt.\n if (isPkcs12.value && unlocked.value) return '已解密(该文件无 MAC 或摘要不支持,密码经解密验证)'\n return ''\n})\nconst integrityOk = computed(() => integrity.value === 'ok' || (isPkcs12.value && unlocked.value))\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"jks\">\n <p v-if=\"status === 'loading'\" class=\"jks__msg\">正在解析 keystore…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"jks__msg jks__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"jks__msg jks__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else-if=\"format\" class=\"jks__scroll\">\n <!-- store header + password -->\n <section class=\"jks__store\">\n <header class=\"jks__storehead\">\n <span class=\"jks__badge jks__badge--type\">{{ storeTypeLabel }}</span>\n <span class=\"jks__storetitle\">{{ isPkcs12 ? 'PKCS#12 密钥库' : 'Java KeyStore' }}</span>\n <span class=\"jks__storemeta\">{{ storeMeta }}</span>\n </header>\n\n <div class=\"jks__pw\" :class=\"{ 'jks__pw--ok': integrityOk }\">\n <Icon name=\"key\" :size=\"14\" />\n <input\n v-model=\"password\"\n type=\"password\"\n class=\"jks__pwinput\"\n :placeholder=\"pwPlaceholder\"\n autocomplete=\"off\"\n @keyup.enter=\"submitPassword\"\n />\n <button type=\"button\" class=\"jks__pwbtn\" @click=\"submitPassword\">\n {{ isPkcs12 && unlocked ? '重新解锁' : '解锁' }}\n </button>\n </div>\n\n <p\n v-if=\"pwTried && integrityText\"\n class=\"jks__integrity\"\n :class=\"`jks__integrity--${integrity}`\"\n >\n <Icon :name=\"integrityOk ? 'check' : 'close'\" :size=\"13\" />\n <span>{{ integrityText }}</span>\n </p>\n\n <p class=\"jks__hint\">全部解析与解密均在浏览器本地完成,密码与密钥不会上传。</p>\n <p v-if=\"jksStore?.truncatedNote\" class=\"jks__warn\">{{ jksStore.truncatedNote }}</p>\n <p v-if=\"p12error\" class=\"jks__warn\">{{ p12error }}</p>\n <p v-for=\"(w, i) in p12meta?.warnings ?? []\" :key=\"i\" class=\"jks__warn\">{{ w }}</p>\n </section>\n\n <!-- PKCS#12 pre-unlock: nothing is visible until decrypted -->\n <div v-if=\"isPkcs12 && !unlocked\" class=\"jks__locked\">\n <Icon name=\"key\" :size=\"26\" class=\"jks__lockicon\" />\n <p>PKCS#12 密钥库的证书与私钥均已加密,输入上方密码后在此显示内容。</p>\n </div>\n\n <!-- entries -->\n <section v-for=\"ev in entryViews\" :key=\"ev.index\" class=\"jks__entry\">\n <header class=\"jks__entryhead\">\n <span class=\"jks__badge\" :class=\"ENTRY_CLASS[ev.entry.type]\">{{\n ENTRY_LABEL[ev.entry.type]\n }}</span>\n <span class=\"jks__alias\" :title=\"ev.entry.alias\">{{ ev.entry.alias }}</span>\n <span v-if=\"ev.entry.createdMs\" class=\"jks__date\">{{\n formatDate(ev.entry.createdMs)\n }}</span>\n </header>\n\n <!-- private key material -->\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__key\">\n <template v-if=\"keyState(ev.index).kind === 'locked'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\">私钥已加密 —— 输入密码后在此显示密钥算法</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'ok'\">\n <Icon name=\"check\" :size=\"13\" class=\"jks__keyicon jks__keyicon--ok\" />\n <span class=\"jks__keytext jks__keytext--ok\"\n >私钥已解密 · {{ (keyState(ev.index) as { text: string }).text }}</span\n >\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'wrong'\">\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">密码错误 —— 无法解密该私钥</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'unsupported'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\"\n >该私钥加密方式暂不支持浏览器内解密({{\n (keyState(ev.index) as { detail?: string }).detail\n }})—— 证书链仍可查看</span\n >\n </template>\n <template v-else>\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">私钥数据无法解析</span>\n </template>\n </div>\n\n <p v-else-if=\"ev.entry.type === 'secretKey'\" class=\"jks__note\">\n JCEKS 对称密钥条目(SecretKey)—— 以 Java 序列化对象存储,暂不解析。\n </p>\n\n <!-- certificate chain -->\n <div v-if=\"ev.certs.length\" class=\"jks__chain\">\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__chainlabel\">\n 证书链({{ ev.certs.length }})\n </div>\n <article v-for=\"cv in ev.certs\" :key=\"cv.id\" class=\"jks__cert\">\n <div class=\"jks__certhead\">\n <span class=\"jks__certcn\">{{ cv.summary.subjectCN || cv.summary.label }}</span>\n <span v-if=\"expiry(cv.summary)\" class=\"jks__cbadge\" :class=\"expiry(cv.summary)?.cls\">\n {{ expiry(cv.summary)?.text }}\n </span>\n </div>\n <p v-if=\"cv.summary.note && !cv.summary.ok\" class=\"jks__note\">{{ cv.summary.note }}</p>\n <dl class=\"jks__dl\">\n <template v-if=\"cv.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(cv.summary.subject) }}</dd>\n </template>\n <template v-if=\"cv.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(cv.summary.issuer) }}</dd>\n </template>\n <template v-if=\"cv.summary.notBefore || cv.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ cv.summary.notBefore ?? '?' }} → {{ cv.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(cv.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(cv.summary) }}</dd>\n </template>\n <template v-if=\"cv.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ cv.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"cv.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"jks__mono\">{{ cv.summary.serialHex }}</dd>\n </template>\n <template v-if=\"cv.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ cv.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"fingerprints[cv.id]\">\n <dt>SHA-256</dt>\n <dd class=\"jks__mono jks__fp\">{{ fingerprints[cv.id] }}</dd>\n </template>\n </dl>\n </article>\n </div>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.jks {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.jks__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.jks__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.jks__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.jks__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.jks__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.jks__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n/* Store header + password ---------------------------------------------------------------------- */\n.jks__store {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__storehead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__storetitle {\n flex-shrink: 0;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__storemeta {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n font-size: 11.5px;\n color: var(--faint);\n}\n\n.jks__pw {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n color: var(--muted);\n}\n.jks__pw--ok {\n color: var(--accent);\n}\n.jks__pwinput {\n flex: 1 1 auto;\n min-width: 0;\n height: 28px;\n padding: 0 9px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.jks__pwinput:focus {\n outline: none;\n border-color: var(--accent);\n}\n.jks__pwbtn {\n flex-shrink: 0;\n height: 28px;\n padding: 0 14px;\n font-family: inherit;\n font-size: 12px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n}\n.jks__pwbtn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.jks__integrity {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n padding: 0 12px 8px;\n font-size: 12px;\n}\n.jks__integrity--ok {\n color: #15803d;\n}\n.jks__integrity--bad {\n color: #b91c1c;\n}\n.jks__integrity--unknown {\n color: var(--accent);\n}\n.jks__hint {\n margin: 0;\n padding: 0 12px 10px;\n font-size: 11.5px;\n line-height: 1.5;\n color: var(--faint);\n}\n.jks__warn {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 12%, transparent);\n border-top: 1px solid var(--border);\n}\n\n.jks__locked {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 10px;\n padding: 40px 24px;\n text-align: center;\n color: var(--muted);\n font-size: 12.5px;\n}\n.jks__lockicon {\n color: var(--faint);\n}\n\n/* Badges ------------------------------------------------------------------------------------- */\n.jks__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n border-radius: 5px;\n padding: 2px 7px;\n}\n.jks__badge--type {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n}\n.jks__badge--key {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 16%, transparent);\n}\n.jks__badge--cert {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.jks__badge--secret {\n color: var(--muted);\n background: var(--surface-2);\n}\n\n/* Entry cards -------------------------------------------------------------------------------- */\n.jks__entry {\n margin-bottom: 14px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__entryhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.jks__alias {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__date {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n\n.jks__key {\n display: flex;\n align-items: center;\n gap: 7px;\n padding: 9px 12px;\n font-size: 12px;\n background: color-mix(in srgb, var(--accent) 4%, transparent);\n border-bottom: 1px solid var(--border);\n}\n.jks__keyicon {\n flex-shrink: 0;\n color: var(--faint);\n}\n.jks__keyicon--ok {\n color: #15803d;\n}\n.jks__keyicon--bad {\n color: #b91c1c;\n}\n.jks__keytext {\n min-width: 0;\n color: var(--muted);\n}\n.jks__keytext--ok {\n color: #15803d;\n font-weight: 500;\n}\n.jks__keytext--bad {\n color: #b91c1c;\n}\n\n.jks__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n}\n\n/* Certificate chain -------------------------------------------------------------------------- */\n.jks__chain {\n padding: 10px 12px 12px;\n}\n.jks__chainlabel {\n margin-bottom: 8px;\n font-size: 11.5px;\n color: var(--faint);\n}\n.jks__cert {\n margin-bottom: 10px;\n border: 1px solid var(--border);\n border-radius: 8px;\n overflow: hidden;\n}\n.jks__cert:last-child {\n margin-bottom: 0;\n}\n.jks__certhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 10px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__certcn {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 12.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__cbadge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.jks__cbadge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.jks__cbadge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.jks__cbadge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n.jks__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 10px;\n}\n.jks__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.jks__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.jks__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.jks__fp {\n word-break: break-all;\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"JksViewer.js","names":[],"sources":["../../src/components/JksViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'\nimport {\n decryptPrivateKey,\n JCEKS_MAGIC,\n JKS_MAGIC,\n JksError,\n readJks,\n verifyStorePassword,\n type JksEntry,\n type JksStore,\n type KeyDecryptResult,\n} from '@/lib/jks'\nimport { looksLikePkcs12, readPkcs12, type Pkcs12Result } from '@/lib/pkcs12'\nimport { parseCertificate, sha256Hex, type CertSummary } from '@/lib/x509'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for keystores. Fetches the BYTES itself (binary-safe) and sniffs the format:\n * - JKS / JCEKS (magic 0xFEEDFEED / 0xCECECECE): the Sun binary format (see lib/jks.ts). Certificates are\n * plaintext, so the whole inventory shows immediately; a password verifies the store MAC and decrypts JKS\n * private keys.\n * - PKCS#12 / PFX (`.p12`/`.pfx`, and the very common `.jks`-that-is-actually-PKCS#12 — keytool defaults to\n * PKCS12 since JDK 9): everything is encrypted (see lib/pkcs12.ts), so nothing shows until the password\n * unlocks it; then certs + private keys appear and the integrity MAC is verified.\n *\n * Both render the same cards: per entry the alias / kind / (JKS) creation date, the private-key algorithm once\n * decrypted, and each certificate's X.509 fields (subject/issuer, validity + expiry badge, key + signature\n * algorithm, SANs, SHA-256 fingerprint — reusing lib/x509.ts). All parsing/decryption is in-browser; the\n * password and key material never leave the page.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Format = 'jks' | 'jceks' | 'pkcs12'\n\ninterface CertView {\n summary: CertSummary\n id: string\n}\ninterface EntryView {\n entry: JksEntry\n index: number\n certs: CertView[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst format = ref<Format | null>(null)\nconst rawBuf = shallowRef<ArrayBuffer | null>(null) // kept for PKCS#12 (decrypted on password submit)\nconst jksStore = shallowRef<JksStore | null>(null)\nconst entryViews = shallowRef<EntryView[]>([])\nconst fingerprints = reactive<Record<string, string>>({})\n\n// Password state: one field drives store-MAC verification and per-key decryption (keytool defaults the store\n// password equal to each key password — the common case).\nconst password = ref('')\nconst pwTried = ref(false)\nconst integrity = ref<'unknown' | 'ok' | 'bad'>('unknown')\nconst unlocked = ref(false) // PKCS#12: bags decrypted\nconst keyResults = reactive<Record<number, KeyDecryptResult>>({})\nconst p12meta = shallowRef<Pkcs12Result | null>(null)\nconst p12error = ref('') // unsupported / malformed (distinct from wrong-password)\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nconst isPkcs12 = computed(() => format.value === 'pkcs12')\nconst storeTypeLabel = computed(() =>\n format.value === 'pkcs12' ? 'PKCS#12' : format.value === 'jceks' ? 'JCEKS' : 'JKS',\n)\nconst hasPrivateKeys = computed(() => entryViews.value.some((e) => e.entry.type === 'privateKey'))\nconst storeMeta = computed(() => {\n const s = jksStore.value\n if (format.value === 'pkcs12') {\n const m = p12meta.value\n if (m) return [...m.encryptionAlgos, m.macAlgorithm].filter(Boolean).join(' · ')\n return '证书与私钥均加密,输入密码解密'\n }\n return s ? `版本 ${s.version} · ${s.entries.length} 个条目` : ''\n})\n\nfunction fingerprintAll(): void {\n for (const ev of entryViews.value) {\n for (const cv of ev.certs) {\n if (cv.summary.ok) {\n void sha256Hex(cv.summary.der)\n .then((hex) => {\n fingerprints[cv.id] = hex\n })\n .catch(() => {})\n }\n }\n }\n}\n\nfunction buildJksEntryViews(store: JksStore): void {\n entryViews.value = store.entries.map((entry, index) => ({\n entry,\n index,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${index}.${ci}` })),\n }))\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const magic =\n bytes.length >= 4 ? ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0 : 0\n if (magic === JKS_MAGIC || magic === JCEKS_MAGIC) {\n const store = readJks(buf)\n format.value = store.storeType\n jksStore.value = store\n buildJksEntryViews(store)\n fingerprintAll()\n } else if (looksLikePkcs12(bytes)) {\n format.value = 'pkcs12'\n rawBuf.value = buf // wait for the password before decrypting\n } else {\n throw new JksError(\n `文件头不匹配(magic=0x${magic.toString(16).padStart(8, '0')})—— 不是 JKS/JCEKS/PKCS#12 密钥库`,\n )\n }\n status.value = 'ready'\n}\n\nfunction resetState(): void {\n errorMsg.value = ''\n format.value = null\n rawBuf.value = null\n jksStore.value = null\n entryViews.value = []\n password.value = ''\n pwTried.value = false\n integrity.value = 'unknown'\n unlocked.value = false\n p12meta.value = null\n p12error.value = ''\n for (const k of Object.keys(fingerprints)) delete fingerprints[k]\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n resetState()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof JksError || e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nfunction submitJksPassword(store: JksStore): void {\n integrity.value = verifyStorePassword(store, password.value) ? 'ok' : 'bad'\n for (const ev of entryViews.value) {\n if (ev.entry.type === 'privateKey' && ev.entry.encryptedKey) {\n keyResults[ev.index] = decryptPrivateKey(ev.entry.encryptedKey, password.value)\n }\n }\n}\n\nfunction buildPkcs12EntryViews(result: Pkcs12Result): void {\n const views: EntryView[] = []\n const usedCerts = new Set<number>()\n let idx = 0\n for (const k of result.keys) {\n const chain = result.certs.filter((c, ci) => {\n const match = !!k.localKeyId && c.localKeyId === k.localKeyId\n if (match) usedCerts.add(ci)\n return match\n })\n const entry: JksEntry = {\n type: 'privateKey',\n alias: k.friendlyName || k.localKeyId || `私钥 ${idx + 1}`,\n createdMs: 0,\n certs: chain.map((c) => ({ type: 'X.509', der: c.der })),\n }\n views.push({\n entry,\n index: idx,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${idx}.${ci}` })),\n })\n keyResults[idx] = {\n ok: true,\n key: { pkcs8: k.pkcs8, algorithm: k.algorithm, bits: k.bits, curve: k.curve },\n }\n idx++\n }\n result.certs.forEach((c, ci) => {\n if (usedCerts.has(ci)) return\n const entry: JksEntry = {\n type: 'trustedCert',\n alias: c.friendlyName || `证书 ${idx + 1}`,\n createdMs: 0,\n certs: [{ type: 'X.509', der: c.der }],\n }\n views.push({ entry, index: idx, certs: [{ summary: parseCertificate(c.der), id: `${idx}.0` }] })\n idx++\n })\n entryViews.value = views\n}\n\nfunction submitPkcs12Password(buf: ArrayBuffer): void {\n const r = readPkcs12(buf, password.value)\n if (!r.ok) {\n unlocked.value = false\n entryViews.value = []\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n if (r.reason === 'wrong-password') {\n integrity.value = 'bad'\n p12error.value = ''\n } else {\n integrity.value = 'unknown'\n p12error.value =\n (r.reason === 'unsupported' ? '暂不支持的加密方式:' : '解析失败:') + (r.detail ?? '')\n }\n return\n }\n p12error.value = ''\n p12meta.value = r.result\n buildPkcs12EntryViews(r.result)\n integrity.value = r.result.macVerified === true ? 'ok' : 'unknown'\n unlocked.value = true\n fingerprintAll()\n}\n\n// Apply the password to whichever format this keystore is.\nfunction submitPassword(): void {\n if (!password.value) return\n pwTried.value = true\n if (format.value === 'pkcs12') {\n if (rawBuf.value) submitPkcs12Password(rawBuf.value)\n } else if (jksStore.value) {\n submitJksPassword(jksStore.value)\n }\n}\n\n// ── friendly-field formatting (shared with the cert cards) ─────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst ENTRY_LABEL: Record<JksEntry['type'], string> = {\n privateKey: '私钥条目',\n trustedCert: '受信任证书',\n secretKey: '密钥条目',\n}\nconst ENTRY_CLASS: Record<JksEntry['type'], string> = {\n privateKey: 'jks__badge--key',\n trustedCert: 'jks__badge--cert',\n secretKey: 'jks__badge--secret',\n}\n\nfunction formatDate(ms: number): string {\n if (!ms) return ''\n try {\n return new Date(ms).toLocaleString('zh-CN', { hour12: false })\n } catch {\n return ''\n }\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'jks__cbadge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'jks__cbadge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'jks__cbadge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'jks__cbadge--ok' }\n}\n\n// The private-key row's decrypted/locked/error state for one entry.\ntype KeyState =\n | { kind: 'locked' }\n | { kind: 'ok'; text: string }\n | { kind: 'wrong' }\n | { kind: 'unsupported'; detail?: string }\n | { kind: 'malformed' }\n\nfunction keyState(index: number): KeyState {\n const r = keyResults[index]\n if (!r) return { kind: 'locked' }\n if (r.ok) {\n const parts = [r.key.algorithm]\n if (r.key.bits) parts.push(`${r.key.bits} 位`)\n if (r.key.curve && r.key.curve !== r.key.algorithm) parts.push(r.key.curve)\n return { kind: 'ok', text: parts.join(' · ') }\n }\n if (r.reason === 'wrong-password') return { kind: 'wrong' }\n if (r.reason === 'unsupported') return { kind: 'unsupported', detail: r.detail }\n return { kind: 'malformed' }\n}\n\nconst pwPlaceholder = computed(() => {\n if (isPkcs12.value)\n return unlocked.value ? '已解锁(可重新输入)' : '输入 PKCS#12 密码解密证书与私钥'\n return hasPrivateKeys.value ? '输入密码:解密私钥并校验完整性' : '输入库密码校验完整性(可选)'\n})\n\n// Integrity/unlock status line after a password attempt (format-aware).\nconst integrityText = computed(() => {\n if (integrity.value === 'bad') {\n return isPkcs12.value ? '密码错误 —— 无法解密' : '完整性校验未通过 —— 密码错误或文件已被修改'\n }\n if (integrity.value === 'ok') {\n if (isPkcs12.value) return `密码正确,完整性校验通过(${p12meta.value?.macAlgorithm ?? 'MAC'})`\n return '完整性校验通过 —— 密码正确,keystore 未被篡改'\n }\n // PKCS#12 unlocked but MAC absent/unsupported: still a successful decrypt.\n if (isPkcs12.value && unlocked.value) return '已解密(该文件无 MAC 或摘要不支持,密码经解密验证)'\n return ''\n})\nconst integrityOk = computed(() => integrity.value === 'ok' || (isPkcs12.value && unlocked.value))\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"jks\">\n <p v-if=\"status === 'loading'\" class=\"jks__msg\">正在解析 keystore…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"jks__msg jks__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"jks__msg jks__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else-if=\"format\" class=\"jks__scroll\">\n <!-- store header + password -->\n <section class=\"jks__store\">\n <header class=\"jks__storehead\">\n <span class=\"jks__badge jks__badge--type\">{{ storeTypeLabel }}</span>\n <span class=\"jks__storetitle\">{{ isPkcs12 ? 'PKCS#12 密钥库' : 'Java KeyStore' }}</span>\n <span class=\"jks__storemeta\">{{ storeMeta }}</span>\n </header>\n\n <div class=\"jks__pw\" :class=\"{ 'jks__pw--ok': integrityOk }\">\n <Icon name=\"key\" :size=\"14\" />\n <input\n v-model=\"password\"\n type=\"password\"\n class=\"jks__pwinput\"\n :placeholder=\"pwPlaceholder\"\n autocomplete=\"off\"\n @keyup.enter=\"submitPassword\"\n />\n <button type=\"button\" class=\"jks__pwbtn\" @click=\"submitPassword\">\n {{ isPkcs12 && unlocked ? '重新解锁' : '解锁' }}\n </button>\n </div>\n\n <p\n v-if=\"pwTried && integrityText\"\n class=\"jks__integrity\"\n :class=\"`jks__integrity--${integrity}`\"\n >\n <Icon :name=\"integrityOk ? 'check' : 'close'\" :size=\"13\" />\n <span>{{ integrityText }}</span>\n </p>\n\n <p class=\"jks__hint\">全部解析与解密均在浏览器本地完成,密码与密钥不会上传。</p>\n <p v-if=\"jksStore?.truncatedNote\" class=\"jks__warn\">{{ jksStore.truncatedNote }}</p>\n <p v-if=\"p12error\" class=\"jks__warn\">{{ p12error }}</p>\n <p v-for=\"(w, i) in p12meta?.warnings ?? []\" :key=\"i\" class=\"jks__warn\">{{ w }}</p>\n </section>\n\n <!-- PKCS#12 pre-unlock: nothing is visible until decrypted -->\n <div v-if=\"isPkcs12 && !unlocked\" class=\"jks__locked\">\n <Icon name=\"key\" :size=\"26\" class=\"jks__lockicon\" />\n <p>PKCS#12 密钥库的证书与私钥均已加密,输入上方密码后在此显示内容。</p>\n </div>\n\n <!-- entries -->\n <section v-for=\"ev in entryViews\" :key=\"ev.index\" class=\"jks__entry\">\n <header class=\"jks__entryhead\">\n <span class=\"jks__badge\" :class=\"ENTRY_CLASS[ev.entry.type]\">{{\n ENTRY_LABEL[ev.entry.type]\n }}</span>\n <span class=\"jks__alias\" :title=\"ev.entry.alias\">{{ ev.entry.alias }}</span>\n <span v-if=\"ev.entry.createdMs\" class=\"jks__date\">{{\n formatDate(ev.entry.createdMs)\n }}</span>\n </header>\n\n <!-- private key material -->\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__key\">\n <template v-if=\"keyState(ev.index).kind === 'locked'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\">私钥已加密 —— 输入密码后在此显示密钥算法</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'ok'\">\n <Icon name=\"check\" :size=\"13\" class=\"jks__keyicon jks__keyicon--ok\" />\n <span class=\"jks__keytext jks__keytext--ok\"\n >私钥已解密 · {{ (keyState(ev.index) as { text: string }).text }}</span\n >\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'wrong'\">\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">密码错误 —— 无法解密该私钥</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'unsupported'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\"\n >该私钥加密方式暂不支持浏览器内解密({{\n (keyState(ev.index) as { detail?: string }).detail\n }})—— 证书链仍可查看</span\n >\n </template>\n <template v-else>\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">私钥数据无法解析</span>\n </template>\n </div>\n\n <p v-else-if=\"ev.entry.type === 'secretKey'\" class=\"jks__note\">\n JCEKS 对称密钥条目(SecretKey)—— 以 Java 序列化对象存储,暂不解析。\n </p>\n\n <!-- certificate chain -->\n <div v-if=\"ev.certs.length\" class=\"jks__chain\">\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__chainlabel\">\n 证书链({{ ev.certs.length }})\n </div>\n <article v-for=\"cv in ev.certs\" :key=\"cv.id\" class=\"jks__cert\">\n <div class=\"jks__certhead\">\n <span class=\"jks__certcn\">{{ cv.summary.subjectCN || cv.summary.label }}</span>\n <span v-if=\"expiry(cv.summary)\" class=\"jks__cbadge\" :class=\"expiry(cv.summary)?.cls\">\n {{ expiry(cv.summary)?.text }}\n </span>\n </div>\n <p v-if=\"cv.summary.note && !cv.summary.ok\" class=\"jks__note\">{{ cv.summary.note }}</p>\n <dl class=\"jks__dl\">\n <template v-if=\"cv.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(cv.summary.subject) }}</dd>\n </template>\n <template v-if=\"cv.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(cv.summary.issuer) }}</dd>\n </template>\n <template v-if=\"cv.summary.notBefore || cv.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ cv.summary.notBefore ?? '?' }} → {{ cv.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(cv.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(cv.summary) }}</dd>\n </template>\n <template v-if=\"cv.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ cv.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"cv.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"jks__mono\">{{ cv.summary.serialHex }}</dd>\n </template>\n <template v-if=\"cv.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ cv.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"fingerprints[cv.id]\">\n <dt>SHA-256</dt>\n <dd class=\"jks__mono jks__fp\">{{ fingerprints[cv.id] }}</dd>\n </template>\n </dl>\n </article>\n </div>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.jks {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.jks__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.jks__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.jks__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.jks__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.jks__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.jks__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n/* Store header + password ---------------------------------------------------------------------- */\n.jks__store {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__storehead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__storetitle {\n flex-shrink: 0;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__storemeta {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n font-size: 11.5px;\n color: var(--faint);\n}\n\n.jks__pw {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n color: var(--muted);\n}\n.jks__pw--ok {\n color: var(--accent);\n}\n.jks__pwinput {\n flex: 1 1 auto;\n min-width: 0;\n height: 28px;\n padding: 0 9px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.jks__pwinput:focus {\n outline: none;\n border-color: var(--accent);\n}\n.jks__pwbtn {\n flex-shrink: 0;\n height: 28px;\n padding: 0 14px;\n font-family: inherit;\n font-size: 12px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n}\n.jks__pwbtn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.jks__integrity {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n padding: 0 12px 8px;\n font-size: 12px;\n}\n.jks__integrity--ok {\n color: var(--hl-name);\n}\n.jks__integrity--bad {\n color: var(--danger);\n}\n.jks__integrity--unknown {\n color: var(--accent);\n}\n.jks__hint {\n margin: 0;\n padding: 0 12px 10px;\n font-size: 11.5px;\n line-height: 1.5;\n color: var(--faint);\n}\n.jks__warn {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 12%, transparent);\n border-top: 1px solid var(--border);\n}\n\n.jks__locked {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 10px;\n padding: 40px 24px;\n text-align: center;\n color: var(--muted);\n font-size: 12.5px;\n}\n.jks__lockicon {\n color: var(--faint);\n}\n\n/* Badges ------------------------------------------------------------------------------------- */\n.jks__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n border-radius: 5px;\n padding: 2px 7px;\n}\n.jks__badge--type {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n}\n.jks__badge--key {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 16%, transparent);\n}\n.jks__badge--cert {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.jks__badge--secret {\n color: var(--muted);\n background: var(--surface-2);\n}\n\n/* Entry cards -------------------------------------------------------------------------------- */\n.jks__entry {\n margin-bottom: 14px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__entryhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.jks__alias {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__date {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n\n.jks__key {\n display: flex;\n align-items: center;\n gap: 7px;\n padding: 9px 12px;\n font-size: 12px;\n background: color-mix(in srgb, var(--accent) 4%, transparent);\n border-bottom: 1px solid var(--border);\n}\n.jks__keyicon {\n flex-shrink: 0;\n color: var(--faint);\n}\n.jks__keyicon--ok {\n color: var(--hl-name);\n}\n.jks__keyicon--bad {\n color: var(--danger);\n}\n.jks__keytext {\n min-width: 0;\n color: var(--muted);\n}\n.jks__keytext--ok {\n color: var(--hl-name);\n font-weight: 500;\n}\n.jks__keytext--bad {\n color: var(--danger);\n}\n\n.jks__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n}\n\n/* Certificate chain -------------------------------------------------------------------------- */\n.jks__chain {\n padding: 10px 12px 12px;\n}\n.jks__chainlabel {\n margin-bottom: 8px;\n font-size: 11.5px;\n color: var(--faint);\n}\n.jks__cert {\n margin-bottom: 10px;\n border: 1px solid var(--border);\n border-radius: 8px;\n overflow: hidden;\n}\n.jks__cert:last-child {\n margin-bottom: 0;\n}\n.jks__certhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 10px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__certcn {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 12.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__cbadge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.jks__cbadge--ok {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.jks__cbadge--warn {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 18%, transparent);\n}\n.jks__cbadge--bad {\n color: var(--danger);\n background: color-mix(in srgb, var(--danger) 16%, transparent);\n}\n.jks__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 10px;\n}\n.jks__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.jks__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.jks__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.jks__fp {\n word-break: break-all;\n}\n</style>\n"],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"JksViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/JksViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'\nimport {\n decryptPrivateKey,\n JCEKS_MAGIC,\n JKS_MAGIC,\n JksError,\n readJks,\n verifyStorePassword,\n type JksEntry,\n type JksStore,\n type KeyDecryptResult,\n} from '@/lib/jks'\nimport { looksLikePkcs12, readPkcs12, type Pkcs12Result } from '@/lib/pkcs12'\nimport { parseCertificate, sha256Hex, type CertSummary } from '@/lib/x509'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for keystores. Fetches the BYTES itself (binary-safe) and sniffs the format:\n * - JKS / JCEKS (magic 0xFEEDFEED / 0xCECECECE): the Sun binary format (see lib/jks.ts). Certificates are\n * plaintext, so the whole inventory shows immediately; a password verifies the store MAC and decrypts JKS\n * private keys.\n * - PKCS#12 / PFX (`.p12`/`.pfx`, and the very common `.jks`-that-is-actually-PKCS#12 — keytool defaults to\n * PKCS12 since JDK 9): everything is encrypted (see lib/pkcs12.ts), so nothing shows until the password\n * unlocks it; then certs + private keys appear and the integrity MAC is verified.\n *\n * Both render the same cards: per entry the alias / kind / (JKS) creation date, the private-key algorithm once\n * decrypted, and each certificate's X.509 fields (subject/issuer, validity + expiry badge, key + signature\n * algorithm, SANs, SHA-256 fingerprint — reusing lib/x509.ts). All parsing/decryption is in-browser; the\n * password and key material never leave the page.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Format = 'jks' | 'jceks' | 'pkcs12'\n\ninterface CertView {\n summary: CertSummary\n id: string\n}\ninterface EntryView {\n entry: JksEntry\n index: number\n certs: CertView[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst format = ref<Format | null>(null)\nconst rawBuf = shallowRef<ArrayBuffer | null>(null) // kept for PKCS#12 (decrypted on password submit)\nconst jksStore = shallowRef<JksStore | null>(null)\nconst entryViews = shallowRef<EntryView[]>([])\nconst fingerprints = reactive<Record<string, string>>({})\n\n// Password state: one field drives store-MAC verification and per-key decryption (keytool defaults the store\n// password equal to each key password — the common case).\nconst password = ref('')\nconst pwTried = ref(false)\nconst integrity = ref<'unknown' | 'ok' | 'bad'>('unknown')\nconst unlocked = ref(false) // PKCS#12: bags decrypted\nconst keyResults = reactive<Record<number, KeyDecryptResult>>({})\nconst p12meta = shallowRef<Pkcs12Result | null>(null)\nconst p12error = ref('') // unsupported / malformed (distinct from wrong-password)\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nconst isPkcs12 = computed(() => format.value === 'pkcs12')\nconst storeTypeLabel = computed(() =>\n format.value === 'pkcs12' ? 'PKCS#12' : format.value === 'jceks' ? 'JCEKS' : 'JKS',\n)\nconst hasPrivateKeys = computed(() => entryViews.value.some((e) => e.entry.type === 'privateKey'))\nconst storeMeta = computed(() => {\n const s = jksStore.value\n if (format.value === 'pkcs12') {\n const m = p12meta.value\n if (m) return [...m.encryptionAlgos, m.macAlgorithm].filter(Boolean).join(' · ')\n return '证书与私钥均加密,输入密码解密'\n }\n return s ? `版本 ${s.version} · ${s.entries.length} 个条目` : ''\n})\n\nfunction fingerprintAll(): void {\n for (const ev of entryViews.value) {\n for (const cv of ev.certs) {\n if (cv.summary.ok) {\n void sha256Hex(cv.summary.der)\n .then((hex) => {\n fingerprints[cv.id] = hex\n })\n .catch(() => {})\n }\n }\n }\n}\n\nfunction buildJksEntryViews(store: JksStore): void {\n entryViews.value = store.entries.map((entry, index) => ({\n entry,\n index,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${index}.${ci}` })),\n }))\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const magic =\n bytes.length >= 4 ? ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0 : 0\n if (magic === JKS_MAGIC || magic === JCEKS_MAGIC) {\n const store = readJks(buf)\n format.value = store.storeType\n jksStore.value = store\n buildJksEntryViews(store)\n fingerprintAll()\n } else if (looksLikePkcs12(bytes)) {\n format.value = 'pkcs12'\n rawBuf.value = buf // wait for the password before decrypting\n } else {\n throw new JksError(\n `文件头不匹配(magic=0x${magic.toString(16).padStart(8, '0')})—— 不是 JKS/JCEKS/PKCS#12 密钥库`,\n )\n }\n status.value = 'ready'\n}\n\nfunction resetState(): void {\n errorMsg.value = ''\n format.value = null\n rawBuf.value = null\n jksStore.value = null\n entryViews.value = []\n password.value = ''\n pwTried.value = false\n integrity.value = 'unknown'\n unlocked.value = false\n p12meta.value = null\n p12error.value = ''\n for (const k of Object.keys(fingerprints)) delete fingerprints[k]\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n resetState()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof JksError || e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nfunction submitJksPassword(store: JksStore): void {\n integrity.value = verifyStorePassword(store, password.value) ? 'ok' : 'bad'\n for (const ev of entryViews.value) {\n if (ev.entry.type === 'privateKey' && ev.entry.encryptedKey) {\n keyResults[ev.index] = decryptPrivateKey(ev.entry.encryptedKey, password.value)\n }\n }\n}\n\nfunction buildPkcs12EntryViews(result: Pkcs12Result): void {\n const views: EntryView[] = []\n const usedCerts = new Set<number>()\n let idx = 0\n for (const k of result.keys) {\n const chain = result.certs.filter((c, ci) => {\n const match = !!k.localKeyId && c.localKeyId === k.localKeyId\n if (match) usedCerts.add(ci)\n return match\n })\n const entry: JksEntry = {\n type: 'privateKey',\n alias: k.friendlyName || k.localKeyId || `私钥 ${idx + 1}`,\n createdMs: 0,\n certs: chain.map((c) => ({ type: 'X.509', der: c.der })),\n }\n views.push({\n entry,\n index: idx,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${idx}.${ci}` })),\n })\n keyResults[idx] = {\n ok: true,\n key: { pkcs8: k.pkcs8, algorithm: k.algorithm, bits: k.bits, curve: k.curve },\n }\n idx++\n }\n result.certs.forEach((c, ci) => {\n if (usedCerts.has(ci)) return\n const entry: JksEntry = {\n type: 'trustedCert',\n alias: c.friendlyName || `证书 ${idx + 1}`,\n createdMs: 0,\n certs: [{ type: 'X.509', der: c.der }],\n }\n views.push({ entry, index: idx, certs: [{ summary: parseCertificate(c.der), id: `${idx}.0` }] })\n idx++\n })\n entryViews.value = views\n}\n\nfunction submitPkcs12Password(buf: ArrayBuffer): void {\n const r = readPkcs12(buf, password.value)\n if (!r.ok) {\n unlocked.value = false\n entryViews.value = []\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n if (r.reason === 'wrong-password') {\n integrity.value = 'bad'\n p12error.value = ''\n } else {\n integrity.value = 'unknown'\n p12error.value =\n (r.reason === 'unsupported' ? '暂不支持的加密方式:' : '解析失败:') + (r.detail ?? '')\n }\n return\n }\n p12error.value = ''\n p12meta.value = r.result\n buildPkcs12EntryViews(r.result)\n integrity.value = r.result.macVerified === true ? 'ok' : 'unknown'\n unlocked.value = true\n fingerprintAll()\n}\n\n// Apply the password to whichever format this keystore is.\nfunction submitPassword(): void {\n if (!password.value) return\n pwTried.value = true\n if (format.value === 'pkcs12') {\n if (rawBuf.value) submitPkcs12Password(rawBuf.value)\n } else if (jksStore.value) {\n submitJksPassword(jksStore.value)\n }\n}\n\n// ── friendly-field formatting (shared with the cert cards) ─────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst ENTRY_LABEL: Record<JksEntry['type'], string> = {\n privateKey: '私钥条目',\n trustedCert: '受信任证书',\n secretKey: '密钥条目',\n}\nconst ENTRY_CLASS: Record<JksEntry['type'], string> = {\n privateKey: 'jks__badge--key',\n trustedCert: 'jks__badge--cert',\n secretKey: 'jks__badge--secret',\n}\n\nfunction formatDate(ms: number): string {\n if (!ms) return ''\n try {\n return new Date(ms).toLocaleString('zh-CN', { hour12: false })\n } catch {\n return ''\n }\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'jks__cbadge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'jks__cbadge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'jks__cbadge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'jks__cbadge--ok' }\n}\n\n// The private-key row's decrypted/locked/error state for one entry.\ntype KeyState =\n | { kind: 'locked' }\n | { kind: 'ok'; text: string }\n | { kind: 'wrong' }\n | { kind: 'unsupported'; detail?: string }\n | { kind: 'malformed' }\n\nfunction keyState(index: number): KeyState {\n const r = keyResults[index]\n if (!r) return { kind: 'locked' }\n if (r.ok) {\n const parts = [r.key.algorithm]\n if (r.key.bits) parts.push(`${r.key.bits} 位`)\n if (r.key.curve && r.key.curve !== r.key.algorithm) parts.push(r.key.curve)\n return { kind: 'ok', text: parts.join(' · ') }\n }\n if (r.reason === 'wrong-password') return { kind: 'wrong' }\n if (r.reason === 'unsupported') return { kind: 'unsupported', detail: r.detail }\n return { kind: 'malformed' }\n}\n\nconst pwPlaceholder = computed(() => {\n if (isPkcs12.value)\n return unlocked.value ? '已解锁(可重新输入)' : '输入 PKCS#12 密码解密证书与私钥'\n return hasPrivateKeys.value ? '输入密码:解密私钥并校验完整性' : '输入库密码校验完整性(可选)'\n})\n\n// Integrity/unlock status line after a password attempt (format-aware).\nconst integrityText = computed(() => {\n if (integrity.value === 'bad') {\n return isPkcs12.value ? '密码错误 —— 无法解密' : '完整性校验未通过 —— 密码错误或文件已被修改'\n }\n if (integrity.value === 'ok') {\n if (isPkcs12.value) return `密码正确,完整性校验通过(${p12meta.value?.macAlgorithm ?? 'MAC'})`\n return '完整性校验通过 —— 密码正确,keystore 未被篡改'\n }\n // PKCS#12 unlocked but MAC absent/unsupported: still a successful decrypt.\n if (isPkcs12.value && unlocked.value) return '已解密(该文件无 MAC 或摘要不支持,密码经解密验证)'\n return ''\n})\nconst integrityOk = computed(() => integrity.value === 'ok' || (isPkcs12.value && unlocked.value))\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"jks\">\n <p v-if=\"status === 'loading'\" class=\"jks__msg\">正在解析 keystore…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"jks__msg jks__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"jks__msg jks__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else-if=\"format\" class=\"jks__scroll\">\n <!-- store header + password -->\n <section class=\"jks__store\">\n <header class=\"jks__storehead\">\n <span class=\"jks__badge jks__badge--type\">{{ storeTypeLabel }}</span>\n <span class=\"jks__storetitle\">{{ isPkcs12 ? 'PKCS#12 密钥库' : 'Java KeyStore' }}</span>\n <span class=\"jks__storemeta\">{{ storeMeta }}</span>\n </header>\n\n <div class=\"jks__pw\" :class=\"{ 'jks__pw--ok': integrityOk }\">\n <Icon name=\"key\" :size=\"14\" />\n <input\n v-model=\"password\"\n type=\"password\"\n class=\"jks__pwinput\"\n :placeholder=\"pwPlaceholder\"\n autocomplete=\"off\"\n @keyup.enter=\"submitPassword\"\n />\n <button type=\"button\" class=\"jks__pwbtn\" @click=\"submitPassword\">\n {{ isPkcs12 && unlocked ? '重新解锁' : '解锁' }}\n </button>\n </div>\n\n <p\n v-if=\"pwTried && integrityText\"\n class=\"jks__integrity\"\n :class=\"`jks__integrity--${integrity}`\"\n >\n <Icon :name=\"integrityOk ? 'check' : 'close'\" :size=\"13\" />\n <span>{{ integrityText }}</span>\n </p>\n\n <p class=\"jks__hint\">全部解析与解密均在浏览器本地完成,密码与密钥不会上传。</p>\n <p v-if=\"jksStore?.truncatedNote\" class=\"jks__warn\">{{ jksStore.truncatedNote }}</p>\n <p v-if=\"p12error\" class=\"jks__warn\">{{ p12error }}</p>\n <p v-for=\"(w, i) in p12meta?.warnings ?? []\" :key=\"i\" class=\"jks__warn\">{{ w }}</p>\n </section>\n\n <!-- PKCS#12 pre-unlock: nothing is visible until decrypted -->\n <div v-if=\"isPkcs12 && !unlocked\" class=\"jks__locked\">\n <Icon name=\"key\" :size=\"26\" class=\"jks__lockicon\" />\n <p>PKCS#12 密钥库的证书与私钥均已加密,输入上方密码后在此显示内容。</p>\n </div>\n\n <!-- entries -->\n <section v-for=\"ev in entryViews\" :key=\"ev.index\" class=\"jks__entry\">\n <header class=\"jks__entryhead\">\n <span class=\"jks__badge\" :class=\"ENTRY_CLASS[ev.entry.type]\">{{\n ENTRY_LABEL[ev.entry.type]\n }}</span>\n <span class=\"jks__alias\" :title=\"ev.entry.alias\">{{ ev.entry.alias }}</span>\n <span v-if=\"ev.entry.createdMs\" class=\"jks__date\">{{\n formatDate(ev.entry.createdMs)\n }}</span>\n </header>\n\n <!-- private key material -->\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__key\">\n <template v-if=\"keyState(ev.index).kind === 'locked'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\">私钥已加密 —— 输入密码后在此显示密钥算法</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'ok'\">\n <Icon name=\"check\" :size=\"13\" class=\"jks__keyicon jks__keyicon--ok\" />\n <span class=\"jks__keytext jks__keytext--ok\"\n >私钥已解密 · {{ (keyState(ev.index) as { text: string }).text }}</span\n >\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'wrong'\">\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">密码错误 —— 无法解密该私钥</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'unsupported'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\"\n >该私钥加密方式暂不支持浏览器内解密({{\n (keyState(ev.index) as { detail?: string }).detail\n }})—— 证书链仍可查看</span\n >\n </template>\n <template v-else>\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">私钥数据无法解析</span>\n </template>\n </div>\n\n <p v-else-if=\"ev.entry.type === 'secretKey'\" class=\"jks__note\">\n JCEKS 对称密钥条目(SecretKey)—— 以 Java 序列化对象存储,暂不解析。\n </p>\n\n <!-- certificate chain -->\n <div v-if=\"ev.certs.length\" class=\"jks__chain\">\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__chainlabel\">\n 证书链({{ ev.certs.length }})\n </div>\n <article v-for=\"cv in ev.certs\" :key=\"cv.id\" class=\"jks__cert\">\n <div class=\"jks__certhead\">\n <span class=\"jks__certcn\">{{ cv.summary.subjectCN || cv.summary.label }}</span>\n <span v-if=\"expiry(cv.summary)\" class=\"jks__cbadge\" :class=\"expiry(cv.summary)?.cls\">\n {{ expiry(cv.summary)?.text }}\n </span>\n </div>\n <p v-if=\"cv.summary.note && !cv.summary.ok\" class=\"jks__note\">{{ cv.summary.note }}</p>\n <dl class=\"jks__dl\">\n <template v-if=\"cv.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(cv.summary.subject) }}</dd>\n </template>\n <template v-if=\"cv.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(cv.summary.issuer) }}</dd>\n </template>\n <template v-if=\"cv.summary.notBefore || cv.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ cv.summary.notBefore ?? '?' }} → {{ cv.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(cv.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(cv.summary) }}</dd>\n </template>\n <template v-if=\"cv.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ cv.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"cv.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"jks__mono\">{{ cv.summary.serialHex }}</dd>\n </template>\n <template v-if=\"cv.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ cv.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"fingerprints[cv.id]\">\n <dt>SHA-256</dt>\n <dd class=\"jks__mono jks__fp\">{{ fingerprints[cv.id] }}</dd>\n </template>\n </dl>\n </article>\n </div>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.jks {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.jks__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.jks__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.jks__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.jks__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.jks__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.jks__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n/* Store header + password ---------------------------------------------------------------------- */\n.jks__store {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__storehead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__storetitle {\n flex-shrink: 0;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__storemeta {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n font-size: 11.5px;\n color: var(--faint);\n}\n\n.jks__pw {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n color: var(--muted);\n}\n.jks__pw--ok {\n color: var(--accent);\n}\n.jks__pwinput {\n flex: 1 1 auto;\n min-width: 0;\n height: 28px;\n padding: 0 9px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.jks__pwinput:focus {\n outline: none;\n border-color: var(--accent);\n}\n.jks__pwbtn {\n flex-shrink: 0;\n height: 28px;\n padding: 0 14px;\n font-family: inherit;\n font-size: 12px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n}\n.jks__pwbtn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.jks__integrity {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n padding: 0 12px 8px;\n font-size: 12px;\n}\n.jks__integrity--ok {\n color: #15803d;\n}\n.jks__integrity--bad {\n color: #b91c1c;\n}\n.jks__integrity--unknown {\n color: var(--accent);\n}\n.jks__hint {\n margin: 0;\n padding: 0 12px 10px;\n font-size: 11.5px;\n line-height: 1.5;\n color: var(--faint);\n}\n.jks__warn {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 12%, transparent);\n border-top: 1px solid var(--border);\n}\n\n.jks__locked {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 10px;\n padding: 40px 24px;\n text-align: center;\n color: var(--muted);\n font-size: 12.5px;\n}\n.jks__lockicon {\n color: var(--faint);\n}\n\n/* Badges ------------------------------------------------------------------------------------- */\n.jks__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n border-radius: 5px;\n padding: 2px 7px;\n}\n.jks__badge--type {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n}\n.jks__badge--key {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 16%, transparent);\n}\n.jks__badge--cert {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.jks__badge--secret {\n color: var(--muted);\n background: var(--surface-2);\n}\n\n/* Entry cards -------------------------------------------------------------------------------- */\n.jks__entry {\n margin-bottom: 14px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__entryhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.jks__alias {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__date {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n\n.jks__key {\n display: flex;\n align-items: center;\n gap: 7px;\n padding: 9px 12px;\n font-size: 12px;\n background: color-mix(in srgb, var(--accent) 4%, transparent);\n border-bottom: 1px solid var(--border);\n}\n.jks__keyicon {\n flex-shrink: 0;\n color: var(--faint);\n}\n.jks__keyicon--ok {\n color: #15803d;\n}\n.jks__keyicon--bad {\n color: #b91c1c;\n}\n.jks__keytext {\n min-width: 0;\n color: var(--muted);\n}\n.jks__keytext--ok {\n color: #15803d;\n font-weight: 500;\n}\n.jks__keytext--bad {\n color: #b91c1c;\n}\n\n.jks__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n}\n\n/* Certificate chain -------------------------------------------------------------------------- */\n.jks__chain {\n padding: 10px 12px 12px;\n}\n.jks__chainlabel {\n margin-bottom: 8px;\n font-size: 11.5px;\n color: var(--faint);\n}\n.jks__cert {\n margin-bottom: 10px;\n border: 1px solid var(--border);\n border-radius: 8px;\n overflow: hidden;\n}\n.jks__cert:last-child {\n margin-bottom: 0;\n}\n.jks__certhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 10px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__certcn {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 12.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__cbadge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.jks__cbadge--ok {\n color: #15803d;\n background: color-mix(in srgb, #22c55e 16%, transparent);\n}\n.jks__cbadge--warn {\n color: #b45309;\n background: color-mix(in srgb, #f59e0b 18%, transparent);\n}\n.jks__cbadge--bad {\n color: #b91c1c;\n background: color-mix(in srgb, #ef4444 16%, transparent);\n}\n.jks__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 10px;\n}\n.jks__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.jks__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.jks__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.jks__fp {\n word-break: break-all;\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"JksViewer.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/JksViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'\nimport {\n decryptPrivateKey,\n JCEKS_MAGIC,\n JKS_MAGIC,\n JksError,\n readJks,\n verifyStorePassword,\n type JksEntry,\n type JksStore,\n type KeyDecryptResult,\n} from '@/lib/jks'\nimport { looksLikePkcs12, readPkcs12, type Pkcs12Result } from '@/lib/pkcs12'\nimport { parseCertificate, sha256Hex, type CertSummary } from '@/lib/x509'\nimport { formatBytes } from '@/lib/format'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Side-viewer for keystores. Fetches the BYTES itself (binary-safe) and sniffs the format:\n * - JKS / JCEKS (magic 0xFEEDFEED / 0xCECECECE): the Sun binary format (see lib/jks.ts). Certificates are\n * plaintext, so the whole inventory shows immediately; a password verifies the store MAC and decrypts JKS\n * private keys.\n * - PKCS#12 / PFX (`.p12`/`.pfx`, and the very common `.jks`-that-is-actually-PKCS#12 — keytool defaults to\n * PKCS12 since JDK 9): everything is encrypted (see lib/pkcs12.ts), so nothing shows until the password\n * unlocks it; then certs + private keys appear and the integrity MAC is verified.\n *\n * Both render the same cards: per entry the alias / kind / (JKS) creation date, the private-key algorithm once\n * decrypted, and each certificate's X.509 fields (subject/issuer, validity + expiry badge, key + signature\n * algorithm, SANs, SHA-256 fingerprint — reusing lib/x509.ts). All parsing/decryption is in-browser; the\n * password and key material never leave the page.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Format = 'jks' | 'jceks' | 'pkcs12'\n\ninterface CertView {\n summary: CertSummary\n id: string\n}\ninterface EntryView {\n entry: JksEntry\n index: number\n certs: CertView[]\n}\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst format = ref<Format | null>(null)\nconst rawBuf = shallowRef<ArrayBuffer | null>(null) // kept for PKCS#12 (decrypted on password submit)\nconst jksStore = shallowRef<JksStore | null>(null)\nconst entryViews = shallowRef<EntryView[]>([])\nconst fingerprints = reactive<Record<string, string>>({})\n\n// Password state: one field drives store-MAC verification and per-key decryption (keytool defaults the store\n// password equal to each key password — the common case).\nconst password = ref('')\nconst pwTried = ref(false)\nconst integrity = ref<'unknown' | 'ok' | 'bad'>('unknown')\nconst unlocked = ref(false) // PKCS#12: bags decrypted\nconst keyResults = reactive<Record<number, KeyDecryptResult>>({})\nconst p12meta = shallowRef<Pkcs12Result | null>(null)\nconst p12error = ref('') // unsupported / malformed (distinct from wrong-password)\n\nconst SIZE_LIMIT = 8 * 1024 * 1024\n\nconst isPkcs12 = computed(() => format.value === 'pkcs12')\nconst storeTypeLabel = computed(() =>\n format.value === 'pkcs12' ? 'PKCS#12' : format.value === 'jceks' ? 'JCEKS' : 'JKS',\n)\nconst hasPrivateKeys = computed(() => entryViews.value.some((e) => e.entry.type === 'privateKey'))\nconst storeMeta = computed(() => {\n const s = jksStore.value\n if (format.value === 'pkcs12') {\n const m = p12meta.value\n if (m) return [...m.encryptionAlgos, m.macAlgorithm].filter(Boolean).join(' · ')\n return '证书与私钥均加密,输入密码解密'\n }\n return s ? `版本 ${s.version} · ${s.entries.length} 个条目` : ''\n})\n\nfunction fingerprintAll(): void {\n for (const ev of entryViews.value) {\n for (const cv of ev.certs) {\n if (cv.summary.ok) {\n void sha256Hex(cv.summary.der)\n .then((hex) => {\n fingerprints[cv.id] = hex\n })\n .catch(() => {})\n }\n }\n }\n}\n\nfunction buildJksEntryViews(store: JksStore): void {\n entryViews.value = store.entries.map((entry, index) => ({\n entry,\n index,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${index}.${ci}` })),\n }))\n}\n\nfunction processBuffer(buf: ArrayBuffer): void {\n const bytes = new Uint8Array(buf)\n const magic =\n bytes.length >= 4 ? ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0 : 0\n if (magic === JKS_MAGIC || magic === JCEKS_MAGIC) {\n const store = readJks(buf)\n format.value = store.storeType\n jksStore.value = store\n buildJksEntryViews(store)\n fingerprintAll()\n } else if (looksLikePkcs12(bytes)) {\n format.value = 'pkcs12'\n rawBuf.value = buf // wait for the password before decrypting\n } else {\n throw new JksError(\n `文件头不匹配(magic=0x${magic.toString(16).padStart(8, '0')})—— 不是 JKS/JCEKS/PKCS#12 密钥库`,\n )\n }\n status.value = 'ready'\n}\n\nfunction resetState(): void {\n errorMsg.value = ''\n format.value = null\n rawBuf.value = null\n jksStore.value = null\n entryViews.value = []\n password.value = ''\n pwTried.value = false\n integrity.value = 'unknown'\n unlocked.value = false\n p12meta.value = null\n p12error.value = ''\n for (const k of Object.keys(fingerprints)) delete fingerprints[k]\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n resetState()\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n processBuffer(await res.arrayBuffer())\n } catch (e) {\n errorMsg.value = e instanceof JksError || e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nfunction submitJksPassword(store: JksStore): void {\n integrity.value = verifyStorePassword(store, password.value) ? 'ok' : 'bad'\n for (const ev of entryViews.value) {\n if (ev.entry.type === 'privateKey' && ev.entry.encryptedKey) {\n keyResults[ev.index] = decryptPrivateKey(ev.entry.encryptedKey, password.value)\n }\n }\n}\n\nfunction buildPkcs12EntryViews(result: Pkcs12Result): void {\n const views: EntryView[] = []\n const usedCerts = new Set<number>()\n let idx = 0\n for (const k of result.keys) {\n const chain = result.certs.filter((c, ci) => {\n const match = !!k.localKeyId && c.localKeyId === k.localKeyId\n if (match) usedCerts.add(ci)\n return match\n })\n const entry: JksEntry = {\n type: 'privateKey',\n alias: k.friendlyName || k.localKeyId || `私钥 ${idx + 1}`,\n createdMs: 0,\n certs: chain.map((c) => ({ type: 'X.509', der: c.der })),\n }\n views.push({\n entry,\n index: idx,\n certs: entry.certs.map((c, ci) => ({ summary: parseCertificate(c.der), id: `${idx}.${ci}` })),\n })\n keyResults[idx] = {\n ok: true,\n key: { pkcs8: k.pkcs8, algorithm: k.algorithm, bits: k.bits, curve: k.curve },\n }\n idx++\n }\n result.certs.forEach((c, ci) => {\n if (usedCerts.has(ci)) return\n const entry: JksEntry = {\n type: 'trustedCert',\n alias: c.friendlyName || `证书 ${idx + 1}`,\n createdMs: 0,\n certs: [{ type: 'X.509', der: c.der }],\n }\n views.push({ entry, index: idx, certs: [{ summary: parseCertificate(c.der), id: `${idx}.0` }] })\n idx++\n })\n entryViews.value = views\n}\n\nfunction submitPkcs12Password(buf: ArrayBuffer): void {\n const r = readPkcs12(buf, password.value)\n if (!r.ok) {\n unlocked.value = false\n entryViews.value = []\n for (const k of Object.keys(keyResults)) delete keyResults[Number(k)]\n if (r.reason === 'wrong-password') {\n integrity.value = 'bad'\n p12error.value = ''\n } else {\n integrity.value = 'unknown'\n p12error.value =\n (r.reason === 'unsupported' ? '暂不支持的加密方式:' : '解析失败:') + (r.detail ?? '')\n }\n return\n }\n p12error.value = ''\n p12meta.value = r.result\n buildPkcs12EntryViews(r.result)\n integrity.value = r.result.macVerified === true ? 'ok' : 'unknown'\n unlocked.value = true\n fingerprintAll()\n}\n\n// Apply the password to whichever format this keystore is.\nfunction submitPassword(): void {\n if (!password.value) return\n pwTried.value = true\n if (format.value === 'pkcs12') {\n if (rawBuf.value) submitPkcs12Password(rawBuf.value)\n } else if (jksStore.value) {\n submitJksPassword(jksStore.value)\n }\n}\n\n// ── friendly-field formatting (shared with the cert cards) ─────────────────────────────────────────\nfunction formatDn(rdns: CertSummary['subject']): string {\n return rdns.map((r) => `${r.name}=${r.value}`).join(', ')\n}\n\nconst ENTRY_LABEL: Record<JksEntry['type'], string> = {\n privateKey: '私钥条目',\n trustedCert: '受信任证书',\n secretKey: '密钥条目',\n}\nconst ENTRY_CLASS: Record<JksEntry['type'], string> = {\n privateKey: 'jks__badge--key',\n trustedCert: 'jks__badge--cert',\n secretKey: 'jks__badge--secret',\n}\n\nfunction formatDate(ms: number): string {\n if (!ms) return ''\n try {\n return new Date(ms).toLocaleString('zh-CN', { hour12: false })\n } catch {\n return ''\n }\n}\n\nfunction publicKeyText(s: CertSummary): string {\n const parts: string[] = []\n if (s.pubKeyAlg) parts.push(s.pubKeyAlg)\n if (s.pubKeyBits) parts.push(`${s.pubKeyBits} 位`)\n if (s.pubKeyCurve && s.pubKeyCurve !== s.pubKeyAlg) parts.push(s.pubKeyCurve)\n return parts.join(' · ')\n}\n\n// Validity badge: future / expired / soon (≤30 days) / valid, from notBefore/notAfter vs now.\nfunction expiry(s: CertSummary): { text: string; cls: string } | null {\n if (s.notAfterMs == null) return null\n const now = Date.now()\n if (s.notBeforeMs != null && now < s.notBeforeMs)\n return { text: '尚未生效', cls: 'jks__cbadge--warn' }\n if (now > s.notAfterMs) return { text: '已过期', cls: 'jks__cbadge--bad' }\n const days = Math.floor((s.notAfterMs - now) / 86_400_000)\n if (days <= 30) return { text: `${days} 天后过期`, cls: 'jks__cbadge--warn' }\n return { text: `有效(剩 ${days} 天)`, cls: 'jks__cbadge--ok' }\n}\n\n// The private-key row's decrypted/locked/error state for one entry.\ntype KeyState =\n | { kind: 'locked' }\n | { kind: 'ok'; text: string }\n | { kind: 'wrong' }\n | { kind: 'unsupported'; detail?: string }\n | { kind: 'malformed' }\n\nfunction keyState(index: number): KeyState {\n const r = keyResults[index]\n if (!r) return { kind: 'locked' }\n if (r.ok) {\n const parts = [r.key.algorithm]\n if (r.key.bits) parts.push(`${r.key.bits} 位`)\n if (r.key.curve && r.key.curve !== r.key.algorithm) parts.push(r.key.curve)\n return { kind: 'ok', text: parts.join(' · ') }\n }\n if (r.reason === 'wrong-password') return { kind: 'wrong' }\n if (r.reason === 'unsupported') return { kind: 'unsupported', detail: r.detail }\n return { kind: 'malformed' }\n}\n\nconst pwPlaceholder = computed(() => {\n if (isPkcs12.value)\n return unlocked.value ? '已解锁(可重新输入)' : '输入 PKCS#12 密码解密证书与私钥'\n return hasPrivateKeys.value ? '输入密码:解密私钥并校验完整性' : '输入库密码校验完整性(可选)'\n})\n\n// Integrity/unlock status line after a password attempt (format-aware).\nconst integrityText = computed(() => {\n if (integrity.value === 'bad') {\n return isPkcs12.value ? '密码错误 —— 无法解密' : '完整性校验未通过 —— 密码错误或文件已被修改'\n }\n if (integrity.value === 'ok') {\n if (isPkcs12.value) return `密码正确,完整性校验通过(${p12meta.value?.macAlgorithm ?? 'MAC'})`\n return '完整性校验通过 —— 密码正确,keystore 未被篡改'\n }\n // PKCS#12 unlocked but MAC absent/unsupported: still a successful decrypt.\n if (isPkcs12.value && unlocked.value) return '已解密(该文件无 MAC 或摘要不支持,密码经解密验证)'\n return ''\n})\nconst integrityOk = computed(() => integrity.value === 'ok' || (isPkcs12.value && unlocked.value))\n\nwatch(() => [props.url, props.name], load, { immediate: true })\nonBeforeUnmount(() => {\n // nothing to revoke; bytes are not retained as object URLs\n})\n</script>\n\n<template>\n <div class=\"jks\">\n <p v-if=\"status === 'loading'\" class=\"jks__msg\">正在解析 keystore…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"jks__msg jks__gate\">\n <p>该文件较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"jks__msg jks__msg--error\">\n <p>无法解析:{{ errorMsg }}</p>\n <button type=\"button\" class=\"jks__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <div v-else-if=\"format\" class=\"jks__scroll\">\n <!-- store header + password -->\n <section class=\"jks__store\">\n <header class=\"jks__storehead\">\n <span class=\"jks__badge jks__badge--type\">{{ storeTypeLabel }}</span>\n <span class=\"jks__storetitle\">{{ isPkcs12 ? 'PKCS#12 密钥库' : 'Java KeyStore' }}</span>\n <span class=\"jks__storemeta\">{{ storeMeta }}</span>\n </header>\n\n <div class=\"jks__pw\" :class=\"{ 'jks__pw--ok': integrityOk }\">\n <Icon name=\"key\" :size=\"14\" />\n <input\n v-model=\"password\"\n type=\"password\"\n class=\"jks__pwinput\"\n :placeholder=\"pwPlaceholder\"\n autocomplete=\"off\"\n @keyup.enter=\"submitPassword\"\n />\n <button type=\"button\" class=\"jks__pwbtn\" @click=\"submitPassword\">\n {{ isPkcs12 && unlocked ? '重新解锁' : '解锁' }}\n </button>\n </div>\n\n <p\n v-if=\"pwTried && integrityText\"\n class=\"jks__integrity\"\n :class=\"`jks__integrity--${integrity}`\"\n >\n <Icon :name=\"integrityOk ? 'check' : 'close'\" :size=\"13\" />\n <span>{{ integrityText }}</span>\n </p>\n\n <p class=\"jks__hint\">全部解析与解密均在浏览器本地完成,密码与密钥不会上传。</p>\n <p v-if=\"jksStore?.truncatedNote\" class=\"jks__warn\">{{ jksStore.truncatedNote }}</p>\n <p v-if=\"p12error\" class=\"jks__warn\">{{ p12error }}</p>\n <p v-for=\"(w, i) in p12meta?.warnings ?? []\" :key=\"i\" class=\"jks__warn\">{{ w }}</p>\n </section>\n\n <!-- PKCS#12 pre-unlock: nothing is visible until decrypted -->\n <div v-if=\"isPkcs12 && !unlocked\" class=\"jks__locked\">\n <Icon name=\"key\" :size=\"26\" class=\"jks__lockicon\" />\n <p>PKCS#12 密钥库的证书与私钥均已加密,输入上方密码后在此显示内容。</p>\n </div>\n\n <!-- entries -->\n <section v-for=\"ev in entryViews\" :key=\"ev.index\" class=\"jks__entry\">\n <header class=\"jks__entryhead\">\n <span class=\"jks__badge\" :class=\"ENTRY_CLASS[ev.entry.type]\">{{\n ENTRY_LABEL[ev.entry.type]\n }}</span>\n <span class=\"jks__alias\" :title=\"ev.entry.alias\">{{ ev.entry.alias }}</span>\n <span v-if=\"ev.entry.createdMs\" class=\"jks__date\">{{\n formatDate(ev.entry.createdMs)\n }}</span>\n </header>\n\n <!-- private key material -->\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__key\">\n <template v-if=\"keyState(ev.index).kind === 'locked'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\">私钥已加密 —— 输入密码后在此显示密钥算法</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'ok'\">\n <Icon name=\"check\" :size=\"13\" class=\"jks__keyicon jks__keyicon--ok\" />\n <span class=\"jks__keytext jks__keytext--ok\"\n >私钥已解密 · {{ (keyState(ev.index) as { text: string }).text }}</span\n >\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'wrong'\">\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">密码错误 —— 无法解密该私钥</span>\n </template>\n <template v-else-if=\"keyState(ev.index).kind === 'unsupported'\">\n <Icon name=\"key\" :size=\"13\" class=\"jks__keyicon\" />\n <span class=\"jks__keytext\"\n >该私钥加密方式暂不支持浏览器内解密({{\n (keyState(ev.index) as { detail?: string }).detail\n }})—— 证书链仍可查看</span\n >\n </template>\n <template v-else>\n <Icon name=\"close\" :size=\"13\" class=\"jks__keyicon jks__keyicon--bad\" />\n <span class=\"jks__keytext jks__keytext--bad\">私钥数据无法解析</span>\n </template>\n </div>\n\n <p v-else-if=\"ev.entry.type === 'secretKey'\" class=\"jks__note\">\n JCEKS 对称密钥条目(SecretKey)—— 以 Java 序列化对象存储,暂不解析。\n </p>\n\n <!-- certificate chain -->\n <div v-if=\"ev.certs.length\" class=\"jks__chain\">\n <div v-if=\"ev.entry.type === 'privateKey'\" class=\"jks__chainlabel\">\n 证书链({{ ev.certs.length }})\n </div>\n <article v-for=\"cv in ev.certs\" :key=\"cv.id\" class=\"jks__cert\">\n <div class=\"jks__certhead\">\n <span class=\"jks__certcn\">{{ cv.summary.subjectCN || cv.summary.label }}</span>\n <span v-if=\"expiry(cv.summary)\" class=\"jks__cbadge\" :class=\"expiry(cv.summary)?.cls\">\n {{ expiry(cv.summary)?.text }}\n </span>\n </div>\n <p v-if=\"cv.summary.note && !cv.summary.ok\" class=\"jks__note\">{{ cv.summary.note }}</p>\n <dl class=\"jks__dl\">\n <template v-if=\"cv.summary.subject.length\">\n <dt>主题</dt>\n <dd>{{ formatDn(cv.summary.subject) }}</dd>\n </template>\n <template v-if=\"cv.summary.issuer.length\">\n <dt>颁发者</dt>\n <dd>{{ formatDn(cv.summary.issuer) }}</dd>\n </template>\n <template v-if=\"cv.summary.notBefore || cv.summary.notAfter\">\n <dt>有效期</dt>\n <dd>{{ cv.summary.notBefore ?? '?' }} → {{ cv.summary.notAfter ?? '?' }}</dd>\n </template>\n <template v-if=\"publicKeyText(cv.summary)\">\n <dt>公钥</dt>\n <dd>{{ publicKeyText(cv.summary) }}</dd>\n </template>\n <template v-if=\"cv.summary.sigAlg\">\n <dt>签名算法</dt>\n <dd>{{ cv.summary.sigAlg }}</dd>\n </template>\n <template v-if=\"cv.summary.serialHex\">\n <dt>序列号</dt>\n <dd class=\"jks__mono\">{{ cv.summary.serialHex }}</dd>\n </template>\n <template v-if=\"cv.summary.sans.length\">\n <dt>备用名 (SAN)</dt>\n <dd>{{ cv.summary.sans.join(',') }}</dd>\n </template>\n <template v-if=\"fingerprints[cv.id]\">\n <dt>SHA-256</dt>\n <dd class=\"jks__mono jks__fp\">{{ fingerprints[cv.id] }}</dd>\n </template>\n </dl>\n </article>\n </div>\n </section>\n </div>\n </div>\n</template>\n\n<style scoped>\n.jks {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.jks__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.jks__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.jks__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.jks__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.jks__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.jks__scroll {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n padding: 14px 14px 60px;\n}\n\n/* Store header + password ---------------------------------------------------------------------- */\n.jks__store {\n margin-bottom: 16px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__storehead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__storetitle {\n flex-shrink: 0;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__storemeta {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n font-size: 11.5px;\n color: var(--faint);\n}\n\n.jks__pw {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n color: var(--muted);\n}\n.jks__pw--ok {\n color: var(--accent);\n}\n.jks__pwinput {\n flex: 1 1 auto;\n min-width: 0;\n height: 28px;\n padding: 0 9px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n}\n.jks__pwinput:focus {\n outline: none;\n border-color: var(--accent);\n}\n.jks__pwbtn {\n flex-shrink: 0;\n height: 28px;\n padding: 0 14px;\n font-family: inherit;\n font-size: 12px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 7px;\n cursor: pointer;\n}\n.jks__pwbtn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.jks__integrity {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n padding: 0 12px 8px;\n font-size: 12px;\n}\n.jks__integrity--ok {\n color: var(--hl-name);\n}\n.jks__integrity--bad {\n color: var(--danger);\n}\n.jks__integrity--unknown {\n color: var(--accent);\n}\n.jks__hint {\n margin: 0;\n padding: 0 12px 10px;\n font-size: 11.5px;\n line-height: 1.5;\n color: var(--faint);\n}\n.jks__warn {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 12%, transparent);\n border-top: 1px solid var(--border);\n}\n\n.jks__locked {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 10px;\n padding: 40px 24px;\n text-align: center;\n color: var(--muted);\n font-size: 12.5px;\n}\n.jks__lockicon {\n color: var(--faint);\n}\n\n/* Badges ------------------------------------------------------------------------------------- */\n.jks__badge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.02em;\n border-radius: 5px;\n padding: 2px 7px;\n}\n.jks__badge--type {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n}\n.jks__badge--key {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 16%, transparent);\n}\n.jks__badge--cert {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.jks__badge--secret {\n color: var(--muted);\n background: var(--surface-2);\n}\n\n/* Entry cards -------------------------------------------------------------------------------- */\n.jks__entry {\n margin-bottom: 14px;\n border: 1px solid var(--border);\n border-radius: 10px;\n background: var(--surface);\n overflow: hidden;\n}\n.jks__entryhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.jks__alias {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__date {\n flex-shrink: 0;\n font-size: 11px;\n color: var(--faint);\n font-variant-numeric: tabular-nums;\n}\n\n.jks__key {\n display: flex;\n align-items: center;\n gap: 7px;\n padding: 9px 12px;\n font-size: 12px;\n background: color-mix(in srgb, var(--accent) 4%, transparent);\n border-bottom: 1px solid var(--border);\n}\n.jks__keyicon {\n flex-shrink: 0;\n color: var(--faint);\n}\n.jks__keyicon--ok {\n color: var(--hl-name);\n}\n.jks__keyicon--bad {\n color: var(--danger);\n}\n.jks__keytext {\n min-width: 0;\n color: var(--muted);\n}\n.jks__keytext--ok {\n color: var(--hl-name);\n font-weight: 500;\n}\n.jks__keytext--bad {\n color: var(--danger);\n}\n\n.jks__note {\n margin: 0;\n padding: 9px 12px;\n font-size: 12px;\n line-height: 1.5;\n color: var(--muted);\n}\n\n/* Certificate chain -------------------------------------------------------------------------- */\n.jks__chain {\n padding: 10px 12px 12px;\n}\n.jks__chainlabel {\n margin-bottom: 8px;\n font-size: 11.5px;\n color: var(--faint);\n}\n.jks__cert {\n margin-bottom: 10px;\n border: 1px solid var(--border);\n border-radius: 8px;\n overflow: hidden;\n}\n.jks__cert:last-child {\n margin-bottom: 0;\n}\n.jks__certhead {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 10px;\n background: var(--surface-2);\n border-bottom: 1px solid var(--border);\n}\n.jks__certcn {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 12.5px;\n font-weight: 600;\n color: var(--text-strong);\n}\n.jks__cbadge {\n flex-shrink: 0;\n font-size: 11px;\n font-weight: 600;\n border-radius: 5px;\n padding: 2px 8px;\n}\n.jks__cbadge--ok {\n color: var(--hl-name);\n background: color-mix(in srgb, var(--hl-name) 16%, transparent);\n}\n.jks__cbadge--warn {\n color: var(--ansi-yellow);\n background: color-mix(in srgb, var(--ansi-yellow) 18%, transparent);\n}\n.jks__cbadge--bad {\n color: var(--danger);\n background: color-mix(in srgb, var(--danger) 16%, transparent);\n}\n.jks__dl {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px 14px;\n margin: 0;\n padding: 10px;\n}\n.jks__dl dt {\n font-size: 12px;\n color: var(--faint);\n white-space: nowrap;\n}\n.jks__dl dd {\n margin: 0;\n font-size: 12.5px;\n color: var(--text);\n word-break: break-word;\n}\n.jks__mono {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.jks__fp {\n word-break: break-all;\n}\n</style>\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import e from "../_virtual/_plugin-vue_export-helper.js";
2
+ import t from "./ModelViewModes.vue_vue_type_script_setup_true_lang.js";
3
+ /* empty css */
4
+ //#region src/components/ModelViewModes.vue
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-e434d913"]]);
6
+ //#endregion
7
+ export { n as default };
8
+
9
+ //# sourceMappingURL=ModelViewModes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModelViewModes.js","names":[],"sources":["../../src/components/ModelViewModes.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ModelViewMode } from '@/lib/modelAppearance'\n\nconst mode = defineModel<ModelViewMode>({ required: true })\nconst options: { value: ModelViewMode; label: string; title: string }[] = [\n { value: 'original', label: '原色', title: '显示模型原有的材质和颜色' },\n { value: 'white', label: '白模', title: '使用统一浅色材质查看形状' },\n { value: 'wireframe', label: '线框', title: '按模型原色显示三角网格,包括背面和已有的内部网格' },\n]\n</script>\n\n<template>\n <div class=\"model-modes\" role=\"group\" aria-label=\"模型查看模式\">\n <button\n v-for=\"option in options\"\n :key=\"option.value\"\n type=\"button\"\n class=\"model-modes__button\"\n :aria-pressed=\"mode === option.value\"\n :title=\"option.title\"\n @click=\"mode = option.value\"\n >\n {{ option.label }}\n </button>\n </div>\n</template>\n\n<style scoped>\n.model-modes {\n display: flex;\n gap: 2px;\n padding: 3px;\n border: 1px solid var(--border-2);\n border-radius: 9px;\n background: var(--surface);\n box-shadow: 0 2px 8px #0000000d;\n}\n.model-modes__button {\n min-width: 48px;\n min-height: 30px;\n padding: 5px 10px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: var(--muted);\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n.model-modes__button:hover {\n color: var(--text-strong);\n background: var(--surface-2);\n}\n.model-modes__button[aria-pressed='true'] {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, var(--surface));\n font-weight: 600;\n}\n.model-modes__button:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n</style>\n"],"mappings":""}
@@ -0,0 +1,11 @@
1
+ import type { ModelViewMode } from '@/lib/modelAppearance';
2
+ type __VLS_ModelProps = {
3
+ modelValue: ModelViewMode;
4
+ };
5
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
+ "update:modelValue": (value: ModelViewMode) => any;
7
+ }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
8
+ "onUpdate:modelValue"?: ((value: ModelViewMode) => any) | undefined;
9
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
+ declare const _default: typeof __VLS_export;
11
+ export default _default;
@@ -0,0 +1,49 @@
1
+ import { Fragment as e, createElementBlock as t, createElementVNode as n, defineComponent as r, openBlock as i, renderList as a, toDisplayString as o, useModel as s } from "vue";
2
+ //#region src/components/ModelViewModes.vue?vue&type=script&setup=true&lang.ts
3
+ var c = {
4
+ class: "model-modes",
5
+ role: "group",
6
+ "aria-label": "模型查看模式"
7
+ }, l = [
8
+ "aria-pressed",
9
+ "title",
10
+ "onClick"
11
+ ], u = /*@__PURE__*/ r({
12
+ __name: "ModelViewModes",
13
+ props: {
14
+ modelValue: { required: !0 },
15
+ modelModifiers: {}
16
+ },
17
+ emits: ["update:modelValue"],
18
+ setup(r) {
19
+ let u = s(r, "modelValue"), d = [
20
+ {
21
+ value: "original",
22
+ label: "原色",
23
+ title: "显示模型原有的材质和颜色"
24
+ },
25
+ {
26
+ value: "white",
27
+ label: "白模",
28
+ title: "使用统一浅色材质查看形状"
29
+ },
30
+ {
31
+ value: "wireframe",
32
+ label: "线框",
33
+ title: "按模型原色显示三角网格,包括背面和已有的内部网格"
34
+ }
35
+ ];
36
+ return (r, s) => (i(), t("div", c, [(i(), t(e, null, a(d, (e) => n("button", {
37
+ key: e.value,
38
+ type: "button",
39
+ class: "model-modes__button",
40
+ "aria-pressed": u.value === e.value,
41
+ title: e.title,
42
+ onClick: (t) => u.value = e.value
43
+ }, o(e.label), 9, l)), 64))]));
44
+ }
45
+ });
46
+ //#endregion
47
+ export { u as default };
48
+
49
+ //# sourceMappingURL=ModelViewModes.vue_vue_type_script_setup_true_lang.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModelViewModes.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/ModelViewModes.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ModelViewMode } from '@/lib/modelAppearance'\n\nconst mode = defineModel<ModelViewMode>({ required: true })\nconst options: { value: ModelViewMode; label: string; title: string }[] = [\n { value: 'original', label: '原色', title: '显示模型原有的材质和颜色' },\n { value: 'white', label: '白模', title: '使用统一浅色材质查看形状' },\n { value: 'wireframe', label: '线框', title: '按模型原色显示三角网格,包括背面和已有的内部网格' },\n]\n</script>\n\n<template>\n <div class=\"model-modes\" role=\"group\" aria-label=\"模型查看模式\">\n <button\n v-for=\"option in options\"\n :key=\"option.value\"\n type=\"button\"\n class=\"model-modes__button\"\n :aria-pressed=\"mode === option.value\"\n :title=\"option.title\"\n @click=\"mode = option.value\"\n >\n {{ option.label }}\n </button>\n </div>\n</template>\n\n<style scoped>\n.model-modes {\n display: flex;\n gap: 2px;\n padding: 3px;\n border: 1px solid var(--border-2);\n border-radius: 9px;\n background: var(--surface);\n box-shadow: 0 2px 8px #0000000d;\n}\n.model-modes__button {\n min-width: 48px;\n min-height: 30px;\n padding: 5px 10px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: var(--muted);\n font: inherit;\n font-size: 12px;\n cursor: pointer;\n}\n.model-modes__button:hover {\n color: var(--text-strong);\n background: var(--surface-2);\n}\n.model-modes__button[aria-pressed='true'] {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 12%, var(--surface));\n font-weight: 600;\n}\n.model-modes__button:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n</style>\n"],"mappings":";;;;;;;;;;;;;;;;;;EAGA,IAAM,IAAO,EAA0B,GAAA,YAAmB,GACpD,IAAoE;GACxE;IAAE,OAAO;IAAY,OAAO;IAAM,OAAO;GAAe;GACxD;IAAE,OAAO;IAAS,OAAO;IAAM,OAAO;GAAe;GACrD;IAAE,OAAO;IAAa,OAAO;IAAM,OAAO;GAA2B;EACvE;yBAIE,EAYM,OAZN,GAYM,EAAA,EAAA,GAXJ,EAUS,GAAA,MAAA,EATU,IAAV,MADT,EAUS,UAAA;GARN,KAAK,EAAO;GACb,MAAK;GACL,OAAM;GACL,gBAAc,EAAA,UAAS,EAAO;GAC9B,OAAO,EAAO;GACd,UAAK,MAAE,EAAA,QAAO,EAAO;OAEnB,EAAO,KAAK,GAAA,GAAA,CAAA"}
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
2
2
  import t from "./ModelViewer.vue_vue_type_script_setup_true_lang.js";
3
3
  /* empty css */
4
4
  //#region src/components/ModelViewer.vue
5
- var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-6d7b6e6f"]]);
5
+ var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-aaa57cde"]]);
6
6
  //#endregion
7
7
  export { n as default };
8
8
 
@@ -1 +1 @@
1
- {"version":3,"file":"ModelViewer.js","names":[],"sources":["../../src/components/ModelViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport * as THREE from 'three'\nimport { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'\nimport { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'\nimport { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'\nimport { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js'\nimport { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'\nimport { fileExt } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer 3D model renderer. Fetches the model bytes and renders them with three.js (async-loaded so the\n * ~1MB library only ships when a model is opened): STL / PLY / OBJ / glTF / GLB. Auto-centers and frames the\n * model, lights it, and gives OrbitControls (drag to rotate, wheel to zoom, right-drag to pan).\n *\n * Best-effort by format: STL/PLY/OBJ are self-contained. A `.glb` is fully embedded and renders cleanly; a\n * `.gltf` that references external `.bin`/textures can't resolve them here (we only hold the one file), so it\n * may render untextured or fail — download it with its assets to view fully. No materials library for OBJ\n * (.mtl isn't fetched), so OBJ shows in a neutral material.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\nconst host = ref<HTMLElement | null>(null)\n\n// Models can be large (dense meshes); gate very large files behind a confirm (everything decodes in-memory).\nconst SIZE_LIMIT = 96 * 1024 * 1024\n\n// three.js handles — kept out of Vue reactivity on purpose (mutated every animation frame).\nlet renderer: THREE.WebGLRenderer | null = null\nlet scene: THREE.Scene | null = null\nlet camera: THREE.PerspectiveCamera | null = null\nlet controls: OrbitControls | null = null\nlet current: THREE.Object3D | null = null\nlet raf = 0\nlet ro: ResizeObserver | null = null\n\nfunction disposeObject(obj: THREE.Object3D): void {\n obj.traverse((child) => {\n const mesh = child as THREE.Mesh\n if (mesh.geometry) mesh.geometry.dispose()\n const mat = mesh.material\n if (Array.isArray(mat)) mat.forEach((m) => m.dispose())\n else if (mat) mat.dispose()\n })\n}\n\nfunction clearModel(): void {\n if (current && scene) {\n scene.remove(current)\n disposeObject(current)\n current = null\n }\n}\n\nfunction initScene(): void {\n const el = host.value\n if (!el || renderer) return\n const w = el.clientWidth || 1\n const h = el.clientHeight || 1\n\n scene = new THREE.Scene()\n camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 2000)\n camera.position.set(2, 1.5, 3)\n\n renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })\n renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1))\n renderer.setSize(w, h)\n el.appendChild(renderer.domElement)\n\n scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 1.1))\n const key = new THREE.DirectionalLight(0xffffff, 1.4)\n key.position.set(4, 6, 5)\n scene.add(key)\n const fill = new THREE.DirectionalLight(0xffffff, 0.5)\n fill.position.set(-5, -2, -4)\n scene.add(fill)\n\n controls = new OrbitControls(camera, renderer.domElement)\n controls.enableDamping = true\n controls.dampingFactor = 0.08\n\n ro = new ResizeObserver(onResize)\n ro.observe(el)\n\n const tick = (): void => {\n raf = requestAnimationFrame(tick)\n controls?.update()\n if (renderer && scene && camera) renderer.render(scene, camera)\n }\n tick()\n}\n\nfunction onResize(): void {\n const el = host.value\n if (!el || !renderer || !camera) return\n const w = el.clientWidth || 1\n const h = el.clientHeight || 1\n renderer.setSize(w, h)\n camera.aspect = w / h\n camera.updateProjectionMatrix()\n}\n\n// Center the model at the origin and pull the camera back to frame its bounding sphere.\nfunction frame(obj: THREE.Object3D): void {\n if (!camera || !controls) return\n const box = new THREE.Box3().setFromObject(obj)\n const size = box.getSize(new THREE.Vector3())\n const center = box.getCenter(new THREE.Vector3())\n obj.position.sub(center)\n const maxDim = Math.max(size.x, size.y, size.z) || 1\n const fov = (camera.fov * Math.PI) / 180\n const dist = (maxDim / (2 * Math.tan(fov / 2))) * 1.6\n camera.position.set(dist * 0.6, dist * 0.45, dist)\n camera.near = Math.max(dist / 1000, 0.001)\n camera.far = dist * 1000\n camera.updateProjectionMatrix()\n controls.target.set(0, 0, 0)\n controls.update()\n}\n\nconst NEUTRAL = (): THREE.Material =>\n new THREE.MeshStandardMaterial({\n color: 0x9aa7b4,\n metalness: 0.1,\n roughness: 0.7,\n side: THREE.DoubleSide,\n })\n\nasync function buildObject(buf: ArrayBuffer, ext: string): Promise<THREE.Object3D> {\n if (ext === 'stl') {\n const geom = new STLLoader().parse(buf)\n geom.computeVertexNormals()\n return new THREE.Mesh(geom, NEUTRAL())\n }\n if (ext === 'ply') {\n const geom = new PLYLoader().parse(buf)\n geom.computeVertexNormals()\n const mat = geom.hasAttribute('color')\n ? new THREE.MeshStandardMaterial({\n vertexColors: true,\n side: THREE.DoubleSide,\n roughness: 0.7,\n })\n : NEUTRAL()\n return new THREE.Mesh(geom, mat)\n }\n if (ext === 'obj') {\n const obj = new OBJLoader().parse(new TextDecoder().decode(buf))\n // OBJ without an .mtl comes in with no material — give meshes the neutral one.\n obj.traverse((child) => {\n const mesh = child as THREE.Mesh\n if (mesh.isMesh) mesh.material = NEUTRAL()\n })\n return obj\n }\n // glTF / GLB\n const data: ArrayBuffer | string = ext === 'gltf' ? new TextDecoder().decode(buf) : buf\n const gltf = await new Promise<{ scene: THREE.Group }>((resolve, reject) => {\n new GLTFLoader().parse(data, '', (g) => resolve(g as unknown as { scene: THREE.Group }), reject)\n })\n return gltf.scene\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const buf = await res.arrayBuffer()\n initScene()\n if (!scene) throw new Error('WebGL 初始化失败')\n clearModel()\n const obj = await buildObject(buf, fileExt(props.name))\n current = obj\n scene.add(obj)\n frame(obj)\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nonMounted(() => {\n watch(() => props.url, load, { immediate: true })\n})\n\nonBeforeUnmount(() => {\n cancelAnimationFrame(raf)\n ro?.disconnect()\n clearModel()\n controls?.dispose()\n if (renderer) {\n renderer.dispose()\n renderer.domElement.remove()\n }\n renderer = null\n scene = null\n camera = null\n controls = null\n})\n</script>\n\n<template>\n <div class=\"mdl\">\n <p v-if=\"status === 'loading'\" class=\"mdl__msg\">正在加载模型…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"mdl__msg mdl__gate\">\n <p>该模型较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"mdl__btn\" @click=\"doLoad\">加载并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"mdl__msg mdl__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <p class=\"mdl__hint\">\n 外部引用的 .gltf(需配套 .bin/贴图)或压缩网格可能无法在此渲染,可下载后用本地工具打开。\n </p>\n <button type=\"button\" class=\"mdl__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <!-- the canvas host is always present so three.js can mount into it; overlaid messages sit above it -->\n <div ref=\"host\" class=\"mdl__host\" :class=\"{ 'mdl__host--hidden': status !== 'ready' }\" />\n <div v-if=\"status === 'ready'\" class=\"mdl__hint mdl__hint--float\">\n 拖动旋转 · 滚轮缩放 · 右键平移\n </div>\n </div>\n</template>\n\n<style scoped>\n.mdl {\n position: relative;\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: radial-gradient(circle at 50% 35%, var(--surface-2), var(--code-bg));\n}\n\n.mdl__msg {\n position: relative;\n z-index: 2;\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.mdl__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n color: var(--danger);\n}\n.mdl__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.mdl__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.mdl__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.mdl__host {\n flex: 1 1 auto;\n min-height: 0;\n touch-action: none;\n}\n.mdl__host--hidden {\n visibility: hidden;\n}\n.mdl__host :deep(canvas) {\n display: block;\n}\n\n.mdl__hint {\n color: var(--faint);\n font-size: 12px;\n line-height: 1.5;\n}\n.mdl__hint--float {\n position: absolute;\n left: 12px;\n bottom: 10px;\n z-index: 2;\n padding: 4px 10px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--surface) 70%, transparent);\n pointer-events: none;\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"ModelViewer.js","names":[],"sources":["../../src/components/ModelViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'\nimport * as THREE from 'three'\nimport { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'\nimport { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'\nimport { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'\nimport { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js'\nimport { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'\nimport { fileExt, isStepFile } from '@/lib/fileType'\nimport { formatBytes } from '@/lib/format'\nimport ViewerLoadError from './ViewerLoadError.vue'\nimport ModelViewModes from './ModelViewModes.vue'\nimport { createModelAppearance, type ModelViewMode } from '@/lib/modelAppearance'\n\n/**\n * Side-viewer 3D model renderer. Fetches the model bytes and renders them with three.js (async-loaded so the\n * ~1MB library only ships when a model is opened): STL / PLY / OBJ / glTF / GLB / STEP. Auto-centers and frames the\n * model, lights it, and gives OrbitControls (drag to rotate, wheel to zoom, right-drag to pan).\n *\n * Best-effort by format: STL/PLY/OBJ are self-contained. A `.glb` is fully embedded and renders cleanly; a\n * `.gltf` that references external `.bin`/textures can't resolve them here (we only hold the one file), so it\n * may render untextured or fail — download it with its assets to view fully. No materials library for OBJ\n * (.mtl isn't fetched), so OBJ shows in a neutral material.\n */\nconst props = defineProps<{ url?: string; name: string; size?: number; format?: 'step' }>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error' | 'cancelled'\nconst status = shallowRef<Status>('loading')\nconst errorMsg = shallowRef('')\nconst loadingLabel = shallowRef('正在加载模型…')\nconst stepLoadError = shallowRef<unknown>(null)\nconst viewMode = shallowRef<ModelViewMode>('original')\nconst host = useTemplateRef<HTMLElement>('host')\nconst isStep = computed(() => props.format === 'step' || isStepFile(props.name))\nconst detectedSize = shallowRef<number>()\nconst sizeLabel = computed(() => formatBytes(detectedSize.value ?? props.size) || '大小未知')\nlet loadId = 0\nlet controller: AbortController | null = null\n\n// Models can be large (dense meshes); gate very large files behind a confirm (everything decodes in-memory).\nconst SIZE_LIMIT = 96 * 1024 * 1024\nconst STEP_SIZE_LIMIT = 20 * 1024 * 1024\n\n// three.js handles — kept out of Vue reactivity on purpose (mutated every animation frame).\nlet renderer: THREE.WebGLRenderer | null = null\nlet scene: THREE.Scene | null = null\nlet camera: THREE.PerspectiveCamera | null = null\nlet controls: OrbitControls | null = null\nlet current: THREE.Object3D | null = null\nlet appearance: ReturnType<typeof createModelAppearance> | null = null\nlet raf = 0\nlet ro: ResizeObserver | null = null\n\nfunction disposeObject(obj: THREE.Object3D): void {\n const geometries = new Set<THREE.BufferGeometry>()\n const materials = new Set<THREE.Material>()\n obj.traverse((child) => {\n const mesh = child as THREE.Mesh\n if (mesh.geometry) geometries.add(mesh.geometry)\n const mat = mesh.material\n if (Array.isArray(mat)) mat.forEach((m) => materials.add(m))\n else if (mat) materials.add(mat)\n })\n geometries.forEach((geometry) => geometry.dispose())\n materials.forEach((mat) => mat.dispose())\n}\n\nfunction clearModel(): void {\n appearance?.dispose()\n appearance = null\n if (current && scene) {\n scene.remove(current)\n disposeObject(current)\n current = null\n }\n}\n\nfunction initScene(): void {\n const el = host.value\n if (!el || renderer) return\n const w = el.clientWidth || 1\n const h = el.clientHeight || 1\n\n scene = new THREE.Scene()\n camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 2000)\n camera.position.set(2, 1.5, 3)\n\n renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })\n renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1))\n renderer.setSize(w, h)\n el.appendChild(renderer.domElement)\n\n scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 1.1))\n const key = new THREE.DirectionalLight(0xffffff, 1.4)\n key.position.set(4, 6, 5)\n scene.add(key)\n const fill = new THREE.DirectionalLight(0xffffff, 0.5)\n fill.position.set(-5, -2, -4)\n scene.add(fill)\n\n controls = new OrbitControls(camera, renderer.domElement)\n controls.enableDamping = true\n controls.dampingFactor = 0.08\n\n ro = new ResizeObserver(onResize)\n ro.observe(el)\n\n const tick = (): void => {\n raf = requestAnimationFrame(tick)\n controls?.update()\n if (renderer && scene && camera) renderer.render(scene, camera)\n }\n tick()\n}\n\nfunction onResize(): void {\n const el = host.value\n if (!el || !renderer || !camera) return\n const w = el.clientWidth || 1\n const h = el.clientHeight || 1\n renderer.setSize(w, h)\n camera.aspect = w / h\n camera.updateProjectionMatrix()\n}\n\n// Center the model at the origin and pull the camera back to frame its bounding sphere.\nfunction frame(obj: THREE.Object3D): void {\n if (!camera || !controls) return\n const box = new THREE.Box3().setFromObject(obj)\n const size = box.getSize(new THREE.Vector3())\n const center = box.getCenter(new THREE.Vector3())\n obj.position.sub(center)\n const maxDim = Math.max(size.x, size.y, size.z) || 1\n const fov = (camera.fov * Math.PI) / 180\n const dist = (maxDim / (2 * Math.tan(fov / 2))) * 1.6\n camera.position.set(dist * 0.6, dist * 0.45, dist)\n camera.near = Math.max(dist / 1000, 0.001)\n camera.far = dist * 1000\n camera.updateProjectionMatrix()\n controls.target.set(0, 0, 0)\n controls.update()\n}\n\nconst NEUTRAL = (): THREE.Material =>\n new THREE.MeshStandardMaterial({\n color: 0x9aa7b4,\n metalness: 0.1,\n roughness: 0.7,\n side: THREE.DoubleSide,\n })\n\nasync function buildObject(\n buf: ArrayBuffer,\n ext: string,\n signal: AbortSignal,\n): Promise<THREE.Object3D> {\n if (ext === 'step' || ext === 'stp') {\n // The optional OCCT assets are loaded only for STEP, never when opening an STL/OBJ/glTF.\n const [parser, builder] = await Promise.all([\n import('@/lib/step').catch((error: unknown) => {\n if (!signal.aborted) stepLoadError.value = error\n throw error\n }),\n import('@/lib/stepObject'),\n ])\n signal.throwIfAborted()\n const model = await parser.parseStep(buf, {\n signal,\n onPhase: (phase) => {\n if (!signal.aborted) {\n loadingLabel.value = phase === 'engine' ? '正在加载 STEP 解析器…' : '正在解析 STEP 模型…'\n }\n },\n })\n signal.throwIfAborted()\n return builder.buildStepObject(model)\n }\n if (ext === 'stl') {\n const geom = new STLLoader().parse(buf)\n geom.computeVertexNormals()\n return new THREE.Mesh(geom, NEUTRAL())\n }\n if (ext === 'ply') {\n const geom = new PLYLoader().parse(buf)\n geom.computeVertexNormals()\n const mat = geom.hasAttribute('color')\n ? new THREE.MeshStandardMaterial({\n vertexColors: true,\n side: THREE.DoubleSide,\n roughness: 0.7,\n })\n : NEUTRAL()\n return new THREE.Mesh(geom, mat)\n }\n if (ext === 'obj') {\n const obj = new OBJLoader().parse(new TextDecoder().decode(buf))\n // OBJ without an .mtl comes in with no material — give meshes the neutral one.\n obj.traverse((child) => {\n const mesh = child as THREE.Mesh\n if (mesh.isMesh) mesh.material = NEUTRAL()\n })\n return obj\n }\n // glTF / GLB\n const data: ArrayBuffer | string = ext === 'gltf' ? new TextDecoder().decode(buf) : buf\n const gltf = await new Promise<{ scene: THREE.Group }>((resolve, reject) => {\n new GLTFLoader().parse(data, '', (g) => resolve(g as unknown as { scene: THREE.Group }), reject)\n })\n return gltf.scene\n}\n\nfunction stopLoad(): void {\n loadId++\n controller?.abort()\n controller = null\n}\n\nfunction cancelLoad(): void {\n stopLoad()\n status.value = 'cancelled'\n}\n\nasync function doLoad(confirmed = false): Promise<void> {\n stopLoad()\n clearModel()\n const id = loadId\n const url = props.url\n const ext = props.format ?? fileExt(props.name)\n const limit = isStep.value ? STEP_SIZE_LIMIT : SIZE_LIMIT\n controller = new AbortController()\n const signal = controller.signal\n status.value = 'loading'\n errorMsg.value = ''\n stepLoadError.value = null\n loadingLabel.value = '正在加载模型…'\n try {\n if (!url) throw new Error('未提供模型文件地址。')\n const res = await fetch(url, { signal })\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n // Do not trust a stale/incorrect manifest size to bypass the gate.\n const contentLength = Number(res.headers.get('Content-Length'))\n if (!confirmed && contentLength > limit) {\n detectedSize.value = contentLength\n void res.body?.cancel()\n status.value = 'gate'\n return\n }\n const buf = await res.arrayBuffer()\n if (id !== loadId) return\n if (!confirmed && buf.byteLength > limit) {\n detectedSize.value = buf.byteLength\n status.value = 'gate'\n return\n }\n initScene()\n if (!scene) throw new Error('WebGL 初始化失败')\n const obj = await buildObject(buf, ext, signal)\n if (id !== loadId) {\n disposeObject(obj)\n return\n }\n current = obj\n appearance = createModelAppearance(obj)\n appearance.setMode(viewMode.value)\n scene.add(obj)\n frame(obj)\n status.value = 'ready'\n } catch (e) {\n if (id !== loadId) return\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n } finally {\n if (id === loadId) controller = null\n }\n}\n\nfunction load(): void {\n stopLoad()\n clearModel()\n stepLoadError.value = null\n detectedSize.value = undefined\n const limit = isStep.value ? STEP_SIZE_LIMIT : SIZE_LIMIT\n if ((props.size && props.size > limit) || (isStep.value && props.size == null)) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nwatch(viewMode, (mode) => appearance?.setMode(mode))\n\nonMounted(() => {\n watch([() => props.url, () => props.name, () => props.format, () => props.size], load, {\n immediate: true,\n })\n})\n\nonBeforeUnmount(() => {\n stopLoad()\n cancelAnimationFrame(raf)\n ro?.disconnect()\n clearModel()\n controls?.dispose()\n if (renderer) {\n renderer.dispose()\n renderer.domElement.remove()\n }\n renderer = null\n scene = null\n camera = null\n controls = null\n})\n</script>\n\n<template>\n <div class=\"mdl\">\n <ModelViewModes v-if=\"status === 'ready'\" v-model=\"viewMode\" class=\"mdl__modes\" />\n <div v-if=\"status === 'loading'\" class=\"mdl__msg mdl__gate\" role=\"status\">\n <p>{{ loadingLabel }}</p>\n <button type=\"button\" class=\"mdl__btn\" @click=\"cancelLoad\">取消加载</button>\n </div>\n\n <div v-else-if=\"status === 'gate'\" class=\"mdl__msg mdl__gate\">\n <p>该模型较大或大小未知({{ sizeLabel }}),解析可能需要较长时间和较多内存。</p>\n <button type=\"button\" class=\"mdl__btn\" @click=\"doLoad(true)\">加载并预览</button>\n </div>\n\n <ViewerLoadError\n v-else-if=\"stepLoadError\"\n :error=\"stepLoadError\"\n :pkg=\"['occt-import-js']\"\n label=\"STEP 模型\"\n :download-url=\"url\"\n :download-name=\"name\"\n retry-label=\"重试加载\"\n @retry=\"doLoad(true)\"\n />\n <div v-else-if=\"status === 'error'\" class=\"mdl__msg mdl__msg--error\">\n <p>加载失败:{{ errorMsg }}</p>\n <p v-if=\"isStep\" class=\"mdl__hint\">\n 复杂装配体或不完整的 STEP 文件可能无法预览,可下载后用本地 CAD 工具打开。\n </p>\n <p v-else class=\"mdl__hint\">\n 外部引用的 .gltf(需配套 .bin/贴图)或压缩网格可能无法在此渲染,可下载后用本地工具打开。\n </p>\n <button type=\"button\" class=\"mdl__btn\" @click=\"doLoad(true)\">重试</button>\n </div>\n <div v-else-if=\"status === 'cancelled'\" class=\"mdl__msg mdl__gate\">\n <p>已取消模型加载。</p>\n <button type=\"button\" class=\"mdl__btn\" @click=\"doLoad(true)\">重新加载</button>\n </div>\n\n <!-- the canvas host is always present so three.js can mount into it; overlaid messages sit above it -->\n <div ref=\"host\" class=\"mdl__host\" :class=\"{ 'mdl__host--hidden': status !== 'ready' }\" />\n <div v-if=\"status === 'ready'\" class=\"mdl__hint mdl__hint--float\">\n 拖动旋转 · 滚轮缩放 · 右键平移\n </div>\n </div>\n</template>\n\n<style scoped>\n.mdl {\n position: relative;\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: radial-gradient(circle at 50% 35%, var(--surface-2), var(--code-bg));\n}\n\n.mdl__msg {\n position: relative;\n z-index: 2;\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.mdl__modes {\n position: absolute;\n top: 12px;\n right: 12px;\n z-index: 2;\n}\n.mdl__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n color: var(--danger);\n}\n.mdl__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.mdl__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.mdl__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.mdl__host {\n flex: 1 1 auto;\n min-height: 0;\n touch-action: none;\n}\n.mdl__host--hidden {\n visibility: hidden;\n}\n.mdl__host :deep(canvas) {\n display: block;\n}\n\n.mdl__hint {\n color: var(--faint);\n font-size: 12px;\n line-height: 1.5;\n}\n.mdl__hint--float {\n position: absolute;\n left: 12px;\n bottom: 10px;\n z-index: 2;\n padding: 4px 10px;\n border-radius: 999px;\n background: color-mix(in srgb, var(--surface) 70%, transparent);\n pointer-events: none;\n}\n</style>\n"],"mappings":""}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Side-viewer 3D model renderer. Fetches the model bytes and renders them with three.js (async-loaded so the
3
- * ~1MB library only ships when a model is opened): STL / PLY / OBJ / glTF / GLB. Auto-centers and frames the
3
+ * ~1MB library only ships when a model is opened): STL / PLY / OBJ / glTF / GLB / STEP. Auto-centers and frames the
4
4
  * model, lights it, and gives OrbitControls (drag to rotate, wheel to zoom, right-drag to pan).
5
5
  *
6
6
  * Best-effort by format: STL/PLY/OBJ are self-contained. A `.glb` is fully embedded and renders cleanly; a
@@ -12,6 +12,7 @@ type __VLS_Props = {
12
12
  url?: string;
13
13
  name: string;
14
14
  size?: number;
15
+ format?: 'step';
15
16
  };
16
17
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
18
  declare const _default: typeof __VLS_export;