PortalView.vue 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. <script setup lang="ts">
  2. /**
  3. * 门户(portal)—— 用户令 2026-10-03:**前端也用 Vue 严格实现观澜门户**。
  4. *
  5. * 做法(与 `scripts/portal_build.py` 的装配口径一致,只是搬到运行时、由 Vue 驱动):
  6. * ① 取 `release/web/portal/shell.html`(门户外壳:样式/脚本/导航,v2.9.2 原文);
  7. * ② 把壳里的 `<!--@TEMPLATE:<id>-->` 逐个替换为 `templates/<id>.html`(28 件交付文档正文,按需并发取);
  8. * ③ `<!--@GOVERNANCE_SOURCES-->` 替换为资料索引 JSON 的渲染(与装配脚本同法:原样内联);
  9. * ④ 成品交给**同源 iframe**(Blob URL)呈现 ⇒ 门户自身的样式与脚本原样运行,观感/行为与 v2.9.2 一致;
  10. * 外层是 Vue 工作台,满足「前端必须使用 Vue.js」。
  11. */
  12. import { onMounted, ref } from 'vue'
  13. import { t } from '../i18n/zh'
  14. const BASE = '/web/portal/'
  15. const frame = ref<HTMLIFrameElement | null>(null)
  16. const state = ref<'loading' | 'ready' | 'error'>('loading')
  17. const info = ref('')
  18. async function text(url: string): Promise<string> {
  19. const r = await fetch(url)
  20. if (!r.ok) throw new Error(url + ' ' + r.status)
  21. return await r.text()
  22. }
  23. onMounted(async () => {
  24. try {
  25. let shell = await text(BASE + 'shell.html')
  26. // ★P43 与 v2.9.2 一致:门户模式下页面标题用门户自己的 <title>
  27. const mTitle = shell.match(/<title[^>]*>([^<]*)<\/title>/i)
  28. if (mTitle && mTitle[1].trim()) document.title = mTitle[1].trim()
  29. const ids = Array.from(shell.matchAll(/<!--@TEMPLATE:([^>]+?)-->/g)).map((m) => m[1])
  30. // ★P57 STAGED 装配:先用**空占位**出壳并立即渲染(不再等 19.3 MB 内嵌件),
  31. // 随后按 6 路并发取回内嵌件,逐个注入 iframe 文档中的 <template id>(用户可见的壳是"秒出"的)。
  32. ids.forEach((id) => {
  33. shell = shell.replace('<!--@TEMPLATE:' + id + '-->', '<template id="' + id + '"></template>')
  34. })
  35. try {
  36. const gs = await text(BASE + 'governance_sources.json')
  37. // ★P51 壳里该标记**已在** <script type="application/json"> 内部 ⇒ 只插 JSON(避免重复 script 标签)
  38. shell = shell.replace('<!--@GOVERNANCE_SOURCES-->', gs.replace(/</g, '\\u003c'))
  39. } catch { /* 索引缺失不阻断门户 */ }
  40. // ★P28:门户里指向旧 SPA 的链接改指新版工作台(新版 UI 仍是 Vue 工作台)
  41. // ★P52 门户在 iframe 内 ⇒ 迁移入口必须 target="_top" 打到顶层窗口(否则外层 Vue 收不到)
  42. const TARGET_TOP = [
  43. [/<a([^>]*?)href="#cms"/g, '<a$1target="_top" href="/detail/v2#tab=vibration"'],
  44. [/<a([^>]*?)href="#documents"/g, '<a$1target="_top" href="/detail/v2#tab=documents"'],
  45. [/<a([^>]*?)href="#recalc"/g, '<a$1target="_top" href="/detail/v2#tab=recalc"'],
  46. [/<a([^>]*?)href="(?:https?:\/\/[^"]*)?\/(?:detail\/v2|v2)[^"]*"/g,
  47. '<a$1target="_top" href="/detail/v2#workbench"'],
  48. ]
  49. for (const [re, to] of TARGET_TOP) shell = shell.replace(re, to)
  50. // ★P73 用户令:门户页去掉「振动·CMS」「交付文档」「数据重算」三个导航项(它们已迁入工作台)
  51. const DROP_NAV = ['cms', 'documents', 'recalc']
  52. for (const id of DROP_NAV) shell = shell.replace(new RegExp('<a[^>]*data-nav="' + id + '"[^>]*>[^<]*</a>', 'g'), '')
  53. info.value = t('pv.assembled', { n: ids.length })
  54. const blob = new Blob([shell], { type: 'text/html;charset=utf-8' })
  55. if (frame.value) frame.value.src = URL.createObjectURL(blob)
  56. bindEntries()
  57. void streamTemplates(ids) // ★P57 后台流式注入,不阻塞首屏
  58. state.value = 'ready'
  59. } catch (e: any) {
  60. info.value = String((e && e.message) || e)
  61. state.value = 'error'
  62. }
  63. })
  64. /** ★P54:门户脚本会拦截导航点击 ⇒ 在**捕获期**先接管这四个入口,直接导航顶层窗口到工作台。
  65. * 选择器:振动·CMS / 交付文档 / 数据重算(data-nav);「登录」= 导航里没有 data-nav 的那个锚。 */
  66. /** ★P57:6 路并发取回内嵌件,边到边注入 iframe 中的 <template id>(惰性元素,可后填)。 */
  67. async function streamTemplates(ids: string[]): Promise<void> {
  68. const LIMIT = 6
  69. let idx = 0, done = 0
  70. const worker = async (): Promise<void> => {
  71. while (idx < ids.length) {
  72. const id = ids[idx++]
  73. try {
  74. const r = await fetch(BASE + 'templates/' + encodeURIComponent(id) + '.html', { cache: 'force-cache' })
  75. const body = r.ok ? await r.text() : ''
  76. const doc = frame.value && frame.value.contentDocument
  77. const holder = doc && doc.getElementById(id)
  78. if (doc && holder) {
  79. const tpl = doc.createElement('template')
  80. tpl.id = id
  81. tpl.innerHTML = body
  82. holder.replaceWith(tpl)
  83. }
  84. } catch { /* 单个失败不阻断其余 */ }
  85. done++
  86. info.value = t('pv.assembled', { n: done })
  87. }
  88. }
  89. await Promise.all(Array.from({ length: LIMIT }, () => worker()))
  90. }
  91. function bindEntries(): void {
  92. const BIND_ENTRIES: [string, string][] = [
  93. ['nav a[data-nav="cms"]', '#tab=vibration'],
  94. ['nav a[data-nav="documents"]', '#tab=documents'],
  95. ['nav a[data-nav="recalc"]', '#tab=recalc'],
  96. ]
  97. const bind = (): void => {
  98. const doc = frame.value && frame.value.contentDocument
  99. if (!doc) return
  100. const go = (href: string) => (ev: Event) => {
  101. ev.preventDefault()
  102. ev.stopImmediatePropagation()
  103. const top = window.top as Window
  104. try { top.location.href = href } catch { location.href = href }
  105. }
  106. for (const [sel, hash] of BIND_ENTRIES) {
  107. const a = doc.querySelector(sel)
  108. if (a) a.addEventListener('click', go('/detail/v2' + hash), true)
  109. }
  110. // 「登录」:门户导航里唯一没有 data-nav 的锚(也可回退到含 18033/v2 的锚)
  111. const login = (doc.querySelector('nav a:not([data-nav])') || doc.querySelector('a[href*="18033"]')) as HTMLElement | null
  112. if (login) login.addEventListener('click', go('/detail/v2#workbench'), true)
  113. // 顺带:门户里的「工作台」悬浮入口(同一文档内的 #workbench 由外层处理)
  114. const wb = doc.querySelector('a.portal-wb') as HTMLElement | null
  115. if (wb) wb.addEventListener('click', go('/detail/v2#workbench'), true)
  116. }
  117. const f = frame.value
  118. if (!f) return
  119. f.addEventListener('load', bind)
  120. bind()
  121. }
  122. </script>
  123. <template>
  124. <!-- 整页门户(与 v2.9.2 的 `/` 一致):外壳自带 .wrap 居中,这里不再套工作台面板 -->
  125. <div class="portal-full">
  126. <div v-if="state !== 'ready'" class="portal-loading">{{ state === 'loading' ? t('pv.loading') : info }}</div>
  127. <a class="portal-wb" href="#workbench" title="切换到 Vue 工作台">工作台</a>
  128. <iframe ref="frame" class="portal-frame" title="观澜门户" />
  129. </div>
  130. </template>
  131. <style scoped>
  132. .portal-full { position: fixed; inset: 0; background: var(--ground, #fff); }
  133. .portal-frame { width: 100%; height: 100%; border: 0; display: block; }
  134. .portal-loading { padding: 60px 24px; color: var(--muted, #666); font-size: 13px; text-align: center; }
  135. .portal-wb { position: fixed; right: 14px; bottom: 14px; z-index: 9; padding: 6px 12px; border-radius: 14px;
  136. background: var(--accent, #0b5fa5); color: #fff; font-size: 12px; text-decoration: none; }
  137. </style>