agent-Specialization/demo/todo_float_window/app.js
JOJO 171833e4d1 feat(quickdock): 新增对话区右侧快捷窗口(待办/子智能体/后台指令/文件记录)
- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」
- 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content
- 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类
- 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
2026-07-22 00:52:36 +08:00

193 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
待办事项悬浮窗 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);
});