/* ============================================================ 工作区菜单重设计 Demo — 交互 数据字段对齐真实结构:workspace_id / label / path / running_task_count ============================================================ */ (function () { 'use strict'; /* ---------- 示例数据(字段语义与 HostWorkspaceManageDialog 一致) ---------- */ const DATA = { host: [ { workspace_id: 'default', label: '默认工作区', path: '~/Desktop/agents', running_task_count: 2 }, { workspace_id: 'ws-client-a', label: '客户 A 项目', path: '~/Desktop/work/client-a', running_task_count: 0 }, { workspace_id: 'ws-research', label: '论文实验', path: '~/Documents/research/thesis', running_task_count: 1 }, { workspace_id: 'ws-playground', label: '临时测试', path: '/tmp/agent-playground', running_task_count: 0 } ], docker: [ { workspace_id: 'default', label: '默认项目', path: '', running_task_count: 0 }, { workspace_id: 'ws-client-a', label: '客户 A 项目', path: '', running_task_count: 0 }, { workspace_id: 'ws-research', label: '论文实验', path: '', running_task_count: 0 } ] }; const state = { mode: 'host', // host=宿主机(工作区) / docker=项目 workspaces: [], currentId: 'default', defaultId: 'default', menuOpenId: null, // 「…」菜单展开的行 renamingId: null, // 重命名内联编辑的行 deletingId: null, // 删除确认内联的行 popoverOpen: true, createOpen: false }; const cloneData = (mode) => DATA[mode].map((w) => ({ ...w })); state.workspaces = cloneData('host'); /* ---------- DOM ---------- */ const canvas = document.getElementById('canvas'); const popover = document.getElementById('wsPopover'); const listEl = document.getElementById('wsList'); const countEl = document.getElementById('wsCount'); const headerTitle = document.getElementById('headerTitle'); const entryBtn = document.getElementById('workspaceEntry'); const entryLabel = document.getElementById('entryLabel'); const entryCurrentName = document.getElementById('entryCurrentName'); const bottomEl = document.getElementById('wsBottom'); const footerEl = document.getElementById('wsFooter'); const createBtn = document.getElementById('createBtn'); const createBtnText = document.getElementById('createBtnText'); const createForm = document.getElementById('createForm'); const createName = document.getElementById('createName'); const createPath = document.getElementById('createPath'); const createCancel = document.getElementById('createCancel'); const statusEl = document.getElementById('stageStatus'); const esc = (s) => String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const setStatus = (msg) => { statusEl.textContent = msg; }; const isDocker = () => state.mode === 'docker'; const noun = () => (isDocker() ? '项目' : '工作区'); /* 内联 SVG 图标(file:// 协议下 会被 Chrome 拦截,故内联) */ const ICON = { folder: '', dots: '' }; /* ============================================================ 浮层定位:锚定「工作区」入口按钮右侧,并夹紧在画布内 ============================================================ */ function positionPopover() { const c = canvas.getBoundingClientRect(); const b = entryBtn.getBoundingClientRect(); const ph = popover.offsetHeight || 0; let top = b.top - c.top - 6; const maxTop = Math.max(8, c.height - ph - 42); /* 42 = 底部状态条 + 余量 */ top = Math.max(8, Math.min(top, maxTop)); popover.style.left = b.right - c.left + 8 + 'px'; popover.style.top = top + 'px'; } function setPopoverOpen(open) { state.popoverOpen = open; popover.classList.toggle('open', open); entryBtn.classList.toggle('active', open); entryBtn.setAttribute('aria-expanded', String(open)); if (open) { positionPopover(); } else { closeTransient(); if (state.createOpen) closeCreateForm(true); } } function closeTransient() { closeMenu(); state.renamingId = null; state.deletingId = null; } /* ============================================================ 「…」二级菜单:fixed 浮层,逃逸容器裁切 (与 PersonalizationDrawer 的 settings-floating-menu 同方案) ============================================================ */ let menuEl = null; function ensureMenuEl() { if (menuEl) return menuEl; menuEl = document.createElement('div'); menuEl.className = 'ws-menu-floating'; document.body.appendChild(menuEl); menuEl.addEventListener('click', (e) => { const btn = e.target.closest('[data-act]'); if (!btn || btn.disabled) return; e.stopPropagation(); handleMenuAction(btn.dataset.act); }); return menuEl; } function openMenu(rowEl, id) { state.menuOpenId = id; state.renamingId = null; state.deletingId = null; renderRows(); const ws = state.workspaces.find((w) => w.workspace_id === id); if (!ws) return; const isDefault = id === state.defaultId; const el = ensureMenuEl(); el.innerHTML = '' + '' + '' + '
' + ''; el.style.display = 'flex'; /* renderRows 后旧行元素已摘除,必须按 data-id 重新查询再取坐标 */ const newRow = listEl.querySelector('.ws-row[data-id="' + id + '"]'); const btn = newRow ? newRow.querySelector('.ws-more') : null; if (!btn) { closeMenu(); return; } const rect = btn.getBoundingClientRect(); const mw = el.offsetWidth; const mh = el.offsetHeight; let left = rect.right - mw; let top = rect.bottom + 4; left = Math.max(8, Math.min(left, window.innerWidth - mw - 8)); if (top + mh > window.innerHeight - 8) top = rect.top - mh - 4; el.style.left = Math.round(left) + 'px'; el.style.top = Math.round(top) + 'px'; requestAnimationFrame(() => el.classList.add('open')); } function closeMenu() { state.menuOpenId = null; if (menuEl) { menuEl.classList.remove('open'); menuEl.style.display = 'none'; } } function handleMenuAction(act) { const id = state.menuOpenId; const ws = state.workspaces.find((w) => w.workspace_id === id); closeMenu(); if (!ws) return; if (act === 'set-default') { state.defaultId = id; setStatus('已将「' + ws.label + '」设为默认' + noun()); renderRows(); } else if (act === 'rename') { state.renamingId = id; renderRows(); } else if (act === 'reveal') { /* 实现参考 InputComposer.vue 的 openProjectInFileManager */ setStatus('在文件夹中打开:' + (ws.path || ws.label)); } else if (act === 'delete') { state.deletingId = id; renderRows(); } } /* ============================================================ 渲染列表行 ============================================================ */ function renderRows() { const current = state.workspaces.find((w) => w.workspace_id === state.currentId); entryCurrentName.textContent = current ? current.label : ''; countEl.textContent = state.workspaces.length + ' 个'; listEl.innerHTML = ''; for (const ws of state.workspaces) { const li = document.createElement('div'); const isCurrent = ws.workspace_id === state.currentId; const isDefault = ws.workspace_id === state.defaultId; li.className = 'ws-row' + (isDocker() ? ' docker' : '') + (isCurrent ? ' current' : '') + (state.menuOpenId === ws.workspace_id ? ' menu-open' : ''); li.dataset.id = ws.workspace_id; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', String(isCurrent)); if (state.deletingId === ws.workspace_id) { /* --- 删除确认内联 --- */ li.innerHTML = '
' + '
删除「' + esc(ws.label) + '」?
' + '
' + '' + '' + '
' + '
'; } else { const metaHtml = (isDefault ? '默认' : '') + ''; const nameHtml = state.renamingId === ws.workspace_id ? '' : '' + esc(ws.label) + ''; const pathHtml = isDocker() ? '' : '' + esc(ws.path || '(未配置路径)') + ''; li.innerHTML = '' + ICON.folder + '' + '' + nameHtml + pathHtml + '' + '' + metaHtml + ''; } listEl.appendChild(li); } /* 重命名输入框自动聚焦 */ const renameInput = listEl.querySelector('[data-rename-input]'); if (renameInput) { renameInput.focus(); renameInput.select(); } } /* ============================================================ 行为 ============================================================ */ function switchWorkspace(id) { if (id === state.currentId) return; state.currentId = id; const ws = state.workspaces.find((w) => w.workspace_id === id); setStatus('已切换到「' + (ws ? ws.label : id) + '」'); renderRows(); } function commitRename(id, value) { const label = value.trim(); if (label) { const ws = state.workspaces.find((w) => w.workspace_id === id); if (ws && ws.label !== label) { ws.label = label; setStatus('已重命名为「' + label + '」'); } } state.renamingId = null; renderRows(); } function removeWorkspace(id) { const idx = state.workspaces.findIndex((w) => w.workspace_id === id); if (idx === -1) return; const removed = state.workspaces[idx]; state.workspaces.splice(idx, 1); if (state.currentId === id && state.workspaces.length) { state.currentId = state.workspaces[0].workspace_id; } if (state.defaultId === id && state.workspaces.length) { state.defaultId = state.workspaces[0].workspace_id; } state.deletingId = null; setStatus('已删除「' + removed.label + '」'); renderRows(); } /* ============================================================ 新建表单:底部区域高度动画 + 交叉淡入淡出 ============================================================ */ let bottomAnimating = false; function swapBottom(toForm, done) { if (bottomAnimating) return; bottomAnimating = true; const fromEl = toForm ? footerEl : createForm; const toEl = toForm ? createForm : footerEl; const h0 = fromEl.offsetHeight; bottomEl.style.height = h0 + 'px'; fromEl.classList.add('fading'); setTimeout(() => { fromEl.hidden = true; fromEl.classList.remove('fading'); toEl.hidden = false; toEl.classList.add('fading'); const h1 = toEl.offsetHeight; bottomEl.style.height = h1 + 'px'; requestAnimationFrame(() => { requestAnimationFrame(() => toEl.classList.remove('fading')); }); setTimeout(() => { bottomEl.style.height = ''; bottomAnimating = false; if (typeof done === 'function') done(); }, 210); }, 130); } function openCreateForm() { if (state.createOpen) return; state.createOpen = true; closeMenu(); createName.value = ''; createPath.value = ''; swapBottom(true, () => createName.focus()); } function closeCreateForm(instant) { if (!state.createOpen) return; state.createOpen = false; if (instant) { createForm.hidden = true; footerEl.hidden = false; bottomEl.style.height = ''; return; } swapBottom(false); } createBtn.addEventListener('click', openCreateForm); createCancel.addEventListener('click', () => closeCreateForm(false)); createForm.addEventListener('submit', (e) => { e.preventDefault(); const label = createName.value.trim() || (isDocker() ? '未命名项目' : '未命名工作区'); const path = createPath.value.trim(); const id = 'ws-' + Date.now().toString(36); state.workspaces.push({ workspace_id: id, label: label, path: path, running_task_count: 0 }); setStatus('已创建「' + label + '」'); closeCreateForm(false); renderRows(); listEl.scrollTop = listEl.scrollHeight; }); /* ============================================================ 事件:列表行(事件委托) ============================================================ */ listEl.addEventListener('click', (e) => { const actBtn = e.target.closest('[data-act]'); const row = e.target.closest('.ws-row'); const id = row ? row.dataset.id : null; if (actBtn) { e.stopPropagation(); const act = actBtn.dataset.act; if (act === 'menu') { if (state.menuOpenId === id) { closeMenu(); renderRows(); } else { openMenu(row, id); } return; } if (act === 'cancel-delete') { state.deletingId = null; renderRows(); return; } if (act === 'confirm-delete') { removeWorkspace(id); return; } } /* 点击行本体:切换工作区(重命名/删除确认态除外) */ if (row && id && !state.renamingId && !state.deletingId) { if (state.menuOpenId) { closeMenu(); renderRows(); return; } switchWorkspace(id); } }); /* 重命名输入:Enter 提交 / Esc 取消 */ listEl.addEventListener('keydown', (e) => { const input = e.target.closest('[data-rename-input]'); if (!input) return; const row = input.closest('.ws-row'); const id = row ? row.dataset.id : null; if (e.key === 'Enter') { e.preventDefault(); commitRename(id, input.value); } if (e.key === 'Escape') { e.preventDefault(); state.renamingId = null; renderRows(); } e.stopPropagation(); }); /* 列表滚动时收起浮动菜单 */ listEl.addEventListener('scroll', () => { if (state.menuOpenId) { closeMenu(); renderRows(); } }); /* ============================================================ 事件:入口按钮 / 外部点击 / Esc ============================================================ */ entryBtn.addEventListener('click', (e) => { e.stopPropagation(); setPopoverOpen(!state.popoverOpen); }); popover.addEventListener('click', (e) => { e.stopPropagation(); }); document.addEventListener('click', (e) => { if (state.menuOpenId && !(menuEl && menuEl.contains(e.target))) { closeMenu(); renderRows(); } if (state.popoverOpen && !popover.contains(e.target) && !entryBtn.contains(e.target)) { setPopoverOpen(false); } }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (state.menuOpenId) { closeMenu(); renderRows(); return; } if (state.renamingId || state.deletingId) { state.renamingId = null; state.deletingId = null; renderRows(); return; } if (state.createOpen) { closeCreateForm(false); return; } if (state.popoverOpen) setPopoverOpen(false); }); window.addEventListener('resize', () => { if (state.popoverOpen) positionPopover(); if (state.menuOpenId) { closeMenu(); renderRows(); } }); /* ============================================================ Demo 外框:主题切换 / 模式切换 ============================================================ */ const themeBtns = document.querySelectorAll('[data-theme-btn]'); themeBtns.forEach((btn) => { btn.addEventListener('click', () => { document.documentElement.setAttribute('data-theme', btn.dataset.themeBtn); themeBtns.forEach((b) => b.classList.toggle('active', b === btn)); }); }); const modeBtns = document.querySelectorAll('[data-mode-btn]'); modeBtns.forEach((btn) => { btn.addEventListener('click', () => { const mode = btn.dataset.modeBtn; if (mode === state.mode) return; state.mode = mode; modeBtns.forEach((b) => b.classList.toggle('active', b === btn)); /* 切换模式:重置数据与瞬时态 */ state.workspaces = cloneData(mode); state.currentId = state.workspaces[0] ? state.workspaces[0].workspace_id : ''; state.defaultId = state.currentId; closeTransient(); if (state.createOpen) closeCreateForm(true); /* 文案切换 */ headerTitle.textContent = noun(); entryLabel.textContent = noun(); createBtnText.textContent = '新建' + noun(); createName.placeholder = isDocker() ? '项目名称' : '工作区名称(可选)'; createPath.style.display = isDocker() ? 'none' : ''; popover.setAttribute('aria-label', noun()); popover.classList.toggle('docker', isDocker()); setStatus(isDocker() ? 'Docker 模式:叫「项目」,不显示路径,「在文件夹中打开」禁用' : '宿主机模式:显示路径,可在文件夹中打开'); renderRows(); positionPopover(); }); }); /* ---------- 初始化 ---------- */ renderRows(); setPopoverOpen(true); })();