diff --git a/demo/todo_float_window/app.js b/demo/todo_float_window/app.js new file mode 100644 index 00000000..0f882266 --- /dev/null +++ b/demo/todo_float_window/app.js @@ -0,0 +1,192 @@ +/* ============================================================ + 待办事项悬浮窗 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); +}); diff --git a/demo/todo_float_window/file.css b/demo/todo_float_window/file.css new file mode 100644 index 00000000..29223761 --- /dev/null +++ b/demo/todo_float_window/file.css @@ -0,0 +1,194 @@ +/* ============================================================ + 文件记录窗口 + 右侧预览侧边栏 + toast 样式 + 复用 style.css 变量;⋯ 按钮复用 runner.css 的 .item-menu-btn + ============================================================ */ + +/* ---------------- 文件条目:[⋯] 左 + 文件名,无状态点 ---------------- */ + +.file-item { + height: var(--row-h); + display: flex; + align-items: center; + gap: 7px; + padding: 0 10px 0 6px; + overflow: hidden; + cursor: pointer; + transition: background 0.12s ease; +} + +.file-item:hover { background: var(--hover-wash); } + +/* 当前正在预览的文件保持高亮 */ +.file-item.is-active { background: var(--surface-wash); } + +.file-item.is-entering { + opacity: 0; + animation: todo-in 0.34s var(--ease-out) forwards; +} + +.file-name { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------------- 右侧预览侧边栏 ---------------- */ + +/* 打开时悬浮窗栈向左让位 */ +.float-stack { transition: right 0.3s var(--ease-out); } +body.preview-open .float-stack { right: calc(20px + 440px + 12px); } + +/* 详情面板同样让位 */ +.detail-panel { transition: right 0.3s var(--ease-out); } +body.preview-open .detail-panel { + right: calc(20px + 440px + 12px + var(--window-max-w) * 0.9 + 12px); +} + +.file-preview { + position: fixed; + top: 20px; + right: 20px; + bottom: 20px; + width: 440px; + max-width: calc(100vw - 80px); + background: var(--surface-float); + border: 1px solid var(--border-subtle); + border-radius: 12px; + box-shadow: var(--shadow-float); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 95; + /* 初始藏在屏幕右缘外;用 visibility 配合 transform 做滑入滑出 */ + transform: translateX(calc(100% + 24px)); + visibility: hidden; + transition: transform 0.3s var(--ease-out), visibility 0s linear 0.3s; +} + +.file-preview.is-open { + transform: translateX(0); + visibility: visible; + transition: transform 0.3s var(--ease-out), visibility 0s; +} + +/* 预览头部:文件名 + 目录 + 关闭 */ +.preview-header { + flex: none; + height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + border-bottom: 1px solid var(--border-subtle); +} + +.preview-header svg { + flex: none; + width: 15px; + height: 15px; + color: var(--accent); +} + +.preview-name { + flex: none; + font-size: 13px; + font-weight: 600; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + color: var(--text-primary); +} + +.preview-path { + flex: 1; + min-width: 0; + font-size: 11.5px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.preview-close { + flex: none; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: 6px; + color: var(--text-tertiary); + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.preview-close svg { width: 13px; height: 13px; color: inherit; } + +.preview-close:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); } + +/* 预览内容:等宽 + 行号 */ +.preview-body { + flex: 1; + overflow: auto; + padding: 8px 0 12px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.preview-body::-webkit-scrollbar { display: none; width: 0; height: 0; } + +.code-line { + display: flex; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.75; +} + +.code-line:hover { background: var(--hover-wash); } + +.line-no { + flex: none; + width: 44px; + padding-right: 12px; + text-align: right; + color: var(--text-tertiary); + user-select: none; +} + +.line-text { + flex: 1; + padding-right: 14px; + white-space: pre; + color: var(--text-primary); +} + +/* ---------------- 文件菜单(普通项,非危险色) ---------------- */ + +.file-menu .menu-item { color: var(--text-primary); } +.file-menu .menu-item:hover { background: var(--surface-wash); } + +/* ---------------- demo toast 反馈 ---------------- */ + +.demo-toast { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%) translateY(8px); + padding: 8px 14px; + border-radius: 8px; + background: #26241f; + color: #faf9f5; + font-size: 12.5px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 400; +} + +.demo-toast.is-show { opacity: 1; transform: translateX(-50%) translateY(0); } diff --git a/demo/todo_float_window/file.js b/demo/todo_float_window/file.js new file mode 100644 index 00000000..ec8b6287 --- /dev/null +++ b/demo/todo_float_window/file.js @@ -0,0 +1,355 @@ +/* ============================================================ + 文件记录窗口 Demo 逻辑 + - 条目:[⋯] 文件名(无状态点);点击条目从右侧滑出预览侧边栏 + - ⋯ 菜单:下载 / 在文件管理器中打开 / 复制路径(工作区相对路径) + - 显示逻辑(正式版):文件被编辑过 + 文件仍存在 + - 复用 runner.js 的 showFloatWindow / scrollRowIntoView + ============================================================ */ + +const fileWindowEl = document.getElementById('fileWindow'); +const fileListEl = document.getElementById('fileList'); +const fileCounterEl = document.getElementById('fileCounter'); + +const fileMenuEl = document.getElementById('fileMenu'); +const menuDownload = document.getElementById('menuDownload'); +const menuReveal = document.getElementById('menuReveal'); +const menuCopyPath = document.getElementById('menuCopyPath'); + +const previewEl = document.getElementById('filePreview'); +const previewNameEl = document.getElementById('previewName'); +const previewPathEl = document.getElementById('previewPath'); +const previewBodyEl = document.getElementById('previewBody'); +const previewCloseBtn = document.getElementById('previewClose'); + +const toastEl = document.getElementById('demoToast'); + +const fileState = { files: [], poolIdx: 0 }; +const previewState = { path: null }; +let menuFilePath = null; +let toastTimer = null; + +/* ---------------- 演示文件数据 ---------------- */ + +const FILES_POOL = [ + { + path: 'static/src/components/chat/ChatArea.vue', + content: ` + +`, + }, + { + path: 'server/chat_flow_tool_loop.py', + content: `async def execute_tool_calls(self, tool_calls, messages): + """执行一轮工具调用,并把结果回填到消息列表。""" + results = [] + for call in tool_calls: + tool_name = call.get("name") + tool_args = call.get("arguments", {}) + handler = self.tool_handlers.get(tool_name) + if handler is None: + results.append({"success": False, "error": f"未知工具: {tool_name}"}) + continue + try: + result = await handler(**tool_args) + results.append(result) + except Exception as exc: + logger.exception("tool %s failed", tool_name) + results.append({"success": False, "error": str(exc)}) + + for call, result in zip(tool_calls, results): + messages.append({ + "role": "tool", + "tool_call_id": call["id"], + "content": format_tool_result_for_context(call["name"], result), + }) + return results`, + }, + { + path: 'docs/float_window_design.md', + content: `# 悬浮窗口设计 + +## 布局 + +- 位置:对话显示区域右上角 +- 排列:多个窗口上下排布,右对齐 +- 尺寸:宽 270px,列表区固定 5 行 + +## 窗口列表 + +1. 待办事项:任务创建 / 完成 / 替换动画 +2. 文件记录:本次对话编辑过的文件 +3. 子智能体:运行状态 + 详情面板 +4. 后台指令:运行状态 + 终端输出 + +## 动画规范 + +- 进入:从左向右位移 14px + 淡入,逐行 60ms 交错 +- 完成:横线从左往右划过 + 文字变灰 +- 移出:从右向左 + 高度收拢 +- 显示区域外的条目:先滚动露出,再播动画`, + }, + { + path: 'config/limits.py', + content: `"""运行限制相关配置。""" + +MAX_TODO_ITEMS = 8 +"""单次待办列表最多条目数。""" + +FLOAT_WINDOW_ROWS = 5 +"""悬浮窗口列表区固定行数。""" + +FLOAT_WINDOW_WIDTH = 270 +"""悬浮窗口宽度(最大宽度的 90%)。""" + +SUB_AGENT_MAX_ROUNDS = 12 +"""子智能体单轮任务最大对话轮数。""" + +LOG_ROTATE_MAX_BYTES = 20 * 1024 * 1024 +"""单日志文件最大体积,超过触发轮转。"""`, + }, +]; + +/* ---------------- 工具 ---------------- */ + +function basename(p) { return p.split('/').pop(); } +function dirname(p) { return p.split('/').slice(0, -1).join('/') + '/'; } + +function showToast(text) { + toastEl.textContent = text; + toastEl.classList.add('is-show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastEl.classList.remove('is-show'), 1800); +} + +function updateFileCounter() { + fileCounterEl.textContent = String(fileState.files.length); +} + +/* ---------------- 文件条目 ---------------- */ + +function simulateEditFile() { + // 正式版逻辑:同一文件只出现一次;被删除的文件从列表移除 + if (fileState.poolIdx >= FILES_POOL.length) { + showToast('演示文件已全部在列表中'); + return; + } + const file = FILES_POOL[fileState.poolIdx]; + fileState.poolIdx += 1; + fileState.files.push(file); + + showFloatWindow(fileWindowEl); + renderFileRow(file); + updateFileCounter(); +} + +function renderFileRow(file) { + const li = document.createElement('li'); + li.className = 'file-item'; + li.dataset.path = file.path; + li.title = file.path; + + const menuBtn = document.createElement('button'); + menuBtn.className = 'item-menu-btn'; + menuBtn.title = '更多'; + menuBtn.innerHTML = ''; + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + showFileMenu(file, menuBtn); + }); + + const nameEl = document.createElement('span'); + nameEl.className = 'file-name'; + nameEl.textContent = basename(file.path); + + li.append(menuBtn, nameEl); + li.addEventListener('click', () => openPreview(file)); + + fileListEl.appendChild(li); + + /* 先隐身插入;若在可视区域外先滚动出来,再播进入动画 */ + li.style.opacity = '0'; + scrollRowIntoView(fileListEl, li).then(() => { + li.classList.add('is-entering'); + li.addEventListener('animationend', () => { + li.classList.remove('is-entering'); + li.style.opacity = ''; + }, { once: true }); + }); +} + +/* ---------------- ⋯ 菜单:下载 / 在文件管理器中打开 / 复制路径 ---------------- */ + +function showFileMenu(file, btn) { + if (!fileMenuEl.hidden && menuFilePath === file.path) { hideFileMenu(); return; } + menuFilePath = file.path; + const rm = document.getElementById('itemMenu'); // 与子智能体菜单互斥 + if (rm) rm.hidden = true; + + const rect = btn.getBoundingClientRect(); + /* ⋯ 在条目最左侧,菜单与按钮左对齐、向下展开 */ + fileMenuEl.style.left = `${rect.left}px`; + fileMenuEl.style.right = 'auto'; + fileMenuEl.style.top = `${rect.bottom + 6}px`; + + fileMenuEl.hidden = false; + fileMenuEl.classList.add('menu-enter'); + fileMenuEl.addEventListener('animationend', () => fileMenuEl.classList.remove('menu-enter'), { once: true }); +} + +function hideFileMenu() { + fileMenuEl.hidden = true; + menuFilePath = null; +} + +function currentMenuFile() { + return fileState.files.find((f) => f.path === menuFilePath); +} + +menuDownload.addEventListener('click', () => { + const file = currentMenuFile(); + if (file) { + const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = basename(file.path); + a.click(); + URL.revokeObjectURL(url); + showToast(`已下载 ${a.download}`); + } + hideFileMenu(); +}); + +menuReveal.addEventListener('click', () => { + // 正式版:复用 git 状态侧边栏逻辑,用默认应用打开;docker 模式下无此选项 + showToast('已在文件管理器中打开(demo 示意)'); + hideFileMenu(); +}); + +menuCopyPath.addEventListener('click', () => { + const file = currentMenuFile(); + if (file) copyText(file.path); + hideFileMenu(); +}); + +function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + () => showToast('已复制相对路径'), + () => fallbackCopy(text), + ); + } else { + fallbackCopy(text); + } +} + +function fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + showToast('已复制相对路径'); + } catch (err) { + showToast('复制失败'); + } + ta.remove(); +} + +document.addEventListener('click', (e) => { + if (!fileMenuEl.hidden && !e.target.closest('.file-menu') && !e.target.closest('.item-menu-btn')) { + hideFileMenu(); + } +}); + +/* ---------------- 右侧预览侧边栏 ---------------- */ + +function openPreview(file) { + if (previewState.path === file.path) { closePreview(); return; } // 再点同一文件收起 + + previewState.path = file.path; + previewNameEl.textContent = basename(file.path); + previewPathEl.textContent = dirname(file.path); + + previewBodyEl.innerHTML = ''; + file.content.split('\n').forEach((line, i) => { + const div = document.createElement('div'); + div.className = 'code-line'; + const no = document.createElement('span'); + no.className = 'line-no'; + no.textContent = String(i + 1); + const text = document.createElement('span'); + text.className = 'line-text'; + text.textContent = line; + div.append(no, text); + previewBodyEl.appendChild(div); + }); + previewBodyEl.scrollTop = 0; + + document.querySelectorAll('.file-item.is-active').forEach((el) => el.classList.remove('is-active')); + const row = fileListEl.querySelector(`[data-path="${CSS.escape(file.path)}"]`); + if (row) row.classList.add('is-active'); + + document.body.classList.add('preview-open'); + previewEl.classList.add('is-open'); +} + +function closePreview() { + previewState.path = null; + document.body.classList.remove('preview-open'); + previewEl.classList.remove('is-open'); + document.querySelectorAll('.file-item.is-active').forEach((el) => el.classList.remove('is-active')); +} + +previewCloseBtn.addEventListener('click', closePreview); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !e.defaultPrevented) { + if (!fileMenuEl.hidden) hideFileMenu(); + else if (previewState.path) closePreview(); + } +}); + +/* 窗口栈整体滚动时,关闭所有已打开的 ⋯ 菜单(菜单是 fixed 定位,不随栈滚动) */ +document.querySelector('.float-stack').addEventListener('scroll', () => { + hideItemMenu(); + hideFileMenu(); +}); + +/* ---------------- 演示按钮 & 自动演示 ---------------- */ + +document.getElementById('btnEditFile').addEventListener('click', simulateEditFile); + +window.addEventListener('load', () => { + setTimeout(simulateEditFile, 2900); + setTimeout(simulateEditFile, 3600); +}); diff --git a/demo/todo_float_window/index.html b/demo/todo_float_window/index.html new file mode 100644 index 00000000..990bbf3b --- /dev/null +++ b/demo/todo_float_window/index.html @@ -0,0 +1,146 @@ + + + + + +悬浮窗口栈 Demo · 待办 / 子智能体 / 后台指令 + + + + + + + +
+
把待办、子智能体、后台指令都做成右上角的悬浮窗口
+
好的,三个窗口上下排布在对话区右上角。子智能体和后台指令会持续产生输出,点击条目可以在左侧展开详情面板。
+
条目左侧的 ⋯ 可以展开菜单(强制关闭);运行中的条目状态点会呼吸,完成后变灰。
+
+ + +
+ + + + + + + + + + + + +
+ + + + + + + + + + + +
+
+ + + + + + + +
+
+
+ + +
+ + +
+

悬浮窗口栈 · 动画演示

+
+ + + + + +
+ + + +
+
+ + + + + + diff --git a/demo/todo_float_window/runner.css b/demo/todo_float_window/runner.css new file mode 100644 index 00000000..7c14dcae --- /dev/null +++ b/demo/todo_float_window/runner.css @@ -0,0 +1,362 @@ +/* ============================================================ + 子智能体 / 后台指令窗口 + 详情面板 + 条目菜单 样式 + 复用 style.css 的变量与窗口基础样式 + ============================================================ */ + +/* ---------------- 条目列表(与待办同规格:固定 5 行) ---------------- */ + +.run-list { + list-style: none; + padding: 0; + height: calc(var(--row-h) * 5); + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.run-list::-webkit-scrollbar { display: none; width: 0; height: 0; } + +/* 条目:[状态点] 左 + 名称 + [⋯] 右,整行可点击 */ +.run-item { + height: var(--row-h); + display: flex; + align-items: center; + gap: 7px; + padding: 0 8px 0 12px; + overflow: hidden; + cursor: pointer; + transition: background 0.12s ease; +} + +.run-item:hover { background: var(--hover-wash); } + +/* 当前打开详情的条目保持高亮 */ +.run-item.is-active { background: var(--surface-wash); } + +/* ⋯ 按钮 */ +.item-menu-btn { + flex: none; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: 5px; + color: var(--text-tertiary); + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.item-menu-btn svg { width: 14px; height: 14px; } + +.item-menu-btn:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); } + +/* 名称 */ +.item-name { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 后台指令名称用等宽字体 */ +.run-item[data-kind="cmd"] .item-name { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; +} + +/* 完成后名称变灰 */ +.run-item.is-done .item-name { color: var(--text-tertiary); } + +/* 状态点:运行中呼吸,完成/终止变灰 */ +.item-status { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: transparent; +} + +.run-item.is-running .item-status { + background: var(--accent); + animation: status-pulse 1.6s ease-in-out infinite; +} + +.run-item.is-done .item-status { background: var(--text-tertiary); } + +@keyframes status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* 进入动画复用待办的 keyframes(todo-in 定义在 style.css,全局可用) */ +.run-item.is-entering { + opacity: 0; + animation: todo-in 0.34s var(--ease-out) forwards; +} + +/* ---------------- 详情面板 ---------------- */ + +/* display:flex 会覆盖 hidden 属性的 display:none,必须显式补回 */ +.detail-panel[hidden] { display: none; } + +.detail-panel { + position: fixed; + top: 20px; + right: calc(20px + var(--window-max-w) * 0.9 + 12px); /* 悬浮窗左侧 */ + width: 480px; + max-width: calc(100vw - 360px); + height: 460px; + max-height: calc(100vh - 40px); + background: var(--surface-float); + border: 1px solid var(--border-subtle); + border-radius: 12px; + box-shadow: var(--shadow-float); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 90; +} + +.detail-panel.panel-enter { animation: panel-in 0.24s var(--ease-out); } +@keyframes panel-in { + from { opacity: 0; transform: translateX(12px); } + to { opacity: 1; transform: translateX(0); } +} + +.detail-panel.panel-leave { animation: panel-out 0.18s ease-in forwards; } +@keyframes panel-out { + to { opacity: 0; transform: translateX(8px); } +} + +/* 详情头部 */ +.detail-header { + flex: none; + height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.detail-status-dot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-tertiary); +} + +.detail-status-dot.is-running { + background: var(--accent); + animation: status-pulse 1.6s ease-in-out infinite; +} + +.detail-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.detail-badge { + flex: none; + font-size: 11px; + line-height: 1; + padding: 4px 8px; + border-radius: 99px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} + +.detail-badge.is-done { background: var(--surface-wash); color: var(--text-tertiary); } + +.detail-close { + flex: none; + margin-left: auto; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: 6px; + color: var(--text-tertiary); + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.detail-close svg { width: 13px; height: 13px; } + +.detail-close:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); } + +/* 详情内容区 */ +.detail-body { + flex: 1; + overflow-y: auto; + padding: 6px 0 10px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.detail-body::-webkit-scrollbar { display: none; width: 0; height: 0; } + +.detail-body.body-fade { animation: body-fade-in 0.18s ease-out; } +@keyframes body-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ---------------- feed 行(工具 + 输出,扁平行式,无嵌套卡片) ---------------- */ + +.feed-row { + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + min-height: 28px; + font-size: 12.5px; + animation: feed-in 0.25s var(--ease-out); +} + +/* 新进度出现动画:淡入 + 轻微上浮 */ +@keyframes feed-in { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 智能体文本输出 */ +.feed-row.feed-text { + align-items: flex-start; + padding-top: 5px; + padding-bottom: 5px; + color: var(--text-secondary); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +/* 工具调用行 */ +.feed-tool .tool-name { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--text-primary); + flex: none; +} + +.feed-tool .tool-param { + color: var(--text-tertiary); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.feed-tool .tool-result { + flex: none; + margin-left: auto; + font-size: 11.5px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +/* 工具运行中 spinner / 完成 ✓ */ +.tool-spinner { + flex: none; + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid var(--border-strong); + border-top-color: var(--accent); + animation: tool-spin 0.7s linear infinite; +} + +@keyframes tool-spin { + to { transform: rotate(360deg); } +} + +.tool-done { + flex: none; + width: 11px; + text-align: center; + font-size: 11px; + color: var(--accent); +} + +/* 终端输出行(后台指令) */ +.feed-row.feed-term { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + min-height: 24px; +} + +.feed-row.feed-term.is-command { + color: var(--text-primary); + font-weight: 600; +} + +/* 终止提示行 */ +.feed-row.feed-killed { color: #c0453a; } + +/* ---------------- 条目 ⋯ 菜单 ---------------- */ + +.item-menu { + position: fixed; + z-index: 300; + min-width: 116px; + padding: 4px; + background: var(--surface-float); + border: 1px solid var(--border-subtle); + border-radius: 8px; + box-shadow: var(--shadow-float); +} + +.item-menu.menu-enter { animation: menu-in 0.16s ease-out; } +@keyframes menu-in { + from { opacity: 0; transform: translateY(-3px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.menu-item { + display: flex; + align-items: center; + width: 100%; + height: 30px; + padding: 0 10px; + background: none; + border: none; + border-radius: 5px; + font-size: 12.5px; + text-align: left; + cursor: pointer; + transition: background 0.12s ease; +} + +.menu-item--danger { color: #c0453a; } +.menu-item--danger:hover:not(:disabled) { background: rgba(192, 69, 58, 0.08); } +.menu-item--danger:disabled { color: var(--text-tertiary); cursor: default; } + +/* ---------------- demo 控制台补充 ---------------- */ + +.demo-divider { + width: 100%; + height: 1px; + background: var(--border-subtle); + margin: 5px 0; +} diff --git a/demo/todo_float_window/runner.js b/demo/todo_float_window/runner.js new file mode 100644 index 00000000..22033033 --- /dev/null +++ b/demo/todo_float_window/runner.js @@ -0,0 +1,421 @@ +/* ============================================================ + 子智能体 / 后台指令窗口 Demo 逻辑 + - 新条目出现动画与待办一致(todo-in keyframes,从左向右进入) + - 运行中条目用定时器模拟流式输出(工具调用 / 终端行) + - 点击条目在左侧展开详情面板:新进度淡入 + 自动向下滚动 + - ⋯ 菜单:强制关闭(完成后置灰);无删除 + ============================================================ */ + +const agentWindowEl = document.getElementById('agentWindow'); +const cmdWindowEl = document.getElementById('cmdWindow'); +const agentListEl = document.getElementById('agentList'); +const cmdListEl = document.getElementById('cmdList'); +const agentCounterEl = document.getElementById('agentCounter'); +const cmdCounterEl = document.getElementById('cmdCounter'); + +const detailPanel = document.getElementById('detailPanel'); +const detailDot = document.getElementById('detailDot'); +const detailTitle = document.getElementById('detailTitle'); +const detailBadge = document.getElementById('detailBadge'); +const detailBody = document.getElementById('detailBody'); +const detailCloseBtn = document.getElementById('detailClose'); + +const itemMenu = document.getElementById('itemMenu'); +const menuKill = document.getElementById('menuKill'); + +const runners = new Map(); +let runnerSeq = 0; +let agentSeq = 0; +let cmdSeq = 0; +const detailState = { id: null }; +let menuRunnerId = null; + +/* ---------------- 演示脚本 ---------------- */ + +const AGENT_ROLES = ['UI Operator', 'Code Reviewer', 'Researcher']; + +const AGENT_SCRIPTS = [ + [ + { type: 'text', text: '收到,先看一下 MonitorDirector 的当前实现…' }, + { type: 'tool', name: 'read_file', param: 'static/src/components/chat/monitor/MonitorDirector.ts', result: '342 行', duration: 1400 }, + { type: 'tool', name: 'read_file', param: 'static/src/stores/monitor.ts', result: '186 行', duration: 1000 }, + { type: 'text', text: '定位到事件队列逻辑,开始修改…' }, + { type: 'tool', name: 'edit_file', param: 'MonitorDirector.ts · 3 处替换', result: '✓ 成功', duration: 1800 }, + { type: 'tool', name: 'run_command', param: 'npm run build', result: '✓ 2.31s', duration: 2600 }, + { type: 'text', text: '构建通过,改动已完成。' }, + ], + [ + { type: 'text', text: '开始审查本次改动的 diff…' }, + { type: 'tool', name: 'run_command', param: 'git diff HEAD~1..HEAD', result: '+86 −24', duration: 1500 }, + { type: 'text', text: '发现两处潜在问题,进一步确认上下文…' }, + { type: 'tool', name: 'read_file', param: 'server/chat_flow_tool_loop.py · 1320-1380 行', result: '已读取', duration: 1200 }, + { type: 'text', text: '审查完成:2 个建议,0 个阻塞问题,已同步给 Team Leader。' }, + ], + [ + { type: 'text', text: '正在检索相关资料…' }, + { type: 'tool', name: 'web_search', param: 'vue3 transition-group FLIP 列表动画', result: '8 条结果', duration: 1600 }, + { type: 'tool', name: 'extract_webpage', param: 'vuejs.org/guide/built-ins/transition-group', result: '4.2k 字', duration: 1300 }, + { type: 'text', text: '整理完成:推荐用 FLIP + 交错延迟实现列表重排动画。' }, + ], +]; + +const CMD_POOL = [ + { + name: 'npm run build', + script: [ + { text: '$ npm run build', isCommand: true }, + { text: '> vite v5.4.19 building for production...' }, + { text: 'transforming (1823) src/main.ts' }, + { text: '✓ 1823 modules transformed.' }, + { text: 'dist/index.html 0.46 kB │ gzip: 0.31 kB' }, + { text: 'dist/assets/index-C8f3k2m.js 412.18 kB │ gzip: 132.40 kB' }, + { text: '✓ built in 2.31s' }, + ], + }, + { + name: 'npm run lint', + script: [ + { text: '$ npm run lint', isCommand: true }, + { text: '> eslint static/src --ext .ts,.vue' }, + { text: 'checked 214 files' }, + { text: '✓ no problems found' }, + ], + }, + { + name: 'python3 -m unittest test.test_server_refactor_smoke', + script: [ + { text: '$ python3 -m unittest test.test_server_refactor_smoke', isCommand: true }, + { text: 'test_chat_smoke ... ok' }, + { text: 'test_status_smoke ... ok' }, + { text: 'test_tasks_smoke ... ok' }, + { text: 'Ran 3 tests in 0.42s' }, + { text: 'OK' }, + ], + }, +]; + +/* ---------------- 工具 ---------------- */ + +function showFloatWindow(el) { + if (!el.hidden) return; + el.hidden = false; + el.classList.add('window-enter'); + el.addEventListener('animationend', () => el.classList.remove('window-enter'), { once: true }); +} + +function updateRunCounter(kind) { + const all = [...runners.values()].filter((r) => r.kind === kind); + const running = all.filter((r) => r.status === 'running').length; + (kind === 'agent' ? agentCounterEl : cmdCounterEl).textContent = `${running}/${all.length}`; +} + +/* ---------------- 创建条目 ---------------- */ + +function createRunner(kind) { + const id = ++runnerSeq; + let name; let script; + if (kind === 'agent') { + name = `${AGENT_ROLES[agentSeq % AGENT_ROLES.length]}_${Math.floor(agentSeq / AGENT_ROLES.length) + 1}`; + script = AGENT_SCRIPTS[agentSeq % AGENT_SCRIPTS.length]; + agentSeq += 1; + } else { + const pool = CMD_POOL[cmdSeq % CMD_POOL.length]; + name = pool.name; + script = pool.script; + cmdSeq += 1; + } + + const runner = { + id, + kind, + name, + status: 'running', // running | done | killed + feed: [], + script, + cursor: 0, + timer: null, + rowEl: null, + listEl: kind === 'agent' ? agentListEl : cmdListEl, + }; + runners.set(id, runner); + + showFloatWindow(kind === 'agent' ? agentWindowEl : cmdWindowEl); + renderRunnerRow(runner); + updateRunCounter(kind); + playScript(runner); +} + +/* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */ +function scrollRowIntoView(listEl, 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); + }; + listEl.addEventListener('scroll', onScroll); + li.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + setTimeout(finish, 800); + }); +} + +function renderRunnerRow(runner) { + const li = document.createElement('li'); + li.className = 'run-item is-running'; + li.dataset.kind = runner.kind; + li.dataset.id = runner.id; + + const statusEl = document.createElement('span'); + statusEl.className = 'item-status'; + + const nameEl = document.createElement('span'); + nameEl.className = 'item-name'; + nameEl.textContent = runner.name; + + const menuBtn = document.createElement('button'); + menuBtn.className = 'item-menu-btn'; + menuBtn.title = '更多'; + menuBtn.innerHTML = ''; + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + showItemMenu(runner, menuBtn); + }); + + li.append(statusEl, nameEl, menuBtn); + li.addEventListener('click', () => openDetail(runner.id)); + + runner.rowEl = li; + runner.listEl.appendChild(li); + + /* 先隐身插入;若在可视区域外先滚动出来,再播进入动画 */ + li.style.opacity = '0'; + scrollRowIntoView(runner.listEl, li).then(() => { + li.classList.add('is-entering'); + li.addEventListener('animationend', () => { + li.classList.remove('is-entering'); + li.style.opacity = ''; + }, { once: true }); + }); +} + +/* ---------------- 模拟流式输出 ---------------- */ + +function playScript(runner) { + const step = () => { + if (runner.status !== 'running') return; + if (runner.cursor >= runner.script.length) { + finishRunner(runner, 'done'); + return; + } + const item = runner.script[runner.cursor]; + runner.cursor += 1; + + if (item.type === 'tool') { + // 工具行先以 running 态出现,duration 后落定结果 + const feedItem = { ...item, status: 'running' }; + pushFeed(runner, feedItem); + runner.timer = setTimeout(() => { + feedItem.status = 'done'; + updateFeedRowEl(feedItem); + runner.timer = setTimeout(step, 380); + }, item.duration); + } else if (item.type === 'text') { + pushFeed(runner, { ...item }); + runner.timer = setTimeout(step, 950); + } else { + // 终端行 + pushFeed(runner, { type: 'term', ...item }); + runner.timer = setTimeout(step, 620); + } + }; + runner.timer = setTimeout(step, 650); +} + +function finishRunner(runner, status) { + if (runner.status !== 'running') return; + runner.status = status; + clearTimeout(runner.timer); + + runner.rowEl.classList.remove('is-running'); + runner.rowEl.classList.add('is-done'); + updateRunCounter(runner.kind); + + if (status === 'killed') { + pushFeed(runner, { type: 'text', killed: true, text: '⛔ 已被强制关闭' }); + } + if (detailState.id === runner.id) updateDetailHeader(runner); +} + +/* ---------------- feed 行渲染 ---------------- */ + +function rowClassFor(item) { + if (item.type === 'tool') return 'feed-row feed-tool'; + if (item.type === 'term') return 'feed-row feed-term' + (item.isCommand ? ' is-command' : ''); + return 'feed-row feed-text' + (item.killed ? ' feed-killed' : ''); +} + +function rowInnerFor(item) { + if (item.type === 'tool') { + const icon = item.status === 'done' + ? '' + : ''; + const result = item.status === 'done' + ? `${item.result}` + : ''; + return `${icon}${item.name}${item.param}${result}`; + } + return escapeText(item.text); +} + +function escapeText(t) { + const div = document.createElement('div'); + div.textContent = t; + return div.innerHTML; +} + +function pushFeed(runner, item) { + runner.feed.push(item); + if (detailState.id === runner.id) appendFeedRow(item); +} + +/* 追加一行到详情面板:新行淡入,贴近底部时自动向下平滑滚动 */ +function appendFeedRow(item) { + const el = detailBody; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + + const div = document.createElement('div'); + div.className = rowClassFor(item); + div.innerHTML = rowInnerFor(item); + el.appendChild(div); + item.el = div; + + if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); +} + +function updateFeedRowEl(item) { + if (!item.el) return; + item.el.className = rowClassFor(item); + item.el.innerHTML = rowInnerFor(item); + const el = detailBody; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); +} + +/* ---------------- 详情面板 ---------------- */ + +function updateDetailHeader(runner) { + detailDot.className = 'detail-status-dot' + (runner.status === 'running' ? ' is-running' : ''); + detailBadge.textContent = runner.status === 'running' ? '运行中' : runner.status === 'done' ? '已完成' : '已终止'; + detailBadge.classList.toggle('is-done', runner.status !== 'running'); +} + +function openDetail(id) { + if (detailState.id === id) { closeDetail(); return; } // 再点同一行收起 + + const runner = runners.get(id); + detailState.id = id; + + detailTitle.textContent = runner.name; + updateDetailHeader(runner); + + detailBody.innerHTML = ''; + runner.feed.forEach((item) => appendFeedRow(item)); + detailBody.scrollTop = detailBody.scrollHeight; // 打开时直接定位到底部 + + document.querySelectorAll('.run-item.is-active').forEach((el) => el.classList.remove('is-active')); + runner.rowEl.classList.add('is-active'); + + if (detailPanel.hidden) { + detailPanel.hidden = false; + detailPanel.classList.remove('panel-leave'); + detailPanel.classList.add('panel-enter'); + setTimeout(() => detailPanel.classList.remove('panel-enter'), 260); + } else { + // 切换条目时内容区快速淡入 + detailBody.classList.remove('body-fade'); + void detailBody.offsetWidth; + detailBody.classList.add('body-fade'); + } +} + +function closeDetail() { + if (detailPanel.hidden) return; + detailState.id = null; + document.querySelectorAll('.run-item.is-active').forEach((el) => el.classList.remove('is-active')); + detailPanel.classList.remove('panel-enter'); + detailPanel.classList.add('panel-leave'); + /* 用 setTimeout 而非 animationend:feed 行动画的 animationend 会冒泡到面板造成干扰 */ + setTimeout(() => { + detailPanel.classList.remove('panel-leave'); + detailPanel.hidden = true; + }, 190); +} + +/* ---------------- 条目 ⋯ 菜单 ---------------- */ + +function showItemMenu(runner, btn) { + if (!itemMenu.hidden && menuRunnerId === runner.id) { hideItemMenu(); return; } + menuRunnerId = runner.id; + const fm = document.getElementById('fileMenu'); // 与文件菜单互斥 + if (fm) fm.hidden = true; + + const rect = btn.getBoundingClientRect(); + /* ⋯ 按钮在行的最右侧,菜单与按钮右对齐、向下展开 */ + itemMenu.style.left = 'auto'; + itemMenu.style.right = `${window.innerWidth - rect.right}px`; + itemMenu.style.top = `${rect.bottom + 6}px`; + menuKill.disabled = runner.status !== 'running'; + + itemMenu.hidden = false; + itemMenu.classList.add('menu-enter'); + itemMenu.addEventListener('animationend', () => itemMenu.classList.remove('menu-enter'), { once: true }); +} + +function hideItemMenu() { + itemMenu.hidden = true; + menuRunnerId = null; +} + +menuKill.addEventListener('click', () => { + const runner = runners.get(menuRunnerId); + if (runner) finishRunner(runner, 'killed'); + hideItemMenu(); +}); + +document.addEventListener('click', (e) => { + if (!itemMenu.hidden && !e.target.closest('.item-menu') && !e.target.closest('.item-menu-btn')) { + hideItemMenu(); + } +}); + +detailCloseBtn.addEventListener('click', closeDetail); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (!itemMenu.hidden) { hideItemMenu(); e.preventDefault(); } + else if (!detailPanel.hidden) { closeDetail(); e.preventDefault(); } + } +}); + +/* ---------------- 演示按钮 & 自动演示 ---------------- */ + +document.getElementById('btnNewAgent').addEventListener('click', () => createRunner('agent')); +document.getElementById('btnNewCmd').addEventListener('click', () => createRunner('cmd')); + +window.addEventListener('load', () => { + setTimeout(() => createRunner('agent'), 1200); + setTimeout(() => createRunner('cmd'), 2100); +}); diff --git a/demo/todo_float_window/style.css b/demo/todo_float_window/style.css new file mode 100644 index 00000000..4090a0a5 --- /dev/null +++ b/demo/todo_float_window/style.css @@ -0,0 +1,276 @@ +/* ============================================================ + 待办事项悬浮窗 Demo 样式 + 配色暂用经典主题暖色系,正式接入时替换为语义 token + ============================================================ */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --surface-base: #faf9f5; + --surface-float: #ffffff; + --surface-wash: #f5f0e8; + --border-subtle: #ece5d8; + --border-strong: #e2d9c8; + --text-primary: #26241f; + --text-secondary: #6f6a5e; + --text-tertiary: #a9a294; + --accent: #cc785c; + --accent-hover: #c06a4e; + --hover-wash: rgba(38, 36, 31, 0.04); + --shadow-float: 0 1px 2px rgba(38, 36, 31, 0.05), 0 8px 24px rgba(38, 36, 31, 0.10); + --row-h: 36px; + --window-max-w: 300px; + --ease-out: cubic-bezier(0.22, 0.9, 0.28, 1); + --ease-strike: cubic-bezier(0.55, 0.06, 0.35, 1); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; + background: var(--surface-base); + color: var(--text-primary); + min-height: 100vh; +} + +/* ---------------- 模拟对话区 ---------------- */ + +.mock-chat { + max-width: 640px; + margin: 0 auto; + padding: 64px 24px 220px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.mock-bubble { + max-width: 78%; + padding: 10px 14px; + border-radius: 14px; + font-size: 14px; + line-height: 1.6; +} + +.mock-bubble--user { align-self: flex-end; background: #efe9de; } +.mock-bubble--ai { align-self: flex-start; background: #ffffff; border: 1px solid var(--border-subtle); } + +/* ---------------- 悬浮窗口栈 ---------------- */ + +.float-stack { + position: fixed; + top: 20px; + right: 20px; + z-index: 100; + display: flex; + flex-direction: column; + align-items: flex-end; /* 多个窗口右对齐,上下排布 */ + gap: 12px; + /* 整体超出屏幕高度时可滚动,滚动条隐藏 */ + max-height: calc(100vh - 40px); + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + /* padding + 负 margin:扩大滚动盒以容纳窗口阴影,避免被 overflow 裁剪 */ + padding: 24px; + margin: -24px; +} + +.float-stack::-webkit-scrollbar { display: none; width: 0; height: 0; } + +/* 窗口:圆角矩形 + 固定宽度(最大宽度的 90%) + 不透明背景 + 克制阴影 */ +.float-window { + flex: none; /* 禁止被栈 flex 压缩,保持固定高度 */ + width: calc(var(--window-max-w) * 0.9); /* 300 * 0.9 = 270px */ + background: var(--surface-float); + border: 1px solid var(--border-subtle); + border-radius: 12px; + box-shadow: var(--shadow-float); + overflow: hidden; +} + +/* 窗口自身出现 / 消失(高度变化由行展开动画带动,这里只做淡入) */ +.float-window.window-enter { animation: window-in 0.3s ease-out; } +@keyframes window-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.float-window.window-leave { animation: window-out 0.22s ease-in forwards; } +@keyframes window-out { + to { opacity: 0; transform: translateY(-4px) scale(0.98); } +} + +/* 窗口标题栏 */ +.float-window__header { + height: 38px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.float-window__icon { width: 15px; height: 15px; color: var(--accent); flex: none; } + +.float-window__title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.02em; +} + +.todo-counter { + margin-left: auto; + font-size: 11.5px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +/* ---------------- 待办列表 ---------------- */ + +/* 窗口高度固定为 5 行,不足留空、超出内部滚动;滚动条完全隐藏。 + 无上下 padding:首行 hover 紧贴标题栏分隔线,末行紧贴窗口底边 */ +.todo-list { + list-style: none; + padding: 0; + height: calc(var(--row-h) * 5); + overflow-y: auto; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* 旧 Edge */ +} + +.todo-list::-webkit-scrollbar { display: none; width: 0; height: 0; } + +/* 每一行直接铺在窗口上:无背景、无边框、无嵌套卡片 */ +/* 待办行不可点击,无 hover 效果 */ +.todo-item { + height: var(--row-h); + display: flex; + align-items: center; + gap: 9px; + padding: 0 12px; + overflow: hidden; /* 配合移出动画的高度收拢 */ +} + +/* 状态点 */ +.todo-dot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + border: 1.5px solid var(--text-tertiary); + background: transparent; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.todo-item.is-done .todo-dot { background: var(--accent); border-color: var(--accent); } + +/* 任务文字:固定一行,超长省略 */ +.todo-text { + position: relative; + flex: 1; + min-width: 0; + font-size: 13px; + line-height: 1; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线 */ +} + +/* 删除线(默认收起) */ +.todo-text::after { + content: ''; + position: absolute; + left: -1px; + right: -1px; + top: 50%; + height: 1.5px; + margin-top: -0.75px; + background: var(--text-tertiary); + border-radius: 1px; + transform: scaleX(0); + transform-origin: left center; + pointer-events: none; +} + +/* 完成态(静态,用于整表渲染时已完成行) */ +.todo-item.is-done .todo-text { color: var(--text-tertiary); } +.todo-item.is-done .todo-text::after { transform: scaleX(1); } + +/* 完成动作瞬间:横线从左往右划过 + 状态点弹跳 */ +.todo-item.just-done .todo-text::after { + animation: strike-in 0.28s var(--ease-strike) forwards; +} +@keyframes strike-in { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +.todo-item.just-done .todo-dot { animation: dot-pop 0.32s var(--ease-out); } +@keyframes dot-pop { + 0% { transform: scale(1); } + 45% { transform: scale(1.6); } + 100% { transform: scale(1); } +} + +/* 进入动画:从左向右位移 + 淡入(stagger 由 JS 写 animation-delay) */ +.todo-item.is-entering { + opacity: 0; + animation: todo-in 0.34s var(--ease-out) forwards; +} +@keyframes todo-in { + from { opacity: 0; transform: translateX(-14px); } + to { opacity: 1; transform: translateX(0); } +} + +/* 移出动画:从右向左位移 + 淡出,随后高度收拢让下方行平滑上移 */ +.todo-item.is-leaving { + animation: todo-out 0.26s ease-in forwards; +} +@keyframes todo-out { + 0% { opacity: 1; transform: translateX(0); height: var(--row-h); } + 55% { opacity: 0; transform: translateX(-14px); height: var(--row-h); } + 100% { opacity: 0; transform: translateX(-14px); height: 0; } +} + +/* ---------------- demo 控制台 ---------------- */ + +.demo-panel { + position: fixed; + left: 20px; + bottom: 20px; + z-index: 100; +} + +.demo-panel__hint { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 10px; +} + +.demo-panel__buttons { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.demo-btn { + height: 34px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid var(--border-strong); + background: var(--surface-float); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.demo-btn:hover { background: var(--surface-wash); } + +.demo-btn--primary { background: var(--accent); border-color: var(--accent); color: #ffffff; } +.demo-btn--primary:hover { background: var(--accent-hover); } + +.demo-btn--ghost { border-style: dashed; color: var(--text-tertiary); } diff --git a/docs/float_window_design.md b/docs/float_window_design.md new file mode 100644 index 00000000..8ece51e8 --- /dev/null +++ b/docs/float_window_design.md @@ -0,0 +1,132 @@ +# 对话区快捷窗口栈 · 设计定稿(2026-07-21) + +> 状态:demo 已验收,正式接入 `static/src` 中。 +> Demo 位置:`demo/todo_float_window/`(目录名为历史遗留,内含全部 4 个窗口;浏览器直接打开 `index.html`)。 +> 本文档是唯一设计依据;压缩对话后凭本文档 + demo 继续。 + +## 0.1 正式接入变更(2026-07-21 用户补充,覆盖 demo 的悬浮定位) + +1. **命名**:该功能正式名称为「**快捷窗口**」(quick dock / quick window)。 +2. **布局:占位而非悬浮**:快捷窗口**不是** `position: fixed` 悬浮,而是在**对话区域右侧空白区域占位显示**的一列,常驻时会**把对话区域向左挤压**(与 git/终端 right-panels 同为 `chat-container` 的 flex 兄弟节点,位于 `` 之后、right-panels 之前)。有内容时才显示该列。 +3. **设置项**:个人空间(PersonalizationDrawer)新增「**隐藏快捷窗口**」开关,**默认关闭**(即默认显示)。字段 `hide_quick_dock: boolean` 存 personalization form。 +4. 详情面板(子智能体/后台指令)保持 fixed 悬浮,从快捷窗口列左侧滑出;文件预览侧边栏在快捷窗口列右侧(git/终端面板左侧)占位/滑出。 +5. 移动端(isMobileViewport)第一版先隐藏。 + +## 0. Demo 文件地图 + +| 文件 | 内容 | +|------|------| +| `index.html` | 页面结构:模拟对话区 + 悬浮栈(4 窗口)+ 详情面板 + 预览侧边栏 + 菜单 + toast | +| `style.css` | 变量(配色/尺寸/缓动)+ 窗口基础 + 待办样式与全部 keyframes | +| `app.js` | 待办逻辑(创建/完成/替换/滚动划线) | +| `runner.css` / `runner.js` | 子智能体 + 后台指令窗口、左侧详情面板、⋯ 菜单(强制关闭) | +| `file.css` / `file.js` | 文件记录窗口、右侧预览侧边栏、⋯ 菜单(下载/打开/复制路径)、toast | + +## 1. 总体布局 + +- 位置:对话区域右侧**占位列**(非悬浮):`chat-container` 的 flex 兄弟节点,宽 294px(270 + 左右 padding 12),`flex: none`,整列上下排布。对话区被自然向左挤压。 +- 窗口顺序(从上到下,固定):**待办事项 → 子智能体 → 后台指令 → 文件**。 +- 窗口上下排布,右对齐,`gap: 12px`。 +- **整列可滚动**:列高 100%,`overflow-y: auto`,滚动条隐藏。 + - 防阴影被 overflow 裁剪:栈 `padding: 24px; margin: -24px`。 + - 栈滚动时关闭所有打开的 ⋯ 菜单(菜单 fixed 定位不随栈滚动)。 +- 各窗口初始隐藏,有内容时淡入出现(fade + translateY(-6px),0.3s)。 + +## 2. 窗口通用规格 + +- **宽度固定 270px**(= 最大宽度 300px × 90%,变量 `--window-max-w` 联动)。 +- **列表区高度固定 5 行**(行高 36px → 180px),不足留空,超出内部滚动,滚动条隐藏。 +- 窗口 `flex: none`(防止被栈 flex 压缩变矮,已踩坑)。 +- 圆角 12px;背景不透明(实体面板禁半透明);中性阴影(无发光);1px 细边框。 +- 标题栏 38px:SVG 图标(accent 色)+ 标题(12.5px 次要色)+ 右侧计数(11.5px 三级色)。 +- **列表无上下 padding**:首行 hover 紧贴标题栏分隔线,末行紧贴窗口底边。 +- 行:固定 36px 高,直接铺在窗口上(无嵌套卡片/背景/边框),超长文字省略号。 +- 正式接入时配色全部换成三主题语义 token(demo 为经典主题暖色写死)。 + +## 3. 各窗口设计 + +### 3.1 待办事项 + +- 行结构:`[状态点] 任务文字`;**无 hover、不可点击**。 +- 计数:`done/total`。 +- 动画: + - **创建**:逐行从左向右(位移 14px + 淡入,0.34s,stagger 60ms)。 + - **完成**:横线从左往右划过文字(260ms,`strike-in` scaleX 0→1,origin left)→ 文字延迟 120ms 变灰 + 状态点变色弹跳(`dot-pop`)。静态完成态(整表渲染时)不播划线,直接呈现。 + - **整表替换**(收到新 todo):旧行从上到下逐行从右向左移出(0.26s,stagger 45ms,**带高度收拢**让下方行平滑上移)→ 新行逐行进入。 +- **区域外条目**:要完成的行未完全显示时,先 `scrollIntoView({behavior:'smooth', block:'nearest'})` 滚出,滚动停稳 90ms 后再播划线(800ms 兜底)。 + +### 3.2 子智能体 / 3.3 后台指令(两者同构,仅详情内容不同) + +- 行结构:`[●状态点] 名称 [⋯]`;**有 hover,整行可点击**;后台指令名称用等宽字体。 +- 状态点:运行中 = accent 色呼吸(`status-pulse` 1.6s);完成/终止 = 灰,名称同步变灰。 +- 计数:`running/total`。 +- 新条目:进入动画同待办;**区域外先滚动露出再播进入动画**(先隐身插入,滚动到位后播)。 +- **无删除**(条目永久保留)。 +- ⋯ 菜单:仅「强制关闭」(警示红色,运行完/已终止置灰);菜单与按钮**右对齐**向下展开;两菜单(本菜单与文件菜单)互斥;Esc / 点空白关闭。 +- **点击行 → 左侧展开详情面板**(480×460,`right: calc(20px + 270px + 12px)`,滑入 0.24s): + - 子智能体:工具调用 + 文本输出流,**扁平行式无嵌套卡片**;工具行:运行中 spinner(旋转)→ 完成变 ✓ + 右侧落定结果;文本行次要色。 + - 后台指令:等宽终端行,首行 `$ 命令` 加粗。 + - 新进度出现动画:淡入 + translateY(5px) 上浮(0.25s)。 + - **自动滚动**:距底 <80px 时新内容平滑滚到底;用户上翻则不打断。 + - 再点同一行 / ✕ / Esc 关闭;点其他行切换(内容区快速淡入)。 +- ⚠️ **实际 API 颗粒度提醒**:子智能体进度 API 没有细化的"运行中流式状态",**每个步骤完成后才推送一次进度**。demo 里的流式效果仅为演示,正式接入时的显示颗粒度届时再定。 + +### 3.4 文件 + +- 行结构:`[⋯] 文件名`;**无状态点**;有 hover,整行可点击。 +- 计数:文件总数。 +- 显示逻辑(正式版):**文件被编辑/创建过 + 文件仍存在**才显示;同一文件按 path 去重只出现一次。 +- ⋯ 菜单(与按钮**左对齐**向下展开): + 1. **下载** + 2. **在文件管理器中打开**——复用 git 状态侧边栏已有逻辑,用默认应用打开;**docker 模式下无此选项** + 3. **复制路径**——工作区内相对路径 +- **点击行 → 右侧滑出预览侧边栏**(非左侧详情面板): + - 440px 宽、全高(top/bottom 20px),`translateX(100%+24px)` ↔ `0`,0.3s。 + - 打开时**悬浮窗栈与详情面板同步左移让位**(栈 `right: 20+440+12`,均带 0.3s transition)。 + - 头部:文件图标 + 文件名(等宽加粗)+ 目录(灰小字)+ 关闭按钮。 + - 内容:等宽 12px + 行号(44px 灰),行 hover 有底色,滚动条隐藏。 + - 再点同一文件 / ✕ / Esc 关闭;点其他文件直接切换。 + - 仅支持特定种类 UTF-8 文本文件(正式版按扩展名白名单)。 + +## 4. 后端配合点(正式接入) + +1. **文件记录**:每次 `write_file` / `edit_file` 成功后,把文件**相对路径**(含时间戳、操作类型)写入**对话文件(对话 JSON)**;同一 path 更新而非追加重复项。 +2. **前端读取**:前端从对话数据中读取文件记录列表渲染文件窗口。 +3. **文件内容**:预览用**现有文件内容 API**(用户确认已有,无需新做)。 +4. 待办 / 子智能体 / 后台指令:接各自现有数据流与事件推送。 + +## 5. 已踩过的实现坑(接入时避开) + +1. `display: flex` 会覆盖 `hidden` 属性的 `display: none` → 必须补 `.panel[hidden] { display: none; }`。 +2. flex 容器会压缩子元素(窗口变矮)→ 窗口加 `flex: none`。 +3. `animationend` 会冒泡(feed 行 → 详情面板),用 `{once:true}` 监听会被提前消费 → 时序控制改用 `setTimeout`。 +4. `overflow` 裁剪窗口阴影 → 滚动容器 `padding + 负 margin` 扩大滚动盒。 +5. 菜单 fixed 定位不随栈滚动 → 栈滚动时主动关闭菜单。 +6. 两个 ⋯ 菜单 + Esc 多层浮层:菜单互斥;Esc 按 菜单 → 详情面板 → 预览栏 顺序逐个关(用 `e.preventDefault()` + `e.defaultPrevented` 串联)。 + +## 6. 正式接入任务清单(2026-07-21 已全部完成) + +- [x] 后端:编辑文件路径记录写入对话 JSON(write/edit 钩子)+ 对话数据带出该列表 +- [x] 前端:`static/src` 快捷窗口占位列容器(非悬浮,挤压对话区) +- [x] 前端:4 个窗口组件 + 详情面板 + 文件预览侧边栏(配色走语义 token 三主题) +- [x] 前端:接入待办 / 子智能体 / 后台指令 / 文件记录的真实数据与事件推送 +- [x] 前端:文件预览内容接现有文件内容 API(扩展名白名单) +- [x] 动画参数按 demo 定稿迁移(位移 14px、stagger 60/45ms、划线 260ms 等) + +### 接入实现位置(2026-07-21) + +**后端** +- `core/main_terminal_parts/tools_execution.py`:`_record_edited_file` / `_remove_edited_file` / `_rename_edited_file` + `_mutate_edited_files`(写入对话 `metadata.edited_files`,同 path 去重;广播 `edited_files_updated`) +- `server/conversation.py`:`GET /api/conversations//messages` 返回 `edited_files`(过滤不存在文件) +- `server/app_legacy.py`:`terminal_broadcast` 白名单加 `edited_files_updated` +- `modules/personalization_manager.py`:`hide_quick_dock` 字段(默认 False) + +**前端** +- `static/src/components/chat/quickdock/`:`QuickDock.vue`(占位列容器+菜单+Esc 分层+轮询)、`TodoWindow.vue`、`RunnerWindow.vue`(子智能体/后台指令通用)、`RunnerDetailPanel.vue`、`FileWindow.vue`、`FilePreviewPanel.vue`、`quickdock.css`(全部 keyframes 与语义 token 配色) +- `static/src/stores/quickDock.ts`:editedFiles / detail / previewPath / menu 状态 +- `static/src/composables/useLegacySocket.ts`:监听 `edited_files_updated`(按 conversation_id 过滤) +- `static/src/app/methods/history.ts`:messages 加载后回填 editedFiles +- `static/src/App.vue`:`` 后挂载 `QuickDock` + `FilePreviewPanel`(移动端与 hide_quick_dock 时不渲染) +- `static/src/components/personalization/PersonalizationDrawer.vue`:「隐藏快捷窗口」设置行(默认隐藏工作区之后) + +**验证状态**:py_compile ✓ / 6 项后端冒烟 ✓ / tsc+stylelint+vite build ✓ / 新文件 eslint 0 问题,存量文件 lint 与基线持平。8091 服务重启后的实际运行验证待手动进行。 diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 446301ad..91d072b3 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -105,6 +105,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "agents_md_auto_inject": False, # AGENTS.md 自动注入开关 "allow_root_file_creation": False, # 允许在根目录创建文件开关 "default_hide_workspace": False, # 默认隐藏工作区 + "hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列) "group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话 "sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表 "sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序 @@ -528,6 +529,12 @@ def sanitize_personalization_payload( else: base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False)) + # 隐藏快捷窗口 + if "hide_quick_dock" in data: + base["hide_quick_dock"] = bool(data.get("hide_quick_dock")) + else: + base["hide_quick_dock"] = bool(base.get("hide_quick_dock", False)) + # 侧边栏按工作区/项目分组显示对话 if "group_sidebar_by_workspace" in data: base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace")) diff --git a/server/app_legacy.py b/server/app_legacy.py index 269839d7..4cb81b8d 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -1196,7 +1196,7 @@ def terminal_broadcast(event_type, data): """广播终端事件到所有订阅者""" try: # 对于全局事件,发送给所有连接的客户端 - if event_type in ('token_update', 'todo_updated'): + if event_type in ('token_update', 'todo_updated', 'edited_files_updated'): socketio.emit(event_type, data) # 全局广播,不限制房间 debug_log(f"全局广播{event_type}: {data}") else: diff --git a/server/conversation.py b/server/conversation.py index 5e044e14..d89a7fa5 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -1018,13 +1018,36 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace: limit = request.args.get('limit', type=int) if limit: messages = messages[-limit:] # 获取最后N条消息 - + + # 快捷窗口:本次对话编辑/创建过的文件记录(过滤不存在的文件) + edited_files: list = [] + raw_edited = (conversation_data.get("metadata") or {}).get("edited_files") + if isinstance(raw_edited, list): + file_manager = getattr(terminal, "file_manager", None) + for item in raw_edited: + if not isinstance(item, dict): + continue + rel_path = str(item.get("path") or "").strip() + if not rel_path: + continue + try: + valid, _err, full_path = file_manager._validate_path(rel_path) if file_manager else (False, None, None) + except Exception: + valid, full_path = False, None + if valid and full_path is not None and full_path.exists() and full_path.is_file(): + edited_files.append({ + "path": rel_path, + "op": item.get("op") or "edit", + "ts": item.get("ts") or "", + }) + return jsonify({ "success": True, "data": { "conversation_id": conversation_id, "messages": messages, - "total_count": len(conversation_data.get("messages", [])) + "total_count": len(conversation_data.get("messages", [])), + "edited_files": edited_files } }) else: diff --git a/server/conversation_bootstrap.py b/server/conversation_bootstrap.py index e1b0e914..ae438257 100644 --- a/server/conversation_bootstrap.py +++ b/server/conversation_bootstrap.py @@ -205,6 +205,28 @@ def bootstrap_conversation(conversation_id: str): "is_truly_active": is_main_running or any(bg_status.values()), } + # 快捷窗口:本次对话编辑/创建过的文件记录(过滤不存在的文件) + edited_files: list = [] + raw_edited = raw_meta.get("edited_files") + if isinstance(raw_edited, list): + file_manager = getattr(ws_terminal, "file_manager", None) + for item in raw_edited: + if not isinstance(item, dict): + continue + rel_path = str(item.get("path") or "").strip() + if not rel_path: + continue + try: + valid, _err, full_path = file_manager._validate_path(rel_path) if file_manager else (False, None, None) + except Exception: + valid, full_path = False, None + if valid and full_path is not None and full_path.exists() and full_path.is_file(): + edited_files.append({ + "path": rel_path, + "op": item.get("op") or "edit", + "ts": item.get("ts") or "", + }) + data: Dict[str, Any] = { "conversation_id": normalized_id, "meta": { @@ -221,6 +243,7 @@ def bootstrap_conversation(conversation_id: str): }, "messages": messages, "running": running, + "edited_files": edited_files, } if is_main_running: diff --git a/static/src/App.vue b/static/src/App.vue index a3968edf..ad027864 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -385,6 +385,13 @@ /> + +
setTimeout(resolve, 0)); + useQuickDockStore().setEditedFiles( + Array.isArray(data.edited_files) ? data.edited_files : [] + ); + // 5. 运行中任务快速恢复(任务/事件/判据已由 bootstrap 聚合, // 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求) const running = data.running || {}; diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index 9ab7d3be..20f79d9c 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { debugLog } from './common'; +import { useQuickDockStore } from '../../stores/quickDock'; const jsonDebug = (...args: any[]) => { console.log('[JSONDEBUG]', ...args); }; @@ -157,6 +158,10 @@ export const historyMethods = { const rawMessages = Array.isArray(messagesData?.data?.messages) ? messagesData.data.messages : []; + // 快捷窗口:回填本次对话编辑/创建文件记录(后端已过滤不存在文件) + useQuickDockStore().setEditedFiles( + Array.isArray(messagesData?.data?.edited_files) ? messagesData.data.edited_files : [] + ); const lastAssistantRaw = [...rawMessages] .reverse() .find((m: any) => m?.role === 'assistant'); diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 49e788e9..b9aa10b6 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { debugLog, goalModeDebugLog } from '../common'; import { useTaskStore } from '../../../stores/task'; +import { useQuickDockStore } from '../../../stores/quickDock'; import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility'; import { debugNotifyLog, @@ -293,6 +294,24 @@ export const lifecycleMethods = { case 'conversation_resolved': this.handleConversationResolved(eventData, eventIdx); break; + + case 'todo_updated': + // 任务期广播的待办快照(创建/勾选/清空都会触发)。live=true 让窗口播动画。 + // 快照直接可用;缺失时退化为 REST 拉取(fetchTodoList 带 conversation_id)。 + if (eventData && 'todo_list' in eventData) { + this.fileSetTodoList(eventData.todo_list || null, true); + } else if (typeof this.scheduleTodoListRefresh === 'function') { + this.scheduleTodoListRefresh(0); + } + break; + + case 'edited_files_updated': + // 快捷窗口文件记录:edit/write/delete/rename 后广播,payload 携带最新列表 + useQuickDockStore().setEditedFiles( + Array.isArray(eventData?.edited_files) ? eventData.edited_files : [], + true + ); + break; case 'compression_state': // 重放历史事件时不处理管理类事件,避免触发副作用(如 toast 闪烁 / 状态回退)。 if (this._rebuildingFromScratch) break; diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index 3fa75c41..59248537 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -1,11 +1,30 @@ // @ts-nocheck import { debugLog, traceLog } from './methods/common'; import { useConversationStore } from '../stores/conversation'; +import { useQuickDockStore } from '../stores/quickDock'; +import { useFileStore } from '../stores/file'; export const watchers = { multiAgentMode(newValue) { useConversationStore().$patch({ multiAgentMode: !!newValue }); }, + currentConversationId(newId, oldId) { + if (newId === oldId) { + return; + } + // 同步到 conversationStore(QuickDock 等组件监听 store 侧的 id) + useConversationStore().setCurrentConversationId(newId || null); + const quickDock = useQuickDockStore(); + // 关闭详情/预览/菜单等瞬态(必须立即) + quickDock.resetTransient(); + if (!newId) { + // /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠 + quickDock.setEditedFiles([]); + useFileStore().setTodoList(null); + } + // 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖; + // 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。 + }, inputMessage() { this.autoResizeInput(); if (typeof this.scheduleComposerDraftPersist === 'function') { diff --git a/static/src/components/chat/quickdock/FilePreviewPanel.vue b/static/src/components/chat/quickdock/FilePreviewPanel.vue new file mode 100644 index 00000000..d28d7dfd --- /dev/null +++ b/static/src/components/chat/quickdock/FilePreviewPanel.vue @@ -0,0 +1,175 @@ + + + diff --git a/static/src/components/chat/quickdock/FileWindow.vue b/static/src/components/chat/quickdock/FileWindow.vue new file mode 100644 index 00000000..8c1937f1 --- /dev/null +++ b/static/src/components/chat/quickdock/FileWindow.vue @@ -0,0 +1,177 @@ + + + diff --git a/static/src/components/chat/quickdock/QuickDock.vue b/static/src/components/chat/quickdock/QuickDock.vue new file mode 100644 index 00000000..a2b2ed2f --- /dev/null +++ b/static/src/components/chat/quickdock/QuickDock.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/static/src/components/chat/quickdock/RunnerDetailPanel.vue b/static/src/components/chat/quickdock/RunnerDetailPanel.vue new file mode 100644 index 00000000..ffcf1a58 --- /dev/null +++ b/static/src/components/chat/quickdock/RunnerDetailPanel.vue @@ -0,0 +1,507 @@ + + + diff --git a/static/src/components/chat/quickdock/RunnerWindow.vue b/static/src/components/chat/quickdock/RunnerWindow.vue new file mode 100644 index 00000000..95cc3131 --- /dev/null +++ b/static/src/components/chat/quickdock/RunnerWindow.vue @@ -0,0 +1,295 @@ + + + diff --git a/static/src/components/chat/quickdock/TodoWindow.vue b/static/src/components/chat/quickdock/TodoWindow.vue new file mode 100644 index 00000000..63ca68dc --- /dev/null +++ b/static/src/components/chat/quickdock/TodoWindow.vue @@ -0,0 +1,309 @@ + + + diff --git a/static/src/components/chat/quickdock/quickdock.css b/static/src/components/chat/quickdock/quickdock.css new file mode 100644 index 00000000..ea0af4f6 --- /dev/null +++ b/static/src/components/chat/quickdock/quickdock.css @@ -0,0 +1,924 @@ +/* ============================================================ + 快捷窗口(Quick Dock)共享样式 + 配色全部走三主题语义 token;动画参数迁移自 demo/todo_float_window + ============================================================ */ + +.quick-dock, +.qd-detail, +.qd-preview, +.qd-menu { + --qd-row-h: 36px; + --qd-width: 270px; + --qd-ease-out: cubic-bezier(0.22, 0.9, 0.28, 1); + --qd-ease-strike: cubic-bezier(0.55, 0.06, 0.35, 1); +} + +/* ---------------- 占位列容器 ---------------- */ + +.quick-dock { + flex: none; + width: 294px; /* 270 + 左右 12 padding */ + padding: 20px 12px 20px 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + transition: + width 0.3s ease, + padding 0.3s ease, + opacity 0.25s ease, + transform 0.3s var(--qd-ease-out); +} + +/* 四个窗口都无内容时收起到 0 宽,不挤压对话区 */ +.quick-dock--empty { + width: 0; + padding: 0; + opacity: 0; + transform: translateX(24px); +} + +.quick-dock__scroll { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + /* padding + 负 margin:扩大滚动盒以容纳窗口阴影,避免被 overflow 裁剪 */ + padding: 24px; + margin: -24px; +} + +.quick-dock__scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +/* ---------------- 窗口通用 ---------------- */ + +.qd-window { + flex: none; /* 禁止被列 flex 压缩变矮 */ + width: var(--qd-width); + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: 12px; + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.qd-window.qd-window-enter { + animation: qd-window-in 0.3s ease-out; +} + +@keyframes qd-window-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.qd-window__header { + height: 38px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 12px; + border-bottom: 1px solid var(--border-default); +} + +.qd-window__icon { + width: 15px; + height: 15px; + color: var(--accent); + flex: none; +} + +.qd-window__title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.02em; +} + +.qd-window__counter { + margin-left: auto; + font-size: 11.5px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +/* 列表:固定 5 行,无上下 padding(首行紧贴标题分隔线),滚动条隐藏 */ +.qd-list { + list-style: none; + margin: 0; + padding: 0; + height: calc(var(--qd-row-h) * 5); + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.qd-list::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +/* ---------------- 行进入 / 移出动画(待办 / 子智能体 / 后台指令 / 文件共用) ---------------- */ + +.qd-row-enter { + opacity: 0; + animation: qd-row-in 0.34s var(--qd-ease-out) forwards; +} + +@keyframes qd-row-in { + from { + opacity: 0; + transform: translateX(-14px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.qd-row-leave { + animation: qd-row-out 0.26s ease-in forwards; +} + +@keyframes qd-row-out { + 0% { + opacity: 1; + transform: translateX(0); + height: var(--qd-row-h); + } + 55% { + opacity: 0; + transform: translateX(-14px); + height: var(--qd-row-h); + } + 100% { + opacity: 0; + transform: translateX(-14px); + height: 0; + } +} + +/* ---------------- 待办行 ---------------- */ + +.qd-todo-item { + height: var(--qd-row-h); + display: flex; + align-items: center; + gap: 9px; + padding: 0 12px; + overflow: hidden; /* 配合移出动画的高度收拢 */ +} + +.qd-todo-dot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + border: 1.5px solid var(--text-tertiary); + background: transparent; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.qd-todo-item.is-done .qd-todo-dot { + background: var(--accent); + border-color: var(--accent); +} + +.qd-todo-text { + position: relative; + flex: 1; + min-width: 0; + font-size: 13px; + line-height: 1; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线 */ +} + +.qd-todo-text::after { + content: ''; + position: absolute; + left: -1px; + right: -1px; + top: 50%; + height: 1.5px; + margin-top: -0.75px; + background: var(--text-tertiary); + border-radius: 1px; + transform: scaleX(0); + transform-origin: left center; + pointer-events: none; +} + +/* 完成静态态(整表渲染时已完成行直接呈现,不播划线) */ +.qd-todo-item.is-done .qd-todo-text { + color: var(--text-tertiary); +} + +.qd-todo-item.is-done .qd-todo-text::after { + transform: scaleX(1); +} + +/* 完成动作瞬间:横线从左往右划过 + 状态点弹跳 */ +.qd-todo-item.just-done .qd-todo-text::after { + animation: qd-strike-in 0.28s var(--qd-ease-strike) forwards; +} + +@keyframes qd-strike-in { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +.qd-todo-item.just-done .qd-todo-dot { + animation: qd-dot-pop 0.32s var(--qd-ease-out); +} + +@keyframes qd-dot-pop { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.6); + } + 100% { + transform: scale(1); + } +} + +/* ---------------- 子智能体 / 后台指令行 ---------------- */ + +.qd-run-item { + height: var(--qd-row-h); + display: flex; + align-items: center; + gap: 7px; + padding: 0 8px 0 12px; + overflow: hidden; + cursor: pointer; + transition: background 0.12s ease; +} + +.qd-run-item:hover { + background: var(--hover-bg); +} + +.qd-run-item.is-active { + background: var(--surface-soft); +} + +.qd-run-status { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: transparent; +} + +/* 状态配色对齐旧工作区面板 .sub-agent-status: + 运行中=info 蓝 / 空闲=次要文字白 / 完成=success 绿 / 失败·超时·终止=danger 红 */ +.qd-run-item.is-running .qd-run-status { + background: var(--state-info); + animation: qd-status-pulse 1.6s ease-in-out infinite; +} + +.qd-run-item.is-idle .qd-run-status { + background: var(--text-secondary); +} + +.qd-run-item.is-done .qd-run-status { + background: var(--state-success); +} + +.qd-run-item.is-ended .qd-run-status { + background: var(--state-danger); +} + +@keyframes qd-status-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +.qd-run-name { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.qd-run-item[data-kind='cmd'] .qd-run-name { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 12px; +} + +.qd-run-item.is-done .qd-run-name, +.qd-run-item.is-ended .qd-run-name { + color: var(--text-tertiary); +} + +/* ⋯ 按钮(行内) */ +.qd-row-menu-btn { + flex: none; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: 5px; + color: var(--text-tertiary); + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.qd-row-menu-btn svg { + width: 14px; + height: 14px; +} + +.qd-row-menu-btn:hover { + background: var(--tab-active); + color: var(--text-secondary); +} + +/* ---------------- 文件行 ---------------- */ + +.qd-file-item { + height: var(--qd-row-h); + display: flex; + align-items: center; + gap: 7px; + padding: 0 10px 0 6px; + overflow: hidden; + cursor: pointer; + transition: background 0.12s ease; +} + +.qd-file-item:hover { + background: var(--hover-bg); +} + +.qd-file-item.is-active { + background: var(--surface-soft); +} + +.qd-file-name { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------------- 详情面板(fixed 浮在列左侧) ---------------- */ + +.qd-detail { + position: fixed; + top: 20px; + right: calc(294px + 12px); /* 快捷窗口列宽 + gap */ + width: 480px; + max-width: calc(100vw - 360px); + height: 460px; + max-height: calc(100vh - 40px); + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: 12px; + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 90; +} + +.qd-detail.panel-enter { + animation: qd-panel-in 0.24s var(--qd-ease-out); +} + +@keyframes qd-panel-in { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.qd-detail.panel-leave { + animation: qd-panel-out 0.18s ease-in forwards; +} + +@keyframes qd-panel-out { + to { + opacity: 0; + transform: translateX(8px); + } +} + +.qd-detail__header { + flex: none; + height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--border-default); +} + +.qd-detail__dot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-tertiary); +} + +.qd-detail__dot.is-running { + background: var(--state-info); + animation: qd-status-pulse 1.6s ease-in-out infinite; +} + +.qd-detail__dot.is-idle { + background: var(--text-secondary); +} + +.qd-detail__dot.is-done { + background: var(--state-success); +} + +.qd-detail__dot.is-ended { + background: var(--state-danger); +} + +.qd-detail__title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.qd-detail__badge { + flex: none; + font-size: 11px; + line-height: 1; + padding: 4px 8px; + border-radius: 99px; + background: var(--surface-soft); + color: var(--text-tertiary); +} + +.qd-detail__badge.is-running { + background: color-mix(in srgb, var(--state-info) 14%, transparent); + color: var(--state-info); +} + +.qd-detail__badge.is-idle { + background: var(--surface-soft); + color: var(--text-secondary); +} + +.qd-detail__badge.is-done { + background: color-mix(in srgb, var(--state-success) 14%, transparent); + color: var(--state-success); +} + +.qd-detail__badge.is-ended { + background: color-mix(in srgb, var(--state-danger) 14%, transparent); + color: var(--state-danger); +} + +.qd-detail__close { + flex: none; + margin-left: auto; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: 6px; + color: var(--text-tertiary); + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.qd-detail__close svg { + width: 13px; + height: 13px; +} + +.qd-detail__close:hover { + background: var(--tab-active); + color: var(--text-secondary); +} + +.qd-detail__body { + flex: 1; + overflow-y: auto; + padding: 6px 0 10px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.qd-detail__body::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.qd-detail__body.body-fade { + animation: qd-body-fade-in 0.18s ease-out; +} + +@keyframes qd-body-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.qd-detail__empty { + padding: 18px 14px; + font-size: 12.5px; + color: var(--text-tertiary); +} + +/* ---------------- feed 行(扁平行式,无嵌套卡片) ---------------- */ + +.qd-feed-row { + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + min-height: 28px; + font-size: 12.5px; +} + +/* 仅「面板打开期间新出现」的行播进入动画;打开时已有的行由组件静态渲染, + 避免每次打开详情全部行重播一遍出现动画 */ +.qd-feed-row.is-new { + animation: qd-feed-in 0.25s var(--qd-ease-out); +} + +@keyframes qd-feed-in { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.qd-feed-row.feed-text { + align-items: flex-start; + padding-top: 5px; + padding-bottom: 5px; + color: var(--text-secondary); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.qd-feed-row.feed-tool .tool-name { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 12px; + color: var(--text-primary); + flex: none; +} + +.qd-feed-row.feed-tool .tool-param { + color: var(--text-tertiary); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.qd-feed-row.feed-tool .tool-result { + flex: none; + margin-left: auto; + font-size: 11.5px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.qd-feed-row.feed-tool .tool-result.is-error { + color: var(--state-danger); +} + +.qd-tool-spinner { + flex: none; + width: 11px; + height: 11px; + border-radius: 50%; + border: 1.5px solid var(--border-strong); + border-top-color: var(--accent); + animation: qd-tool-spin 0.7s linear infinite; +} + +@keyframes qd-tool-spin { + to { + transform: rotate(360deg); + } +} + +.qd-tool-done { + flex: none; + width: 11px; + text-align: center; + font-size: 11px; + color: var(--accent); +} + +.qd-feed-row.feed-term { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 12px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + min-height: 24px; +} + +.qd-feed-row.feed-term.is-command { + color: var(--text-primary); + font-weight: 600; +} + +.qd-feed-row.feed-killed { + color: var(--state-danger); +} + +/* ---------------- 文件预览侧边栏(占位列) ---------------- */ + +.qd-preview { + flex: none; + width: 452px; /* 440 + 右 12 padding */ + max-width: calc(100vw - 200px); + padding: 20px 12px 20px 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 0; +} + +.qd-preview__panel { + flex: 1; + min-height: 0; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: 12px; + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 预览侧边栏滑入滑出(对齐 git/终端面板 right-panels-slide 的参数) */ +.qd-preview-slide-enter-active, +.qd-preview-slide-leave-active { + transition: + width 0.24s ease, + padding 0.24s ease, + opacity 0.2s ease, + transform 0.24s var(--qd-ease-out); + overflow: hidden; +} + +.qd-preview-slide-enter-from, +.qd-preview-slide-leave-to { + width: 0 !important; + padding-right: 0 !important; + opacity: 0; + transform: translateX(24px); +} + +.qd-preview__header { + flex: none; + height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + border-bottom: 1px solid var(--border-default); +} + +.qd-preview__header > svg { + flex: none; + width: 15px; + height: 15px; + color: var(--accent); +} + +.qd-preview__name { + flex: none; + max-width: 45%; + font-size: 13px; + font-weight: 600; + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.qd-preview__path { + flex: 1; + min-width: 0; + font-size: 11.5px; + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.qd-preview__close { + flex: none; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + border-radius: 6px; + color: var(--text-tertiary); + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.qd-preview__close svg { + width: 13px; + height: 13px; +} + +.qd-preview__close:hover { + background: var(--tab-active); + color: var(--text-secondary); +} + +.qd-preview__body { + flex: 1; + overflow: auto; + padding: 8px 0 12px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.qd-preview__body::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.qd-preview__loading, +.qd-preview__error { + padding: 18px 14px; + font-size: 12.5px; + color: var(--text-tertiary); +} + +.qd-preview__error { + color: var(--state-danger); +} + +.qd-code-line { + display: flex; + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.75; +} + +.qd-code-line:hover { + background: var(--hover-bg); +} + +.qd-code-line .line-no { + flex: none; + width: 44px; + padding-right: 12px; + text-align: right; + color: var(--text-tertiary); + user-select: none; +} + +.qd-code-line .line-text { + flex: 1; + padding-right: 14px; + white-space: pre; + color: var(--text-primary); +} + +/* ---------------- 全局 ⋯ 菜单(fixed 单例) ---------------- */ + +.qd-menu { + position: fixed; + z-index: 300; + min-width: 116px; + padding: 4px; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: 8px; + box-shadow: var(--shadow-card); +} + +.qd-menu.menu-enter { + animation: qd-menu-in 0.16s ease-out; +} + +@keyframes qd-menu-in { + from { + opacity: 0; + transform: translateY(-3px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.qd-menu__item { + display: flex; + align-items: center; + width: 100%; + height: 30px; + padding: 0 10px; + background: none; + border: none; + border-radius: 5px; + font-size: 12.5px; + text-align: left; + color: var(--text-primary); + cursor: pointer; + transition: background 0.12s ease; +} + +.qd-menu__item:hover:not(:disabled) { + background: var(--surface-soft); +} + +.qd-menu__item--danger { + color: var(--state-danger); +} + +.qd-menu__item--danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--state-danger) 8%, transparent); +} + +.qd-menu__item:disabled { + color: var(--text-tertiary); + cursor: default; +} diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 0ea6be8f..8976d180 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -637,6 +637,28 @@ class="fancy-path" > +