From 36c561fa7e8d626baec3e0d3fe8a335543e7dfca Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 01:01:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20+=20=E9=9D=A2=E6=9D=BF=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E5=B7=A5=E4=BD=9C=E5=8C=BA=EF=BC=88=E6=8B=93=E6=89=91?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=20+=20=E5=B8=83=E5=B1=80=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E4=B8=80=E7=AD=89=E5=85=AC=E6=B0=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/src/client/index.tsx | 144 +++++++++++++++++- 01_content/src/client/locales.ts | 26 ++++ 01_content/src/client/styles.ts | 18 ++- 02_process/PRD.md | 5 +- .../worklogs/2026-08-16_worktable-v2.md | 12 ++ 5 files changed, 196 insertions(+), 9 deletions(-) diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 963fe6a..93e834f 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -43,6 +43,8 @@ type ProjectsState = { nameOverrides: Record /** 本地快捷方式条目。 */ shortcuts: Shortcut[] + /** 用户自建的布局条目(「+」新建工作区保存的 LayoutSpec)。 */ + layouts: LayoutSpec[] } const PERSIST_KEY = 'dsh.worktable.view.v1' @@ -62,6 +64,38 @@ const OVER_BOTTOM_PX = 24 const OVER_TOP_PX = 24 const OVER_SIDE_PX = 80 +/** 拓扑预设(聊天窗恒贴右,PRD §13.2 硬约束):左右/三栏/上一下二/井字 */ +const PRESET_DEFS = [ + { id: '2h', topCount: 0, contentCount: 1 }, + { id: '3h', topCount: 0, contentCount: 2 }, + { id: 't2', topCount: 1, contentCount: 1 }, + { id: 'grid', topCount: 2, contentCount: 1 }, +] as const + +function presetTotal(def: { topCount: number; contentCount: number }): number { + return def.topCount + def.contentCount +} + +function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec { + const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0] + const mk = (i: number, url: string) => ({ + id: 'p' + (i + 1), + title: '内容' + (i + 1), + min: 200, + content: { kind: 'iframe' as const, url }, + }) + const top = Array.from({ length: def.topCount }, (_, i) => mk(i, urls[i] ?? 'about:blank')) + const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i, urls[def.topCount + i] ?? 'about:blank')) + return { + id: 'layout-' + Date.now().toString(36), + title: name, + top: top.length > 0 ? top : null, + main, + chatWidth: { default: 360, min: 240, max: 600 }, + topHeight: { default: 200, min: 120, max: 480 }, + } +} + const DEFAULT_VIEW: ViewState = { query: '', searchOpen: false, @@ -76,6 +110,7 @@ const DEFAULT_PROJECTS: ProjectsState = { hidden: [], nameOverrides: {}, shortcuts: [], + layouts: [], } function loadView(): ViewState { @@ -114,6 +149,9 @@ function loadProjects(): ProjectsState { shortcuts: Array.isArray(p.shortcuts) ? p.shortcuts.filter((s: any) => s && typeof s.id === 'string' && typeof s.name === 'string' && typeof s.href === 'string') : [], + layouts: Array.isArray(p.layouts) + ? p.layouts.filter((l: any) => l && typeof l.id === 'string' && typeof l.title === 'string' && Array.isArray(l.main)) + : [], } } catch { return { ...DEFAULT_PROJECTS } @@ -167,6 +205,10 @@ function WorktableSection(props: any) { const [scIcon, setScIcon] = useState('') const [scHref, setScHref] = useState('') const [scError, setScError] = useState(false) + const [wsPreset, setWsPreset] = useState('2h') + const [wsName, setWsName] = useState('') + const [wsUrls, setWsUrls] = useState(['', '', '']) + const [wsError, setWsError] = useState(false) const [float, setFloat] = useState(() => view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null, ) @@ -319,12 +361,14 @@ function WorktableSection(props: any) { }, []) // ── 有效排序 ── - // 手动:持久化 order(过滤已卸载 id)→ 新注册 id 追加尾部; + // 手动:持久化 order(过滤已卸载 id)→ 新注册 id 与布局 id 追加尾部; // 最近:有 lastUsed 的按时间降序在前,其余按手动序在后。 + const layoutIds = useMemo(() => projects.layouts.map((l) => l.id), [projects.layouts]) + const allIds = useMemo(() => [...registeredIds, ...layoutIds], [registeredIds, layoutIds]) const effectiveOrder = useMemo(() => { - const known = new Set(registeredIds) + const known = new Set(allIds) const stored = projects.order.filter((id) => known.has(id)) - const rest = registeredIds.filter((id) => !stored.includes(id)) + const rest = allIds.filter((id) => !stored.includes(id)) let list = [...stored, ...rest] if (view.orderBy === 'recent') { const hot = list.filter((id) => projects.lastUsed[id] != null) @@ -333,7 +377,7 @@ function WorktableSection(props: any) { list = [...hot, ...cold] } return list - }, [registeredIds, projects.order, projects.lastUsed, view.orderBy]) + }, [allIds, projects.order, projects.lastUsed, view.orderBy]) const ownerProps = { query: view.query.trim(), @@ -466,11 +510,36 @@ function WorktableSection(props: any) { persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.filter((s) => s.id !== id) })) } + // ── 布局(新建工作区) ── + const saveLayout = () => { + const name = wsName.trim() + const def = PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0] + const total = presetTotal(def) + const urls = wsUrls.slice(0, total).map((u) => u.trim()) + if (!name || urls.some((u) => !/^(\/|https?:\/\/)/i.test(u))) { setWsError(true); return } + const layout = buildLayout(wsPreset, name, urls) + persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout] })) + setWsName(''); setWsUrls(['', '', '']); setWsError(false) + setAddOpen(false) + openSplit(layout) + reportUsed(layout.id) + } + + const removeLayout = (id: string) => { + persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })) + } + const query = view.query.trim() const queryLower = query.toLowerCase() const visibleShortcuts = projects.shortcuts.filter((s) => !queryLower || (s.name + ' ' + s.href).toLowerCase().includes(queryLower), ) + const visibleLayouts = projects.layouts.filter((l) => { + if (projects.hidden.includes(l.id)) return false + if (!queryLower) return true + const paneTitles = [...(l.top ?? []), ...l.main].map((p) => p.title).join(' ') + return (l.title + ' ' + paneTitles).toLowerCase().includes(queryLower) + }) const isFloat = float != null const floatStyle = isFloat @@ -536,10 +605,12 @@ function WorktableSection(props: any) { if (!wide) { const projectIcons = registeredIds.map((id) => metas[id]?.icon ?? '📦') const shortcutIcons = projects.shortcuts.map((s) => s.icon) - const icons = [...projectIcons, ...shortcutIcons] + const layoutIcons = projects.layouts.map(() => '🧱') + const icons = [...projectIcons, ...shortcutIcons, ...layoutIcons] const railNames = [ ...registeredIds.map((id) => projects.nameOverrides[id] ?? metas[id]?.name ?? id), ...projects.shortcuts.map((s) => s.name), + ...projects.layouts.map((l) => projects.nameOverrides[l.id] ?? l.title), ] // 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位 const railStyle = isFloat && railRect @@ -641,6 +712,40 @@ function WorktableSection(props: any) { {scError &&

{t('add.shortcutInvalid')}

} +
+ {t('add.workspaceTitle')} +

{t('add.workspaceDesc')}

+
+ {PRESET_DEFS.map((def) => ( + + ))} +
+
+ { setWsName(e.target.value); setWsError(false) }} /> + {Array.from({ length: presetTotal(PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0]) }, (_, i) => ( + { + setWsUrls((prev) => { const next = [...prev]; next[i] = e.target.value; return next }) + setWsError(false) + }} + /> + ))} + +
+ {wsError &&

{t('add.layoutInvalid')}

}
)} @@ -665,7 +770,8 @@ function WorktableSection(props: any) { {effectiveOrder.map((id) => { const meta = metas[id] - const display = projects.nameOverrides[id] ?? meta?.name ?? id + const layout = projects.layouts.find((l) => l.id === id) + const display = projects.nameOverrides[id] ?? layout?.title ?? meta?.name ?? id const isHidden = projects.hidden.includes(id) return (
{ dragIdRef.current = null }} > ≡ - {meta?.icon ?? '📦'} + {layout ? '🧱' : (meta?.icon ?? '📦')} toggleHidden(id)}> {isHidden ? '🙈' : '👁'} + {layout && ( + + )}
) })} @@ -713,6 +822,27 @@ function WorktableSection(props: any) { {renderProjectSlot ? renderProjectSlot('sidebar.worktable.project', ownerProps) :
{t('empty')}
} + {visibleLayouts.map((l) => { + const paneCount = [...(l.top ?? []), ...l.main].length + return ( + + ) + })} {visibleShortcuts.length > 0 && ( diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 10c80e4..e7c6fd9 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -39,6 +39,19 @@ export const zh = { 'manage.deleteShortcut': '删除快捷方式', 'manage.reset': '恢复默认', 'shortcut.badge': '本地', + 'layout.badge': '布局', + 'layout.desc': '{n} 内容窗 · 对话', + 'add.workspaceTitle': '新建工作区', + 'add.workspaceDesc': '选一个拓扑,填好各窗内容,保存为工作台布局(其中一窗恒为对话)。', + 'add.layoutNamePh': '布局名称', + 'add.paneUrlPh': '内容地址(/xxx/ 或 https://)', + 'add.layoutSave': '保存并打开', + 'add.layoutInvalid': '请填写布局名称,且每个内容地址以 / 或 http(s) 开头', + 'preset.2h': '左右两栏', + 'preset.3h': '三栏横排', + 'preset.t2': '上一下二', + 'preset.grid': '井字四栏', + 'manage.deleteLayout': '删除布局', 'empty': '暂无项目', } satisfies Record @@ -80,6 +93,19 @@ export const en = { 'manage.deleteShortcut': 'Delete shortcut', 'manage.reset': 'Reset defaults', 'shortcut.badge': 'Local', + 'layout.badge': 'Layout', + 'layout.desc': '{n} content panes · chat', + 'add.workspaceTitle': 'New workspace', + 'add.workspaceDesc': 'Pick a topology, fill in the pane contents, save as a worktable layout (one pane is always the chat).', + 'add.layoutNamePh': 'Layout name', + 'add.paneUrlPh': 'Content URL (/xxx/ or https://)', + 'add.layoutSave': 'Save & open', + 'add.layoutInvalid': 'Enter a layout name, and each content URL must start with / or http(s)', + 'preset.2h': 'Side by side', + 'preset.3h': 'Three columns', + 'preset.t2': 'Top + two', + 'preset.grid': '2×2 grid', + 'manage.deleteLayout': 'Delete layout', 'empty': 'No projects yet', } satisfies Record diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index e8c08ae..c6edcaf 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -32,7 +32,7 @@ export const css = [ // 菜单分隔线 '.dsh-wt_menuSep{height:1px;background:var(--dsw-alias-border-l1,#262b36);margin:4px 2px}', // 添加面板 - '.dsh-wt_add{max-height:340px;overflow:auto}', + '.dsh-wt_add{max-height:500px;overflow:auto}', '.dsh-wt_addText{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2);padding:2px 8px 4px;margin:0}', '.dsh-wt_addLink{color:var(--dsw-alias-state-accent-primary,#4f8ef7);text-decoration:none}', '.dsh-wt_addForm{display:flex;flex-direction:column;gap:5px;padding:4px 8px 8px}', @@ -82,4 +82,20 @@ export const css = [ '.dsh-wt_splitDivider{background:var(--dsw-alias-border-l2,#3a4150);cursor:col-resize;touch-action:none;box-sizing:border-box}', '.dsh-wt_splitDividerH{cursor:row-resize}', '.dsh-wt_splitDivider:hover,.dsh-wt_splitDividerH:hover{background:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + // 新建工作区(拓扑预设) + '.dsh-wt_presets{display:flex;gap:5px;padding:2px 8px 4px}', + '.dsh-wt_preset{flex:1;padding:5px 4px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:15px;cursor:pointer}', + '.dsh-wt_preset:hover{border-color:var(--dsw-alias-border-l2,#3a4150)}', + '.dsh-wt_preset[data-on=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + // 布局条目卡片 + '.dsh-wt_layout{display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-align:left}', + '.dsh-wt_layout:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', + '.dsh-wt_layout[data-on=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', + '.dsh-wt_layout[data-on=true] .dsh-wt_layoutName{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + '.dsh-wt_layoutIcon{flex:none;font-size:13px}', + '.dsh-wt_layoutText{flex:1;min-width:0;display:flex;flex-direction:column;gap:0}', + '.dsh-wt_layoutName{font-size:12px;font-weight:600;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', + '.dsh-wt_layoutDesc{font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary,#6e7683);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', + '.dsh-wt_layoutBadge{flex:none;font-size:9px;line-height:14px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06));color:var(--dsw-alias-label-tertiary,#6e7683)}', + '.dsh-wt_layoutArrow{flex:none;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:14px;line-height:20px}', ].join('\n') diff --git a/02_process/PRD.md b/02_process/PRD.md index d7a7c88..3a71f44 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -358,7 +358,10 @@ type SplitPane = { - **M1 布局引擎**:✅ 核心已实现(2026-08-16)——`01_content/src/client/split.tsx` 通用分栏引擎 (本版布局模型 = 标题栏 + 顶部通栏行(可选) + 主行内容窗 + 右下聊天窗;聊天窗 marginLeft+marginTop 组合挤法;会话切换重锚定不关闭;chat/top/pane 三级分隔线拖拽;`dsh.worktable.split.v1` 持久化), - owner props 新增 `openSplit(spec)`;⬜ 待做:拓扑预设选择器与「+」面板「新建工作区」UI; + owner props 新增 `openSplit(spec)`; + ✅ 「+」面板「新建工作区」(2026-08-16):拓扑预设选择(左右/三栏横排/上一下二/井字,聊天恒贴右)→ + 布局名称 + 各窗 URL → 保存并打开;布局条目进入项目区(🧱 卡片、选中态、搜索、隐藏/改名/↑↓ 排序/ + 删除、折叠图标框),存 `dsh.worktable.projects.v1.layouts`; - **互斥规则(2026-08-16 用户反馈定案)**:同一时刻仅一个分栏工作区—— ① 反选:同一项目卡片再点 = 关闭;② 替换:不同项目互斥(选 B 关 A); ③ 实现:引擎内开前先关旧;对外广播 `dsh:split-claim` 共享协议并监听让位; diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index a7be154..6adb753 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -269,3 +269,15 @@ 用户此后手动选择「最近」会写入标记并被尊重;persistView 统一写入标记。 fresh 状态默认本就 manual(DEFAULTS.orderBy='manual')。 - 验证:构建 + node --check 通过;服务端实时下发(含 sortMigratedV2);待用户刷新确认。 + +## 补记(「+」新建工作区:拓扑预设 + 布局条目) + +- 完成 M1 剩余项(PRD §13.5/§13.8): + ① 「+」面板新增「新建工作区」区:四个拓扑预设(左右两栏/三栏横排/上一下二/井字四栏, + 聊天窗恒贴右)+ 布局名称 + 各内容窗 URL(校验 / 或 http(s) 开头)→ 保存并直接打开; + ② 布局条目 = 一等公民:进入项目区(🧱 卡片 + 布局角标 + N 窗描述),参与搜索/隐藏/改名/ + ↑↓ 与拖拽排序/「最近」排序(打开计使用)/选中态(activeSplitId)/折叠图标框;管理条内可删除; + ③ 持久化进 dsh.worktable.projects.v1.layouts(LayoutSpec 数组)。 +- 词典新增 12 键(zh/en);样式新增预设按钮与布局卡片。 +- 验证:构建 + node --check 通过;服务端实时下发(含 PRESET_DEFS/saveLayout/dsh-wt_layout); + 待用户刷新实测。