515 lines
17 KiB
HTML
515 lines
17 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>≤6 块顺序动画 Demo</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f6f1e6;
|
||
--card: rgba(255, 255, 255, 0.86);
|
||
--border: #e0d7c7;
|
||
--shadow: 0 16px 44px rgba(91, 74, 54, 0.12);
|
||
--text: #4b3e2f;
|
||
--text-muted: #9a8d7d;
|
||
--accent: #b98a59;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 24px;
|
||
background: radial-gradient(circle at 18% 18%, rgba(185, 138, 89, 0.08), transparent 28%), linear-gradient(180deg, #faf7f0, #f5f1e7);
|
||
font-family: "Inter", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
color: var(--text);
|
||
}
|
||
|
||
.page { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||
|
||
.panel {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
box-shadow: var(--shadow);
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||
button {
|
||
border: 1px solid var(--border);
|
||
background: #fff;
|
||
color: var(--text);
|
||
border-radius: 12px;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
box-shadow: 0 6px 18px rgba(91, 74, 54, 0.08);
|
||
}
|
||
button.primary { background: linear-gradient(135deg, #cfa976, #b98957); border-color: #c49556; color: #fffdf8; }
|
||
button:active { transform: translateY(1px); }
|
||
button:disabled { opacity: 0.55; cursor: not-allowed; box-shadow: none; }
|
||
.hint { color: var(--text-muted); font-size: 13px; }
|
||
|
||
.stack-shell {
|
||
position: relative;
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
background: var(--card);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
transition: height 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
min-height: 0;
|
||
}
|
||
.stack-inner { width: 100%; }
|
||
|
||
.block {
|
||
background: transparent;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.block:last-child { border-bottom: none; }
|
||
|
||
.collapsible-block {
|
||
margin: 0;
|
||
border: none;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.collapsible-header {
|
||
padding: 14px 20px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
user-select: none;
|
||
background: rgba(255, 255, 255, 0.78);
|
||
transition: background-color 0.2s ease;
|
||
position: relative;
|
||
}
|
||
.arrow {
|
||
width: 20px; height: 20px; display: grid; place-items: center;
|
||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
color: var(--text-muted); font-size: 18px;
|
||
}
|
||
.collapsible-block.expanded .arrow { transform: rotate(90deg); }
|
||
.status-icon { width: 24px; height: 24px; display: grid; place-items: center; font-size: 18px; }
|
||
.status-text { font-size: 14px; font-weight: 600; color: var(--text); }
|
||
.tool-desc { color: var(--text-muted); font-size: 12px; margin-left: 6px; }
|
||
|
||
.collapsible-content {
|
||
max-height: 0;
|
||
overflow: hidden;
|
||
opacity: 0;
|
||
transition:
|
||
max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||
opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.collapsible-block.expanded .collapsible-content {
|
||
max-height: 600px;
|
||
opacity: 1;
|
||
}
|
||
.content-inner {
|
||
padding: 16px 20px 24px 56px; /* 适度底部留白 */
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
color: var(--text-muted);
|
||
white-space: pre-wrap; /* 保留换行以展示流式文本 */
|
||
}
|
||
.content-inner::after {
|
||
content: '';
|
||
display: block;
|
||
height: 12px; /* 确保末行与底部有稳定间距 */
|
||
}
|
||
|
||
.progress-indicator {
|
||
position: absolute; bottom: 0; left: 0; height: 2px;
|
||
background: var(--accent);
|
||
animation: progress-bar 2s ease-in-out infinite;
|
||
opacity: 1;
|
||
}
|
||
@keyframes progress-bar {
|
||
0% { width: 0%; left: 0%; }
|
||
50% { width: 40%; left: 30%; }
|
||
100% { width: 0%; left: 100%; }
|
||
}
|
||
|
||
.footer-note { color: var(--text-muted); font-size: 12px; text-align: right; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<div class="panel">
|
||
<div class="controls">
|
||
<button id="play" class="primary">开始演示 (0→6块)</button>
|
||
<button id="reset">重置</button>
|
||
</div>
|
||
<div class="hint">严格顺序:思考(展开→折叠) → 工具 → 思考 → 工具... 直至 6 块。动画曲线、展开/折叠与现有项目一致。</div>
|
||
</div>
|
||
|
||
<div class="stack-shell" id="shell">
|
||
<div class="stack-inner" id="inner"></div>
|
||
</div>
|
||
|
||
<div class="footer-note">demo/stacked-blocks/stack6.html · 单文件顺序动画示例(≤6 块场景)</div>
|
||
</div>
|
||
|
||
<script>
|
||
const shell = document.getElementById('shell');
|
||
const inner = document.getElementById('inner');
|
||
const btnPlay = document.getElementById('play');
|
||
const btnReset = document.getElementById('reset');
|
||
|
||
let running = false;
|
||
let timers = [];
|
||
let idSeed = 1;
|
||
let shellHeight = 0; // 记录当前容器高度,跟随内容变化
|
||
|
||
const pick = () => {
|
||
const samples = [
|
||
'评估用户意图,列出检索关键词。',
|
||
'构造搜索查询,并准备调用工具。',
|
||
'生成中间结论,等待工具补充。',
|
||
'对结果做归因与去重。',
|
||
'落盘前再次校验上下文。'
|
||
];
|
||
return samples[Math.floor(Math.random() * samples.length)];
|
||
};
|
||
|
||
const createBlockEl = (type) => {
|
||
const block = document.createElement('div');
|
||
block.className = 'block';
|
||
block.dataset.id = `blk-${idSeed++}`;
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'collapsible-block ' + (type === 'thinking' ? 'thinking-block' : 'tool-block');
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'collapsible-header';
|
||
|
||
const arrow = document.createElement('div');
|
||
arrow.className = 'arrow';
|
||
arrow.textContent = '›';
|
||
|
||
const icon = document.createElement('div');
|
||
icon.className = 'status-icon';
|
||
icon.textContent = type === 'thinking' ? '🧠' : '🔍';
|
||
|
||
const text = document.createElement('span');
|
||
text.className = 'status-text';
|
||
text.textContent = type === 'thinking' ? '思考过程' : '搜索完成';
|
||
|
||
const desc = document.createElement('span');
|
||
desc.className = 'tool-desc';
|
||
desc.textContent = type === 'thinking' ? '正在思考...' : '“RAG 模型 2024 2025 常用”';
|
||
|
||
header.appendChild(arrow);
|
||
header.appendChild(icon);
|
||
header.appendChild(text);
|
||
header.appendChild(desc);
|
||
|
||
const contentWrap = document.createElement('div');
|
||
contentWrap.className = 'collapsible-content';
|
||
|
||
const innerContent = document.createElement('div');
|
||
innerContent.className = 'content-inner';
|
||
innerContent.textContent = pick();
|
||
|
||
contentWrap.appendChild(innerContent);
|
||
|
||
wrap.appendChild(header);
|
||
wrap.appendChild(contentWrap);
|
||
block.appendChild(wrap);
|
||
|
||
return { block, wrap, arrow, contentWrap, desc, text, innerContent };
|
||
};
|
||
|
||
const animateHeight = (from, to) => {
|
||
if (from === to) return Promise.resolve();
|
||
return new Promise((resolve) => {
|
||
shell.style.height = from + 'px';
|
||
shell.getBoundingClientRect();
|
||
shell.style.height = to + 'px';
|
||
const onEnd = () => {
|
||
shell.removeEventListener('transitionend', onEnd);
|
||
resolve();
|
||
};
|
||
shell.addEventListener('transitionend', onEnd, { once: true });
|
||
});
|
||
};
|
||
|
||
const measureProjectedHeight = (overrides = {}) => {
|
||
const blocks = Array.from(inner.querySelectorAll('.block'));
|
||
return blocks.reduce((acc, b, idx) => {
|
||
const wrap = b.firstElementChild;
|
||
const header = b.querySelector('.collapsible-header');
|
||
const content = b.querySelector('.collapsible-content');
|
||
const progress = b.querySelector('.progress-indicator');
|
||
const id = b.dataset.id;
|
||
const expanded =
|
||
Object.prototype.hasOwnProperty.call(overrides, id)
|
||
? overrides[id]
|
||
: wrap?.classList.contains('expanded');
|
||
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||
const contentH = content ? (expanded ? content.scrollHeight : 0) : 0;
|
||
const progressH = progress ? progress.getBoundingClientRect().height : 0;
|
||
const borderH = idx < blocks.length - 1 ? parseFloat(getComputedStyle(b).borderBottomWidth) || 0 : 0;
|
||
return acc + Math.ceil(headerH + contentH + progressH + borderH);
|
||
}, 0);
|
||
};
|
||
|
||
const capturePositions = () => {
|
||
const map = {};
|
||
inner.querySelectorAll('.block').forEach((b) => {
|
||
map[b.dataset.id] = { top: b.offsetTop, height: b.offsetHeight };
|
||
});
|
||
return map;
|
||
};
|
||
|
||
const playFLIP = (prevMap, nextMap) => {
|
||
if (!prevMap || !nextMap) return;
|
||
inner.querySelectorAll('.block').forEach((b) => {
|
||
const id = b.dataset.id;
|
||
const old = prevMap[id];
|
||
const next = nextMap[id];
|
||
if (!old || !next) return;
|
||
const dy = old.top - next.top;
|
||
if (Math.abs(dy) < 0.5) return;
|
||
b.style.transform = `translateY(${dy}px)`;
|
||
b.style.transition = 'transform 220ms ease';
|
||
requestAnimationFrame(() => {
|
||
b.style.transform = '';
|
||
});
|
||
b.addEventListener(
|
||
'transitionend',
|
||
() => {
|
||
b.style.transition = '';
|
||
},
|
||
{ once: true }
|
||
);
|
||
});
|
||
};
|
||
|
||
const updateHeightToContent = async (overrides = {}, prevPositions) => {
|
||
const target = Math.max(0, Math.ceil(measureProjectedHeight(overrides)));
|
||
const heightAnim = animateHeight(shellHeight, target);
|
||
shellHeight = target;
|
||
if (prevPositions) {
|
||
const nextMap = capturePositions();
|
||
playFLIP(prevPositions, nextMap);
|
||
}
|
||
await heightAnim;
|
||
};
|
||
|
||
const waitForContentTransition = (content) =>
|
||
new Promise((resolve) => {
|
||
if (!content) return resolve();
|
||
let settled = false;
|
||
const timer = setTimeout(() => {
|
||
if (!settled) {
|
||
settled = true;
|
||
resolve();
|
||
}
|
||
}, 500);
|
||
const handler = (e) => {
|
||
if (e.target === content && e.propertyName === 'max-height') {
|
||
if (!settled) {
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
content.removeEventListener('transitionend', handler);
|
||
resolve();
|
||
}
|
||
}
|
||
};
|
||
content.addEventListener('transitionend', handler);
|
||
});
|
||
|
||
const applyContentTransition = (content, toHeight, toOpacity) => {
|
||
if (!content) return;
|
||
const current = content.style.maxHeight;
|
||
if (!current || current === '0px') {
|
||
content.style.maxHeight = content.scrollHeight + 'px';
|
||
}
|
||
content.getBoundingClientRect(); // reflow
|
||
content.style.maxHeight = toHeight;
|
||
content.style.opacity = toOpacity;
|
||
};
|
||
|
||
const setExpandedState = (wrap, expanded) => {
|
||
const content = wrap.querySelector('.collapsible-content');
|
||
wrap.classList.toggle('expanded', expanded);
|
||
if (expanded) {
|
||
applyContentTransition(content, `${content.scrollHeight}px`, '1');
|
||
} else {
|
||
applyContentTransition(content, '0px', '0');
|
||
}
|
||
};
|
||
|
||
const toggleBlock = async (wrap) => {
|
||
const block = wrap.parentElement;
|
||
const id = block?.dataset.id;
|
||
const content = wrap.querySelector('.collapsible-content');
|
||
const prev = capturePositions();
|
||
const next = !wrap.classList.contains('expanded');
|
||
setExpandedState(wrap, next);
|
||
const overrides = id ? { [id]: next } : {};
|
||
await Promise.all([updateHeightToContent(overrides, prev), waitForContentTransition(content)]);
|
||
};
|
||
|
||
const addThinking = async () => {
|
||
const { block, wrap, arrow, contentWrap, desc } = createBlockEl('thinking');
|
||
wrap.dataset.type = 'thinking';
|
||
wrap.querySelector('.collapsible-header')?.addEventListener('click', () => toggleBlock(wrap));
|
||
|
||
// 初始:思考进行中,进度条
|
||
const progress = document.createElement('div');
|
||
progress.className = 'progress-indicator';
|
||
wrap.appendChild(progress);
|
||
|
||
const prev = capturePositions();
|
||
inner.appendChild(block);
|
||
setExpandedState(wrap, true);
|
||
const contentEl = wrap.querySelector('.content-inner');
|
||
if (contentEl) contentEl.textContent = '';
|
||
streamThinkingContent(contentEl, wrap);
|
||
await updateHeightToContent({ [block.dataset.id]: true }, prev);
|
||
|
||
// 思考结束
|
||
await wait(THINKING_DURATION);
|
||
desc.textContent = '思考完成,自动折叠';
|
||
wrap.removeChild(progress);
|
||
|
||
// 思考完成后稍等再折叠
|
||
await wait(THINKING_COLLAPSE_DELAY);
|
||
|
||
// 折叠时整体高度同步缩回
|
||
await collapseBlock(wrap);
|
||
};
|
||
|
||
const addTool = async () => {
|
||
const { block, wrap, desc } = createBlockEl('tool');
|
||
wrap.dataset.type = 'tool';
|
||
// 工具默认折叠,不做额外淡入,靠容器生长露出
|
||
wrap.querySelector('.collapsible-header')?.addEventListener('click', () => toggleBlock(wrap));
|
||
|
||
// 工具进行中进度条
|
||
const progress = document.createElement('div');
|
||
progress.className = 'progress-indicator';
|
||
wrap.appendChild(progress);
|
||
|
||
const prev = capturePositions();
|
||
inner.appendChild(block);
|
||
setExpandedState(wrap, false);
|
||
await updateHeightToContent({ [block.dataset.id]: false }, prev);
|
||
|
||
// 工具完成
|
||
await wait(TOOL_DURATION);
|
||
desc.textContent = '工具完成 · 结果已写入';
|
||
wrap.removeChild(progress);
|
||
};
|
||
|
||
const collapseBlock = async (wrap) => {
|
||
const block = wrap.parentElement;
|
||
const id = block?.dataset.id;
|
||
const content = wrap.querySelector('.collapsible-content');
|
||
const prev = capturePositions();
|
||
setExpandedState(wrap, false);
|
||
await Promise.all([updateHeightToContent(id ? { [id]: false } : {}, prev), waitForContentTransition(content)]);
|
||
};
|
||
|
||
const wait = (ms) => new Promise((r) => timers.push(setTimeout(r, ms)));
|
||
|
||
const THINKING_DURATION = 4800; // 进度条能跑 2-3 轮
|
||
const THINKING_COLLAPSE_DELAY = 200; // 与现有动画节奏相近的轻微缓冲
|
||
const TOOL_DURATION = 4200; // 工具执行展示时间
|
||
const STREAM_INTERVAL = 90;
|
||
|
||
const randomLetters = (len = 2) => {
|
||
const chars = 'abcdefghijklmnopqrstuvwxyz';
|
||
let out = '';
|
||
for (let i = 0; i < len; i++) {
|
||
out += chars[Math.floor(Math.random() * chars.length)];
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const streamThinkingContent = (contentEl, wrap) => {
|
||
if (!contentEl) return;
|
||
let charInLine = 0;
|
||
const blockId = wrap.parentElement?.dataset.id;
|
||
const start = performance.now();
|
||
const contentWrap = wrap.querySelector('.collapsible-content');
|
||
|
||
const tick = () => {
|
||
if (!running) return;
|
||
const elapsed = performance.now() - start;
|
||
if (elapsed >= THINKING_DURATION - STREAM_INTERVAL) return;
|
||
|
||
const chunk = randomLetters(2);
|
||
chunk.split('').forEach((ch) => {
|
||
if (charInLine >= 10) {
|
||
contentEl.textContent += '\n';
|
||
charInLine = 0;
|
||
}
|
||
contentEl.textContent += ch;
|
||
charInLine += 1;
|
||
});
|
||
|
||
// 调整内容与容器高度
|
||
contentEl.style.maxHeight = contentEl.scrollHeight + 'px';
|
||
if (contentWrap) {
|
||
contentWrap.style.maxHeight = contentWrap.scrollHeight + 'px';
|
||
}
|
||
const overrides = blockId ? { [blockId]: true } : {};
|
||
updateHeightToContent(overrides, null);
|
||
|
||
const t = setTimeout(tick, STREAM_INTERVAL);
|
||
timers.push(t);
|
||
};
|
||
|
||
tick();
|
||
};
|
||
|
||
const runSequence = async () => {
|
||
const sequence = ['thinking', 'tool', 'thinking', 'tool', 'thinking', 'tool'];
|
||
for (const type of sequence) {
|
||
if (!running) break;
|
||
if (type === 'thinking') {
|
||
await addThinking();
|
||
} else {
|
||
await addTool();
|
||
}
|
||
await wait(300);
|
||
}
|
||
running = false;
|
||
btnPlay.disabled = false;
|
||
};
|
||
|
||
const reset = () => {
|
||
timers.forEach((t) => clearTimeout(t));
|
||
timers = [];
|
||
running = false;
|
||
inner.innerHTML = '';
|
||
shell.style.height = '0px';
|
||
shellHeight = 0;
|
||
btnPlay.disabled = false;
|
||
};
|
||
|
||
btnPlay.addEventListener('click', () => {
|
||
if (running) return;
|
||
reset();
|
||
running = true;
|
||
btnPlay.disabled = true;
|
||
runSequence();
|
||
});
|
||
|
||
btnReset.addEventListener('click', reset);
|
||
|
||
// 初始高度 0,待首块生长露出
|
||
shell.style.height = '0px';
|
||
</script>
|
||
</body>
|
||
</html>
|