astrion-website/demo/site-assets/site.js
JOJO 56697a04ed feat: 移动端适配与演示区手动预览 + 沙箱平台口径更新
- 修复窄屏 git clone 块横向溢出撑破整页(hero-clone max-width + code 块内横滚)
- 顶栏:移动端保留 GitHub 入口;Astrion 标题改为星空开关按钮(停 rAF + 隐藏画布,卡顿排查用)
- 演示区:废弃 IntersectionObserver 懒加载,改为「点击预览」手动触发(移动端卡顿定位)
- 文档:沙箱平台口径——macOS/Windows 已实测,Linux 未测试未适配暂不可用(02/05 章中英四份)

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-04 11:06:30 +08:00

477 lines
18 KiB
JavaScript
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.

/* Astrion 官网主页交互
* 1) 多层视差星空(无连线、无光晕:实心星点 + 闪烁 + 极慢漂移 + 鼠标/滚动视差 + 偶发流星)
* 顶栏 Astrion 标题 = 星空开关:点击切换开/关(关 = 停 rAF + 隐藏画布,移动端卡顿排查用)
* 2) 顶栏滚动态
* 3) FAQ 手风琴平滑展开details + 高度动画)
* 4) 演示区 iframe 等比缩放(内部固定 1400×860 逻辑尺寸)
* 5) 挂载品牌眼睛(眨眼 + 鼠标追踪)
*/
(function () {
'use strict';
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* 站点语言(第 12 节文案替换与动画模块共用localStorage 优先 → 浏览器语言zh 开头中文,否则英文)。
提前到这里:动画初始化(第 6-9 节)需要先用 setLocale 注入语言。 */
function detectSiteLocale() {
try {
var stored = localStorage.getItem('astrion_site_locale');
if (stored === 'zh-CN' || stored === 'en-US') return stored;
} catch (e) {}
var nav = (navigator.language || '').toLowerCase();
return nav.indexOf('zh') === 0 ? 'zh-CN' : 'en-US';
}
var siteLocale = detectSiteLocale();
/* ═══════════ 1) 星空 ═══════════ */
var canvas = document.getElementById('starfield');
var ctx = canvas.getContext('2d');
var W = 0;
var H = 0;
var DPR = Math.min(window.devicePixelRatio || 1, 2);
// 层:远 → 近。count 按屏幕面积缩放drift 为每秒下落 pxparallax 为鼠标/滚动视差系数
var LAYERS = [
{ density: 1 / 5200, sizeMin: 0.4, sizeMax: 0.9, drift: 1.2, parallax: 6, twinkle: 0.35, alpha: 0.55 },
{ density: 1 / 9800, sizeMin: 0.7, sizeMax: 1.3, drift: 2.6, parallax: 14, twinkle: 0.55, alpha: 0.75 },
{ density: 1 / 21000, sizeMin: 1.1, sizeMax: 1.9, drift: 4.5, parallax: 26, twinkle: 0.8, alpha: 1.0 }
];
var stars = [];
var meteors = [];
var nextMeteorAt = 0;
function resize() {
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
seed();
if (reduceMotion) draw(0, 0, 0);
}
function seed() {
stars = [];
var area = W * H;
for (var li = 0; li < LAYERS.length; li++) {
var L = LAYERS[li];
var n = Math.max(12, Math.round(area * L.density));
for (var i = 0; i < n; i++) {
stars.push({
layer: li,
x: Math.random() * W,
y: Math.random() * H,
r: L.sizeMin + Math.random() * (L.sizeMax - L.sizeMin),
// 闪烁相位与速度(部分星几乎不闪,画面才稳)
phase: Math.random() * Math.PI * 2,
speed: 0.25 + Math.random() * 0.9,
amp: 0.25 + Math.random() * 0.75
});
}
}
}
// 鼠标视差目标值
var mouseTx = 0;
var mouseTy = 0;
var mouseX = 0;
var mouseY = 0;
window.addEventListener('mousemove', function (e) {
mouseTx = e.clientX / W - 0.5;
mouseTy = e.clientY / H - 0.5;
}, { passive: true });
function scheduleMeteor(now) {
// 2248 秒一颗,保持稀有
nextMeteorAt = now + 22000 + Math.random() * 26000;
}
function spawnMeteor() {
var fromLeft = Math.random() < 0.5;
var startX = fromLeft ? -40 : W * (0.35 + Math.random() * 0.65);
var startY = Math.random() * H * 0.4;
var angle = fromLeft ? 0.32 + Math.random() * 0.2 : Math.PI - (0.32 + Math.random() * 0.2);
var speed = 380 + Math.random() * 240;
meteors.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.abs(Math.sin(angle)) * speed,
life: 0,
ttl: 0.7 + Math.random() * 0.35
});
}
var lastTs = 0;
var rafId = 0; // 当前 rAF 句柄(星空开关用)
var starsEnabled = true;
function tick(ts) {
if (!starsEnabled) return; // 开关关闭:不再排下一帧,循环自然终止
if (document.hidden) {
lastTs = ts;
rafId = requestAnimationFrame(tick);
return;
}
var dt = Math.min((ts - lastTs) / 1000 || 0, 0.05);
lastTs = ts;
var now = ts;
// 鼠标视差缓动
mouseX += (mouseTx - mouseX) * 0.04;
mouseY += (mouseTy - mouseY) * 0.04;
if (now >= nextMeteorAt) {
spawnMeteor();
scheduleMeteor(now);
}
draw(dt, ts, window.scrollY || 0);
// 流星生命推进
for (var i = meteors.length - 1; i >= 0; i--) {
var m = meteors[i];
m.life += dt;
m.x += m.vx * dt;
m.y += m.vy * dt;
if (m.life > m.ttl || m.y > H + 60) meteors.splice(i, 1);
}
rafId = requestAnimationFrame(tick);
}
function draw(dt, ts, scrollY) {
ctx.clearRect(0, 0, W, H);
var t = ts / 1000;
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var L = LAYERS[s.layer];
if (dt) {
s.y += L.drift * dt;
if (s.y > H + 4) { s.y = -4; s.x = Math.random() * W; }
}
// 视差:滚动让星野整体上移(近处更明显),鼠标让层间错动
var px = s.x + mouseX * L.parallax * 2;
var py = s.y - (scrollY * L.parallax * 0.012) + mouseY * L.parallax;
// 纵向包裹,保证滚动后星点仍铺满
var wrappedY = ((py % (H + 8)) + (H + 8)) % (H + 8) - 4;
var tw = 1 - L.twinkle * 0.5 + L.twinkle * 0.5 * Math.sin(t * s.speed + s.phase) * s.amp;
var a = L.alpha * Math.max(0.15, Math.min(1, tw));
ctx.globalAlpha = a;
ctx.fillStyle = '#dfe6f5';
ctx.fillRect(px, wrappedY, s.r, s.r);
}
// 流星:短细线段,头部略亮(线性渐变线条,不是光晕)
for (var j = 0; j < meteors.length; j++) {
var m = meteors[j];
var fade = 1 - m.life / m.ttl;
var tailX = m.x - m.vx * 0.14;
var tailY = m.y - m.vy * 0.14;
var grad = ctx.createLinearGradient(tailX, tailY, m.x, m.y);
grad.addColorStop(0, 'rgba(223,230,245,0)');
grad.addColorStop(1, 'rgba(223,230,245,' + (0.6 * fade).toFixed(3) + ')');
ctx.globalAlpha = 1;
ctx.strokeStyle = grad;
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(m.x, m.y);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
resize();
window.addEventListener('resize', resize, { passive: true });
if (reduceMotion) {
draw(0, 0, 0); // 静态星图
} else {
scheduleMeteor(performance.now() + 6000); // 首颗稍提前,证明存在
rafId = requestAnimationFrame(tick);
// 滚动时重绘由 rAF 持续驱动,无需 scroll 监听
}
// 顶栏标题 = 星空开关(移动端卡顿排查用):点击切换开/关。
// 关闭必须「停 rAF 循环 + 隐藏画布」同时做——只藏画布仍每帧渲染,对比测试就失去意义。
function setStarsEnabled(on) {
starsEnabled = on;
canvas.style.display = on ? '' : 'none';
var btn = document.getElementById('brandToggle');
if (btn) {
btn.classList.toggle('stars-off', !on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false'); // aria-pressed 语义 = 星空开启
}
if (on) {
if (!reduceMotion && !rafId) { lastTs = 0; rafId = requestAnimationFrame(tick); }
} else if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
}
var brandToggle = document.getElementById('brandToggle');
if (brandToggle) {
brandToggle.addEventListener('click', function () { setStarsEnabled(!starsEnabled); });
}
/* ═══════════ 2) 顶栏滚动态 ═══════════ */
var topbar = document.getElementById('topbar');
function onScroll() {
topbar.classList.toggle('scrolled', (window.scrollY || 0) > 24);
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
/* ═══════════ 3) FAQ 平滑手风琴 ═══════════ */
var faqItems = document.querySelectorAll('.faq-item');
faqItems.forEach(function (item) {
var summary = item.querySelector('.faq-q');
var panel = item.querySelector('.faq-a');
summary.addEventListener('click', function (e) {
e.preventDefault();
if (reduceMotion) {
item.open = !item.open;
return;
}
if (item.open) {
// 收起:先固定高度再过渡到 0。open 属性必须等动画结束才移除(否则内容瞬消、动画播不了),
// 但图标由 [open] 驱动会因此延迟变回 +——点击瞬间加 closing 类让图标立即切回(与展开对称)。
item.classList.add('closing');
var h0 = panel.offsetHeight;
panel.style.height = h0 + 'px';
requestAnimationFrame(function () {
panel.style.transition = 'height 0.22s ease';
panel.style.height = '0px';
});
panel.addEventListener('transitionend', function done() {
panel.removeEventListener('transitionend', done);
panel.style.cssText = '';
item.open = false;
item.classList.remove('closing');
});
} else {
item.classList.remove('closing');
item.open = true;
var h1 = panel.offsetHeight;
panel.style.height = '0px';
requestAnimationFrame(function () {
panel.style.transition = 'height 0.24s ease';
panel.style.height = h1 + 'px';
});
panel.addEventListener('transitionend', function done() {
panel.removeEventListener('transitionend', done);
panel.style.cssText = '';
});
}
});
});
/* ═══════════ 4) 演示区等比缩放 + 严格懒加载 ═══════════ */
var frame = document.getElementById('demoFrame');
var iframe = document.getElementById('demoIframe');
var preview = document.getElementById('demoPreview');
var previewBtn = document.getElementById('demoPreviewBtn');
var DEMO_W = 1400;
var DEMO_H = 860;
// 手动预览2026-09-04 起,取代原 IntersectionObserver 严格懒加载演示壳较重main.js 2.1MB
// 连「接近视口自动注入」都不做——点「点击预览」才注入 src 开始渲染(卡顿排查期可彻底排除演示区变量)。
if (iframe && iframe.dataset.src && previewBtn) {
previewBtn.addEventListener('click', function () {
var dict = (window.SITE_I18N && window.SITE_I18N[siteLocale]) || (window.SITE_I18N && window.SITE_I18N['zh-CN']) || {};
previewBtn.textContent = dict['demo.previewLoading'] || '加载中…';
previewBtn.disabled = true;
iframe.src = iframe.dataset.src;
delete iframe.dataset.src;
}, { once: true });
}
// 防白闪iframe 子文档整页导航(新建对话跳 /new浏览器会把 iframe 区域画成不透明白,
// 与子文档 CSS 无关(逐帧实测证实:子文档计算背景全程深色,屏幕仍出现全白帧);且新文档首帧
// 在 theme.ts 写 data-theme 前会命中产品首屏回退浅色 #faf9f5#app 无条件 background 所致)。
// 对策:导航期间隐藏 iframe露出 demo-frame 深色面板底),子文档等 data-theme 落地且首帧绘制后
// postMessage 通知显示demo.htmlload 事件兜底load 时 module 脚本已执行data-theme 已在)。
if (iframe) {
var markDemoFrameReady = function () {
iframe.classList.add('demo-frame-ready');
if (preview) preview.classList.add('demo-preview--hidden'); // 手动预览:子文档就绪后撤掉占位层
};
window.addEventListener('message', function (e) {
if (e.origin === location.origin && e.data === 'astrion-demo-frame-ready') markDemoFrameReady();
});
iframe.addEventListener('load', function () {
// 每次新文档 load 后重挂导航监听window 对象随导航重建)。
// beforeunload 早于 pagehide网络请求发起前就触发给父页留出一个 SW 往返的时间隐藏,
// 实测只靠 pagehide 会漏掉浏览器画白的前 2 帧。两个都挂,幂等。
try {
var hide = function () { iframe.classList.remove('demo-frame-ready'); };
iframe.contentWindow.addEventListener('beforeunload', hide);
iframe.contentWindow.addEventListener('pagehide', hide);
} catch (e) {}
markDemoFrameReady(); // 兜底postMessage 丢失时 load 也要显示
});
}
function fitDemo() {
var available = frame.clientWidth;
var scale = Math.min(1, available / DEMO_W);
iframe.style.transform = 'scale(' + scale + ')';
frame.style.height = Math.round(DEMO_H * scale) + 'px';
}
if ('ResizeObserver' in window) {
new ResizeObserver(fitDemo).observe(frame);
}
window.addEventListener('resize', fitDemo, { passive: true });
fitDemo();
/* ═══════════ 5) 品牌眼睛 ═══════════ */
if (window.AstrionAvatar) {
AstrionAvatar.mount(document.getElementById('heroMascot'), { mode: 'idle', blink: true, track: true });
}
/* 动画模块语言注入init 前先 setLocale语言切换时第 12 节会再次 setLocale 触发重建续播) */
['AnimModes', 'AnimSandbox', 'AnimRecords', 'AnimVersion'].forEach(function (name) {
if (window[name] && window[name].setLocale) window[name].setLocale(siteLocale);
});
/* ═══════════ 6) 运行模式动画(功能 01 行) ═══════════ */
if (window.AnimModes && document.getElementById('animModes')) {
AnimModes.init(document.getElementById('animModes'));
}
/* ═══════════ 7) 沙箱与网络授权动画(功能 02 行) ═══════════ */
if (window.AnimSandbox && document.getElementById('animSandbox')) {
AnimSandbox.init(document.getElementById('animSandbox'));
}
/* ═══════════ 8) 修改记录动画(功能 03 行) ═══════════ */
if (window.AnimRecords && document.getElementById('animRecords')) {
AnimRecords.init(document.getElementById('animRecords'));
}
/* ═══════════ 9) 版本控制动画(功能 04 行) ═══════════ */
if (window.AnimVersion && document.getElementById('animVersion')) {
AnimVersion.init(document.getElementById('animVersion'));
}
/* ═══════════ 10) hero 区 git clone 复制 ═══════════ */
var heroClone = document.getElementById('heroClone');
if (heroClone) {
var heroCloneTimer = null;
heroClone.addEventListener('click', function () {
var cmd = 'git clone https://github.com/JOJO6618/astrion.git';
var done = function () {
heroClone.classList.add('copied');
clearTimeout(heroCloneTimer);
heroCloneTimer = setTimeout(function () {
heroClone.classList.remove('copied');
}, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(cmd).then(done).catch(function () {});
}
});
}
/* ═══════════ 11) 联系我 邮箱复制 ═══════════ */
var contactCopy = document.getElementById('contactCopy');
if (contactCopy) {
var contactCopyTimer = null;
contactCopy.addEventListener('click', function () {
var mail = 'yao884417@outlook.com';
var done = function () {
contactCopy.classList.add('copied');
clearTimeout(contactCopyTimer);
contactCopyTimer = setTimeout(function () {
contactCopy.classList.remove('copied');
}, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(mail).then(done).catch(function () {});
}
});
}
/* ═══════════ 12) 站点多语言(主页 zh-CN / en-US ═══════════
* siteLocale 已在 IIFE 顶部检测(动画初始化要用);本脚本在 body 末尾同步执行,
* 首帧绘制前完成替换,不会闪源语言。
* 字典在 i18n.jswindow.SITE_I18N标记约定
* data-i18n → textContent / data-i18n-html → innerHTML / data-i18n-aria → aria-label /
* data-i18n-attr-title → title 属性meta[name=description] 用 meta.desc 键。
*/
var SITE_I18N = window.SITE_I18N || {};
var langToggle = document.getElementById('langToggle');
function applySiteLocale(locale) {
var dict = SITE_I18N[locale];
if (!dict) return;
document.documentElement.setAttribute('lang', locale);
var i, nodes, key;
nodes = document.querySelectorAll('[data-i18n]');
for (i = 0; i < nodes.length; i++) {
key = nodes[i].getAttribute('data-i18n');
if (dict[key] != null) nodes[i].textContent = dict[key];
}
nodes = document.querySelectorAll('[data-i18n-html]');
for (i = 0; i < nodes.length; i++) {
key = nodes[i].getAttribute('data-i18n-html');
if (dict[key] != null) nodes[i].innerHTML = dict[key];
}
nodes = document.querySelectorAll('[data-i18n-aria]');
for (i = 0; i < nodes.length; i++) {
key = nodes[i].getAttribute('data-i18n-aria');
if (dict[key] != null) nodes[i].setAttribute('aria-label', dict[key]);
}
nodes = document.querySelectorAll('[data-i18n-attr-title]');
for (i = 0; i < nodes.length; i++) {
key = nodes[i].getAttribute('data-i18n-attr-title');
if (dict[key] != null) nodes[i].setAttribute('title', dict[key]);
}
var metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc && dict['meta.desc'] != null) {
metaDesc.setAttribute('content', dict['meta.desc']);
}
if (langToggle) {
langToggle.textContent = locale === 'zh-CN' ? 'English' : '中文';
langToggle.setAttribute('aria-label', locale === 'zh-CN' ? 'Switch to English' : '切换为中文');
}
/* 动画模块联动setLocale 内部重建 DOM 并续播当前场景(详见各 anim-*.js */
['AnimModes', 'AnimSandbox', 'AnimRecords', 'AnimVersion'].forEach(function (name) {
if (window[name] && window[name].setLocale) window[name].setLocale(locale);
});
}
if (siteLocale !== 'zh-CN') applySiteLocale(siteLocale);
if (langToggle) {
langToggle.textContent = siteLocale === 'zh-CN' ? 'English' : '中文';
langToggle.addEventListener('click', function () {
siteLocale = siteLocale === 'zh-CN' ? 'en-US' : 'zh-CN';
try { localStorage.setItem('astrion_site_locale', siteLocale); } catch (e) {}
applySiteLocale(siteLocale);
});
}
})();