feat: 项目卡片统一加框+小字精简 + 图标点击可选(18emoji) + 新建布局与travelatlas唯一互斥

This commit is contained in:
Aisland
2026-08-17 21:22:20 +08:00
parent 25c5cb84d0
commit b59194a6a9
6 changed files with 206 additions and 6 deletions
+87 -5
View File
@@ -74,6 +74,9 @@ const PRESET_DEFS = [
{ id: 't3', leftCount: 0, topCount: 1, contentCount: 2, chatFull: true },
] as const
/** 侧栏图标备选集(emoji):布局/快捷方式的图标,点击可换(首项 🧱 为布局默认) */
const EMOJI_SET = ['🧱', '🏠', '🎓', '🚗', '✈️', '🌍', '🏥', '📚', '✏️', '⚙️', '🎨', '🎮', '🌏', '📐', '🧪', '🤖', '📦', '💬']
function buildLayout(presetId: string, name: string): LayoutSpec {
const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0]
const mk = (i: number): SplitPane => ({
@@ -283,6 +286,8 @@ function WorktableSection(props: any) {
const [wsPreset, setWsPreset] = useState<string>('2h')
const [wsName, setWsName] = useState('')
const [wsError, setWsError] = useState(false)
/** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */
const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut'; id: string; x: number; y: number } | null>(null)
const [float, setFloat] = useState<FloatRect | null>(() =>
view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null,
)
@@ -622,6 +627,28 @@ function WorktableSection(props: any) {
persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) }))
}
// ── 图标选择器(布局 / 快捷方式的侧栏 emoji 点击可换) ──
const openIconPick = (kind: 'layout' | 'shortcut', id: string, e: any) => {
e.stopPropagation()
e.preventDefault()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = Math.min(r.right + 8, Math.max(16, window.innerWidth - 284))
const y = Math.max(MIN_TOP, Math.min(r.top - 4, window.innerHeight - 316))
setIconPick({ kind, id, x, y })
}
const setLayoutIcon = (id: string, icon: string) => {
persistProjects((prev) => ({
...prev,
layouts: prev.layouts.map((l) => (l.id === id ? { ...l, icon } : l)),
}))
}
const setShortcutIcon = (id: string, icon: string) => {
persistProjects((prev) => ({
...prev,
shortcuts: prev.shortcuts.map((s) => (s.id === id ? { ...s, icon } : s)),
}))
}
const query = view.query.trim()
const queryLower = query.toLowerCase()
const visibleShortcuts = projects.shortcuts.filter((s) =>
@@ -705,7 +732,7 @@ function WorktableSection(props: any) {
if (!wide) {
const projectIcons = registeredIds.map((id) => metas[id]?.icon ?? '📦')
const shortcutIcons = projects.shortcuts.map((s) => s.icon)
const layoutIcons = projects.layouts.map(() => '🧱')
const layoutIcons = projects.layouts.map((l) => l.icon ?? '🧱')
const icons = [...projectIcons, ...shortcutIcons, ...layoutIcons]
const railNames = [
...registeredIds.map((id) => projects.nameOverrides[id] ?? metas[id]?.name ?? id),
@@ -819,6 +846,35 @@ function WorktableSection(props: any) {
</div>
)}
{iconPick && (
<>
<div className="dsh-wt_popBackdrop" onClick={() => setIconPick(null)} />
<div className="dsh-wt_iconPop" style={{ left: iconPick.x, top: iconPick.y }}>
<div className="dsh-wt_iconPopTitle">{t('icons.title')}</div>
<div className="dsh-wt_iconGrid">
{EMOJI_SET.map((em) => {
const cur = iconPick.kind === 'layout'
? (projects.layouts.find((l) => l.id === iconPick.id)?.icon ?? '🧱')
: (projects.shortcuts.find((s) => s.id === iconPick.id)?.icon ?? '🔗')
return (
<button
key={em}
type="button"
className="dsh-wt_iconCell"
data-on={cur === em ? 'true' : 'false'}
onClick={() => {
if (iconPick.kind === 'layout') setLayoutIcon(iconPick.id, em)
else setShortcutIcon(iconPick.id, em)
setIconPick(null)
}}
>{em}</button>
)
})}
</div>
</div>
</>
)}
{viewOptionsOpen && (
<div className="dsh-wt_menu">
<span className="dsh-wt_menuLabel">{t('sort.label')}</span>
@@ -858,7 +914,15 @@ function WorktableSection(props: any) {
onDragEnd={() => { dragIdRef.current = null }}
>
<span className="dsh-wt_manageGrip" aria-hidden>≡</span>
<span className="dsh-wt_manageIcon" aria-hidden>{layout ? '🧱' : (meta?.icon ?? '📦')}</span>
{layout
? <span
className="dsh-wt_manageIcon dsh-wt_iconPick"
role="button"
tabIndex={0}
title={t('icons.change')}
onClick={(e) => openIconPick('layout', id, e)}
>{layout.icon ?? '🧱'}</span>
: <span className="dsh-wt_manageIcon" aria-hidden>{meta?.icon ?? '📦'}</span>}
<input
className="dsh-wt_manageInput"
value={display}
@@ -879,7 +943,13 @@ function WorktableSection(props: any) {
{projects.shortcuts.map((s) => (
<div key={s.id} className="dsh-wt_manageRow dsh-wt_manageRowSc">
<span className="dsh-wt_manageGrip" aria-hidden>🔗</span>
<span className="dsh-wt_manageIcon" aria-hidden>{s.icon}</span>
<span
className="dsh-wt_manageIcon dsh-wt_iconPick"
role="button"
tabIndex={0}
title={t('icons.change')}
onClick={(e) => openIconPick('shortcut', s.id, e)}
>{s.icon}</span>
<span className="dsh-wt_manageScName">{s.name}</span>
<button type="button" className="dsh-wt_manageBtn" title={t('manage.deleteShortcut')} onClick={() => removeShortcut(s.id)}>✕</button>
</div>
@@ -903,7 +973,13 @@ function WorktableSection(props: any) {
style={{ order: effectiveOrder.indexOf(l.id) + 1000 }}
onClick={() => { openSplit(l); reportUsed(l.id) }}
>
<span className="dsh-wt_layoutIcon" aria-hidden>🧱</span>
<span
className="dsh-wt_layoutIcon dsh-wt_iconPick"
role="button"
tabIndex={0}
title={t('icons.change')}
onClick={(e) => openIconPick('layout', l.id, e)}
>{l.icon ?? '🧱'}</span>
<span className="dsh-wt_layoutText">
<span className="dsh-wt_layoutName">{projects.nameOverrides[l.id] ?? l.title}</span>
<span className="dsh-wt_layoutDesc">{t('layout.desc', { n: String(paneCount) })}</span>
@@ -926,7 +1002,13 @@ function WorktableSection(props: any) {
rel="noreferrer noopener"
title={s.href}
>
<span className="dsh-wt_shortcutIcon" aria-hidden>{s.icon}</span>
<span
className="dsh-wt_shortcutIcon dsh-wt_iconPick"
role="button"
tabIndex={0}
title={t('icons.change')}
onClick={(e) => openIconPick('shortcut', s.id, e)}
>{s.icon}</span>
<span className="dsh-wt_shortcutName">{s.name}</span>
<span className="dsh-wt_shortcutBadge">{t('shortcut.badge')}</span>
</a>
+4
View File
@@ -67,6 +67,8 @@ export const zh = {
'pane.subagentsEmpty': '当前会话暂无子代理',
'split.flip': '切换对话窗位置(左⇄右)',
'split.dragSwap': '拖动标题栏与其他窗交换位置',
'icons.title': '选择图标',
'icons.change': '更换图标',
'empty': '暂无项目',
} satisfies Record<string, string>
@@ -136,6 +138,8 @@ export const en = {
'pane.subagentsEmpty': 'No subagents in the current session',
'split.flip': 'Flip chat side (left ⇄ right)',
'split.dragSwap': 'Drag the title bar to swap panes',
'icons.title': 'Pick an icon',
'icons.change': 'Change icon',
'empty': 'No projects yet',
} satisfies Record<WorktableKey, string>
+12
View File
@@ -45,6 +45,8 @@ export type LayoutSpec = {
topHeight?: { default: number; min: number; max: number }
/** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */
chatSide?: 'left' | 'right'
/** 布局条目图标(emoji;工作台侧栏展示,点击可换) */
icon?: string
/** 聊天窗通高(整列):为 true 时聊天占整条右/左列,内容区(含 top 行)全部排在其另一侧 */
chatFullHeight?: boolean
}
@@ -729,6 +731,16 @@ export const splitStore: SplitState = {
},
}
/** 兼容桥:travelatlas 分栏浮层(.ta_split)出现 = 其引擎打开 → 本引擎让位。
* 不改动 travelatlas 代码,仅在 DOM 层观察其浮层挂载。 */
if (typeof document !== 'undefined' && document.body) {
const taObserver = new MutationObserver(() => {
if (!splitStore.active) return
if (document.querySelector('.ta_split')) splitStore.close()
})
taObserver.observe(document.body, { childList: true, subtree: true })
}
/** 分配各窗宽度(最后一个拿余量) */
function allocate(panes: SplitPane[], ws: number[], total: number) {
const out: { pane: SplitPane; left: number; width: number }[] = []
+16
View File
@@ -107,6 +107,22 @@ export const css = [
'.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}',
// 项目卡片统一:入驻插件卡片(travelatlas/planreview)与布局卡片同款常显框(静止态可见边框+底)
'.dsh-wt_projects .ta_card,.dsh-wt_projects .pr_card{border-color:var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
'.dsh-wt_projects .ta_card:hover,.dsh-wt_projects .pr_card:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_projects .ta_card[data-on=true],.dsh-wt_projects .pr_card[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_projects .ta_cardDesc,.dsh-wt_projects .pr_cardDesc{display:none}',
// 图标选择弹窗(固定定位,点击项目/布局/快捷方式的图标唤出)
'.dsh-wt_iconPop{position:fixed;z-index:81;width:256px;padding:8px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:10px;background:var(--dsw-alias-bg-base,#0b0e14);box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
'.dsh-wt_iconPopTitle{font-size:10px;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#6e7683);padding:1px 4px 6px}',
'.dsh-wt_iconGrid{display:grid;grid-template-columns:repeat(6,1fr);gap:4px}',
'.dsh-wt_iconCell{display:flex;align-items:center;justify-content:center;aspect-ratio:1;border:1px solid transparent;border-radius:6px;background:transparent;font-size:17px;line-height:1;cursor:pointer;padding:0}',
'.dsh-wt_iconCell:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05));border-color:var(--dsw-alias-border-l2,#3a4150)}',
'.dsh-wt_iconCell[data-on=true]{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08));border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
// 可点图标(布局卡 / 管理行 / 快捷方式上的图标,点击换 emoji)
'.dsh-wt_iconPick{flex:none;display:inline-flex;align-items:center;justify-content:center;padding:1px;border:none;background:transparent;font:inherit;cursor:pointer;border-radius:4px}',
'.dsh-wt_iconPick:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08))}',
// 窗内容:选择器 / 自定义 / 浏览器 / 占位
'.dsh-wt_panePicker{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:12px;overflow:auto}',
'.dsh-wt_panePicker-row{flex-direction:row;flex-wrap:wrap}',