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