Compare commits
2 Commits
7639e0677b
...
713659a644
| Author | SHA1 | Date | |
|---|---|---|---|
| 713659a644 | |||
| 93304bd2b8 |
BIN
demo/stacked-blocks/1.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
demo/stacked-blocks/2.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
demo/stacked-blocks/3.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
demo/stacked-blocks/4.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
demo/stacked-blocks/5.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
demo/stacked-blocks/6.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
37
demo/stacked-blocks/index.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>堆叠块拼接 Demo</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="panel controls-panel">
|
||||||
|
<div class="controls">
|
||||||
|
<button id="playLoop" class="primary">开始自动演示</button>
|
||||||
|
<button id="stepOnce">单次循环</button>
|
||||||
|
<button id="pauseLoop" disabled>暂停</button>
|
||||||
|
<button id="clearAll">清空</button>
|
||||||
|
<button id="toggleAll" disabled>展开全部</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
· ≤6 块:下缘生长到新高度,思考默认展开,结束自动折叠。<br />
|
||||||
|
· >6 块:顶部出现“更多”,只保留最近 6 块,整体高度恒定 7 行;新增时整列上移,新块自底部露出再展开。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack-wrapper">
|
||||||
|
<div id="stackShell" class="stack-shell empty">
|
||||||
|
<div id="stackInner" class="stack-inner"></div>
|
||||||
|
<div class="empty-hint">还没有块,点击上方按钮体验拼接动画。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-note">demo/stacked-blocks · 原生 JS 静态示例</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
342
demo/stacked-blocks/script.js
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
(() => {
|
||||||
|
const MAX_VISIBLE = 6;
|
||||||
|
const blocks = [];
|
||||||
|
let showAll = false;
|
||||||
|
let idSeed = 1;
|
||||||
|
let running = false;
|
||||||
|
let timers = [];
|
||||||
|
|
||||||
|
const stackShell = document.getElementById('stackShell');
|
||||||
|
const stackInner = document.getElementById('stackInner');
|
||||||
|
const btnPlay = document.getElementById('playLoop');
|
||||||
|
const btnStep = document.getElementById('stepOnce');
|
||||||
|
const btnPause = document.getElementById('pauseLoop');
|
||||||
|
const btnClear = document.getElementById('clearAll');
|
||||||
|
const btnToggleAll = document.getElementById('toggleAll');
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
'评估用户意图,先汇总检索关键字,再推演候选工具。',
|
||||||
|
'正在调用 web_search,拼接 query 并去重关键词。',
|
||||||
|
'校验上下文有效性,准备输出草稿。',
|
||||||
|
'工具结果将写入下一个文本块,留意引用。',
|
||||||
|
'自动回溯最近 3 条工具调用,避免重复请求。'
|
||||||
|
];
|
||||||
|
|
||||||
|
const pick = () => samples[Math.floor(Math.random() * samples.length)];
|
||||||
|
|
||||||
|
const createBlock = type => ({
|
||||||
|
id: `${type}-${idSeed++}`,
|
||||||
|
type,
|
||||||
|
title: type === 'thinking' ? '思考过程' : '搜索完成',
|
||||||
|
detail: pick(),
|
||||||
|
streaming: true,
|
||||||
|
expanded: type === 'thinking',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const computeVisible = () => {
|
||||||
|
if (showAll || blocks.length <= MAX_VISIBLE) return [...blocks];
|
||||||
|
const hidden = blocks.length - MAX_VISIBLE;
|
||||||
|
return [{ id: `more-${hidden}`, type: 'more', hidden }, ...blocks.slice(-MAX_VISIBLE)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateButtons = () => {
|
||||||
|
const overLimit = blocks.length > MAX_VISIBLE;
|
||||||
|
btnToggleAll.disabled = !overLimit;
|
||||||
|
btnToggleAll.textContent = showAll ? '收起到最近 6 个' : '展开全部';
|
||||||
|
};
|
||||||
|
|
||||||
|
const measurePositions = () => {
|
||||||
|
const map = {};
|
||||||
|
stackInner.querySelectorAll('[data-id]').forEach(el => {
|
||||||
|
map[el.dataset.id] = el.getBoundingClientRect();
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const animateHeight = (prev, next) => {
|
||||||
|
if (prev === next) return;
|
||||||
|
stackShell.style.height = prev + 'px';
|
||||||
|
// force reflow
|
||||||
|
stackShell.getBoundingClientRect();
|
||||||
|
stackShell.style.transition = 'height 280ms cubic-bezier(0.25, 0.9, 0.3, 1)';
|
||||||
|
stackShell.style.height = next + 'px';
|
||||||
|
const clear = () => {
|
||||||
|
stackShell.style.transition = '';
|
||||||
|
stackShell.removeEventListener('transitionend', clear);
|
||||||
|
};
|
||||||
|
stackShell.addEventListener('transitionend', clear);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFLIP = (prev, isGrowMode, addedId) => {
|
||||||
|
stackInner.querySelectorAll('[data-id]').forEach(el => {
|
||||||
|
const id = el.dataset.id;
|
||||||
|
const isMoreRow = id.startsWith('more');
|
||||||
|
const now = el.getBoundingClientRect();
|
||||||
|
const old = prev[id];
|
||||||
|
|
||||||
|
if (old) {
|
||||||
|
const dy = old.top - now.top;
|
||||||
|
if (Math.abs(dy) > 0.5) {
|
||||||
|
el.style.transform = `translateY(${dy}px)`;
|
||||||
|
el.style.transition = 'transform 220ms ease, opacity 220ms ease';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
el.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
() => {
|
||||||
|
el.style.transform = '';
|
||||||
|
el.style.transition = '';
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const usePeek = !showAll && blocks.length <= MAX_VISIBLE && id === addedId;
|
||||||
|
if (usePeek) {
|
||||||
|
el.classList.add('peek');
|
||||||
|
requestAnimationFrame(() => el.classList.remove('peek'));
|
||||||
|
} else if (isGrowMode || isMoreRow) {
|
||||||
|
el.classList.add('flow-enter');
|
||||||
|
requestAnimationFrame(() => el.classList.add('flow-enter-active'));
|
||||||
|
el.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
() => {
|
||||||
|
el.classList.remove('flow-enter', 'flow-enter-active');
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
} else if (id === addedId) {
|
||||||
|
el.classList.add('peek');
|
||||||
|
requestAnimationFrame(() => el.classList.remove('peek'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBlockRow = block => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = `row block-row type-${block.type}`;
|
||||||
|
row.dataset.id = block.id;
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'row-main';
|
||||||
|
main.addEventListener('click', () => {
|
||||||
|
block.expanded = !block.expanded;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrow = document.createElement('div');
|
||||||
|
arrow.className = `arrow ${block.expanded ? 'open' : ''}`;
|
||||||
|
arrow.textContent = '›';
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'row-icon';
|
||||||
|
icon.textContent = block.type === 'thinking' ? '🧠' : '🔍';
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'row-text';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'row-title';
|
||||||
|
title.textContent = block.title;
|
||||||
|
|
||||||
|
const sub = document.createElement('div');
|
||||||
|
sub.className = 'row-sub';
|
||||||
|
if (block.type === 'thinking') {
|
||||||
|
sub.textContent = block.streaming ? '思考中 · 默认展开' : '思考完成 · 点击查看详情';
|
||||||
|
} else {
|
||||||
|
sub.textContent = block.streaming ? '工具调用中 · 运行后自动收起' : '工具完成 · 点击展开结果';
|
||||||
|
}
|
||||||
|
|
||||||
|
text.appendChild(title);
|
||||||
|
text.appendChild(sub);
|
||||||
|
|
||||||
|
const pill = document.createElement('div');
|
||||||
|
pill.className = `pill ${block.streaming ? '' : 'green'}`;
|
||||||
|
pill.textContent = block.streaming ? '进行中' : '已完成';
|
||||||
|
|
||||||
|
main.appendChild(arrow);
|
||||||
|
main.appendChild(icon);
|
||||||
|
main.appendChild(text);
|
||||||
|
main.appendChild(pill);
|
||||||
|
|
||||||
|
const detailWrap = document.createElement('div');
|
||||||
|
detailWrap.className = 'row-detail';
|
||||||
|
detailWrap.style.display = block.expanded ? 'block' : 'none';
|
||||||
|
|
||||||
|
const detailInner = document.createElement('div');
|
||||||
|
detailInner.className = 'row-detail-inner';
|
||||||
|
detailInner.textContent = block.detail;
|
||||||
|
detailWrap.appendChild(detailInner);
|
||||||
|
|
||||||
|
row.appendChild(main);
|
||||||
|
row.appendChild(detailWrap);
|
||||||
|
|
||||||
|
if (block.streaming) {
|
||||||
|
const progress = document.createElement('div');
|
||||||
|
progress.className = 'progress';
|
||||||
|
row.appendChild(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMoreRow = (id, hidden) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row more-row';
|
||||||
|
row.dataset.id = id;
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
showAll = true;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'more-dot';
|
||||||
|
dot.textContent = '···';
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'row-title';
|
||||||
|
title.textContent = '更多';
|
||||||
|
const tip = document.createElement('div');
|
||||||
|
tip.className = 'more-tip';
|
||||||
|
tip.textContent = `已折叠 ${hidden} 个更早的块,点击展开查看`;
|
||||||
|
text.appendChild(title);
|
||||||
|
text.appendChild(tip);
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'more-btn';
|
||||||
|
btn.textContent = '展开';
|
||||||
|
|
||||||
|
row.appendChild(dot);
|
||||||
|
row.appendChild(text);
|
||||||
|
row.appendChild(btn);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (options = {}) => {
|
||||||
|
const prev = measurePositions();
|
||||||
|
const prevHeight = stackShell.getBoundingClientRect().height || 0;
|
||||||
|
const visible = computeVisible();
|
||||||
|
stackInner.innerHTML = '';
|
||||||
|
|
||||||
|
if (!visible.length) {
|
||||||
|
stackShell.classList.add('empty');
|
||||||
|
updateButtons();
|
||||||
|
animateHeight(prevHeight, 140);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stackShell.classList.remove('empty');
|
||||||
|
|
||||||
|
visible.forEach(block => {
|
||||||
|
if (block.type === 'more') {
|
||||||
|
stackInner.appendChild(createMoreRow(block.id, block.hidden));
|
||||||
|
} else {
|
||||||
|
stackInner.appendChild(createBlockRow(block));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGrowMode = showAll || blocks.length <= MAX_VISIBLE;
|
||||||
|
requestAnimationFrame(() => applyFLIP(prev, isGrowMode, options.newlyAddedId));
|
||||||
|
updateButtons();
|
||||||
|
const nextHeight = stackInner.scrollHeight;
|
||||||
|
animateHeight(prevHeight || nextHeight, nextHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBlock = type => {
|
||||||
|
const block = createBlock(type);
|
||||||
|
blocks.push(block);
|
||||||
|
render({ newlyAddedId: block.id });
|
||||||
|
|
||||||
|
// 返回 Promise,结束时机取决于类型
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (type === 'thinking') {
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
block.streaming = false;
|
||||||
|
block.detail += ' · 已产出结论,等待工具。';
|
||||||
|
render();
|
||||||
|
}, 1200)
|
||||||
|
);
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
block.expanded = false;
|
||||||
|
render();
|
||||||
|
resolve();
|
||||||
|
}, 2000)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
block.streaming = false;
|
||||||
|
block.detail = '检索到 3 条结果,已写入后续回复。';
|
||||||
|
render();
|
||||||
|
resolve();
|
||||||
|
}, 1400)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const wait = ms => new Promise(r => timers.push(setTimeout(r, ms)));
|
||||||
|
|
||||||
|
const runCycle = async () => {
|
||||||
|
const thinkingDone = await addBlock('thinking');
|
||||||
|
await wait(200); // 给折叠留一帧
|
||||||
|
await addBlock('tool');
|
||||||
|
await wait(500); // 循环间隔
|
||||||
|
};
|
||||||
|
|
||||||
|
const playLoop = async () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
btnPlay.disabled = true;
|
||||||
|
btnStep.disabled = true;
|
||||||
|
btnPause.disabled = false;
|
||||||
|
while (running) {
|
||||||
|
await runCycle();
|
||||||
|
}
|
||||||
|
btnPlay.disabled = false;
|
||||||
|
btnStep.disabled = false;
|
||||||
|
btnPause.disabled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepOnce = async () => {
|
||||||
|
if (running) return;
|
||||||
|
btnStep.disabled = true;
|
||||||
|
await runCycle();
|
||||||
|
btnStep.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseLoop = () => {
|
||||||
|
running = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
timers.forEach(t => clearTimeout(t));
|
||||||
|
timers = [];
|
||||||
|
running = false;
|
||||||
|
blocks.length = 0;
|
||||||
|
showAll = false;
|
||||||
|
render();
|
||||||
|
btnPlay.disabled = false;
|
||||||
|
btnStep.disabled = false;
|
||||||
|
btnPause.disabled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
btnPlay.addEventListener('click', playLoop);
|
||||||
|
btnStep.addEventListener('click', stepOnce);
|
||||||
|
btnPause.addEventListener('click', pauseLoop);
|
||||||
|
btnClear.addEventListener('click', clearAll);
|
||||||
|
btnToggleAll.addEventListener('click', () => {
|
||||||
|
if (blocks.length <= MAX_VISIBLE) return;
|
||||||
|
showAll = !showAll;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始播放一轮示例,演示“先思考后工具”的顺序
|
||||||
|
stepOnce();
|
||||||
|
})();
|
||||||
514
demo/stacked-blocks/stack6.html
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
<!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>
|
||||||
841
demo/stacked-blocks/stack7.html
Normal file
@ -0,0 +1,841 @@
|
|||||||
|
<!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>
|
||||||
324
demo/stacked-blocks/style.css
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
:root {
|
||||||
|
--paper: #f7f3ea;
|
||||||
|
--card: #fdfbf6;
|
||||||
|
--ink: #5b4a36;
|
||||||
|
--muted: #9a8d7d;
|
||||||
|
--line: #e9e1d6;
|
||||||
|
--shadow: 0 18px 48px rgba(91, 74, 54, 0.12);
|
||||||
|
--accent: #b98a59;
|
||||||
|
--accent-weak: rgba(185, 138, 89, 0.18);
|
||||||
|
--green: #4c9f70;
|
||||||
|
--peek-clip: inset(42% 0 0 0 round 0 0 18px 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: radial-gradient(circle at 20% 18%, rgba(185, 138, 89, 0.08), transparent 28%),
|
||||||
|
radial-gradient(circle at 86% 12%, rgba(76, 159, 112, 0.08), transparent 30%),
|
||||||
|
linear-gradient(180deg, #faf7f0, #f5f1e7);
|
||||||
|
font-family: "Inter", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
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;
|
||||||
|
box-shadow: 0 8px 22px rgba(185, 138, 89, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-shell {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 280ms cubic-bezier(0.25, 0.9, 0.3, 1), box-shadow 180ms ease;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-shell.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-shell:not(.empty) .empty-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-inner {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row {
|
||||||
|
padding: 16px 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0.48));
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 40px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform 240ms ease;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f2ede4;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 18px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(91, 74, 54, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--accent-weak);
|
||||||
|
background: rgba(185, 138, 89, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.green {
|
||||||
|
border-color: rgba(76, 159, 112, 0.16);
|
||||||
|
background: rgba(76, 159, 112, 0.12);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-detail {
|
||||||
|
padding-left: 64px;
|
||||||
|
padding-right: 4px;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-detail-inner {
|
||||||
|
background: #fffdfa;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(185, 138, 89, 0.16);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin-left: 64px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, rgba(185, 138, 89, 0.05), rgba(185, 138, 89, 0.8), rgba(185, 138, 89, 0.05));
|
||||||
|
animation: shimmer 1.3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from {
|
||||||
|
transform: translateX(-40%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(40%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-row {
|
||||||
|
background: linear-gradient(180deg, #f0ebe1, #e6dece);
|
||||||
|
color: #4a3f30;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(74, 63, 48, 0.08);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-dot {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(74, 63, 48, 0.12);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(74, 63, 48, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn {
|
||||||
|
border: 1px solid rgba(74, 63, 48, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #4a3f30;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn:hover {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 6px 14px rgba(74, 63, 48, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-enter {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-enter-active {
|
||||||
|
max-height: 420px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: max-height 260ms cubic-bezier(0.25, 0.9, 0.3, 1), opacity 220ms ease, transform 260ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek {
|
||||||
|
clip-path: var(--peek-clip);
|
||||||
|
transform: translateY(18px);
|
||||||
|
opacity: 0.65;
|
||||||
|
transition: clip-path 260ms ease, transform 260ms ease, opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-row,
|
||||||
|
.more-row {
|
||||||
|
transition: transform 220ms ease, opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
BIN
demo/stacked-blocks/截图/截屏2025-12-31 23.59.37.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
demo/stacked-blocks/截图/截屏2025-12-31 23.59.45.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
demo/stacked-blocks/截图/截屏2025-12-31 23.59.55.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
demo/stacked-blocks/截图/截屏2025-12-31 23.59.59.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
demo/stacked-blocks/截屏2025-12-31 20.13.03.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
112
static/demo/style/1.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-body: #F3F4F6;
|
||||||
|
--bg-sidebar: #FFFFFF;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--border-color: #E5E7EB;
|
||||||
|
--text-primary: #111827;
|
||||||
|
--text-secondary: #6B7280;
|
||||||
|
--accent: #2563EB; /* 商务蓝 */
|
||||||
|
--accent-bg: #EFF6FF;
|
||||||
|
}
|
||||||
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg-body); color: var(--text-primary); display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar { width: 280px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 20px; }
|
||||||
|
.logo { font-weight: 700; font-size: 18px; color: var(--text-primary); margin-bottom: 30px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.logo span { background: var(--accent); color: white; padding: 4px 8px; border-radius: 6px; font-size: 12px; }
|
||||||
|
.nav-item { padding: 10px 12px; margin-bottom: 4px; border-radius: 6px; color: var(--text-secondary); font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 10px;}
|
||||||
|
.nav-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
|
||||||
|
.nav-item:hover:not(.active) { background: #F9FAFB; }
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; padding: 0 40px; overflow-y: auto; align-items: center; }
|
||||||
|
.chat-container { max-width: 800px; width: 100%; padding-top: 40px; padding-bottom: 100px; }
|
||||||
|
|
||||||
|
/* User Message */
|
||||||
|
.user-msg { background: transparent; padding: 16px; font-size: 16px; border-bottom: 1px solid var(--border-color); margin-bottom: 30px; }
|
||||||
|
|
||||||
|
/* AI Message */
|
||||||
|
.ai-msg { margin-bottom: 30px; }
|
||||||
|
.ai-text { margin-bottom: 20px; line-height: 1.6; color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Action Cards (Search/File) */
|
||||||
|
.step-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px; /* 较小的圆角 */
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05); /* 极轻微的阴影 */
|
||||||
|
}
|
||||||
|
.icon-box { width: 32px; height: 32px; border-radius: 6px; background: var(--bg-body); display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Input Area */
|
||||||
|
.input-wrapper { position: fixed; bottom: 30px; width: 100%; max-width: 800px; }
|
||||||
|
.input-box {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.send-btn { width: 32px; height: 32px; background: var(--text-primary); border-radius: 50%; border: none; }
|
||||||
|
</style>
|
||||||
|
<title>Light Theme Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="logo">AI Agent <span>v5.3</span></div>
|
||||||
|
<div class="nav-item active">📁 项目文件</div>
|
||||||
|
<div class="nav-item">⚙️ 管理面板</div>
|
||||||
|
<div style="margin-top:20px; font-size:12px; color:#9CA3AF; font-weight:600;">TEMP</div>
|
||||||
|
<div class="nav-item" style="padding-left: 0;">└ 📄 user_upload</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="user-msg">
|
||||||
|
搜索一下原神目前卡池是什么,写在一个文件里。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-msg">
|
||||||
|
<div class="ai-text">我来帮您搜索原神当前卡池信息并写入文件。</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-box">🔍</div>
|
||||||
|
<div style="flex:1">搜索完成 <span style="color:var(--text-secondary)">"原神当前卡池 2024年12月"</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-box">🔍</div>
|
||||||
|
<div style="flex:1">搜索完成 <span style="color:var(--text-secondary)">"原神 5.3版本卡池"</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-box">📄</div>
|
||||||
|
<div style="flex:1">文件创建成功 <span style="font-family:monospace; background:#F3F4F6; padding:2px 6px; border-radius:4px;">genshin_banner.txt</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<div class="input-box">
|
||||||
|
<span>输入消息... (Ctrl+Enter 发送)</span>
|
||||||
|
<button class="send-btn"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
111
static/demo/style/2.html
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-body: #0D1117; /* GitHub Dark Dimmed */
|
||||||
|
--bg-sidebar: #161B22;
|
||||||
|
--bg-card: #21262D;
|
||||||
|
--border-color: #30363D;
|
||||||
|
--text-primary: #C9D1D9;
|
||||||
|
--text-secondary: #8B949E;
|
||||||
|
--accent: #2EA043; /* 极客绿 */
|
||||||
|
--accent-hover: #3FB950;
|
||||||
|
}
|
||||||
|
body { margin: 0; font-family: "SF Mono", "Consolas", "Roboto Mono", sans-serif; /* 代码字体风格 */ background: var(--bg-body); color: var(--text-primary); display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar { width: 280px; background: var(--bg-sidebar); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 20px; }
|
||||||
|
.logo { font-weight: 700; font-size: 16px; color: var(--text-primary); margin-bottom: 30px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--border-color); padding-bottom: 20px;}
|
||||||
|
.status-dot { width: 8px; height: 8px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 8px var(--accent); }
|
||||||
|
|
||||||
|
.nav-item { padding: 10px 12px; margin-bottom: 4px; border-radius: 4px; color: var(--text-secondary); font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: 0.2s;}
|
||||||
|
.nav-item.active { background: rgba(46, 160, 67, 0.15); color: #FFF; border-left: 3px solid var(--accent); }
|
||||||
|
.nav-item:hover:not(.active) { color: #FFF; background: var(--bg-card); }
|
||||||
|
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; padding: 0 40px; overflow-y: auto; align-items: center; }
|
||||||
|
.chat-container { max-width: 800px; width: 100%; padding-top: 40px; padding-bottom: 100px; }
|
||||||
|
|
||||||
|
.user-msg { background: var(--bg-card); border-radius: 8px; padding: 16px; font-size: 15px; border: 1px solid var(--border-color); margin-bottom: 30px; }
|
||||||
|
|
||||||
|
.ai-msg { margin-bottom: 30px; }
|
||||||
|
.ai-text { margin-bottom: 20px; line-height: 1.6; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.step-card {
|
||||||
|
background: #161B22;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.step-card:hover { border-color: var(--text-secondary); }
|
||||||
|
.icon-box { color: var(--accent); font-family: monospace; }
|
||||||
|
|
||||||
|
.input-wrapper { position: fixed; bottom: 30px; width: 100%; max-width: 800px; }
|
||||||
|
.input-box {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.send-btn { width: 30px; height: 30px; background: var(--accent); border-radius: 4px; border: none; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
<title>Dark Theme Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
AI_AGENT_SYSTEM v5.3
|
||||||
|
</div>
|
||||||
|
<div class="nav-item active">~/project_files</div>
|
||||||
|
<div class="nav-item">~/monitoring</div>
|
||||||
|
<div style="margin-top:20px; font-size:11px; color:#484F58; text-transform:uppercase; letter-spacing:1px;">Directory</div>
|
||||||
|
<div class="nav-item">📁 temp</div>
|
||||||
|
<div class="nav-item">📄 user_upload</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="user-msg">
|
||||||
|
> 搜索一下原神目前卡池是什么,写在一个文件里
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-msg">
|
||||||
|
<div class="ai-text">正在执行任务序列...</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-box">[SEARCH]</div>
|
||||||
|
<div style="flex:1">Query: "原神当前卡池 2024年12月"</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-box">[SEARCH]</div>
|
||||||
|
<div style="flex:1">Query: "原神 5.3版本卡池"</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-box">[WRITE]</div>
|
||||||
|
<div style="flex:1">Created: <span style="color:var(--accent)">./temp/genshin_banner.txt</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<div class="input-box">
|
||||||
|
<span>_ 输入指令...</span>
|
||||||
|
<button class="send-btn"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
177
static/demo/style/3.html
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.65);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.5);
|
||||||
|
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||||
|
--text-primary: #2D3748;
|
||||||
|
--text-secondary: #718096;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
|
/* 弥散光感背景 */
|
||||||
|
background: radial-gradient(circle at 10% 20%, rgb(239, 246, 255) 0%, rgb(219, 228, 255) 90%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增加一个背景装饰球 */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
left: -10%;
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(169, 139, 254, 0.4) 0%, rgba(255,255,255,0) 70%);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar - 悬浮的玻璃侧栏 */
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
display: flex; flex-direction: column; padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-weight: 800; font-size: 20px;
|
||||||
|
background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 12px 16px; margin-bottom: 8px; border-radius: 16px;
|
||||||
|
color: var(--text-secondary); font-size: 14px; cursor: pointer; font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||||
|
color: #5B4BC4;
|
||||||
|
}
|
||||||
|
.nav-item:hover:not(.active) { background: rgba(255,255,255,0.5); }
|
||||||
|
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; padding: 0 20px; overflow-y: auto; align-items: center; position: relative; }
|
||||||
|
.chat-container { max-width: 760px; width: 100%; padding-top: 40px; padding-bottom: 120px; }
|
||||||
|
|
||||||
|
/* 对话气泡 */
|
||||||
|
.user-msg {
|
||||||
|
background: white;
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 20px 20px 4px 20px; /* 不规则圆角 */
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
margin-left: auto;
|
||||||
|
max-width: 80%;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg { margin-bottom: 30px; }
|
||||||
|
|
||||||
|
/* 步骤卡片 - 玻璃条 */
|
||||||
|
.step-card {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
.icon-circle {
|
||||||
|
width: 36px; height: 36px; border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center; color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框 - 悬浮胶囊 */
|
||||||
|
.input-wrapper { position: fixed; bottom: 30px; width: 100%; max-width: 760px; z-index: 10; }
|
||||||
|
.input-box {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid white;
|
||||||
|
border-radius: 100px; /* 胶囊形状 */
|
||||||
|
padding: 12px 12px 12px 24px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.send-btn {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
border-radius: 50%; border: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.send-btn:hover { transform: scale(1.05); }
|
||||||
|
</style>
|
||||||
|
<title>Creative Glass Theme</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="logo">AI Agent ✨</div>
|
||||||
|
<div class="nav-item active">Project Files</div>
|
||||||
|
<div class="nav-item">Dashboard</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="nav-item">Settings</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="user-msg">
|
||||||
|
搜索一下原神目前卡池是什么,写在一个文件里。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-msg">
|
||||||
|
<div class="ai-text" style="padding-left: 8px;">好的,我已经为您整理了卡池信息。</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-circle">🔍</div>
|
||||||
|
<div style="flex:1">Web Search: <b>Genshin Impact Banners</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-circle">⚙️</div>
|
||||||
|
<div style="flex:1">Processing Data...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="icon-circle">📂</div>
|
||||||
|
<div style="flex:1">File Saved: <b style="color:#6B46C1">banner_info.txt</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<div class="input-box">
|
||||||
|
<span>Ask me anything...</span>
|
||||||
|
<button class="send-btn"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
static/icons/未命名文件夹.zip
Normal file
18
static/icons/未命名文件夹/bot.svg
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M12 8V4H8" />
|
||||||
|
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||||
|
<path d="M2 14h2" />
|
||||||
|
<path d="M20 14h2" />
|
||||||
|
<path d="M15 13v2" />
|
||||||
|
<path d="M9 13v2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 380 B |
1
static/icons/未命名文件夹/layers.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>
|
||||||
|
After Width: | Height: | Size: 491 B |
15
static/icons/未命名文件夹/menu.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M4 5h16" />
|
||||||
|
<path d="M4 12h16" />
|
||||||
|
<path d="M4 19h16" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 279 B |
1
static/icons/未命名文件夹/发送.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11zm7.318-19.539l-10.94 10.939"/></svg>
|
||||||
|
After Width: | Height: | Size: 372 B |
5
static/icons/未命名文件夹/对话记录.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M6 7c0-2 1.6-3.6 3.6-3.6h13c2 0 3.6 1.6 3.6 3.6v10c0 2-1.6 3.6-3.6 3.6h-6.2L12.4 25l0.7-4.4H9.6c-2 0-3.6-1.6-3.6-3.6V7Z" />
|
||||||
|
<path d="M10.4 11h11.2" />
|
||||||
|
<path d="M10.4 15h7.2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 360 B |
1
static/icons/未命名文件夹/搜索.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="m21 21l-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
14
static/icons/未命名文件夹/新建.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 377 B |
14
static/icons/未命名文件夹/用户.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 299 B |
1
static/icons/未命名文件夹/问题.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.08 4h.01"/></svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
@ -35,6 +35,154 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="stackedBlocksEnabled">
|
||||||
|
<template v-for="(group, groupIndex) in splitActionGroups(msg.actions || [], index)" :key="group.key">
|
||||||
|
<StackedBlocks
|
||||||
|
v-if="group.kind === 'stack'"
|
||||||
|
class="stacked-blocks-wrapper"
|
||||||
|
:actions="group.actions"
|
||||||
|
:expanded-blocks="expandedBlocks"
|
||||||
|
:icon-style="iconStyleSafe"
|
||||||
|
:toggle-block="toggleBlock"
|
||||||
|
:register-thinking-ref="registerThinkingRef"
|
||||||
|
:handle-thinking-scroll="handleThinkingScroll"
|
||||||
|
:get-tool-animation-class="getToolAnimationClass"
|
||||||
|
:get-tool-icon="getToolIcon"
|
||||||
|
:get-tool-status-text="getToolStatusText"
|
||||||
|
:get-tool-description="getToolDescription"
|
||||||
|
:format-search-topic="formatSearchTopic"
|
||||||
|
:format-search-time="formatSearchTime"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="action-item"
|
||||||
|
:key="group.action?.id || `${index}-${group.actionIndex}`"
|
||||||
|
:class="{
|
||||||
|
'streaming-content': group.action?.streaming,
|
||||||
|
'completed-tool': group.action?.type === 'tool' && !group.action?.streaming,
|
||||||
|
'immediate-show':
|
||||||
|
group.action?.streaming || group.action?.type === 'text' || group.action?.type === 'thinking',
|
||||||
|
'thinking-finished': group.action?.type === 'thinking' && !group.action?.streaming
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="group.action?.type === 'thinking'"
|
||||||
|
class="collapsible-block thinking-block"
|
||||||
|
:class="{ expanded: expandedBlocks?.has(group.action.blockId || `${index}-thinking-${group.actionIndex}`) }"
|
||||||
|
>
|
||||||
|
<div class="collapsible-header" @click="toggleBlock(group.action.blockId || `${index}-thinking-${group.actionIndex}`)">
|
||||||
|
<div class="arrow"></div>
|
||||||
|
<div class="status-icon">
|
||||||
|
<span class="thinking-icon" :class="{ 'thinking-animation': group.action.streaming }">
|
||||||
|
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-text">{{ group.action.streaming ? '正在思考...' : '思考过程' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="collapsible-content">
|
||||||
|
<div
|
||||||
|
class="content-inner thinking-content"
|
||||||
|
:ref="el => registerThinkingRef(group.action.blockId || `${index}-thinking-${group.actionIndex}`, el)"
|
||||||
|
@scroll="
|
||||||
|
handleThinkingScroll(
|
||||||
|
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
|
||||||
|
$event
|
||||||
|
)
|
||||||
|
"
|
||||||
|
style="max-height: 240px; overflow-y: auto;"
|
||||||
|
>
|
||||||
|
{{ group.action.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="group.action?.type === 'text'" class="text-output">
|
||||||
|
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
|
||||||
|
<div v-if="group.action.streaming" v-html="renderMarkdown(group.action.content, true)"></div>
|
||||||
|
<div v-else v-html="renderMarkdown(group.action.content, false)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="group.action?.type === 'system'" class="system-action">
|
||||||
|
<div class="system-action-content">
|
||||||
|
{{ group.action.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="group.action?.type === 'append_payload'"
|
||||||
|
class="append-placeholder"
|
||||||
|
:class="{ 'append-error': group.action.append?.success === false }"
|
||||||
|
>
|
||||||
|
<div class="append-placeholder-content">
|
||||||
|
<template v-if="group.action.append?.success !== false">
|
||||||
|
<div class="icon-label append-status">
|
||||||
|
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
|
||||||
|
<span>已写入 {{ group.action.append?.path || '目标文件' }} 的追加内容(内容已保存至文件)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="icon-label append-status append-error-text">
|
||||||
|
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span>
|
||||||
|
<span>向 {{ group.action.append?.path || '目标文件' }} 写入失败,内容已截获供后续修复。</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="append-meta" v-if="group.action.append">
|
||||||
|
<span v-if="group.action.append.lines !== null && group.action.append.lines !== undefined">
|
||||||
|
· 行数 {{ group.action.append.lines }}
|
||||||
|
</span>
|
||||||
|
<span v-if="group.action.append.bytes !== null && group.action.append.bytes !== undefined">
|
||||||
|
· 字节 {{ group.action.append.bytes }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="append-warning icon-label" v-if="group.action.append?.forced">
|
||||||
|
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
|
||||||
|
<span>未检测到结束标记,请根据提示继续补充。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="group.action?.type === 'append'"
|
||||||
|
class="append-placeholder"
|
||||||
|
:class="{ 'append-error': group.action.append?.success === false }"
|
||||||
|
>
|
||||||
|
<div class="append-placeholder-content">
|
||||||
|
<div class="icon-label append-status">
|
||||||
|
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
|
||||||
|
<span>{{ group.action.append?.summary || '文件追加完成' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="append-meta" v-if="group.action.append">
|
||||||
|
<span>{{ group.action.append.path || '目标文件' }}</span>
|
||||||
|
<span v-if="group.action.append.lines">· 行数 {{ group.action.append.lines }}</span>
|
||||||
|
<span v-if="group.action.append.bytes">· 字节 {{ group.action.append.bytes }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="append-warning icon-label" v-if="group.action.append?.forced">
|
||||||
|
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
|
||||||
|
<span>未检测到结束标记,请按提示继续补充。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToolAction
|
||||||
|
v-else-if="group.action?.type === 'tool'"
|
||||||
|
:action="group.action"
|
||||||
|
:expanded="expandedBlocks?.has(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
|
||||||
|
:icon-style="iconStyleSafe"
|
||||||
|
:get-tool-animation-class="getToolAnimationClass"
|
||||||
|
:get-tool-icon="getToolIcon"
|
||||||
|
:get-tool-status-text="getToolStatusText"
|
||||||
|
:get-tool-description="getToolDescription"
|
||||||
|
:format-search-topic="formatSearchTopic"
|
||||||
|
:format-search-time="formatSearchTime"
|
||||||
|
:streaming-message="streamingMessage"
|
||||||
|
@toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="(action, actionIndex) in msg.actions || []"
|
v-for="(action, actionIndex) in msg.actions || []"
|
||||||
:key="action.id || `${index}-${actionIndex}`"
|
:key="action.id || `${index}-${actionIndex}`"
|
||||||
@ -75,7 +223,6 @@
|
|||||||
{{ action.content }}
|
{{ action.content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="action.streaming" class="progress-indicator"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="action.type === 'text'" class="text-output">
|
<div v-else-if="action.type === 'text'" class="text-output">
|
||||||
@ -161,6 +308,7 @@
|
|||||||
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="system-message">
|
<div v-else class="system-message">
|
||||||
<div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }">
|
<div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }">
|
||||||
@ -184,8 +332,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||||
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
messages: Array<any>;
|
messages: Array<any>;
|
||||||
@ -203,6 +353,9 @@ const props = defineProps<{
|
|||||||
formatSearchTime: (filters: Record<string, any>) => string;
|
formatSearchTime: (filters: Record<string, any>) => string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const personalization = usePersonalizationStore();
|
||||||
|
const stackedBlocksEnabled = computed(() => personalization.experiments.stackedBlocksEnabled);
|
||||||
|
|
||||||
const DEFAULT_GENERATING_TEXT = '生成中…';
|
const DEFAULT_GENERATING_TEXT = '生成中…';
|
||||||
const rootEl = ref<HTMLElement | null>(null);
|
const rootEl = ref<HTMLElement | null>(null);
|
||||||
const thinkingRefs = new Map<string, HTMLElement | null>();
|
const thinkingRefs = new Map<string, HTMLElement | null>();
|
||||||
@ -226,6 +379,50 @@ function iconStyleSafe(key: string, size?: string) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
|
||||||
|
const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
|
||||||
|
const result: Array<
|
||||||
|
| { kind: 'stack'; actions: any[]; key: string }
|
||||||
|
| { kind: 'single'; action: any; actionIndex: number; key: string }
|
||||||
|
> = [];
|
||||||
|
let buffer: any[] = [];
|
||||||
|
|
||||||
|
const flushBuffer = () => {
|
||||||
|
if (buffer.length >= 2) {
|
||||||
|
result.push({
|
||||||
|
kind: 'stack',
|
||||||
|
actions: buffer.slice(),
|
||||||
|
key: `stack-${messageIndex}-${result.length}`
|
||||||
|
});
|
||||||
|
} else if (buffer.length === 1) {
|
||||||
|
const single = buffer[0];
|
||||||
|
result.push({
|
||||||
|
kind: 'single',
|
||||||
|
action: single,
|
||||||
|
actionIndex: actions.indexOf(single),
|
||||||
|
key: single.id || `single-${messageIndex}-${result.length}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
buffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
actions.forEach((action, idx) => {
|
||||||
|
if (isStackable(action)) {
|
||||||
|
buffer.push(action);
|
||||||
|
} else {
|
||||||
|
flushBuffer();
|
||||||
|
result.push({
|
||||||
|
kind: 'single',
|
||||||
|
action,
|
||||||
|
actionIndex: idx,
|
||||||
|
key: action.id || `single-${messageIndex}-${idx}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushBuffer();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
function getGeneratingLetters(message: any) {
|
function getGeneratingLetters(message: any) {
|
||||||
const label =
|
const label =
|
||||||
typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()
|
typeof message?.generatingLabel === 'string' && message.generatingLabel.trim()
|
||||||
|
|||||||
302
static/src/components/chat/StackedBlocks.vue
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="stacked-shell"
|
||||||
|
ref="shell"
|
||||||
|
:style="{
|
||||||
|
height: `${shellHeight}px`,
|
||||||
|
paddingTop: moreVisible ? `${moreHeight}px` : '0px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="stacked-more-block"
|
||||||
|
ref="moreBlock"
|
||||||
|
:class="{ visible: moreVisible }"
|
||||||
|
:style="{ height: `${moreVisible ? moreHeight : 0}px` }"
|
||||||
|
@click="toggleMore"
|
||||||
|
>
|
||||||
|
<img class="more-icon" src="/static/icons/align-left.svg" alt="展开" />
|
||||||
|
<div class="more-copy">
|
||||||
|
<span class="more-title">{{ moreTitle }}</span>
|
||||||
|
<span class="more-desc">{{ moreDesc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stacked-viewport" :style="{ height: `${viewportHeight}px` }">
|
||||||
|
<div class="stacked-inner" ref="inner" :style="{ transform: `translateY(${innerOffset}px)` }">
|
||||||
|
<div v-for="(action, idx) in stackableActions" :key="blockKey(action, idx)" class="stacked-item">
|
||||||
|
<div
|
||||||
|
v-if="action.type === 'thinking'"
|
||||||
|
class="collapsible-block thinking-block stacked-block"
|
||||||
|
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
|
||||||
|
>
|
||||||
|
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||||||
|
<div class="arrow"></div>
|
||||||
|
<div class="status-icon">
|
||||||
|
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
|
||||||
|
<span class="icon icon-sm" :style="iconStyle('brain')" aria-hidden="true"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
|
||||||
|
<div
|
||||||
|
class="content-inner thinking-content"
|
||||||
|
:ref="el => registerThinking(blockKey(action, idx), el)"
|
||||||
|
@scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)"
|
||||||
|
style="max-height: 240px; overflow-y: auto;"
|
||||||
|
>
|
||||||
|
{{ action.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="action.type === 'tool'"
|
||||||
|
class="collapsible-block tool-block stacked-block"
|
||||||
|
:class="{
|
||||||
|
expanded: isExpanded(action, idx),
|
||||||
|
processing: isToolProcessing(action),
|
||||||
|
completed: isToolCompleted(action)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||||||
|
<div class="arrow"></div>
|
||||||
|
<div class="status-icon">
|
||||||
|
<span
|
||||||
|
class="tool-icon icon icon-md"
|
||||||
|
:class="getToolAnimationClass(action.tool)"
|
||||||
|
:style="iconStyle(getToolIcon(action.tool))"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span class="status-text">{{ getToolStatusText(action.tool) }}</span>
|
||||||
|
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
|
||||||
|
<div class="content-inner">
|
||||||
|
<div v-if="action.tool?.name === 'web_search' && action.tool?.result">
|
||||||
|
<div class="search-meta">
|
||||||
|
<div><strong>搜索内容:</strong>{{ action.tool.result.query || action.tool.arguments?.query }}</div>
|
||||||
|
<div><strong>主题:</strong>{{ formatSearchTopic(action.tool.result.filters || {}) }}</div>
|
||||||
|
<div><strong>时间范围:</strong>{{ formatSearchTime(action.tool.result.filters || {}) }}</div>
|
||||||
|
<div><strong>结果数量:</strong>{{ action.tool.result.total_results }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="action.tool.result.results && action.tool.result.results.length" class="search-result-list">
|
||||||
|
<div v-for="item in action.tool.result.results" :key="item.url || item.index" class="search-result-item">
|
||||||
|
<div class="search-result-title">{{ item.title || '无标题' }}</div>
|
||||||
|
<div class="search-result-url">
|
||||||
|
<a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a><span v-else>无可用链接</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-empty">未返回详细的搜索结果。</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="action.tool?.name === 'run_python' && action.tool?.result">
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-label">代码:</div>
|
||||||
|
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="action.tool.result.output" class="output-block">
|
||||||
|
<div class="output-label">输出:</div>
|
||||||
|
<pre>{{ action.tool.result.output }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<pre>{{ JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StackedBlocks' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
actions: any[];
|
||||||
|
expandedBlocks: Set<string>;
|
||||||
|
iconStyle: (key: string, size?: string) => Record<string, string>;
|
||||||
|
toggleBlock: (blockId: string) => void;
|
||||||
|
registerThinkingRef?: (key: string, el: Element | null) => void;
|
||||||
|
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
||||||
|
getToolAnimationClass: (tool: any) => Record<string, unknown>;
|
||||||
|
getToolIcon: (tool: any) => string;
|
||||||
|
getToolStatusText: (tool: any) => string;
|
||||||
|
getToolDescription: (tool: any) => string;
|
||||||
|
formatSearchTopic: (filters: Record<string, any>) => string;
|
||||||
|
formatSearchTime: (filters: Record<string, any>) => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const VISIBLE_LIMIT = 6;
|
||||||
|
|
||||||
|
const shell = ref<HTMLElement | null>(null);
|
||||||
|
const inner = ref<any>(null);
|
||||||
|
const moreBlock = ref<any>(null);
|
||||||
|
|
||||||
|
const showAll = ref(false);
|
||||||
|
const shellHeight = ref(0);
|
||||||
|
const moreHeight = ref(0);
|
||||||
|
const innerOffset = ref(0);
|
||||||
|
const viewportHeight = ref(0);
|
||||||
|
const contentHeights = ref<Record<string, number>>({});
|
||||||
|
|
||||||
|
const stackableActions = computed(() => (props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool')));
|
||||||
|
|
||||||
|
const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT));
|
||||||
|
const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
|
||||||
|
const totalSteps = computed(() => stackableActions.value.length);
|
||||||
|
const moreTitle = computed(() => (showAll.value ? '已展开全部' : '更多'));
|
||||||
|
const moreDesc = computed(() =>
|
||||||
|
showAll.value ? `共 ${totalSteps.value} 个步骤` : `${hiddenCount.value} 个步骤折叠`
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockKey = (action: any, idx: number) => action?.blockId || action?.id || `stacked-${idx}`;
|
||||||
|
|
||||||
|
const isExpanded = (action: any, idx: number) => props.expandedBlocks?.has(blockKey(action, idx));
|
||||||
|
const isExpandedById = (blockId: string) => props.expandedBlocks?.has(blockId);
|
||||||
|
const contentStyle = (blockId: string) => {
|
||||||
|
const h = Math.max(0, contentHeights.value[blockId] || 0);
|
||||||
|
return { maxHeight: isExpandedById(blockId) ? `${h}px` : '0px' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToolProcessing = (action: any) => {
|
||||||
|
const status = action?.tool?.status;
|
||||||
|
return status === 'preparing' || status === 'running';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToolCompleted = (action: any) => action?.tool?.status === 'completed';
|
||||||
|
|
||||||
|
const toggleMore = () => {
|
||||||
|
showAll.value = !showAll.value;
|
||||||
|
nextTick(() => {
|
||||||
|
setShellMetrics();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBlock = (blockId: string) => {
|
||||||
|
if (typeof props.toggleBlock === 'function') {
|
||||||
|
props.toggleBlock(blockId);
|
||||||
|
nextTick(() => {
|
||||||
|
setShellMetrics();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerThinking = (key: string, el: Element | null) => {
|
||||||
|
if (typeof props.registerThinkingRef === 'function') {
|
||||||
|
props.registerThinkingRef(key, el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThinkingScrollInternal = (blockId: string, event: Event) => {
|
||||||
|
if (typeof props.handleThinkingScroll === 'function') {
|
||||||
|
props.handleThinkingScroll(blockId, event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveEl = (target: any): HTMLElement | null => {
|
||||||
|
if (!target) return null;
|
||||||
|
if (target instanceof HTMLElement) return target;
|
||||||
|
if (target.$el && target.$el instanceof HTMLElement) return target.$el;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moreBaseHeight = () => {
|
||||||
|
const moreEl = resolveEl(moreBlock.value);
|
||||||
|
if (!moreEl) return 56;
|
||||||
|
const label = moreEl.querySelector('.more-title') as HTMLElement | null;
|
||||||
|
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
|
||||||
|
const innerEl = resolveEl(inner.value);
|
||||||
|
const shellEl = resolveEl(shell.value);
|
||||||
|
if (!shellEl || !innerEl) return;
|
||||||
|
|
||||||
|
const children = Array.from(innerEl.children) as HTMLElement[];
|
||||||
|
const heights: number[] = [];
|
||||||
|
const nextContentHeights: Record<string, number> = {};
|
||||||
|
|
||||||
|
children.forEach((el, idx) => {
|
||||||
|
const action = stackableActions.value[idx];
|
||||||
|
const key = blockKey(action, idx);
|
||||||
|
const header = el.querySelector('.collapsible-header') as HTMLElement | null;
|
||||||
|
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
|
||||||
|
const progress = el.querySelector('.progress-indicator') as HTMLElement | null;
|
||||||
|
const expanded = typeof expandedOverride[key] === 'boolean' ? expandedOverride[key] : isExpanded(action, idx);
|
||||||
|
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||||
|
const contentHeight = content ? Math.ceil(content.scrollHeight) : 0;
|
||||||
|
nextContentHeights[key] = contentHeight;
|
||||||
|
const contentH = expanded ? contentHeight : 0;
|
||||||
|
const progressH = progress ? progress.getBoundingClientRect().height : 0;
|
||||||
|
const borderH = idx < children.length - 1 ? parseFloat(getComputedStyle(el).borderBottomWidth) || 0 : 0;
|
||||||
|
heights.push(Math.ceil(headerH + contentH + progressH + borderH));
|
||||||
|
});
|
||||||
|
|
||||||
|
contentHeights.value = nextContentHeights;
|
||||||
|
|
||||||
|
const sum = (arr: number[]) => (arr.length ? arr.reduce((a, b) => a + b, 0) : 0);
|
||||||
|
const totalHeight = sum(heights);
|
||||||
|
const hiddenHeight = sum(heights.slice(0, Math.max(0, heights.length - VISIBLE_LIMIT)));
|
||||||
|
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
|
||||||
|
|
||||||
|
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
|
||||||
|
|
||||||
|
const targetShell = moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
|
||||||
|
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
|
||||||
|
|
||||||
|
shellHeight.value = targetShell;
|
||||||
|
innerOffset.value = targetOffset;
|
||||||
|
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let heightRaf: number | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const innerEl = resolveEl(inner.value);
|
||||||
|
setShellMetrics();
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (heightRaf) {
|
||||||
|
cancelAnimationFrame(heightRaf);
|
||||||
|
}
|
||||||
|
heightRaf = requestAnimationFrame(() => {
|
||||||
|
setShellMetrics();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (innerEl) {
|
||||||
|
resizeObserver.observe(innerEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
}
|
||||||
|
if (heightRaf) {
|
||||||
|
cancelAnimationFrame(heightRaf);
|
||||||
|
heightRaf = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([stackableActions, moreVisible], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
setShellMetrics();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
||||||
|
() => {
|
||||||
|
nextTick(() => setShellMetrics());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@ -207,6 +207,29 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="behavior-field">
|
||||||
|
<div class="behavior-field-header">
|
||||||
|
<span class="field-title">堆叠块显示</span>
|
||||||
|
<p class="field-desc">使用新版堆叠动画展示思考/工具块,超过 6 条自动收纳为“更多”。默认开启。</p>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="experiments.stackedBlocksEnabled"
|
||||||
|
@change="handleStackedBlocksToggle($event)"
|
||||||
|
/>
|
||||||
|
<span class="fancy-check" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||||
|
pathLength="575.0541381835938"
|
||||||
|
class="fancy-path"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>在对话区使用堆叠动画(可随时切换回传统列表)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="behavior-field">
|
<div class="behavior-field">
|
||||||
<div class="behavior-field-header">
|
<div class="behavior-field-header">
|
||||||
<span class="field-title">自动生成对话标题</span>
|
<span class="field-title">自动生成对话标题</span>
|
||||||
@ -218,6 +241,15 @@
|
|||||||
:checked="form.auto_generate_title"
|
:checked="form.auto_generate_title"
|
||||||
@change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })"
|
@change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })"
|
||||||
/>
|
/>
|
||||||
|
<span class="fancy-check" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||||
|
pathLength="575.0541381835938"
|
||||||
|
class="fancy-path"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span>使用快速模型为新对话生成含 emoji 的简短标题</span>
|
<span>使用快速模型为新对话生成含 emoji 的简短标题</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -295,6 +327,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-else-if="activeTab === 'theme'" key="theme" class="personal-page theme-page">
|
||||||
|
<div class="theme-section">
|
||||||
|
<div class="theme-header">
|
||||||
|
<div>
|
||||||
|
<h3>界面主题</h3>
|
||||||
|
<p>在浅色、深色和 Claude 经典之间一键切换。会立即应用并保存在本地。</p>
|
||||||
|
</div>
|
||||||
|
<div class="theme-badge">Beta</div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-grid">
|
||||||
|
<button
|
||||||
|
v-for="option in themeOptions"
|
||||||
|
:key="option.id"
|
||||||
|
type="button"
|
||||||
|
class="theme-card"
|
||||||
|
:class="{ active: activeTheme === option.id }"
|
||||||
|
@click.prevent="applyThemeOption(option.id)"
|
||||||
|
>
|
||||||
|
<div class="theme-card-head">
|
||||||
|
<div class="theme-dot-row">
|
||||||
|
<span
|
||||||
|
v-for="(color, idx) in option.swatches"
|
||||||
|
:key="`${option.id}-${idx}`"
|
||||||
|
class="theme-dot"
|
||||||
|
:style="{ background: color }"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span class="theme-check" v-if="activeTheme === option.id">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card-body">
|
||||||
|
<h4>{{ option.label }}</h4>
|
||||||
|
<p>{{ option.desc }}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="theme-note">虚拟显示器保持原样;仅主界面、侧边栏与个人空间配色随主题改变。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section v-else-if="activeTab === 'experiments'" key="experiments" class="personal-page experiment-page">
|
<section v-else-if="activeTab === 'experiments'" key="experiments" class="personal-page experiment-page">
|
||||||
<div class="experiment-hero">
|
<div class="experiment-hero">
|
||||||
<div class="experiment-visual" aria-hidden="true">
|
<div class="experiment-visual" aria-hidden="true">
|
||||||
@ -381,6 +452,8 @@ import { ref, computed } from 'vue';
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
import { useResourceStore } from '@/stores/resource';
|
import { useResourceStore } from '@/stores/resource';
|
||||||
|
import { useTheme } from '@/utils/theme';
|
||||||
|
import type { ThemeKey } from '@/utils/theme';
|
||||||
|
|
||||||
defineOptions({ name: 'PersonalizationDrawer' });
|
defineOptions({ name: 'PersonalizationDrawer' });
|
||||||
|
|
||||||
@ -406,10 +479,11 @@ const {
|
|||||||
const baseTabs = [
|
const baseTabs = [
|
||||||
{ id: 'preferences', label: '个性化设置' },
|
{ id: 'preferences', label: '个性化设置' },
|
||||||
{ id: 'behavior', label: '模型行为' },
|
{ id: 'behavior', label: '模型行为' },
|
||||||
|
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' },
|
||||||
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
|
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PersonalTab = 'preferences' | 'behavior' | 'experiments' | 'admin-monitor';
|
type PersonalTab = 'preferences' | 'behavior' | 'theme' | 'experiments' | 'admin-monitor';
|
||||||
|
|
||||||
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
|
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
|
||||||
|
|
||||||
@ -427,7 +501,6 @@ const swipeState = ref<{ startY: number; active: boolean }>({ startY: 0, active:
|
|||||||
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
|
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
|
||||||
|
|
||||||
const runModeOptions: Array<{ id: string; label: string; desc: string; value: RunModeValue; badge?: string }> = [
|
const runModeOptions: Array<{ id: string; label: string; desc: string; value: RunModeValue; badge?: string }> = [
|
||||||
{ id: 'auto', label: '跟随系统', desc: '沿用工作区默认设置', value: null },
|
|
||||||
{ id: 'fast', label: '快速模式', desc: '追求响应速度,跳过思考模型', value: 'fast' },
|
{ id: 'fast', label: '快速模式', desc: '追求响应速度,跳过思考模型', value: 'fast' },
|
||||||
{ id: 'thinking', label: '思考模式', desc: '首轮回复会先输出思考过程', value: 'thinking', badge: '推荐' },
|
{ id: 'thinking', label: '思考模式', desc: '首轮回复会先输出思考过程', value: 'thinking', badge: '推荐' },
|
||||||
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
|
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
|
||||||
@ -520,10 +593,48 @@ const handleLiquidGlassToggle = (event: Event) => {
|
|||||||
personalization.setLiquidGlassExperimentEnabled(!!target?.checked);
|
personalization.setLiquidGlassExperimentEnabled(!!target?.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStackedBlocksToggle = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
personalization.setStackedBlocksEnabled(!!target?.checked);
|
||||||
|
};
|
||||||
|
|
||||||
const openAdminPanel = () => {
|
const openAdminPanel = () => {
|
||||||
window.open('/admin/monitor', '_blank', 'noopener');
|
window.open('/admin/monitor', '_blank', 'noopener');
|
||||||
personalization.closeDrawer();
|
personalization.closeDrawer();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== 主题切换 =====
|
||||||
|
import { useTheme } from '@/utils/theme';
|
||||||
|
import type { ThemeKey } from '@/utils/theme';
|
||||||
|
|
||||||
|
const { setTheme, loadTheme } = useTheme();
|
||||||
|
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
|
||||||
|
{
|
||||||
|
id: 'claude',
|
||||||
|
label: 'Claude 经典',
|
||||||
|
desc: '仿 Claude 的米色质感,柔和高对比',
|
||||||
|
swatches: ['#eeece2', '#f7f3ea', '#da7756']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'light',
|
||||||
|
label: '浅灰 · 明亮',
|
||||||
|
desc: '浅灰 + 白,清晰对比适合日间工作',
|
||||||
|
swatches: ['#f4f5f7', '#ffffff', '#4f8bff']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dark',
|
||||||
|
label: '深灰 · 夜间',
|
||||||
|
desc: '深灰 + 黑,低亮度并保持彩色点缀',
|
||||||
|
swatches: ['#0f1115', '#1d2230', '#5ad1c9']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeTheme = ref<ThemeKey>(loadTheme());
|
||||||
|
|
||||||
|
const applyThemeOption = (theme: ThemeKey) => {
|
||||||
|
activeTheme.value = theme;
|
||||||
|
setTheme(theme);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -4,9 +4,11 @@ import App from './App.vue';
|
|||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import 'prismjs/themes/prism.css';
|
import 'prismjs/themes/prism.css';
|
||||||
import './styles/index.scss';
|
import './styles/index.scss';
|
||||||
|
import { installTheme } from './utils/theme';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
installTheme();
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@ -23,6 +23,7 @@ interface LiquidGlassPosition {
|
|||||||
interface ExperimentState {
|
interface ExperimentState {
|
||||||
liquidGlassEnabled: boolean;
|
liquidGlassEnabled: boolean;
|
||||||
liquidGlassPosition: LiquidGlassPosition | null;
|
liquidGlassPosition: LiquidGlassPosition | null;
|
||||||
|
stackedBlocksEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersonalizationState {
|
interface PersonalizationState {
|
||||||
@ -65,7 +66,8 @@ const defaultForm = (): PersonalForm => ({
|
|||||||
|
|
||||||
const defaultExperimentState = (): ExperimentState => ({
|
const defaultExperimentState = (): ExperimentState => ({
|
||||||
liquidGlassEnabled: false,
|
liquidGlassEnabled: false,
|
||||||
liquidGlassPosition: null
|
liquidGlassPosition: null,
|
||||||
|
stackedBlocksEnabled: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const isValidPosition = (value: any): value is LiquidGlassPosition => {
|
const isValidPosition = (value: any): value is LiquidGlassPosition => {
|
||||||
@ -91,7 +93,9 @@ const loadExperimentState = (): ExperimentState => {
|
|||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return {
|
return {
|
||||||
liquidGlassEnabled: Boolean(parsed?.liquidGlassEnabled),
|
liquidGlassEnabled: Boolean(parsed?.liquidGlassEnabled),
|
||||||
liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null
|
liquidGlassPosition: isValidPosition(parsed?.liquidGlassPosition) ? parsed?.liquidGlassPosition : null,
|
||||||
|
stackedBlocksEnabled:
|
||||||
|
typeof parsed?.stackedBlocksEnabled === 'boolean' ? parsed.stackedBlocksEnabled : defaultExperimentState().stackedBlocksEnabled
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('无法读取实验功能设置:', error);
|
console.warn('无法读取实验功能设置:', error);
|
||||||
@ -452,6 +456,16 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
toggleLiquidGlassExperiment() {
|
toggleLiquidGlassExperiment() {
|
||||||
this.setLiquidGlassExperimentEnabled(!this.experiments.liquidGlassEnabled);
|
this.setLiquidGlassExperimentEnabled(!this.experiments.liquidGlassEnabled);
|
||||||
},
|
},
|
||||||
|
setStackedBlocksEnabled(enabled: boolean) {
|
||||||
|
this.experiments = {
|
||||||
|
...this.experiments,
|
||||||
|
stackedBlocksEnabled: !!enabled
|
||||||
|
};
|
||||||
|
this.persistExperiments();
|
||||||
|
},
|
||||||
|
toggleStackedBlocks() {
|
||||||
|
this.setStackedBlocksEnabled(!this.experiments.stackedBlocksEnabled);
|
||||||
|
},
|
||||||
updateLiquidGlassPosition(position: LiquidGlassPosition | null) {
|
updateLiquidGlassPosition(position: LiquidGlassPosition | null) {
|
||||||
this.experiments = {
|
this.experiments = {
|
||||||
...this.experiments,
|
...this.experiments,
|
||||||
|
|||||||
@ -13,6 +13,11 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] {
|
||||||
|
background: var(--claude-bg);
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Global icon utility */
|
/* Global icon utility */
|
||||||
.icon {
|
.icon {
|
||||||
--icon-size: 1em;
|
--icon-size: 1em;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/* CSS variables + shared design tokens */
|
/* CSS variables + shared design tokens */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
--app-viewport: 100vh;
|
--app-viewport: 100vh;
|
||||||
--app-bottom-inset: env(safe-area-inset-bottom, 0px);
|
--app-bottom-inset: env(safe-area-inset-bottom, 0px);
|
||||||
--claude-bg: #eeece2;
|
--claude-bg: #eeece2;
|
||||||
@ -8,8 +9,10 @@
|
|||||||
--claude-left-rail: #f7f3ea;
|
--claude-left-rail: #f7f3ea;
|
||||||
--claude-sidebar: rgba(255, 255, 255, 0.68);
|
--claude-sidebar: rgba(255, 255, 255, 0.68);
|
||||||
--claude-border: rgba(118, 103, 84, 0.25);
|
--claude-border: rgba(118, 103, 84, 0.25);
|
||||||
|
--claude-border-strong: rgba(118, 103, 84, 0.35);
|
||||||
--claude-text: #3d3929;
|
--claude-text: #3d3929;
|
||||||
--claude-text-secondary: #7f7766;
|
--claude-text-secondary: #7f7766;
|
||||||
|
--claude-text-tertiary: #a59a86;
|
||||||
--claude-muted: rgba(121, 109, 94, 0.4);
|
--claude-muted: rgba(121, 109, 94, 0.4);
|
||||||
--claude-accent: #da7756;
|
--claude-accent: #da7756;
|
||||||
--claude-accent-strong: #bd5d3a;
|
--claude-accent-strong: #bd5d3a;
|
||||||
@ -21,4 +24,149 @@
|
|||||||
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
|
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
|
||||||
--claude-success: #76b086;
|
--claude-success: #76b086;
|
||||||
--claude-warning: #d99845;
|
--claude-warning: #d99845;
|
||||||
|
/* Theme-neutral surfaces */
|
||||||
|
--theme-surface-card: #fffaf4;
|
||||||
|
--theme-surface-strong: #ffffff;
|
||||||
|
--theme-surface-soft: rgba(255, 255, 255, 0.92);
|
||||||
|
--theme-surface-muted: rgba(255, 255, 255, 0.85);
|
||||||
|
--theme-overlay-scrim: rgba(33, 24, 14, 0.55);
|
||||||
|
--theme-shadow-strong: 0 28px 60px rgba(38, 28, 18, 0.25);
|
||||||
|
--theme-shadow-soft: 0 12px 24px rgba(38, 28, 18, 0.12);
|
||||||
|
--theme-shadow-mid: 0 20px 45px rgba(16, 24, 40, 0.08);
|
||||||
|
--theme-control-border: rgba(118, 103, 84, 0.25);
|
||||||
|
--theme-control-border-strong: rgba(118, 103, 84, 0.35);
|
||||||
|
--theme-switch-track: #d7d1c5;
|
||||||
|
--theme-chip-bg: rgba(118, 103, 84, 0.08);
|
||||||
|
--theme-chip-border: rgba(118, 103, 84, 0.2);
|
||||||
|
--theme-badge-bg: rgba(118, 103, 84, 0.12);
|
||||||
|
--theme-tab-active: rgba(189, 93, 58, 0.12);
|
||||||
|
--theme-mobile-menu: rgba(255, 255, 255, 0.35);
|
||||||
|
--theme-mobile-menu-shadow: 0 12px 30px rgba(38, 28, 18, 0.15);
|
||||||
|
--theme-card-border-strong: rgba(118, 103, 84, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='claude'] {
|
||||||
|
color-scheme: light;
|
||||||
|
--claude-bg: #eeece2;
|
||||||
|
--claude-panel: rgba(255, 255, 255, 0.82);
|
||||||
|
--claude-left-rail: #f7f3ea;
|
||||||
|
--claude-sidebar: rgba(255, 255, 255, 0.68);
|
||||||
|
--claude-border: rgba(118, 103, 84, 0.25);
|
||||||
|
--claude-border-strong: rgba(118, 103, 84, 0.35);
|
||||||
|
--claude-text: #3d3929;
|
||||||
|
--claude-text-secondary: #7f7766;
|
||||||
|
--claude-text-tertiary: #a59a86;
|
||||||
|
--claude-muted: rgba(121, 109, 94, 0.4);
|
||||||
|
--claude-accent: #da7756;
|
||||||
|
--claude-accent-strong: #bd5d3a;
|
||||||
|
--claude-deep: #f2a93b;
|
||||||
|
--claude-deep-strong: #d07a14;
|
||||||
|
--claude-highlight: rgba(218, 119, 86, 0.14);
|
||||||
|
--claude-button-hover: #c76541;
|
||||||
|
--claude-button-active: #a95331;
|
||||||
|
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
|
||||||
|
--claude-success: #76b086;
|
||||||
|
--claude-warning: #d99845;
|
||||||
|
--theme-surface-card: #fffaf4;
|
||||||
|
--theme-surface-strong: #ffffff;
|
||||||
|
--theme-surface-soft: rgba(255, 255, 255, 0.92);
|
||||||
|
--theme-surface-muted: rgba(255, 255, 255, 0.85);
|
||||||
|
--theme-overlay-scrim: rgba(33, 24, 14, 0.55);
|
||||||
|
--theme-shadow-strong: 0 28px 60px rgba(38, 28, 18, 0.25);
|
||||||
|
--theme-shadow-soft: 0 12px 24px rgba(38, 28, 18, 0.12);
|
||||||
|
--theme-shadow-mid: 0 20px 45px rgba(16, 24, 40, 0.08);
|
||||||
|
--theme-control-border: rgba(118, 103, 84, 0.25);
|
||||||
|
--theme-control-border-strong: rgba(118, 103, 84, 0.35);
|
||||||
|
--theme-switch-track: #d7d1c5;
|
||||||
|
--theme-chip-bg: rgba(118, 103, 84, 0.08);
|
||||||
|
--theme-chip-border: rgba(118, 103, 84, 0.2);
|
||||||
|
--theme-badge-bg: rgba(118, 103, 84, 0.12);
|
||||||
|
--theme-tab-active: rgba(189, 93, 58, 0.12);
|
||||||
|
--theme-mobile-menu: rgba(255, 255, 255, 0.35);
|
||||||
|
--theme-mobile-menu-shadow: 0 12px 30px rgba(38, 28, 18, 0.15);
|
||||||
|
--theme-card-border-strong: rgba(118, 103, 84, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
--claude-bg: #f5f5f4;
|
||||||
|
--claude-panel: rgba(255, 255, 255, 0.97);
|
||||||
|
--claude-left-rail: #faf9f7;
|
||||||
|
--claude-sidebar: rgba(255, 255, 255, 0.94);
|
||||||
|
--claude-border: rgba(26, 27, 30, 0.12);
|
||||||
|
--claude-border-strong: rgba(26, 27, 30, 0.2);
|
||||||
|
--claude-text: #1b1c1f;
|
||||||
|
--claude-text-secondary: #3c3f46;
|
||||||
|
--claude-text-tertiary: #5a5f69;
|
||||||
|
--claude-muted: rgba(27, 28, 31, 0.28);
|
||||||
|
--claude-accent: #4a4f58;
|
||||||
|
--claude-accent-strong: #2f343d;
|
||||||
|
--claude-deep: #6b6f78;
|
||||||
|
--claude-deep-strong: #4d515a;
|
||||||
|
--claude-highlight: rgba(74, 79, 88, 0.12);
|
||||||
|
--claude-button-hover: #3d424b;
|
||||||
|
--claude-button-active: #2f343c;
|
||||||
|
--claude-shadow: 0 16px 40px rgba(27, 28, 31, 0.12);
|
||||||
|
--claude-success: #3f6f4f;
|
||||||
|
--claude-warning: #7a6a3a;
|
||||||
|
--theme-surface-card: #ffffff;
|
||||||
|
--theme-surface-strong: #ffffff;
|
||||||
|
--theme-surface-soft: rgba(255, 255, 255, 0.98);
|
||||||
|
--theme-surface-muted: rgba(255, 255, 255, 0.94);
|
||||||
|
--theme-overlay-scrim: rgba(17, 16, 14, 0.45);
|
||||||
|
--theme-shadow-strong: 0 32px 72px rgba(17, 16, 14, 0.18);
|
||||||
|
--theme-shadow-soft: 0 18px 42px rgba(17, 16, 14, 0.12);
|
||||||
|
--theme-shadow-mid: 0 20px 45px rgba(17, 16, 14, 0.1);
|
||||||
|
--theme-control-border: rgba(26, 27, 30, 0.12);
|
||||||
|
--theme-control-border-strong: rgba(26, 27, 30, 0.2);
|
||||||
|
--theme-switch-track: #dededd;
|
||||||
|
--theme-chip-bg: rgba(74, 79, 88, 0.08);
|
||||||
|
--theme-chip-border: rgba(74, 79, 88, 0.18);
|
||||||
|
--theme-badge-bg: rgba(74, 79, 88, 0.14);
|
||||||
|
--theme-tab-active: rgba(74, 79, 88, 0.12);
|
||||||
|
--theme-mobile-menu: rgba(255, 255, 255, 0.88);
|
||||||
|
--theme-mobile-menu-shadow: 0 14px 32px rgba(17, 16, 14, 0.16);
|
||||||
|
--theme-card-border-strong: rgba(26, 27, 30, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--claude-bg: #08090d;
|
||||||
|
--claude-panel: rgba(16, 17, 23, 0.92);
|
||||||
|
--claude-left-rail: #0b0c11;
|
||||||
|
--claude-sidebar: rgba(13, 14, 19, 0.94);
|
||||||
|
--claude-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--claude-border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--claude-text: #f8f9fc;
|
||||||
|
--claude-text-secondary: #d7dbe8;
|
||||||
|
--claude-text-tertiary: #aeb6c7;
|
||||||
|
--claude-muted: rgba(248, 249, 252, 0.22);
|
||||||
|
--claude-accent: #62e0d2;
|
||||||
|
--claude-accent-strong: #3bb8aa;
|
||||||
|
--claude-deep: #8cc7ff;
|
||||||
|
--claude-deep-strong: #5b9fe6;
|
||||||
|
--claude-highlight: rgba(98, 224, 210, 0.2);
|
||||||
|
--claude-button-hover: #4ac8ba;
|
||||||
|
--claude-button-active: #37aa9e;
|
||||||
|
--claude-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
--claude-success: #82e8b3;
|
||||||
|
--claude-warning: #f0c76a;
|
||||||
|
--theme-surface-card: #0f1117;
|
||||||
|
--theme-surface-strong: #141823;
|
||||||
|
--theme-surface-soft: rgba(20, 24, 35, 0.95);
|
||||||
|
--theme-surface-muted: rgba(16, 19, 28, 0.92);
|
||||||
|
--theme-overlay-scrim: rgba(0, 0, 0, 0.72);
|
||||||
|
--theme-shadow-strong: 0 32px 80px rgba(0, 0, 0, 0.7);
|
||||||
|
--theme-shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
--theme-shadow-mid: 0 22px 50px rgba(0, 0, 0, 0.5);
|
||||||
|
--theme-control-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--theme-control-border-strong: rgba(255, 255, 255, 0.16);
|
||||||
|
--theme-switch-track: #262b34;
|
||||||
|
--theme-chip-bg: rgba(98, 224, 210, 0.14);
|
||||||
|
--theme-chip-border: rgba(98, 224, 210, 0.26);
|
||||||
|
--theme-badge-bg: rgba(98, 224, 210, 0.18);
|
||||||
|
--theme-tab-active: rgba(98, 224, 210, 0.16);
|
||||||
|
--theme-mobile-menu: rgba(16, 19, 28, 0.78);
|
||||||
|
--theme-mobile-menu-shadow: 0 18px 40px rgba(0, 0, 0, 0.62);
|
||||||
|
--theme-card-border-strong: rgba(255, 255, 255, 0.16);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -265,7 +265,9 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition:
|
||||||
|
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.26s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible-block.expanded .collapsible-content {
|
.collapsible-block.expanded .collapsible-content {
|
||||||
@ -279,6 +281,11 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--claude-text-secondary);
|
color: var(--claude-text-secondary);
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-inner::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-item {
|
.action-item {
|
||||||
@ -299,6 +306,131 @@
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stacked-blocks-wrapper {
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-shell {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--claude-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--claude-card);
|
||||||
|
box-shadow: var(--claude-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
|
||||||
|
padding-top 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-viewport {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-item {
|
||||||
|
border-bottom: 1px solid var(--claude-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-block {
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-more-block {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
background: var(--claude-card);
|
||||||
|
border-bottom: 0 solid var(--claude-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 22px;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
transition:
|
||||||
|
height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
|
||||||
|
padding 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
|
||||||
|
border-bottom-width 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
|
||||||
|
opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-more-block.visible {
|
||||||
|
opacity: 1;
|
||||||
|
padding: 14px 22px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-desc {
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-enter-active {
|
||||||
|
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-move {
|
||||||
|
transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-block .collapsible-content {
|
||||||
|
transition:
|
||||||
|
max-height 280ms cubic-bezier(0.25, 0.9, 0.3, 1),
|
||||||
|
opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
.progress-indicator {
|
.progress-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
.personal-page-overlay {
|
.personal-page-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(33, 24, 14, 0.55);
|
background: var(--theme-overlay-scrim);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -16,10 +16,10 @@
|
|||||||
width: min(96vw, 1020px);
|
width: min(96vw, 1020px);
|
||||||
height: calc(100vh - 40px);
|
height: calc(100vh - 40px);
|
||||||
max-height: 760px;
|
max-height: 760px;
|
||||||
background: #fffaf4;
|
background: var(--theme-surface-card);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
border: 1px solid var(--theme-control-border);
|
||||||
box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25);
|
box-shadow: var(--theme-shadow-strong);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--claude-text);
|
color: var(--claude-text);
|
||||||
@ -76,8 +76,8 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.35);
|
border: 1px solid var(--theme-control-border-strong);
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: var(--theme-surface-soft);
|
||||||
color: var(--claude-text);
|
color: var(--claude-text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -86,8 +86,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.personal-page-logout:hover {
|
.personal-page-logout:hover {
|
||||||
background: #fff;
|
background: var(--theme-surface-strong);
|
||||||
box-shadow: 0 12px 24px rgba(38, 28, 18, 0.12);
|
box-shadow: var(--theme-shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.personalization-body {
|
.personalization-body {
|
||||||
@ -107,8 +107,8 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: var(--theme-surface-muted);
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid var(--theme-control-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-text {
|
.toggle-text {
|
||||||
@ -141,7 +141,7 @@
|
|||||||
.switch-slider {
|
.switch-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: #d7d1c5;
|
background-color: var(--theme-switch-track);
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
@ -170,6 +170,81 @@
|
|||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--theme-control-border);
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row:hover {
|
||||||
|
border-color: var(--theme-control-border);
|
||||||
|
box-shadow: none;
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row:focus-within {
|
||||||
|
border-color: var(--theme-control-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row .fancy-check {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row .fancy-check svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fancy-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--claude-text-secondary);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
transition: stroke-dasharray 0.5s ease, stroke-dashoffset 0.5s ease, stroke 0.2s ease;
|
||||||
|
stroke-dasharray: 241 9999999;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row input:checked + .fancy-check {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row input:checked + .fancy-check .fancy-path {
|
||||||
|
stroke: var(--claude-accent);
|
||||||
|
stroke-dasharray: 70.5096664428711 9999999;
|
||||||
|
stroke-dashoffset: -262.2723388671875;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row span {
|
||||||
|
color: var(--claude-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.personalization-layout {
|
.personalization-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 200px minmax(0, 1fr);
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
@ -186,8 +261,8 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid var(--theme-control-border);
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: var(--theme-surface-soft);
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
@ -217,9 +292,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.personal-tab-button.active {
|
.personal-tab-button.active {
|
||||||
background: rgba(189, 93, 58, 0.12);
|
background: var(--theme-tab-active);
|
||||||
color: var(--claude-accent);
|
color: var(--claude-accent);
|
||||||
box-shadow: 0 10px 24px rgba(189, 93, 58, 0.15);
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-tab-button:focus-visible {
|
.personal-tab-button:focus-visible {
|
||||||
@ -239,8 +314,8 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.18);
|
border: 1px solid var(--theme-control-border);
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: var(--theme-surface-soft);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -279,10 +354,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: var(--theme-surface-muted);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.18);
|
border: 1px solid var(--theme-control-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.behavior-field {
|
.behavior-field {
|
||||||
@ -310,9 +385,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: var(--theme-surface-soft);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.18);
|
border: 1px solid var(--theme-control-border);
|
||||||
padding: 28px 30px;
|
padding: 28px 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,10 +482,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid var(--theme-control-border);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: var(--theme-surface-muted);
|
||||||
box-shadow: 0 20px 45px rgba(16, 24, 40, 0.08);
|
box-shadow: var(--theme-shadow-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.experiment-toggle-info h4 {
|
.experiment-toggle-info h4 {
|
||||||
@ -430,8 +505,8 @@
|
|||||||
.experiment-note {
|
.experiment-note {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 18px 22px;
|
padding: 18px 22px;
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: var(--theme-surface-muted);
|
||||||
border: 1px dashed rgba(118, 103, 84, 0.3);
|
border: 1px dashed var(--theme-control-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.experiment-note p {
|
.experiment-note p {
|
||||||
@ -601,9 +676,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.run-mode-card {
|
.run-mode-card {
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid var(--theme-control-border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: var(--theme-surface-soft);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -616,14 +691,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.run-mode-card:hover {
|
.run-mode-card:hover {
|
||||||
border-color: rgba(118, 103, 84, 0.4);
|
border-color: var(--claude-accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.run-mode-card.active {
|
.run-mode-card.active {
|
||||||
border-color: var(--claude-accent);
|
border-color: var(--claude-accent);
|
||||||
background: rgba(118, 103, 84, 0.08);
|
background: var(--theme-tab-active);
|
||||||
box-shadow: 0 8px 20px rgba(118, 103, 84, 0.18);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.run-mode-card-header {
|
.run-mode-card-header {
|
||||||
@ -643,7 +718,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--claude-accent);
|
color: var(--claude-accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(118, 103, 84, 0.12);
|
background: var(--theme-badge-bg);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
@ -663,10 +738,10 @@
|
|||||||
.thinking-presets button {
|
.thinking-presets button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid var(--theme-control-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #fff;
|
background: var(--theme-surface-strong);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -678,7 +753,7 @@
|
|||||||
.thinking-presets button.active {
|
.thinking-presets button.active {
|
||||||
border-color: var(--claude-accent);
|
border-color: var(--claude-accent);
|
||||||
color: var(--claude-accent);
|
color: var(--claude-accent);
|
||||||
background: rgba(118, 103, 84, 0.08);
|
background: var(--theme-tab-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking-presets button small {
|
.thinking-presets button small {
|
||||||
@ -704,7 +779,7 @@
|
|||||||
width: 120px;
|
width: 120px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
border: 1px solid var(--theme-control-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking-hint {
|
.thinking-hint {
|
||||||
@ -737,8 +812,8 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(118, 103, 84, 0.08);
|
background: var(--theme-chip-bg);
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid var(--theme-chip-border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -752,6 +827,128 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================= */
|
||||||
|
/* 主题切换 */
|
||||||
|
/* ========================================= */
|
||||||
|
|
||||||
|
.theme-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
background: var(--theme-surface-soft);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--theme-control-border);
|
||||||
|
padding: 22px 24px 26px;
|
||||||
|
box-shadow: var(--theme-shadow-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-header h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-badge {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--theme-badge-bg);
|
||||||
|
color: var(--claude-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card {
|
||||||
|
border: 1px solid var(--theme-control-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--theme-surface-strong);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:hover {
|
||||||
|
border-color: var(--claude-accent);
|
||||||
|
box-shadow: var(--theme-shadow-soft);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card.active {
|
||||||
|
border-color: var(--claude-accent);
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dot-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-check {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--claude-accent);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-body h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================= */
|
/* ========================================= */
|
||||||
/* 移动端面板入口 */
|
/* 移动端面板入口 */
|
||||||
/* ========================================= */
|
/* ========================================= */
|
||||||
@ -815,9 +1012,9 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: var(--theme-mobile-menu);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-shadow: 0 12px 30px rgba(38, 28, 18, 0.15);
|
box-shadow: var(--theme-mobile-menu-shadow);
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -878,7 +1075,7 @@
|
|||||||
.mobile-panel-overlay {
|
.mobile-panel-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(16, 11, 7, 0.45);
|
background: var(--theme-overlay-scrim);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1009,7 +1206,7 @@
|
|||||||
.confirm-overlay {
|
.confirm-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(33, 24, 14, 0.5);
|
background: var(--theme-overlay-scrim);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1022,11 +1219,11 @@
|
|||||||
|
|
||||||
.confirm-modal {
|
.confirm-modal {
|
||||||
width: min(360px, 100%);
|
width: min(360px, 100%);
|
||||||
background: var(--claude-bg);
|
background: var(--theme-surface-card);
|
||||||
border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35));
|
border: 1px solid var(--theme-control-border-strong);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-shadow: 0 18px 40px rgba(61, 57, 41, 0.22);
|
box-shadow: var(--theme-shadow-soft);
|
||||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1049,7 +1246,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-button {
|
.confirm-button {
|
||||||
border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35));
|
border: 1px solid var(--theme-control-border-strong);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--claude-text);
|
color: var(--claude-text);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -1115,8 +1312,8 @@
|
|||||||
.personal-section {
|
.personal-section {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: var(--theme-surface-soft);
|
||||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
border: 1px solid var(--theme-control-border);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1157,7 +1354,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.personal-section.personal-considerations .consideration-item {
|
.personal-section.personal-considerations .consideration-item {
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: var(--theme-surface-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
@ -1185,8 +1382,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.4);
|
border: 1px solid var(--theme-control-border-strong);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: var(--theme-surface-soft);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1213,8 +1410,8 @@
|
|||||||
.tone-preset-buttons button {
|
.tone-preset-buttons button {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(118, 103, 84, 0.4);
|
border: 1px solid var(--theme-control-border);
|
||||||
background: #fff;
|
background: var(--theme-surface-strong);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@ -1266,8 +1463,8 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px dashed rgba(118, 103, 84, 0.5);
|
border: 1px dashed var(--theme-control-border-strong);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: var(--theme-surface-soft);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
static/src/utils/theme.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
type ThemeKey = 'claude' | 'light' | 'dark';
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'agents_ui_theme';
|
||||||
|
|
||||||
|
const applyTheme = (theme: ThemeKey) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTheme = (): ThemeKey => {
|
||||||
|
if (typeof window === 'undefined') return 'claude';
|
||||||
|
const saved = window.localStorage.getItem(THEME_STORAGE_KEY) as ThemeKey | null;
|
||||||
|
if (saved === 'light' || saved === 'dark' || saved === 'claude') return saved;
|
||||||
|
return 'claude';
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistTheme = (theme: ThemeKey) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installTheme = () => {
|
||||||
|
const theme = loadTheme();
|
||||||
|
applyTheme(theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const setTheme = (theme: ThemeKey) => {
|
||||||
|
applyTheme(theme);
|
||||||
|
persistTheme(theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = () => applyTheme(loadTheme());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { setTheme, restore, loadTheme };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { ThemeKey };
|
||||||