| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144 |
- <script setup lang="ts">
- /**
- * 门户(portal)—— 用户令 2026-10-03:**前端也用 Vue 严格实现观澜门户**。
- *
- * 做法(与 `scripts/portal_build.py` 的装配口径一致,只是搬到运行时、由 Vue 驱动):
- * ① 取 `release/web/portal/shell.html`(门户外壳:样式/脚本/导航,v2.9.2 原文);
- * ② 把壳里的 `<!--@TEMPLATE:<id>-->` 逐个替换为 `templates/<id>.html`(28 件交付文档正文,按需并发取);
- * ③ `<!--@GOVERNANCE_SOURCES-->` 替换为资料索引 JSON 的渲染(与装配脚本同法:原样内联);
- * ④ 成品交给**同源 iframe**(Blob URL)呈现 ⇒ 门户自身的样式与脚本原样运行,观感/行为与 v2.9.2 一致;
- * 外层是 Vue 工作台,满足「前端必须使用 Vue.js」。
- */
- import { onMounted, ref } from 'vue'
- import { t } from '../i18n/zh'
- const BASE = '/web/portal/'
- const frame = ref<HTMLIFrameElement | null>(null)
- const state = ref<'loading' | 'ready' | 'error'>('loading')
- const info = ref('')
- async function text(url: string): Promise<string> {
- const r = await fetch(url)
- if (!r.ok) throw new Error(url + ' ' + r.status)
- return await r.text()
- }
- onMounted(async () => {
- try {
- let shell = await text(BASE + 'shell.html')
- // ★P43 与 v2.9.2 一致:门户模式下页面标题用门户自己的 <title>
- const mTitle = shell.match(/<title[^>]*>([^<]*)<\/title>/i)
- if (mTitle && mTitle[1].trim()) document.title = mTitle[1].trim()
- const ids = Array.from(shell.matchAll(/<!--@TEMPLATE:([^>]+?)-->/g)).map((m) => m[1])
- // ★P57 STAGED 装配:先用**空占位**出壳并立即渲染(不再等 19.3 MB 内嵌件),
- // 随后按 6 路并发取回内嵌件,逐个注入 iframe 文档中的 <template id>(用户可见的壳是"秒出"的)。
- ids.forEach((id) => {
- shell = shell.replace('<!--@TEMPLATE:' + id + '-->', '<template id="' + id + '"></template>')
- })
- try {
- const gs = await text(BASE + 'governance_sources.json')
- // ★P51 壳里该标记**已在** <script type="application/json"> 内部 ⇒ 只插 JSON(避免重复 script 标签)
- shell = shell.replace('<!--@GOVERNANCE_SOURCES-->', gs.replace(/</g, '\\u003c'))
- } catch { /* 索引缺失不阻断门户 */ }
- // ★P28:门户里指向旧 SPA 的链接改指新版工作台(新版 UI 仍是 Vue 工作台)
- // ★P52 门户在 iframe 内 ⇒ 迁移入口必须 target="_top" 打到顶层窗口(否则外层 Vue 收不到)
- const TARGET_TOP = [
- [/<a([^>]*?)href="#cms"/g, '<a$1target="_top" href="/detail/v2#tab=vibration"'],
- [/<a([^>]*?)href="#documents"/g, '<a$1target="_top" href="/detail/v2#tab=documents"'],
- [/<a([^>]*?)href="#recalc"/g, '<a$1target="_top" href="/detail/v2#tab=recalc"'],
- [/<a([^>]*?)href="(?:https?:\/\/[^"]*)?\/(?:detail\/v2|v2)[^"]*"/g,
- '<a$1target="_top" href="/detail/v2#workbench"'],
- ]
- for (const [re, to] of TARGET_TOP) shell = shell.replace(re, to)
- // ★P73 用户令:门户页去掉「振动·CMS」「交付文档」「数据重算」三个导航项(它们已迁入工作台)
- const DROP_NAV = ['cms', 'documents', 'recalc']
- for (const id of DROP_NAV) shell = shell.replace(new RegExp('<a[^>]*data-nav="' + id + '"[^>]*>[^<]*</a>', 'g'), '')
- info.value = t('pv.assembled', { n: ids.length })
- const blob = new Blob([shell], { type: 'text/html;charset=utf-8' })
- if (frame.value) frame.value.src = URL.createObjectURL(blob)
- bindEntries()
- void streamTemplates(ids) // ★P57 后台流式注入,不阻塞首屏
- state.value = 'ready'
- } catch (e: any) {
- info.value = String((e && e.message) || e)
- state.value = 'error'
- }
- })
- /** ★P54:门户脚本会拦截导航点击 ⇒ 在**捕获期**先接管这四个入口,直接导航顶层窗口到工作台。
- * 选择器:振动·CMS / 交付文档 / 数据重算(data-nav);「登录」= 导航里没有 data-nav 的那个锚。 */
- /** ★P57:6 路并发取回内嵌件,边到边注入 iframe 中的 <template id>(惰性元素,可后填)。 */
- async function streamTemplates(ids: string[]): Promise<void> {
- const LIMIT = 6
- let idx = 0, done = 0
- const worker = async (): Promise<void> => {
- while (idx < ids.length) {
- const id = ids[idx++]
- try {
- const r = await fetch(BASE + 'templates/' + encodeURIComponent(id) + '.html', { cache: 'force-cache' })
- const body = r.ok ? await r.text() : ''
- const doc = frame.value && frame.value.contentDocument
- const holder = doc && doc.getElementById(id)
- if (doc && holder) {
- const tpl = doc.createElement('template')
- tpl.id = id
- tpl.innerHTML = body
- holder.replaceWith(tpl)
- }
- } catch { /* 单个失败不阻断其余 */ }
- done++
- info.value = t('pv.assembled', { n: done })
- }
- }
- await Promise.all(Array.from({ length: LIMIT }, () => worker()))
- }
- function bindEntries(): void {
- const BIND_ENTRIES: [string, string][] = [
- ['nav a[data-nav="cms"]', '#tab=vibration'],
- ['nav a[data-nav="documents"]', '#tab=documents'],
- ['nav a[data-nav="recalc"]', '#tab=recalc'],
- ]
- const bind = (): void => {
- const doc = frame.value && frame.value.contentDocument
- if (!doc) return
- const go = (href: string) => (ev: Event) => {
- ev.preventDefault()
- ev.stopImmediatePropagation()
- const top = window.top as Window
- try { top.location.href = href } catch { location.href = href }
- }
- for (const [sel, hash] of BIND_ENTRIES) {
- const a = doc.querySelector(sel)
- if (a) a.addEventListener('click', go('/detail/v2' + hash), true)
- }
- // 「登录」:门户导航里唯一没有 data-nav 的锚(也可回退到含 18033/v2 的锚)
- const login = (doc.querySelector('nav a:not([data-nav])') || doc.querySelector('a[href*="18033"]')) as HTMLElement | null
- if (login) login.addEventListener('click', go('/detail/v2#workbench'), true)
- // 顺带:门户里的「工作台」悬浮入口(同一文档内的 #workbench 由外层处理)
- const wb = doc.querySelector('a.portal-wb') as HTMLElement | null
- if (wb) wb.addEventListener('click', go('/detail/v2#workbench'), true)
- }
- const f = frame.value
- if (!f) return
- f.addEventListener('load', bind)
- bind()
- }
- </script>
- <template>
- <!-- 整页门户(与 v2.9.2 的 `/` 一致):外壳自带 .wrap 居中,这里不再套工作台面板 -->
- <div class="portal-full">
- <div v-if="state !== 'ready'" class="portal-loading">{{ state === 'loading' ? t('pv.loading') : info }}</div>
- <a class="portal-wb" href="#workbench" title="切换到 Vue 工作台">工作台</a>
- <iframe ref="frame" class="portal-frame" title="观澜门户" />
- </div>
- </template>
- <style scoped>
- .portal-full { position: fixed; inset: 0; background: var(--ground, #fff); }
- .portal-frame { width: 100%; height: 100%; border: 0; display: block; }
- .portal-loading { padding: 60px 24px; color: var(--muted, #666); font-size: 13px; text-align: center; }
- .portal-wb { position: fixed; right: 14px; bottom: 14px; z-index: 9; padding: 6px 12px; border-radius: 14px;
- background: var(--accent, #0b5fa5); color: #fff; font-size: 12px; text-decoration: none; }
- </style>
|