agent-Specialization/demo/workspace_menu_redesign/app.js
JOJO b593d63856 feat(frontend): 侧边栏紧凑化与挤压式重设计,移除工作区面板功能拆分
- 对话侧边栏:行高/字号/圆角紧凑化,fixed 覆盖式改为文档流挤压式展开,移除工作区/虚拟显示器按钮
- 新增 WorkspaceSwitcher 浮层组件:列表即切换器,内联重命名/删除确认/新建表单动画,「…」菜单 fixed 逃逸裁切,刚好 7 行内部滚动
- 移除 LeftPanel 及两个旧工作区对话框:管理工作区迁入侧边栏,文件管理迁入输入栏 + 菜单(最多 4 项滚动)
- 个人空间移除默认隐藏工作区/隐藏快捷窗口设置项;删除 panelMode 门控轮询(QuickDock 常驻轮询 + socket 推送替代)
- 修复 flex 压缩导致菜单行高失效、分组对话 viewport 裁切最后一行、action-wrap 撑高对话项、/new 残留最近对话待办(无对话 id 不再请求 todo-list)
- 工作区入口按钮改内联 layers 图标并统一 18px,host/docker 模式 localStorage 缓存避免按钮首屏延迟出现
2026-07-23 16:47:49 +08:00

497 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
工作区菜单重设计 Demo — 交互
数据字段对齐真实结构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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const setStatus = (msg) => { statusEl.textContent = msg; };
const isDocker = () => state.mode === 'docker';
const noun = () => (isDocker() ? '项目' : '工作区');
/* 内联 SVG 图标file:// 协议下 <use> 会被 Chrome 拦截,故内联) */
const ICON = {
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>',
dots: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>'
};
/* ============================================================
浮层定位:锚定「工作区」入口按钮右侧,并夹紧在画布内
============================================================ */
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 =
'<button type="button" data-act="set-default"' + (isDefault ? ' disabled' : '') + '>设为默认</button>' +
'<button type="button" data-act="rename">重命名</button>' +
'<button type="button" data-act="reveal"' + (isDocker() ? ' disabled title="Docker 模式不可用"' : '') + '>在文件夹中打开</button>' +
'<div class="ws-menu-sep"></div>' +
'<button type="button" class="danger" data-act="delete">删除</button>';
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 =
'<div class="ws-confirm">' +
'<div class="ws-confirm-text">删除「' + esc(ws.label) + '」?</div>' +
'<div class="ws-confirm-actions">' +
'<button type="button" class="ws-btn ghost" data-act="cancel-delete">取消</button>' +
'<button type="button" class="ws-btn danger" data-act="confirm-delete">删除</button>' +
'</div>' +
'</div>';
} else {
const metaHtml =
(isDefault ? '<span class="ws-tag">默认</span>' : '') +
'<button type="button" class="ws-more" data-act="menu" aria-label="更多操作" aria-expanded="' +
String(state.menuOpenId === ws.workspace_id) + '">' + ICON.dots + '</button>';
const nameHtml = state.renamingId === ws.workspace_id
? '<input class="ws-rename-input" data-rename-input value="' + esc(ws.label) + '" />'
: '<span class="ws-row-name">' + esc(ws.label) + '</span>';
const pathHtml = isDocker()
? ''
: '<span class="ws-row-path">' + esc(ws.path || '(未配置路径)') + '</span>';
li.innerHTML =
'<span class="ws-row-icon">' + ICON.folder + '</span>' +
'<span class="ws-row-text">' + nameHtml + pathHtml + '</span>' +
'<span class="ws-row-meta">' + metaHtml + '</span>';
}
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);
})();