842 lines
28 KiB
HTML
842 lines
28 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>≥7 块传送带 Demo</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f6f1e6;
|
||
--card: rgba(255, 255, 255, 0.86);
|
||
--card-solid: #ffffff;
|
||
--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),
|
||
padding-top 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
min-height: 0;
|
||
padding-top: 0;
|
||
}
|
||
.stack-inner { width: 100%; position: relative; z-index: 1; }
|
||
|
||
.more-block {
|
||
position: absolute;
|
||
inset: 0 0 auto 0;
|
||
background: var(--card-solid);
|
||
border-bottom: 0 solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 0 16px;
|
||
height: 0;
|
||
opacity: 0;
|
||
overflow: hidden;
|
||
z-index: 20;
|
||
cursor: pointer;
|
||
transition:
|
||
height 280ms cubic-bezier(0.4,0,0.2,1),
|
||
padding 280ms cubic-bezier(0.4,0,0.2,1),
|
||
border-bottom-width 280ms cubic-bezier(0.4,0,0.2,1),
|
||
opacity 200ms ease;
|
||
}
|
||
.more-block.visible {
|
||
opacity: 1;
|
||
padding: 14px 16px;
|
||
border-bottom-width: 1px;
|
||
}
|
||
.more-label { font-weight: 700; color: var(--text); }
|
||
.more-desc { color: var(--text-muted); font-size: 12px; }
|
||
.more-expand { margin-left: auto; font-size: 12px; color: var(--accent); cursor: pointer; user-select: none; }
|
||
|
||
.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→10块)</button>
|
||
<button id="reset">重置</button>
|
||
<span class="hint">前 6 块动画与 stack6 完全一致;第 7 块起触发“更多”+传送带。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stack-shell" id="shell">
|
||
<div class="more-block" id="moreBlock">
|
||
<span class="more-label">⋯ 更多</span>
|
||
<span class="more-desc" id="moreDesc">0 条折叠</span>
|
||
</div>
|
||
<div class="stack-inner" id="inner"></div>
|
||
</div>
|
||
|
||
<div class="footer-note">demo/stacked-blocks/stack7.html · 7+ 块传送带示例</div>
|
||
</div>
|
||
|
||
<script>
|
||
const shell = document.getElementById('shell');
|
||
const inner = document.getElementById('inner');
|
||
const moreBlock = document.getElementById('moreBlock');
|
||
const moreDesc = document.getElementById('moreDesc');
|
||
const btnPlay = document.getElementById('play');
|
||
const btnReset = document.getElementById('reset');
|
||
|
||
let running = false;
|
||
let timers = [];
|
||
let idSeed = 1;
|
||
let shellHeight = 0;
|
||
let blocks = [];
|
||
let showAll = false;
|
||
let toggling = false;
|
||
|
||
// 为了便于快速查看 demo,前 6 个阶段缩短时长(参考 stack6)
|
||
const THINKING_DURATION = 2400; // 原 4800
|
||
const THINKING_COLLAPSE_DELAY = 120; // 原 200
|
||
const TOOL_DURATION = 2000; // 原 4200
|
||
const STREAM_INTERVAL = 90;
|
||
const VISIBLE = 6;
|
||
|
||
const pick = () => {
|
||
const samples = [
|
||
'评估用户意图,列出检索关键词。',
|
||
'构造搜索查询,并准备调用工具。',
|
||
'生成中间结论,等待工具补充。',
|
||
'对结果做归因与去重。',
|
||
'落盘前再次校验上下文。'
|
||
];
|
||
return samples[Math.floor(Math.random() * samples.length)];
|
||
};
|
||
|
||
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 wait = (ms) => new Promise((r) => timers.push(setTimeout(r, ms)));
|
||
|
||
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);
|
||
|
||
header.addEventListener('click', () => toggleBlock(wrap));
|
||
|
||
return { block, wrap, contentWrap, innerContent, desc };
|
||
};
|
||
|
||
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 measureBlockHeight = (block, isLast) => {
|
||
const header = block.querySelector('.collapsible-header');
|
||
const content = block.querySelector('.collapsible-content');
|
||
const progress = block.querySelector('.progress-indicator');
|
||
const wrap = block.firstElementChild;
|
||
const expanded = wrap?.classList.contains('expanded');
|
||
|
||
const headerH = header ? Math.ceil(header.getBoundingClientRect().height) : 0;
|
||
const contentH = content && expanded ? Math.ceil(content.scrollHeight) : 0;
|
||
const progressH = progress ? Math.ceil(progress.getBoundingClientRect().height) : 0;
|
||
const borderH = !isLast ? parseFloat(getComputedStyle(block).borderBottomWidth) || 0 : 0;
|
||
return headerH + contentH + progressH + borderH;
|
||
};
|
||
|
||
const measureListHeight = (listEls) => {
|
||
return listEls.reduce((acc, b, idx) => acc + measureBlockHeight(b, idx === listEls.length - 1), 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 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();
|
||
content.style.maxHeight = toHeight;
|
||
content.style.opacity = toOpacity;
|
||
};
|
||
|
||
const setExpandedState = (wrap, expanded) => {
|
||
const content = wrap.querySelector('.collapsible-content');
|
||
wrap.classList.toggle('expanded', expanded);
|
||
if (content) {
|
||
const target = expanded ? content.scrollHeight : 0;
|
||
content.style.maxHeight = target + 'px';
|
||
content.style.opacity = expanded ? '1' : '0';
|
||
}
|
||
};
|
||
|
||
const normalizeBlockState = (block) => {
|
||
const wrap = block?.firstElementChild;
|
||
if (!wrap) return;
|
||
const content = wrap.querySelector('.collapsible-content');
|
||
const expanded = wrap.classList.contains('expanded');
|
||
if (content) {
|
||
content.style.maxHeight = expanded ? content.scrollHeight + 'px' : '0px';
|
||
content.style.opacity = expanded ? '1' : '0';
|
||
}
|
||
block.style.transition = '';
|
||
block.style.transform = '';
|
||
};
|
||
|
||
const toggleBlock = async (wrap) => {
|
||
const content = wrap.querySelector('.collapsible-content');
|
||
const prev = capturePositions();
|
||
const next = !wrap.classList.contains('expanded');
|
||
setExpandedState(wrap, next);
|
||
const nextPos = capturePositions();
|
||
await Promise.all([updateHeight(prev, nextPos), waitForContentTransition(content)]);
|
||
};
|
||
|
||
const streamThinkingContent = (contentEl, wrap) => {
|
||
if (!contentEl) return;
|
||
let charInLine = 0;
|
||
const start = performance.now();
|
||
|
||
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;
|
||
});
|
||
|
||
const contentWrap = wrap.querySelector('.collapsible-content');
|
||
contentEl.style.maxHeight = contentEl.scrollHeight + 'px';
|
||
if (contentWrap) contentWrap.style.maxHeight = contentWrap.scrollHeight + 'px';
|
||
|
||
updateHeight();
|
||
|
||
const t = setTimeout(tick, STREAM_INTERVAL);
|
||
timers.push(t);
|
||
};
|
||
tick();
|
||
};
|
||
|
||
const updateMore = (hiddenCount, refHeight, forceVisible = false, labelOverride = null) => {
|
||
const shouldShow = hiddenCount > 0 || forceVisible;
|
||
if (shouldShow) {
|
||
moreBlock.classList.add('visible');
|
||
moreBlock.style.height = refHeight + 'px';
|
||
shell.style.paddingTop = refHeight + 'px';
|
||
moreDesc.textContent = labelOverride ?? `${hiddenCount} 条折叠`;
|
||
} else {
|
||
moreBlock.classList.remove('visible');
|
||
moreBlock.style.height = '0px';
|
||
shell.style.paddingTop = '0px';
|
||
moreDesc.textContent = '无折叠';
|
||
}
|
||
};
|
||
|
||
const updateHeight = async (prevPositions, nextPositions) => {
|
||
const children = Array.from(inner.children);
|
||
const visibleH = measureListHeight(children);
|
||
const moreH = moreBlock.classList.contains('visible') ? parseFloat(moreBlock.style.height) || 0 : 0;
|
||
const target = Math.max(0, moreH + visibleH);
|
||
const anim = animateHeight(shellHeight, target);
|
||
shellHeight = target;
|
||
if (prevPositions && nextPositions) {
|
||
playFLIP(prevPositions, nextPositions);
|
||
}
|
||
await anim;
|
||
};
|
||
|
||
const conveyorShift = async (firstHeight, firstOverflow) => {
|
||
const items = Array.from(inner.children);
|
||
if (!items.length) return;
|
||
|
||
const targetShell = shellHeight + (firstOverflow ? firstHeight : 0);
|
||
if (firstOverflow) {
|
||
moreBlock.classList.add('visible');
|
||
moreBlock.style.height = firstHeight + 'px';
|
||
moreDesc.textContent = `${blocks.length - VISIBLE} 条折叠`;
|
||
shell.style.paddingTop = firstHeight + 'px';
|
||
}
|
||
|
||
// 传送带上移
|
||
const movePromise = new Promise((resolve) => {
|
||
let done = 0;
|
||
items.forEach((el) => {
|
||
el.style.transition = 'transform 260ms cubic-bezier(0.4,0,0.2,1)';
|
||
el.style.transform = `translateY(-${firstHeight}px)`;
|
||
el.addEventListener(
|
||
'transitionend',
|
||
() => {
|
||
done += 1;
|
||
if (done === items.length) resolve();
|
||
},
|
||
{ once: true }
|
||
);
|
||
});
|
||
requestAnimationFrame(() => {});
|
||
});
|
||
|
||
const heightPromise = animateHeight(shellHeight, targetShell).then(() => {
|
||
shellHeight = targetShell;
|
||
});
|
||
|
||
await Promise.all([movePromise, heightPromise]);
|
||
|
||
// 清理
|
||
const first = inner.firstElementChild;
|
||
if (first) inner.removeChild(first);
|
||
Array.from(inner.children).forEach((el) => {
|
||
el.style.transition = '';
|
||
el.style.transform = '';
|
||
});
|
||
};
|
||
|
||
// 展开抽屉动画:把折叠的块从顶部“吐”出来,现有 6 个块整体下移
|
||
const expandDrawer = async (hiddenBlocks, visibleBlocks) => {
|
||
if (!hiddenBlocks.length) return render({ mode: 'collapse' }); // nothing hidden, fallback
|
||
|
||
// 先放入 DOM 便于测量
|
||
inner.innerHTML = '';
|
||
const full = [...hiddenBlocks, ...visibleBlocks];
|
||
full.forEach((b) => {
|
||
normalizeBlockState(b);
|
||
b.style.transition = 'none';
|
||
b.style.transform = '';
|
||
b.style.opacity = '1';
|
||
inner.appendChild(b);
|
||
});
|
||
|
||
const totalH = measureListHeight(full);
|
||
const ref = full[0] || null;
|
||
const refH = ref ? measureBlockHeight(ref) : 0;
|
||
const firstVisible = visibleBlocks[0] || null;
|
||
const hiddenH = firstVisible ? firstVisible.offsetTop : measureListHeight(hiddenBlocks); // 精确到分割线
|
||
|
||
// 顶部展开块保持占位
|
||
updateMore(hiddenBlocks.length, refH, true, '已展开全部');
|
||
|
||
// 初始状态:整体向上移 hiddenH,使当前 6 个保持原位,隐藏块在遮挡上方
|
||
full.forEach((b, idx) => {
|
||
b.style.transition = 'none';
|
||
b.style.transform = `translateY(-${hiddenH}px)`;
|
||
if (idx < hiddenBlocks.length) b.style.opacity = '0';
|
||
});
|
||
|
||
inner.getBoundingClientRect(); // reflow
|
||
|
||
// 整体下滑,隐藏块同步显现(无级联,整体运动)
|
||
full.forEach((b, idx) => {
|
||
b.style.transition = `transform 260ms cubic-bezier(0.4,0,0.2,1), opacity 220ms ease`;
|
||
});
|
||
|
||
const targetH = refH + totalH;
|
||
const heightAnim = animateHeight(shellHeight, targetH).then(() => {
|
||
shellHeight = targetH;
|
||
});
|
||
|
||
requestAnimationFrame(() => {
|
||
full.forEach((b, idx) => {
|
||
b.style.transform = '';
|
||
if (idx < hiddenBlocks.length) b.style.opacity = '1';
|
||
});
|
||
});
|
||
|
||
// 等待动画结束
|
||
await Promise.all([heightAnim, wait(300)]);
|
||
|
||
// 清理内联样式,恢复自然流
|
||
full.forEach((b) => {
|
||
b.style.transition = '';
|
||
b.style.transform = '';
|
||
b.style.opacity = '';
|
||
});
|
||
};
|
||
|
||
// 折叠抽屉动画:把隐藏块一起“收回”到顶部
|
||
const collapseDrawer = async (hiddenBlocks, visibleBlocks) => {
|
||
if (!hiddenBlocks.length) {
|
||
// 没有需要折叠的,直接常规渲染
|
||
inner.innerHTML = '';
|
||
visibleBlocks.forEach((b) => {
|
||
normalizeBlockState(b);
|
||
inner.appendChild(b);
|
||
});
|
||
const ref = visibleBlocks[0] || blocks[0] || null;
|
||
const refH = ref ? measureBlockHeight(ref) : 0;
|
||
updateMore(0, refH, false, null);
|
||
await updateHeight();
|
||
return;
|
||
}
|
||
|
||
inner.innerHTML = '';
|
||
const full = [...hiddenBlocks, ...visibleBlocks];
|
||
full.forEach((b) => {
|
||
normalizeBlockState(b);
|
||
b.style.transition = 'none';
|
||
b.style.transform = '';
|
||
b.style.opacity = '1';
|
||
inner.appendChild(b);
|
||
});
|
||
|
||
const ref = full[0] || null;
|
||
const refH = ref ? measureBlockHeight(ref) : 0;
|
||
const firstVisible = visibleBlocks[0] || null;
|
||
const hiddenH = firstVisible ? firstVisible.offsetTop : measureListHeight(hiddenBlocks);
|
||
const visibleH = measureListHeight(visibleBlocks);
|
||
|
||
// 确保“更多”占位存在,显示折叠数量
|
||
updateMore(hiddenBlocks.length, refH, true, `${hiddenBlocks.length} 条折叠`);
|
||
|
||
inner.getBoundingClientRect(); // reflow
|
||
|
||
// 统一动画:整体向上移 hiddenH,隐藏块淡出
|
||
full.forEach((b, idx) => {
|
||
b.style.transition = `transform 260ms cubic-bezier(0.4,0,0.2,1)`;
|
||
});
|
||
|
||
const targetHeight = refH + visibleH;
|
||
const heightAnim = animateHeight(shellHeight, targetHeight).then(() => {
|
||
shellHeight = targetHeight;
|
||
});
|
||
|
||
requestAnimationFrame(() => {
|
||
full.forEach((b) => {
|
||
b.style.transform = `translateY(-${hiddenH}px)`;
|
||
});
|
||
});
|
||
|
||
await Promise.all([heightAnim, wait(300)]);
|
||
|
||
// 移除隐藏块,恢复最近 VISIBLE
|
||
inner.innerHTML = '';
|
||
visibleBlocks.forEach((b) => {
|
||
b.style.transition = '';
|
||
b.style.transform = '';
|
||
inner.appendChild(b);
|
||
});
|
||
|
||
updateMore(hiddenBlocks.length, refH, false, null);
|
||
await updateHeight();
|
||
};
|
||
|
||
const render = async (options = {}) => {
|
||
const { mode } = options; // mode: 'expand' | 'collapse' | undefined
|
||
const prevPositions = capturePositions();
|
||
|
||
const hiddenRaw = Math.max(0, blocks.length - VISIBLE);
|
||
const visibleBlocks = showAll ? blocks : blocks.slice(-VISIBLE);
|
||
|
||
// 展开动画:折叠块逐个“吐”出
|
||
if (mode === 'expand') {
|
||
const hiddenBlocks = blocks.slice(0, -VISIBLE);
|
||
const visibleList = blocks.slice(-VISIBLE);
|
||
await expandDrawer(hiddenBlocks, visibleList);
|
||
return;
|
||
}
|
||
|
||
// 收起动画:整体上收
|
||
if (mode === 'collapse') {
|
||
const hiddenBlocks = blocks.slice(0, -VISIBLE);
|
||
const visibleList = blocks.slice(-VISIBLE);
|
||
await collapseDrawer(hiddenBlocks, visibleList);
|
||
return;
|
||
}
|
||
|
||
// 折叠或常规重渲染
|
||
inner.innerHTML = '';
|
||
visibleBlocks.forEach((b) => {
|
||
normalizeBlockState(b);
|
||
b.style.transition = '';
|
||
b.style.transform = '';
|
||
inner.appendChild(b);
|
||
});
|
||
|
||
const ref = visibleBlocks[0] || blocks[0] || null;
|
||
const refH = ref ? measureBlockHeight(ref) : 0;
|
||
const hiddenCount = showAll ? 0 : hiddenRaw;
|
||
const forceVisible = showAll; // 展开后依然保留“展开块”占位,避免顶部跳变
|
||
const label = showAll ? '已展开全部' : null;
|
||
updateMore(hiddenCount, refH, forceVisible, label);
|
||
|
||
const nextPositions = capturePositions();
|
||
await updateHeight(prevPositions, nextPositions);
|
||
};
|
||
|
||
const addThinking = async () => {
|
||
const { block, wrap, innerContent, desc } = createBlockEl('thinking');
|
||
wrap.dataset.type = 'thinking';
|
||
|
||
const progress = document.createElement('div');
|
||
progress.className = 'progress-indicator';
|
||
wrap.appendChild(progress);
|
||
|
||
if (innerContent) innerContent.textContent = '';
|
||
|
||
const willOverflow = !showAll && blocks.length >= VISIBLE;
|
||
const hiddenBefore = Math.max(0, blocks.length - VISIBLE);
|
||
|
||
blocks.push(block);
|
||
|
||
if (willOverflow) {
|
||
inner.appendChild(block);
|
||
const first = inner.firstElementChild;
|
||
const firstH = first ? measureBlockHeight(first) : measureBlockHeight(block);
|
||
const firstOverflow = hiddenBefore === 0;
|
||
|
||
setExpandedState(wrap, true);
|
||
streamThinkingContent(innerContent, wrap);
|
||
|
||
await conveyorShift(firstH, firstOverflow);
|
||
|
||
// 保留最近 VISIBLE
|
||
inner.innerHTML = '';
|
||
blocks.slice(-VISIBLE).forEach((b) => inner.appendChild(b));
|
||
const ref = inner.firstElementChild || block;
|
||
const refH = ref ? measureBlockHeight(ref) : firstH;
|
||
updateMore(blocks.length - VISIBLE, refH);
|
||
await updateHeight();
|
||
} else {
|
||
const prev = capturePositions();
|
||
inner.appendChild(block);
|
||
setExpandedState(wrap, true);
|
||
streamThinkingContent(innerContent, wrap);
|
||
const next = capturePositions();
|
||
await updateHeight(prev, next);
|
||
}
|
||
|
||
await wait(THINKING_DURATION);
|
||
desc.textContent = '思考完成,自动折叠';
|
||
wrap.removeChild(progress);
|
||
await wait(THINKING_COLLAPSE_DELAY);
|
||
setExpandedState(wrap, false);
|
||
await updateHeight();
|
||
|
||
if (showAll) {
|
||
const ref = blocks[0] || null;
|
||
const refH = ref ? measureBlockHeight(ref) : 0;
|
||
updateMore(Math.max(0, blocks.length - VISIBLE), refH, true, '已展开全部');
|
||
}
|
||
};
|
||
|
||
const addTool = async () => {
|
||
const { block, wrap, desc } = createBlockEl('tool');
|
||
wrap.dataset.type = 'tool';
|
||
setExpandedState(wrap, false);
|
||
|
||
const progress = document.createElement('div');
|
||
progress.className = 'progress-indicator';
|
||
wrap.appendChild(progress);
|
||
|
||
const willOverflow = !showAll && blocks.length >= VISIBLE;
|
||
const hiddenBefore = Math.max(0, blocks.length - VISIBLE);
|
||
|
||
blocks.push(block);
|
||
|
||
if (willOverflow) {
|
||
inner.appendChild(block);
|
||
const first = inner.firstElementChild;
|
||
const firstH = first ? measureBlockHeight(first) : measureBlockHeight(block);
|
||
const firstOverflow = hiddenBefore === 0;
|
||
|
||
await conveyorShift(firstH, firstOverflow);
|
||
|
||
inner.innerHTML = '';
|
||
blocks.slice(-VISIBLE).forEach((b) => inner.appendChild(b));
|
||
const ref = inner.firstElementChild || block;
|
||
const refH = ref ? measureBlockHeight(ref) : firstH;
|
||
updateMore(blocks.length - VISIBLE, refH);
|
||
await updateHeight();
|
||
} else {
|
||
const prev = capturePositions();
|
||
inner.appendChild(block);
|
||
const next = capturePositions();
|
||
await updateHeight(prev, next);
|
||
}
|
||
|
||
await wait(TOOL_DURATION);
|
||
desc.textContent = '工具完成 · 结果已写入';
|
||
wrap.removeChild(progress);
|
||
await updateHeight();
|
||
|
||
if (showAll) {
|
||
const ref = blocks[0] || null;
|
||
const refH = ref ? measureBlockHeight(ref) : 0;
|
||
updateMore(Math.max(0, blocks.length - VISIBLE), refH, true, '已展开全部');
|
||
}
|
||
};
|
||
|
||
const runSequence = async () => {
|
||
const sequence = ['thinking', 'tool', 'thinking', 'tool', '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;
|
||
blocks = [];
|
||
shellHeight = 0;
|
||
showAll = false;
|
||
toggling = false;
|
||
inner.innerHTML = '';
|
||
moreBlock.classList.remove('visible');
|
||
moreBlock.style.height = '0px';
|
||
shell.style.height = '0px';
|
||
btnPlay.disabled = false;
|
||
moreDesc.textContent = '无折叠';
|
||
};
|
||
|
||
moreBlock.addEventListener('click', async () => {
|
||
if (!blocks.length || toggling) return;
|
||
const hiddenCount = Math.max(0, blocks.length - VISIBLE);
|
||
if (hiddenCount === 0 && !showAll) return; // 无折叠内容
|
||
toggling = true;
|
||
const targetShowAll = !showAll;
|
||
showAll = targetShowAll;
|
||
await render({ mode: targetShowAll ? 'expand' : 'collapse' });
|
||
toggling = false;
|
||
});
|
||
|
||
btnPlay.addEventListener('click', () => {
|
||
if (running) return;
|
||
reset();
|
||
running = true;
|
||
btnPlay.disabled = true;
|
||
runSequence();
|
||
});
|
||
|
||
btnReset.addEventListener('click', reset);
|
||
|
||
shell.style.height = '0px';
|
||
</script>
|
||
</body>
|
||
</html>
|