astrion-website/demo/site-assets/anim-modes.js

345 lines
13 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.

/* 运行模式动画 —— 可复用模块(主页 feature 行 / anim-demo 页共用)
* AnimModes.init(container):在容器内建 DOM模式 tab + 消息区 + 输入栏 + 假鼠标),
* 点击 tab 切换并从头播放,再次点击重播。
*/
window.AnimModes = (function () {
'use strict';
/* 站点多语言locale 由 setLocale 注入(默认 zh-CNT(zh, en) 就地双语。
切换语言后 setLocale 重新 init 重建 DOMtab 文案更新)并续播 lastMode。
gen 为模块级:重新 init 时递增作废旧闭包里的未完成序列,避免旧动画继续跑。 */
var locale = 'zh-CN';
var lastContainer = null;
var lastMode = 'plan';
var gen = 0;
function T(zh, en) { return locale === 'en-US' ? en : zh; }
function setLocale(l) {
if (l !== 'zh-CN' && l !== 'en-US') return;
if (l === locale) return;
locale = l;
if (lastContainer) init(lastContainer);
}
function init(container) {
lastContainer = container;
container.innerHTML =
'<div class="anim-window" id="animWindow">' +
'<div class="anim-topbar">' +
'<button class="anim-mode-tab" data-mode="plan">' + T('计划', 'Plan') + '</button>' +
'<button class="anim-mode-tab" data-mode="ask">' + T('询问', 'Ask') + '</button>' +
'<button class="anim-mode-tab" data-mode="execute">' + T('执行', 'Execute') + '</button>' +
'</div>' +
'<div class="anim-chat"></div>' +
'<div class="anim-composer"><span class="anim-composer-text"></span><span class="anim-composer-caret"></span></div>' +
'<div class="anim-cursor" aria-hidden="true">' +
'<svg viewBox="0 0 24 24" width="18" height="18"><path d="M5 3 L19 12 L12.5 12.8 L15.5 20 L13 21 L10 13.5 L5 17 Z" fill="#ffffff" stroke="#1a1f2e" stroke-width="1.4" stroke-linejoin="round"/></svg>' +
'</div>' +
'</div>';
var chat = container.querySelector('.anim-chat');
var composer = container.querySelector('.anim-composer');
var composerText = composer.querySelector('.anim-composer-text');
var cursor = container.querySelector('.anim-cursor');
var tabs = Array.prototype.slice.call(container.querySelectorAll('.anim-mode-tab'));
var animWindow = container.querySelector('.anim-window');
/* ── 代次令牌(模块级):切换模式/语言重建时作废旧序列的所有后续步骤 ── */
/* rAF 累计「可见时间」的等待:后台标签页 rAF 暂停,流程随之冻结,恢复时从冻结点继续——
setTimeout 在后台只是降频不会停,会攒动画导致恢复瞬间全部放完 */
function rafWait(ms) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
last = now;
if (acc >= ms) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
/** 可中断 sleep醒来后发现已切换模式则抛错终止序列 */
async function step(ms, myGen) {
await rafWait(ms);
if (myGen !== gen) throw new Error('cancelled');
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
/* ── 基础构件 ── */
/** 入场元素统一入口:挂载并平滑滚到底部(旧内容自然顶出) */
function push(node) {
chat.appendChild(node);
requestAnimationFrame(function () {
chat.scrollTo({ top: chat.scrollHeight, behavior: 'smooth' });
});
return node;
}
function addUserMsg(text) {
return push(el('div', 'msg-user anim-item', text));
}
function addThinkRow() {
var row = el('div', 'think-row anim-item');
var dots = el('span', 'think-dots');
dots.appendChild(el('i'));
dots.appendChild(el('i'));
dots.appendChild(el('i'));
row.appendChild(dots);
return push(row);
}
function removeEl(e) {
if (e && e.parentNode) e.parentNode.removeChild(e);
}
/** 往目标元素里逐字打出(带光标),返回 Promise */
async function typeInto(span, text, myGen, cps) {
var caret = el('span', 'type-caret');
span.parentNode.insertBefore(caret, span.nextSibling);
for (var i = 0; i < text.length; i++) {
span.textContent += text[i];
chat.scrollTop = chat.scrollHeight; // 打字时保持底部可见
await step(cps || 34, myGen);
}
removeEl(caret);
}
/** AI 裸文字消息:逐字流式 */
async function typeAiMsg(text, myGen) {
var b = el('div', 'msg-ai anim-item');
var span = el('span');
b.appendChild(span);
push(b);
await typeInto(span, text, myGen, 34);
return b;
}
/** 工具摘要行:状态图标 + intent + 关键参数(不显示工具名) */
async function addToolLine(intent, param, runMs, myGen) {
var b = el('div', 'tool-line is-running anim-item');
b.appendChild(el('span', 'tool-status'));
b.appendChild(el('span', 'tool-intent', intent));
if (param) b.appendChild(el('span', 'tool-param', param));
push(b);
await step(runMs, myGen);
b.classList.remove('is-running');
b.classList.add('is-done');
return b;
}
/* ── 假鼠标:出现 → 移到目标中心 → 点击 → 消失 ── */
function cursorMoveTo(x, y, immediate) {
if (immediate) cursor.style.transition = 'none';
cursor.style.transform = 'translate(' + x + 'px,' + y + 'px)';
if (immediate) {
cursor.getBoundingClientRect(); // 强制 reflow恢复过渡
cursor.style.transition = '';
}
}
async function cursorClick(targetEl, myGen) {
var win = animWindow;
var wr = win.getBoundingClientRect();
var tr = targetEl.getBoundingClientRect();
cursorMoveTo(wr.width - 60, wr.height - 40, true);
cursor.classList.add('is-visible');
await step(260, myGen);
cursorMoveTo(tr.left - wr.left + tr.width / 2 - 4, tr.top - wr.top + tr.height / 2 - 2, false);
await step(760, myGen);
cursor.classList.add('is-clicking');
targetEl.classList.add('is-pressed');
await step(140, myGen);
cursor.classList.remove('is-clicking');
targetEl.classList.remove('is-pressed');
await step(120, myGen);
cursor.classList.remove('is-visible');
}
/* ── 模式一:计划 ── */
async function playPlan(myGen) {
addUserMsg(T('帮我给这个项目加一个设置页面', 'Add a settings page to this project'));
await step(700, myGen);
var think = addThinkRow();
await step(1300, myGen);
removeEl(think);
await addToolLine(T('规划设计页面', 'Plan the page design'), '.astrion/plan/settings-page.md', 900, myGen);
await step(350, myGen);
// 计划:标题与条目全部逐字流式
var body = el('div', 'plan-body anim-item');
push(body);
var title = el('div', 'plan-title');
var titleSpan = el('span');
title.appendChild(titleSpan);
body.appendChild(title);
await typeInto(titleSpan, T('设置页面开发计划', 'Settings page development plan'), myGen, 55);
var items = [
T('梳理现有个性化设置字段与保存接口', 'Review existing personalization fields and save API'),
T('编写页面布局与表单组件', 'Build page layout and form components'),
T('接入保存并构建验证', 'Wire up saving and verify the build')
];
for (var i = 0; i < items.length; i++) {
var row = el('div', 'plan-item');
row.appendChild(el('b', null, (i + 1) + '.'));
var itemSpan = el('span');
row.appendChild(itemSpan);
body.appendChild(row);
await typeInto(itemSpan, items[i], myGen, 26);
await step(120, myGen);
}
var actions = el('div', 'plan-actions anim-item');
var approve = el('button', 'plan-btn is-primary', T('批准计划', 'Approve plan'));
actions.appendChild(approve);
body.appendChild(actions);
chat.scrollTo({ top: chat.scrollHeight, behavior: 'smooth' });
await step(700, myGen);
// 鼠标点击批准
await cursorClick(approve, myGen);
approve.textContent = T('已批准 ✓', 'Approved ✓');
approve.classList.remove('is-primary');
approve.classList.add('is-approved');
await step(700, myGen);
// 按钮淡出收起,释放垂直空间
actions.classList.add('is-gone');
await step(380, myGen);
removeEl(actions);
// 批准后开始实施
await typeAiMsg(T('好的,开始实施。', 'OK, starting implementation.'), myGen);
await step(250, myGen);
await addToolLine(T('编写设置页面组件', 'Write settings page component'), 'SettingsPanel.tsx', 1000, myGen);
await step(280, myGen);
await addToolLine(T('构建验证', 'Verify build'), 'npm run build', 1000, myGen);
await step(320, myGen);
await typeAiMsg(T('设置页面已完成,构建通过。', 'Settings page done, build passed.'), myGen);
}
/* ── 模式二:询问 ── */
async function playAsk(myGen) {
addUserMsg(T('帮我优化一下这个页面的性能', "Help me optimize this page's performance"));
await step(700, myGen);
var think = addThinkRow();
await step(1100, myGen);
removeEl(think);
await typeAiMsg(T('好的,我先和您聊聊。这个页面是首次加载慢,还是滚动、交互的时候慢?', 'Sure, let me ask first. Is the page slow on initial load, or during scrolling and interaction?'), myGen);
await step(500, myGen);
// 输入栏出现,用户逐字输入回答
composerText.textContent = '';
composer.classList.add('is-visible');
await step(450, myGen);
var reply = T('主要是首次加载慢', 'Mainly the initial load');
for (var i = 0; i < reply.length; i++) {
composerText.textContent += reply[i];
await step(70, myGen);
}
await step(600, myGen);
// 发送:输入栏淡出,内容变成右侧用户气泡
composer.classList.add('is-sent');
await step(240, myGen);
composer.classList.remove('is-visible', 'is-sent');
composerText.textContent = '';
addUserMsg(reply);
await step(700, myGen);
// 模型开始调查
var think2 = addThinkRow();
await step(1000, myGen);
removeEl(think2);
await typeAiMsg(T('好的,我来调查一下。', 'OK, let me investigate.'), myGen);
await step(250, myGen);
await addToolLine(T('分析构建产物体积', 'Analyze build output size'), 'npm run build', 1200, myGen);
await step(300, myGen);
await typeAiMsg(T('找到了:图表库被全量引入,首屏多出 2.1MB。', 'Found it: the chart library is fully imported, adding 2.1MB to the first load.'), myGen);
}
/* ── 模式三:执行 ── */
async function playExecute(myGen) {
addUserMsg(T('把页面上的按钮都改成蓝色', 'Change all buttons on the page to blue'));
await step(700, myGen);
var think = addThinkRow();
await step(900, myGen);
removeEl(think);
await typeAiMsg(T('好的,我来改。', "OK, I'll change them."), myGen);
await step(250, myGen);
await addToolLine(T('修改按钮颜色', 'Update button colors'), 'css/style.css', 1000, myGen);
await step(300, myGen);
await addToolLine(T('构建验证', 'Verify build'), 'npm run build', 1100, myGen);
await step(350, myGen);
await typeAiMsg(T('已完成,所有按钮已切换为蓝色。', 'Done — all buttons are now blue.'), myGen);
}
/* ── 调度 ── */
var PLAYERS = { plan: playPlan, ask: playAsk, execute: playExecute };
function setActiveTab(mode) {
tabs.forEach(function (t) {
t.classList.toggle('is-active', t.dataset.mode === mode);
});
}
function play(mode) {
lastMode = mode;
gen += 1;
var myGen = gen;
chat.innerHTML = '';
chat.scrollTop = 0;
composerText.textContent = '';
composer.classList.remove('is-visible', 'is-sent');
cursor.classList.remove('is-visible', 'is-clicking');
setActiveTab(mode);
PLAYERS[mode](myGen).catch(function (e) {
if (e && e.message !== 'cancelled') throw e;
});
}
tabs.forEach(function (t) {
t.addEventListener('click', function () { play(t.dataset.mode); });
});
// 首次进入默认播放当前模式(初始为「计划」;语言切换重 init 后续播 lastMode——
// 进入视口才开始:页面打开时用户可能还没滚到功能区,避免错过开场
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
play(lastMode);
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init, setLocale: setLocale };
})();