- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」 - 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content - 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类 - 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
193 lines
6.7 KiB
JavaScript
193 lines
6.7 KiB
JavaScript
/* ============================================================
|
||
待办事项悬浮窗 Demo 交互逻辑
|
||
三组动画:
|
||
1. 创建:逐行从左向右进入(stagger)
|
||
2. 完成:横线从左往右划过 + 文字变灰
|
||
3. 更新:旧行逐行向左移出(高度收拢)→ 新行逐行进入
|
||
============================================================ */
|
||
|
||
const ENTER_STAGGER = 60; // 进入动画每行间隔 ms
|
||
const LEAVE_STAGGER = 45; // 移出动画每行间隔 ms
|
||
const LEAVE_DURATION = 260; // 单行移出动画时长 ms
|
||
|
||
const listEl = document.getElementById('todoList');
|
||
const windowEl = document.getElementById('todoWindow');
|
||
const counterEl = document.getElementById('todoCounter');
|
||
|
||
const state = { items: [], busy: false };
|
||
|
||
/* ---------------- 演示数据 ---------------- */
|
||
|
||
const SCENARIOS = {
|
||
initial: [
|
||
{ text: '梳理对话区布局,确定悬浮窗挂载位置' },
|
||
{ text: '实现悬浮窗容器:圆角、最小 / 最大宽度' },
|
||
{ text: '待办行直接铺在窗口上,去掉嵌套卡片' },
|
||
{ text: '编写创建 / 完成 / 更新三组动画' },
|
||
{ text: '联调后端 todo 事件,接入真实数据' },
|
||
],
|
||
updated: [
|
||
{ text: '复核悬浮窗最大 / 最小宽度表现', done: true },
|
||
{ text: '把划线动画时序再调顺手一点' },
|
||
{ text: '超长任务名省略号验证(这一条故意写得特别特别长来测试溢出)' },
|
||
{ text: '设计文件编辑记录窗口的数据结构' },
|
||
{ text: '子智能体与后台指令窗口渲染方案' },
|
||
{ text: '三种主题下核对一遍配色' },
|
||
],
|
||
long: [
|
||
{ text: '拉取最新代码并跑一遍冒烟测试' },
|
||
{ text: '检查待办窗口在窄屏下的最小宽度' },
|
||
{ text: '把完成动画的横线改成从左向右' },
|
||
{ text: '补五条以上时的内部滚动' },
|
||
{ text: '写一条特别长的任务用来验证文字溢出时省略号效果到底行不行' },
|
||
{ text: '同步更新 AGENTS.md 的布局说明' },
|
||
{ text: '给悬浮窗预留多窗口上下排布的间距' },
|
||
{ text: '收拾 demo 控制台,准备演示' },
|
||
],
|
||
};
|
||
|
||
/* ---------------- 工具 ---------------- */
|
||
|
||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
function updateCounter() {
|
||
const done = state.items.filter((t) => t.done).length;
|
||
counterEl.textContent = `${done}/${state.items.length}`;
|
||
}
|
||
|
||
function ensureWindowVisible() {
|
||
if (!windowEl.hidden) return;
|
||
windowEl.hidden = false;
|
||
windowEl.classList.add('window-enter');
|
||
windowEl.addEventListener('animationend', () => windowEl.classList.remove('window-enter'), { once: true });
|
||
}
|
||
|
||
/* ---------------- 渲染 ---------------- */
|
||
|
||
function renderTodos(items) {
|
||
listEl.innerHTML = '';
|
||
items.forEach((item, i) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'todo-item is-entering' + (item.done ? ' is-done' : '');
|
||
li.style.animationDelay = `${i * ENTER_STAGGER}ms`;
|
||
|
||
const dot = document.createElement('span');
|
||
dot.className = 'todo-dot';
|
||
const text = document.createElement('span');
|
||
text.className = 'todo-text';
|
||
text.textContent = item.text;
|
||
|
||
li.append(dot, text);
|
||
li.addEventListener('animationend', () => {
|
||
li.classList.remove('is-entering');
|
||
li.style.animationDelay = '';
|
||
}, { once: true });
|
||
|
||
listEl.appendChild(li);
|
||
});
|
||
updateCounter();
|
||
}
|
||
|
||
/* 让当前列表逐行向左移出,返回动画总耗时 */
|
||
async function playLeaveAnimation() {
|
||
const rows = [...listEl.children];
|
||
if (!rows.length) return;
|
||
rows.forEach((li, i) => {
|
||
li.style.animationDelay = `${i * LEAVE_STAGGER}ms`;
|
||
li.classList.add('is-leaving');
|
||
});
|
||
await wait(rows.length * LEAVE_STAGGER + LEAVE_DURATION + 20);
|
||
}
|
||
|
||
/* ---------------- 三个核心动作 ---------------- */
|
||
|
||
/* 1. 创建 / 整表替换(收到新 todo 列表) */
|
||
async function replaceTodos(newItems) {
|
||
if (state.busy) return;
|
||
state.busy = true;
|
||
|
||
await playLeaveAnimation(); // 旧列表逐行移出
|
||
state.items = newItems.map((t) => ({ text: t.text, done: !!t.done }));
|
||
ensureWindowVisible();
|
||
renderTodos(state.items); // 新列表逐行进入
|
||
|
||
state.busy = false;
|
||
}
|
||
|
||
/* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */
|
||
function scrollRowIntoView(li) {
|
||
return new Promise((resolve) => {
|
||
const listRect = listEl.getBoundingClientRect();
|
||
const liRect = li.getBoundingClientRect();
|
||
const fullyVisible = liRect.top >= listRect.top && liRect.bottom <= listRect.bottom;
|
||
if (fullyVisible) { resolve(); return; }
|
||
|
||
let done = false;
|
||
let timer;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
clearTimeout(timer);
|
||
listEl.removeEventListener('scroll', onScroll);
|
||
resolve();
|
||
};
|
||
const onScroll = () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(finish, 90); // 滚动停止 90ms 后认为到位
|
||
};
|
||
listEl.addEventListener('scroll', onScroll);
|
||
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
setTimeout(finish, 800); // 兜底,避免意外卡死
|
||
});
|
||
}
|
||
|
||
/* 2. 完成下一项(先滚动到可见 → 再划线动画) */
|
||
async function completeNext() {
|
||
if (state.busy) return;
|
||
const idx = state.items.findIndex((t) => !t.done);
|
||
if (idx === -1) return;
|
||
|
||
state.busy = true;
|
||
const li = listEl.children[idx];
|
||
if (li) await scrollRowIntoView(li); // 未完全显示的先滚出来
|
||
|
||
state.items[idx].done = true;
|
||
if (li) {
|
||
li.classList.add('is-done', 'just-done');
|
||
setTimeout(() => li.classList.remove('just-done'), 400);
|
||
}
|
||
updateCounter();
|
||
state.busy = false;
|
||
}
|
||
|
||
/* 3. 清空(移出后隐藏窗口) */
|
||
async function clearTodos() {
|
||
if (state.busy || !state.items.length) return;
|
||
state.busy = true;
|
||
|
||
await playLeaveAnimation();
|
||
state.items = [];
|
||
renderTodos(state.items);
|
||
|
||
windowEl.classList.add('window-leave');
|
||
windowEl.addEventListener('animationend', () => {
|
||
windowEl.classList.remove('window-leave');
|
||
windowEl.hidden = true;
|
||
}, { once: true });
|
||
|
||
state.busy = false;
|
||
}
|
||
|
||
/* ---------------- 绑定演示按钮 ---------------- */
|
||
|
||
document.getElementById('btnCreate').addEventListener('click', () => replaceTodos(SCENARIOS.initial));
|
||
document.getElementById('btnComplete').addEventListener('click', completeNext);
|
||
document.getElementById('btnUpdate').addEventListener('click', () => replaceTodos(SCENARIOS.updated));
|
||
document.getElementById('btnLong').addEventListener('click', () => replaceTodos(SCENARIOS.long));
|
||
document.getElementById('btnClear').addEventListener('click', clearTodos);
|
||
|
||
/* 页面加载后自动演示一次创建 */
|
||
window.addEventListener('load', () => {
|
||
setTimeout(() => replaceTodos(SCENARIOS.initial), 500);
|
||
});
|