YD-WEB/src/components/OriginalSiteRenderer.vue
2026-07-20 15:49:49 +08:00

553 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>