YD-WEB/src/components/OriginalSiteRenderer.vue

553 lines
20 KiB
Vue
Raw Normal View History

2026-07-20 15:49:49 +08:00
<template>
<div class="original-site-page">
<div v-if="error" class="original-site-page__error">{{ error }}</div>
<div v-else ref="container" v-html="bodyHtml"></div>
<!-- 移动端菜单遮罩(由 Vue 管理,不受页面内容影响) -->
<div class="yuedong-menu-backdrop" :class="{ show: menuOpen }" @click="closeMobileMenu"></div>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { originalPages } from '../data/originalPages.generated';
const route = useRoute();
const router = useRouter();
const container = ref(null);
const bodyHtml = ref('');
const error = ref('');
const menuOpen = ref(false);
const managedAttr = 'data-original-site-managed';
const persistentAttr = 'data-original-site-persistent';
const defaultTitle = document.title;
const defaultDescription =
document.querySelector('meta[name="description"]')?.getAttribute('content') ?? '';
let loadToken = 0;
let prevPath = '';
const loadedExternalScripts = new Set();
const loadedStyles = new Set();
const assetExtensions =
/\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|bmp|ico|ttf|otf|woff2?|eot|mp4|webm|ogg|mp3|wav|pdf|zip|txt|json|xml|map)$/i;
const isProductsPage = (path) => /\/products(?:_\d+)?$/.test(path);
const scrollToProductsList = () => {
const target = document.querySelector('.yuedong_goods_list');
if (target) {
target.scrollIntoView({ behavior: 'instant', block: 'start' });
}
};
// 替换缺失的 iconfont-caidan 图标为 SVG 汉堡菜单
const menuIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`;
const replaceMenuIcon = () => {
document.querySelectorAll('.iconfont.iconfont-caidan').forEach((el) => {
el.innerHTML = menuIconSvg;
el.classList.remove('iconfont-caidan');
});
};
// 分页按钮:将"上一页"/"下一页"文字替换为左右箭头图标
const prevArrowSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="7" height="13" viewBox="0 0 7 13" fill="none"><path d="M6.5 12.5L0.5 6.5L6.5 0.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const nextArrowSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="7" height="13" viewBox="0 0 7 13" fill="none"><path d="M0.5 12.5L6.5 6.5L0.5 0.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const replacePaginationArrows = () => {
const pageSize = document.querySelector('#pagesize ul');
if (!pageSize) return;
const items = pageSize.querySelectorAll(':scope > li');
if (items.length >= 2) {
const firstLink = items[0].querySelector('a');
const lastLink = items[items.length - 1].querySelector('a');
if (firstLink && !firstLink.querySelector('svg')) {
firstLink.innerHTML = prevArrowSvg;
}
if (lastLink && !lastLink.querySelector('svg')) {
lastLink.innerHTML = nextArrowSvg;
}
}
};
// 修复解决方案页面:翻译英文文本、修复缺失图片
const fixSolutionPage = () => {
// --- /solution 列表页 ---
// 翻译解决方案标题
document.querySelectorAll('.solution-title').forEach((el) => {
if (el.textContent.trim() === 'JumpArena X') {
el.innerHTML = 'JumpArena X <small style="font-size:0.7em;color:#888;font-weight:400">智能赛事系统</small>';
}
});
// 翻译解决方案描述
document.querySelectorAll('.solution-list p').forEach((el) => {
const html = el.innerHTML;
if (html.includes('successfully established a fully digital')) {
el.innerHTML = html.replace(
/The "Yuedong" Smart Rope Skipping Competition System successfully established a fully digital and intelligent event management platform that streamlines competition operations, improves judging accuracy, and enhances the overall efficiency and transparency of professional rope skipping events\./,
'跃动智能跳绳竞赛系统成功构建了全数字化、智能化的赛事管理平台,优化了赛事运营流程,提升了裁判精准度,全面增强了专业跳绳赛事的效率与透明度。'
);
}
if (html.includes('By integrating intelligent scoring')) {
el.innerHTML = html.replace(
/By integrating intelligent scoring, paperless operation, real-time visualization, and full-process traceability, the system promotes the standardization, fairness, and digital transformation of rope skipping competitions\./,
'通过集成智能评分、无纸化操作、实时可视化与全流程追溯,该系统推动了跳绳竞赛的标准化、公平性与数字化转型。'
);
}
});
// --- /solution 和 /jumparena-x ---
// 修复缺失的订阅区背景图
document.querySelectorAll('img[src$="image_327.png"]').forEach((img) => {
img.style.display = 'none';
});
// 修复 /jumparena-x 详情页缺失的 SVG 图标(中文文件名URL编码)
const infoIconSvg = (name) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;color:#888">${name === 'location' ? '<circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 6 8 12 8 12s8-6 8-12a8 8 0 0 0-8-8z"/>' : name === 'time' ? '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>' : '<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>'}</svg>`;
document.querySelectorAll('img[src*="%E5%87%BA%E5%8F%91%E5%9C%B0"]').forEach((img) => {
img.outerHTML = infoIconSvg('location');
});
document.querySelectorAll('img[src*="%E6%97%B6%E9%97%B4_"]').forEach((img) => {
img.outerHTML = infoIconSvg('time');
});
document.querySelectorAll('img[src*="%E4%BA%A7%E5%93%812_1"]').forEach((img) => {
img.outerHTML = infoIconSvg('monitor');
});
};
// 判断是否为解决方案相关页面
const isSolutionPage = (path) => /\/solution/.test(path);
// 移动端菜单:由 Vue 状态驱动,事件委托只做转发
let mobileMenuInited = false;
const initMobileMenu = () => {
if (mobileMenuInited) return;
mobileMenuInited = true;
// 全局委托:菜单按钮和关闭按钮点击 → 转发到 Vue 状态
document.addEventListener('click', (e) => {
const toggle = e.target.closest('[data-bs-toggle="collapse"][data-bs-target="#main_nav"]');
if (toggle) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
menuOpen.value = !menuOpen.value;
return;
}
const closeBtn = e.target.closest('.collapse .qico-guanbi');
if (closeBtn) {
e.preventDefault();
menuOpen.value = false;
}
}, true);
};
const closeMobileMenu = () => {
menuOpen.value = false;
};
// 同步 Vue 状态到实际 DOM 元素
const syncMobileMenu = () => {
const menu = document.querySelector('#main_nav');
if (!menu) return;
if (menuOpen.value) {
menu.classList.remove('collapse');
menu.classList.add('show');
menu.style.cssText = 'display:block!important;visibility:visible!important;opacity:1!important;height:100vh!important';
document.body.style.overflow = 'hidden';
} else {
menu.classList.remove('show', 'collapsing');
menu.classList.add('collapse');
menu.style.cssText = '';
document.body.style.overflow = '';
}
};
const normalizeRoutePath = (path) => {
if (!path || path === '/') return '/';
return path.endsWith('/') ? path.slice(0, -1) : path;
};
const removeManagedNodes = () => {
document.querySelectorAll(`[${managedAttr}="true"]`).forEach((node) => node.remove());
};
const closePopups = () => {
document
.querySelectorAll(
'.cd-popup, .click-popup, .custom-pop-inquiry, .custom-pop-inquiry-form, .modal, .modal-backdrop'
)
.forEach((node) => {
node.classList.remove('is-visible', 'show', 'active', 'open');
if (node.classList.contains('modal-backdrop')) node.remove();
});
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
};
const restoreDefaults = () => {
document.title = defaultTitle;
const descriptionMeta = document.querySelector('meta[name="description"]');
if (descriptionMeta) descriptionMeta.setAttribute('content', defaultDescription);
document.documentElement.lang = 'zh-CN';
document.body.removeAttribute('style');
document.body.className = '';
};
const stripRelativePrefix = (value) => value.replace(/^(\.\.\/|\.\/)+/, '');
const rewriteAssetUrl = (value) => {
if (!value) return value;
if (
value.startsWith('javascript:') ||
value.startsWith('mailto:') ||
value.startsWith('tel:') ||
value.startsWith('data:') ||
value.startsWith('#') ||
value.startsWith('http://www.w3.org/')
) {
return value;
}
// 已经是绝对站点路径,直接返回,避免双重 /site/ 前缀
if (value.startsWith('/site/')) return value;
const knownAsset = /(?:^|\/)(assets|oss(?:-dev)?|cf)\//;
if (knownAsset.test(value)) {
let normalized = stripRelativePrefix(value);
normalized = normalized.replace(/^https?:\/\/(?:www\.)?ydrope\.com\//, '');
normalized = normalized.replace(/^https?:\/\//, '');
normalized = normalized.replace(/^\/+/, '');
return `/site/${normalized}`;
}
if (/^https?:\/\//i.test(value)) return value;
if (value.startsWith('/')) return value;
// 处理 images/ 开头的路径,映射到 assets/images/
if (/^images\//.test(value)) {
return `/site/assets/${stripRelativePrefix(value)}`;
}
return `/site/${stripRelativePrefix(value)}`;
};
const toAppRoute = (value) => {
if (!value) return value;
if (
value.startsWith('javascript:') ||
value.startsWith('mailto:') ||
value.startsWith('tel:') ||
value.startsWith('data:') ||
value.startsWith('#')
) {
return value;
}
if (/(?:^|\/)(assets|oss(?:-dev)?|cf)\//.test(value)) {
return rewriteAssetUrl(value);
}
try {
const parsed = new URL(value, window.location.origin);
if (parsed.origin !== window.location.origin && !/ydrope\.com$/i.test(parsed.hostname)) {
return parsed.toString();
}
let pathname = parsed.pathname;
pathname = pathname.replace(/^\/site/, '');
pathname = pathname.replace(/\/index\.html$/i, '/');
pathname = pathname.replace(/\.html$/i, '');
if (assetExtensions.test(pathname)) {
return rewriteAssetUrl(value);
}
if (!pathname || pathname === '/') pathname = '/';
if (pathname.endsWith('/') && pathname !== '/') pathname = pathname.slice(0, -1);
return `${pathname}${parsed.search}${parsed.hash}`;
} catch {
return value;
}
};
const rewriteSrcset = (srcset) => {
const entries = srcset
.split(',')
.map((item) => {
const parts = item.trim().split(/\s+/);
if (!parts[0]) return '';
return [rewriteAssetUrl(parts[0]), ...parts.slice(1)].join(' ');
})
.filter(Boolean)
// 过滤掉 OSS 图片处理链接(x-oss-process),这些是云端动态生成的缩略图,本地不存在
.filter((entry) => !/x-oss-process/i.test(entry));
return entries.join(', ');
};
const rewriteCssText = (text) =>
text.replace(/url\((['"]?)(?!data:|https?:|\/)([^'")]+)\1\)/gi, (_match, quote, url) => {
const rewritten = rewriteAssetUrl(url);
return `url(${quote}${rewritten}${quote})`;
});
const rewriteNodeUrls = (rootNode) => {
rootNode.querySelectorAll('[src]').forEach((node) => {
node.setAttribute('src', rewriteAssetUrl(node.getAttribute('src')));
});
rootNode.querySelectorAll('[data-src]').forEach((node) => {
const src = rewriteAssetUrl(node.getAttribute('data-src'));
node.setAttribute('data-src', src);
if (!node.getAttribute('src') || /placeholder|small-load/.test(node.getAttribute('src'))) {
node.setAttribute('src', src);
}
});
rootNode.querySelectorAll('[poster]').forEach((node) => {
node.setAttribute('poster', rewriteAssetUrl(node.getAttribute('poster')));
});
rootNode.querySelectorAll('[srcset]').forEach((node) => {
const rewritten = rewriteSrcset(node.getAttribute('srcset') || '');
if (rewritten) {
node.setAttribute('srcset', rewritten);
} else {
node.removeAttribute('srcset');
}
});
rootNode.querySelectorAll('a[href]').forEach((node) => {
node.setAttribute('href', toAppRoute(node.getAttribute('href')));
});
rootNode.querySelectorAll('form[action]').forEach((node) => {
node.setAttribute('action', toAppRoute(node.getAttribute('action')));
});
};
const updateHead = (doc) => {
removeManagedNodes();
document.title = doc.title || defaultTitle;
document.documentElement.lang = doc.documentElement.lang || 'zh-CN';
const descriptionMeta = document.querySelector('meta[name="description"]');
const sourceDescription = doc.querySelector('meta[name="description"]');
if (descriptionMeta && sourceDescription) {
descriptionMeta.setAttribute('content', sourceDescription.getAttribute('content') ?? '');
}
doc
.querySelectorAll(
'meta[name="keywords"], meta[property^="og:"], meta[name^="twitter:"], link[rel]:not([rel~="icon"]), style'
)
.forEach((node) => {
const cloned = document.createElement(node.tagName.toLowerCase());
Array.from(node.attributes).forEach((attr) => {
const attrValue = attr.name === 'href' ? rewriteAssetUrl(attr.value) : attr.value;
cloned.setAttribute(attr.name, attrValue);
});
if (node.tagName.toLowerCase() === 'style') cloned.textContent = rewriteCssText(node.textContent || '');
const href = cloned.getAttribute('href');
if (href && cloned.tagName.toLowerCase() === 'link') {
if (loadedStyles.has(href)) return;
loadedStyles.add(href);
cloned.setAttribute(persistentAttr, 'true');
} else {
cloned.setAttribute(managedAttr, 'true');
}
document.head.appendChild(cloned);
});
};
const collectScripts = (doc) =>
Array.from(doc.querySelectorAll('script'))
.map((script) => ({
src: script.getAttribute('src') ? rewriteAssetUrl(script.getAttribute('src')) : '',
type: script.getAttribute('type') || '',
text: script.textContent || '',
async: script.hasAttribute('async'),
defer: script.hasAttribute('defer')
}))
.filter((script) => {
const source = script.src || script.text;
return !/(shops-popup|okki\/analyze|googletagmanager|matomo|Sentry|trackPageView|custom_pop_inquiry_form)/i.test(
source
);
});
const runScripts = async (scripts, token) => {
for (const item of scripts) {
if (token !== loadToken) return;
await new Promise((resolve) => {
const script = document.createElement('script');
script.setAttribute(managedAttr, 'true');
if (item.type) script.type = item.type;
if (item.async) script.async = true;
if (item.defer) script.defer = true;
if (item.src) {
const loadOnce = /\/tpl-common\/common\/js\/|\/cdn-cgi\/scripts\/|\/sections\/shared\/head\/index\.js/i.test(item.src);
if (loadOnce && loadedExternalScripts.has(item.src)) {
resolve();
return;
}
if (loadOnce) loadedExternalScripts.add(item.src);
script.src = item.src;
script.onload = () => resolve();
script.onerror = () => resolve();
} else {
script.textContent = item.text;
resolve();
}
document.body.appendChild(script);
});
}
window.dispatchEvent(new Event('load'));
};
const loadPage = async () => {
const token = ++loadToken;
closePopups();
menuOpen.value = false;
const key = normalizeRoutePath(route.path);
const html = originalPages[key] || originalPages[`${key}/`] || originalPages['/'];
if (!html) {
error.value = '页面不存在';
return;
}
error.value = '';
removeManagedNodes();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 产品分页页面之间切换:局部替换产品列表区域,避免整页闪烁
const currentIsProducts = isProductsPage(route.path);
const prevIsProducts = isProductsPage(prevPath);
if (currentIsProducts && prevIsProducts && bodyHtml.value) {
const newBody = doc.body.cloneNode(true);
newBody.querySelectorAll('script, style, link[rel="stylesheet"]').forEach((node) => node.remove());
rewriteNodeUrls(newBody);
const newGoodsList = newBody.querySelector('.yuedong_goods_list');
const existingGoodsList = document.querySelector('.yuedong_goods_list');
if (newGoodsList && existingGoodsList) {
// newGoodsList 的 URL 已被 rewriteNodeUrls(newBody) 处理过,直接使用
existingGoodsList.innerHTML = newGoodsList.innerHTML;
updateHead(doc);
// 分页箭头和菜单图标在脚本运行前替换,避免被覆盖
replacePaginationArrows();
replaceMenuIcon();
const scripts = collectScripts(doc);
if (token === loadToken) await runScripts(scripts, token);
closePopups();
if (token === loadToken) {
await nextTick();
scrollToProductsList();
}
prevPath = route.path;
return;
}
}
// 正常完整页面加载
const body = doc.body.cloneNode(true);
const scripts = collectScripts(doc);
document.body.className = doc.body.className;
const bodyStyle = doc.body.getAttribute('style');
if (bodyStyle) document.body.setAttribute('style', bodyStyle);
else document.body.removeAttribute('style');
body.querySelectorAll('script, style, link[rel="stylesheet"]').forEach((node) => node.remove());
rewriteNodeUrls(body);
updateHead(doc);
bodyHtml.value = body.innerHTML;
await nextTick();
if (token === loadToken) await runScripts(scripts, token);
closePopups();
// 替换缺失的菜单图标和分页箭头,初始化移动端菜单
if (token === loadToken) {
await nextTick();
replaceMenuIcon();
replacePaginationArrows();
initMobileMenu();
syncMobileMenu();
if (isSolutionPage(route.path)) {
fixSolutionPage();
}
}
prevPath = route.path;
};
const handleLinkClick = (event) => {
const anchor = event.target.closest('a');
if (!anchor) return;
if (anchor.classList.contains('cd-popup-trigger')) {
event.preventDefault();
document.querySelector('.custom-pop-inquiry, .click-popup')?.classList.add('is-visible');
return;
}
if (anchor.classList.contains('cd-popup-close')) {
event.preventDefault();
closePopups();
return;
}
if (anchor.target && anchor.target !== '_self') return;
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const href = anchor.getAttribute('href');
if (!href) return;
if (
href.startsWith('javascript:') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#') ||
href.startsWith('/site/')
) {
return;
}
if (/^https?:\/\//i.test(href)) {
const url = new URL(href);
if (url.origin !== window.location.origin) return;
}
event.preventDefault();
router.push(href);
};
onMounted(() => {
container.value?.addEventListener('click', handleLinkClick);
});
onUnmounted(() => {
container.value?.removeEventListener('click', handleLinkClick);
removeManagedNodes();
restoreDefaults();
});
watch(
() => route.fullPath,
() => {
loadPage();
},
{ immediate: true }
);
// 菜单状态变化时同步 DOM
watch(menuOpen, () => {
syncMobileMenu();
});
</script>