chore: snapshot before stacked blocks demo

This commit is contained in:
JOJO 2026-01-01 01:09:24 +08:00
parent 7639e0677b
commit 93304bd2b8
36 changed files with 2981 additions and 56 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
demo/stacked-blocks/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
demo/stacked-blocks/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
demo/stacked-blocks/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
demo/stacked-blocks/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
demo/stacked-blocks/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
demo/stacked-blocks/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View 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>

View 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();
})();

View 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>

View 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>

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

112
static/demo/style/1.html Normal file
View 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
View 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
View 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>

Binary file not shown.

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -295,6 +295,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 +420,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 +447,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');
@ -524,6 +566,39 @@ 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>

View File

@ -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');

View File

@ -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;

View File

@ -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);
} }

View File

@ -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;
} }
@ -186,8 +186,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 +217,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 +239,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 +279,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 +310,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 +407,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 +430,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 +601,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 +616,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 +643,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 +663,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 +678,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 +704,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 +737,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 +752,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 +937,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 +1000,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 +1131,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 +1144,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 +1171,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 +1237,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 +1279,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 +1307,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 +1335,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 +1388,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
View 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 };