From 581cb6486939743b1eeda171f5fe120862b70d34 Mon Sep 17 00:00:00 2001 From: Flashhhhhhzj <165341332+Flashhhhhhzj@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:47:56 +0800 Subject: [PATCH 01/24] feat: unify modal styles and enhance UI for overview and quick links pages --- MODAL_OPTIMIZATION_SUMMARY.md | 181 ++++++ .../features/workspace/HomeWorkspace.tsx | 217 ++----- .../features/workspace/OverviewPage.tsx | 208 +++++++ .../features/workspace/QuickLinksPage.tsx | 199 +++++++ .../styles/features/confirm-dialog.css | 13 +- .../src/renderer/styles/features/home.css | 63 +-- .../styles/features/modal-components.css | 534 ++++++++++++++++++ .../src/renderer/styles/features/modals.css | 226 ++++++-- .../src/renderer/styles/features/overview.css | 377 +++++++++++++ .../renderer/styles/features/quick-links.css | 75 +++ .../src/renderer/styles/workstation.css | 3 + 11 files changed, 1805 insertions(+), 291 deletions(-) create mode 100644 MODAL_OPTIMIZATION_SUMMARY.md create mode 100644 apps/desktop/src/renderer/features/workspace/OverviewPage.tsx create mode 100644 apps/desktop/src/renderer/features/workspace/QuickLinksPage.tsx create mode 100644 apps/desktop/src/renderer/styles/features/modal-components.css create mode 100644 apps/desktop/src/renderer/styles/features/overview.css create mode 100644 apps/desktop/src/renderer/styles/features/quick-links.css diff --git a/MODAL_OPTIMIZATION_SUMMARY.md b/MODAL_OPTIMIZATION_SUMMARY.md new file mode 100644 index 0000000..3c902de --- /dev/null +++ b/MODAL_OPTIMIZATION_SUMMARY.md @@ -0,0 +1,181 @@ +# TermDock 弹窗样式优化总结 + +## 完成时间 +2026年6月17日 + +## 优化目标 +统一和美化所有弹窗组件的UI样式,提升用户体验和视觉一致性。 + +## 已优化的弹窗组件 + +### 1. **基础弹窗样式** (modals.css) +- ✅ 增强的背景遮罩:更强的模糊效果(8px)和暗化 +- ✅ 统一的卡片样式:圆角16px、现代阴影效果 +- ✅ 流畅的进入动画:淡入+上滑+缩放组合 +- ✅ 统一的按钮样式:40px高度、10px圆角、悬停效果 +- ✅ 优化的输入框样式:42px高度、focus状态阴影 +- ✅ 现代化的复选框:圆角设计、流畅的选中动画 + +### 2. **确认对话框** (ConfirmActionDialog) +- ✅ 宽度:480px +- ✅ 描述区域:带背景色的卡片样式 +- ✅ 按钮布局:右对齐,统一间距 +- ✅ 危险操作:红色高亮样式 + +### 3. **文件操作弹窗** (FileActionModal) +- ✅ 宽度:480px +- ✅ 描述提示:卡片化设计 +- ✅ 表单字段:清晰的标签和输入框 +- ✅ 提示信息:灰色小字说明 +- ✅ 统一的输入框高度和圆角 + +### 4. **文件权限弹窗** (FilePermissionModal) +- ✅ 宽度:540px +- ✅ 文件名显示:等宽字体卡片 +- ✅ 权限矩阵:分组卡片展示 +- ✅ 复选框/单选框:现代化圆角设计 +- ✅ 递归选项:独立的选项卡片 + +### 5. **Root访问弹窗** (RootAccessModal) +- ✅ 宽度:500px +- ✅ 说明文字:卡片化背景 +- ✅ 元数据显示:键值对卡片 +- ✅ 密码提示:蓝色信息框 +- ✅ 密码输入:安全的password类型 + +### 6. **SSH主机验证弹窗** (SshHostVerificationModal) +- ✅ 宽度:500px +- ✅ 指纹显示:等宽字体卡片 +- ✅ 三个操作按钮:拒绝、临时接受、永久接受 +- ✅ 错误提示:红色警告框 +- ✅ 主机信息:清晰的元数据展示 + +### 7. **SSH凭证弹窗** (SshCredentialsModal) +- ✅ 宽度:500px +- ✅ 用户名输入:自动聚焦 +- ✅ 密码输入:安全模式 +- ✅ 提示信息:友好的说明文字 +- ✅ 主机信息展示:元数据卡片 + +### 8. **连接管理器** (ConnectionManagerModal) +- ✅ 宽度:1040px +- ✅ 头部设计:标题+搜索框 +- ✅ 搜索框:圆角8px、focus状态 +- ✅ 表格样式:圆角卡片、悬停效果 +- ✅ 操作按钮:隐藏直到悬停 + +### 9. **命令管理器** (CommandManagerModal) +- ✅ 宽度:900px +- ✅ 侧边栏:命令列表导航 +- ✅ 列表项:圆角8px、悬停高亮 +- ✅ 活动状态:边框高亮 + +## 设计系统统一 + +### 圆角规范 +- 小元素(按钮、输入框):8-10px +- 卡片、弹窗:12-16px +- 复选框/单选框:6px / 50% + +### 间距规范 +- 元素间距:12-16px +- 内边距:12-20px +- 按钮内边距:0 16-20px + +### 颜色系统 +- 主文本:`var(--text-main)` +- 次要文本:`var(--text-muted)` / `var(--text-secondary)` +- 背景层次:`var(--bg-main)` / `var(--bg-card)` / `var(--bg-hover)` +- 边框:`var(--border-light)` +- 错误:红色系 `#f87171` / `rgba(239, 68, 68, ...)` +- 信息:蓝色系 `#60a5fa` / `rgba(96, 165, 250, ...)` + +### 动画规范 +- 过渡时间:0.2-0.25s +- 缓动函数:`cubic-bezier(0.4, 0, 0.2, 1)` +- 悬停效果:`translateY(-1px)` + 阴影增强 + +### 阴影系统 +- sm: `0 2px 8px rgba(0, 0, 0, 0.1)` +- md: `0 8px 32px rgba(0, 0, 0, 0.35)` +- lg: `0 20px 60px rgba(0, 0, 0, 0.5)` + +## 新增样式文件 + +### modal-components.css +包含所有弹窗组件的通用样式: +- 文件操作相关(FileActionModal、RootAccessModal) +- SSH交互相关(SshHostVerificationModal、SshCredentialsModal) +- 权限管理(FilePermissionModal) +- 管理器组件(ConnectionManagerModal、CommandManagerModal) +- 通用元素(icon-button、表单字段、复选框等) + +### overview.css +概览页面的样式: +- Hero区块 +- 统计卡片 +- 最近连接网格 +- 快速操作网格 + +### quick-links.css +快速链接页面的样式: +- 页面头部 +- 按钮样式 +- 复用home.css的表格样式 + +## 用户体验改进 + +1. **视觉一致性**:所有弹窗使用统一的圆角、间距、颜色 +2. **交互反馈**:悬停、焦点、点击状态都有清晰的视觉反馈 +3. **信息层次**:通过卡片、背景色、字体大小区分信息重要性 +4. **动画流畅**:所有交互都有平滑的过渡动画 +5. **可访问性**:保持良好的对比度和可读性 + +## 兼容性 + +- ✅ 支持暗色主题(使用CSS变量) +- ✅ 支持亮色主题(使用CSS变量) +- ✅ 响应式设计(min/max宽度限制) +- ✅ WebKit特性支持(backdrop-filter、app-region) + +## 未来改进建议 + +1. 考虑添加亮色主题的特定优化 +2. 可以添加更多的微交互动画 +3. 考虑添加键盘快捷键提示 +4. 可以增加弹窗的拖拽功能 +5. 考虑添加弹窗大小调整功能 + +## 测试状态 + +- ✅ TypeScript类型检查通过(新增代码部分) +- ✅ CSS语法验证通过 +- ✅ 样式文件正确引入 +- ⚠️ 需要实际运行测试视觉效果(因沙盒限制未能运行dev server) + +## 文件变更列表 + +### 新增文件 +- `apps/desktop/src/renderer/features/workspace/OverviewPage.tsx` +- `apps/desktop/src/renderer/features/workspace/QuickLinksPage.tsx` +- `apps/desktop/src/renderer/styles/features/overview.css` +- `apps/desktop/src/renderer/styles/features/quick-links.css` +- `apps/desktop/src/renderer/styles/features/modal-components.css` + +### 修改文件 +- `apps/desktop/src/renderer/features/workspace/HomeWorkspace.tsx` - 拆分为两个页面 +- `apps/desktop/src/renderer/styles/features/modals.css` - 统一基础弹窗样式 +- `apps/desktop/src/renderer/styles/features/home.css` - 调整布局 +- `apps/desktop/src/renderer/styles/features/confirm-dialog.css` - 优化确认对话框 +- `apps/desktop/src/renderer/styles/workstation.css` - 引入新样式文件 + +## 总结 + +本次优化彻底统一了TermDock中所有弹窗组件的视觉风格,采用了现代化的设计语言,提升了整体用户体验。所有弹窗现在都具有: +- 一致的外观和感觉 +- 流畅的动画效果 +- 清晰的信息层次 +- 良好的交互反馈 +- 现代化的视觉设计 + +同时,首页被重构为概览页和快速链接两个独立页面,提供了更好的信息组织和用户导航体验。 diff --git a/apps/desktop/src/renderer/features/workspace/HomeWorkspace.tsx b/apps/desktop/src/renderer/features/workspace/HomeWorkspace.tsx index 1f5b1f3..6d99175 100644 --- a/apps/desktop/src/renderer/features/workspace/HomeWorkspace.tsx +++ b/apps/desktop/src/renderer/features/workspace/HomeWorkspace.tsx @@ -1,10 +1,8 @@ import type { ConnectionProfile, ConnectionFolder } from '@termdock/core' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { t } from '../../i18n' - -type ConnectionTreeNode = - | (ConnectionFolder & { children: ConnectionTreeNode[] }) - | (ConnectionProfile & { children?: never }) +import { OverviewPage } from './OverviewPage' +import { QuickLinksPage } from './QuickLinksPage' export function HomeWorkspace({ profiles, @@ -15,21 +13,10 @@ export function HomeWorkspace({ folders?: ConnectionFolder[] onOpen(profileId: string): void }) { - const [expandedFolders, setExpandedFolders] = useState>(new Set()) - const [activeTab, setActiveTab] = useState<'dashboard' | 'terminal' | 'ssh-manager' | 'settings'>('dashboard') + const [activeTab, setActiveTab] = useState<'overview' | 'quick-links' | 'ssh-manager' | 'settings'>('overview') const desktopApi = window.termdock - const toggleFolder = (folderId: string, event?: React.MouseEvent) => { - event?.stopPropagation() - setExpandedFolders(prev => { - const next = new Set(prev) - if (next.has(folderId)) next.delete(folderId) - else next.add(folderId) - return next - }) - } - const handleOpenNewConnection = () => { if (desktopApi) { void desktopApi.openConnectionFormWindow('create') @@ -54,137 +41,6 @@ export function HomeWorkspace({ } } - const tree = useMemo(() => { - const items: ConnectionTreeNode[] = [ - ...profiles.map((profile, index) => ({ - ...profile, - order: typeof profile.order === 'number' ? profile.order : index * 1000 - })), - ...folders.map((folder, index) => ({ - ...folder, - order: typeof folder.order === 'number' ? folder.order : (profiles.length + index) * 1000, - children: [] - })) - ] - - const roots: ConnectionTreeNode[] = [] - const map = new Map() - - items.forEach(item => { - map.set(item.id, item) - }) - - items.forEach(item => { - const parent = item.parentId ? map.get(item.parentId) : undefined - if (parent?.type === 'folder') { - parent.children.push(item) - } else { - roots.push(item) - } - }) - - const sortNodes = (nodes: ConnectionTreeNode[]) => { - nodes.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - nodes.forEach(n => { - if (n.type === 'folder') sortNodes(n.children) - }) - } - sortNodes(roots) - return roots - }, [profiles, folders]) - - const renderNode = (node: ConnectionTreeNode, depth: number) => { - const isFolder = node.type === 'folder' - const isExpanded = expandedFolders.has(node.id) - - const handleRowClick = (e: React.MouseEvent) => { - if (isFolder) { - toggleFolder(node.id, e) - } else { - onOpen(node.id) - } - } - - return ( -
-
{ - if (event.key === 'Enter' || event.key === ' ') { - if (isFolder) { - toggleFolder(node.id) - } else { - onOpen(node.id) - } - } - }} - role="button" - tabIndex={0} - > -
- - {isFolder ? 'folder' : 'dns'} - -
-
- {isFolder && ( - - chevron_right - - )} - {node.name} -
-
{isFolder ? '--' : (node.note || '/')}
-
{isFolder ? '--' : node.username}
-
-
- {isFolder ? t.homeFolderType : node.type.toUpperCase()} -
-
- -
-
- {isFolder && isExpanded && node.children && ( -
- {node.children.map((child) => renderNode(child, depth + 1))} - {node.children.length === 0 && ( -
-
- folder -
-
- {t.emptyFolder} -
-
--
-
--
-
-
- FOLDER -
-
-
- )} -
- )} -
- ) - } - return (
{/* SideNavBar Component */} @@ -205,15 +61,23 @@ export function HomeWorkspace({ {/* Navigation Section */}
@@ -103,7 +103,7 @@ export function OverviewPage({ {recentProfiles.length > 0 && (
-

最近使用

+

{t.overviewRecentConnections}

{recentProfiles.map((profile) => ( @@ -154,7 +154,7 @@ export function OverviewPage({ {/* Quick Actions */}
-

快速操作

+

{t.overviewQuickActions}

-

连接管理

-

管理所有连接配置

+

{t.connectionManager}

+

{t.overviewConnectionManagerDescription}

-

查看文档

-

获取使用帮助和指南

+

{t.overviewDocsTitle}

+

{t.overviewDocsDescription}

diff --git a/apps/desktop/src/renderer/i18n.ts b/apps/desktop/src/renderer/i18n.ts index 93f71d9..cdb1cd5 100644 --- a/apps/desktop/src/renderer/i18n.ts +++ b/apps/desktop/src/renderer/i18n.ts @@ -337,14 +337,32 @@ const zhCN = { settings: '设置', generalSettings: '通用设置', appearanceTheme: '外观与主题', - languageSelection: '语言与国际化', + languageSelection: 'Language 语言', + languageZhCN: '简体中文', + languageEnglish: 'English', themeSelection: '主题选择', managerToolsShortcut: '管理工具', + settingsConnectionManagerDescription: '管理所有远程服务器连接与分组,配置 SSH / SFTP 证书、跳板机及代理隧道。', + settingsCommandManagerDescription: '管理常用命令与快捷脚本,分类存储命令,并在 SSH 终端中一键快速发送。', systemLogsInfo: '系统与日志', + settingsLogsDescription: '应用程序在运行期间会产生日志。如果遇到任何连接错误或意外崩溃,你可以打开日志目录并查看具体的日志信息。', logsDirectory: '日志目录', aboutAppInfo: '关于 TermDock', versionLabel: '应用版本', environmentInfo: '运行环境', + overviewWelcomeTitle: '欢迎使用 TermDock', + overviewWelcomeSubtitle: '强大的终端管理工具,让远程连接更简单高效', + overviewTotalConnections: '总连接数', + overviewSshConnections: 'SSH 连接', + overviewSecureFtpConnections: 'Secure FTP', + overviewFtpConnections: 'FTP 连接', + overviewRecentConnections: '最近使用', + overviewQuickActions: '快速操作', + overviewCommandManagerDescription: '管理你的快捷命令模板', + overviewConnectionManagerDescription: '管理所有连接配置', + overviewDocsTitle: '查看文档', + overviewDocsDescription: '获取使用帮助和指南', + overviewGithubDescription: '访问项目源代码', notReady: '稍后接入', closeConfirmTitle: '退出确认', closeConfirmActiveWarn: '当前有正在运行的远程连接,退出或关闭窗口可能会中断这些连接。', @@ -688,14 +706,32 @@ const enUS: typeof zhCN = { settings: 'Settings', generalSettings: 'General Settings', appearanceTheme: 'Appearance & Theme', - languageSelection: 'Language & Locale', + languageSelection: 'Language 语言', + languageZhCN: 'Simplified Chinese', + languageEnglish: 'English', themeSelection: 'Theme Selection', managerToolsShortcut: 'Management Tools', + settingsConnectionManagerDescription: 'Manage remote server connections and groups, including SSH / SFTP credentials, jump hosts, and proxy tunnels.', + settingsCommandManagerDescription: 'Organize reusable commands and quick scripts, then send them to the current SSH terminal with one click.', systemLogsInfo: 'System & Logs', + settingsLogsDescription: 'The app writes runtime logs while it is running. If you hit a connection error or unexpected crash, open the logs directory to inspect the details.', logsDirectory: 'Logs Directory', aboutAppInfo: 'About TermDock', versionLabel: 'Version', environmentInfo: 'Environment', + overviewWelcomeTitle: 'Welcome to TermDock', + overviewWelcomeSubtitle: 'A powerful terminal workspace that makes remote connections simpler and more efficient.', + overviewTotalConnections: 'Total Connections', + overviewSshConnections: 'SSH Connections', + overviewSecureFtpConnections: 'Secure FTP', + overviewFtpConnections: 'FTP Connections', + overviewRecentConnections: 'Recent Connections', + overviewQuickActions: 'Quick Actions', + overviewCommandManagerDescription: 'Manage your reusable command templates.', + overviewConnectionManagerDescription: 'Manage all saved connection profiles.', + overviewDocsTitle: 'Documentation', + overviewDocsDescription: 'Read usage guides and help docs.', + overviewGithubDescription: 'View the project source code.', notReady: 'Coming later', closeConfirmTitle: 'Confirm Exit', closeConfirmActiveWarn: 'There are active remote connections running. Exiting or closing the window may disconnect them.', @@ -728,7 +764,24 @@ function resolveInitialLocale(): AppLocale { try { const nextLocale = window.localStorage.getItem('termdock.locale') - return nextLocale === 'enUS' || nextLocale === 'zhCN' ? nextLocale : defaultLocale + if (nextLocale === 'enUS' || nextLocale === 'zhCN') { + return nextLocale + } + + const preferredLocales = window.navigator.languages?.length + ? window.navigator.languages + : [window.navigator.language] + const matchedLocale = preferredLocales.find(locale => typeof locale === 'string' && locale.length > 0) + + if (matchedLocale?.toLowerCase().startsWith('zh')) { + return 'zhCN' + } + + if (matchedLocale?.toLowerCase().startsWith('en')) { + return 'enUS' + } + + return defaultLocale } catch { return defaultLocale } From 6e0eba32ddae874709f1abd0fcf690cd38a5b49c Mon Sep 17 00:00:00 2001 From: St0ff3l Date: Thu, 18 Jun 2026 22:27:32 +0800 Subject: [PATCH 13/24] =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=A0=B7=E5=BC=8F=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E8=BF=9E=E6=8E=A5=E5=85=B3=E9=97=AD=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- apps/desktop/src/renderer/App.tsx | 124 ++++++++--------- .../features/common/ConfirmActionDialog.tsx | 28 ++-- apps/desktop/src/renderer/i18n.ts | 4 + .../styles/features/confirm-dialog.css | 125 ++++++++++++++++-- .../renderer/styles/themes/default-dark.css | 28 ++++ .../renderer/styles/themes/default-light.css | 28 ++++ 6 files changed, 254 insertions(+), 83 deletions(-) diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 069f487..a83c6c1 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -480,7 +480,7 @@ export function App() { const [shortcutCloseConfirm, setShortcutCloseConfirm] = useState<{ tabId: string title: string - variant: 'connecting' | 'active-last-session' + variant: 'connecting' | 'active-session' | 'active-last-session' } | null>(null) const [closingSessionTabIds, setClosingSessionTabIds] = useState([]) @@ -513,13 +513,13 @@ export function App() { } const unsubscribe = desktopApi.onWindowCloseRequest((event) => { - const hasActive = workspaceRef.current.tabs.some( - (tab) => workspaceRef.current.sessions[tab.id]?.connected - ) + const hasActive = workspaceRef.current.tabs.some((tab) => isTabActivelyConnected(tab)) if (desktopApi.platform === 'darwin') { if (event.isQuit) { setCloseConfirmDialog({ isQuit: true, hasActiveConnections: hasActive }) + } else if (hasActive) { + setCloseConfirmDialog({ isQuit: false, hasActiveConnections: true }) } else { void desktopApi.confirmCloseWindow('hide') } @@ -1029,6 +1029,9 @@ export function App() { setFormError(null) } + const isTabActivelyConnected = (tab: WorkspaceTab | null | undefined) => + Boolean(tab && (tab.status === 'connecting' || tab.status === 'connected')) + const closeCurrentWindow = () => { void desktopApi?.closeCurrentWindow() } @@ -1442,6 +1445,16 @@ export function App() { return } + const targetTab = visibleWorkspaceTabs.find((tab) => tab.id === tabId) ?? null + if (isTabActivelyConnected(targetTab)) { + setShortcutCloseConfirm({ + tabId, + title: targetTab?.title ?? '', + variant: targetTab?.status === 'connecting' ? 'connecting' : 'active-session' + }) + return + } + try { await closeSessionTabById(tabId) } catch (err) { @@ -1567,14 +1580,17 @@ export function App() { if (activeSessionTab) { const isLastSessionTab = visibleWorkspaceTabs.length === 1 - const needsDisconnectConfirm = isLastSessionTab - && (activeSessionTab.status === 'connecting' || activeSessionTab.status === 'connected') + const needsDisconnectConfirm = isTabActivelyConnected(activeSessionTab) if (needsDisconnectConfirm) { setShortcutCloseConfirm({ tabId: activeSessionTab.id, title: activeSessionTab.title, - variant: activeSessionTab.status === 'connecting' ? 'connecting' : 'active-last-session' + variant: activeSessionTab.status === 'connecting' + ? 'connecting' + : isLastSessionTab + ? 'active-last-session' + : 'active-session' }) return } @@ -3148,6 +3164,8 @@ export function App() { description={ (shortcutCloseConfirm.variant === 'connecting' ? t.closeShortcutConnectingDescription + : shortcutCloseConfirm.variant === 'active-session' + ? t.closeShortcutActiveDescription : t.closeShortcutLastActiveDescription) .replace('{name}', shortcutCloseConfirm.title) } @@ -3156,29 +3174,24 @@ export function App() { onConfirm={() => { void confirmShortcutCloseConnectingTab() }} - title={shortcutCloseConfirm.variant === 'connecting' ? t.closeShortcutConnectingTitle : t.closeShortcutLastActiveTitle} + title={ + shortcutCloseConfirm.variant === 'connecting' + ? t.closeShortcutConnectingTitle + : shortcutCloseConfirm.variant === 'active-session' + ? t.closeShortcutActiveTitle + : t.closeShortcutLastActiveTitle + } /> ) : null} {closeConfirmDialog ? ( -
-
-
- {t.closeConfirmTitle} - -
-
+ {closeConfirmDialog.hasActiveConnections ? ( -
+
{t.closeConfirmActiveWarn}
) : closeConfirmDialog.isQuit ? ( @@ -3187,43 +3200,30 @@ export function App() { {!closeConfirmDialog.isQuit ? (
{t.closeConfirmWindowsMsg}
) : null} -
-
- - {!closeConfirmDialog.isQuit ? ( - - ) : null} - -
-
-
+ + } + extraActions={!closeConfirmDialog.isQuit ? ( + + ) : null} + onClose={() => { + setCloseConfirmDialog(null) + void desktopApi?.confirmCloseWindow('cancel') + }} + onConfirm={() => { + setCloseConfirmDialog(null) + void desktopApi?.confirmCloseWindow('quit') + }} + title={t.closeConfirmTitle} + /> ) : null} ) diff --git a/apps/desktop/src/renderer/features/common/ConfirmActionDialog.tsx b/apps/desktop/src/renderer/features/common/ConfirmActionDialog.tsx index 406ffa1..efa428c 100644 --- a/apps/desktop/src/renderer/features/common/ConfirmActionDialog.tsx +++ b/apps/desktop/src/renderer/features/common/ConfirmActionDialog.tsx @@ -1,10 +1,13 @@ -import { t } from '../../i18n' import { createPortal } from 'react-dom' +import type { ReactNode } from 'react' +import { t } from '../../i18n' export function ConfirmActionDialog({ cancelLabel = t.cancel, confirmLabel, + confirmVariant = 'danger', description, + extraActions = null, isSubmitting = false, onClose, onConfirm, @@ -12,23 +15,30 @@ export function ConfirmActionDialog({ }: { cancelLabel?: string confirmLabel: string - description: string + confirmVariant?: 'danger' | 'primary' + description: ReactNode + extraActions?: ReactNode isSubmitting?: boolean onClose(): void onConfirm(): void title: string }) { + const confirmButtonClassName = confirmVariant === 'primary' + ? 'confirm-action-dialog__button confirm-action-dialog__button--primary' + : 'confirm-action-dialog__button confirm-action-dialog__button--danger' + const cancelButtonClassName = 'confirm-action-dialog__button confirm-action-dialog__button--secondary' + const dialog = (
event.stopPropagation()}> -
- {title} - +
+
{title}
+
{description}
-
{description}
-
- - + {extraActions} + diff --git a/apps/desktop/src/renderer/i18n.ts b/apps/desktop/src/renderer/i18n.ts index cdb1cd5..9659971 100644 --- a/apps/desktop/src/renderer/i18n.ts +++ b/apps/desktop/src/renderer/i18n.ts @@ -374,6 +374,8 @@ const zhCN = { closeShortcutConnectingDescription: '“{name}” 仍在连接中。现在关闭会中断这次连接,确定继续吗?', closeShortcutLastActiveTitle: '关闭当前连接', closeShortcutLastActiveDescription: '“{name}” 是当前最后一个活跃连接。关闭后会先退回首页新标签页,确定继续吗?', + closeShortcutActiveTitle: '关闭活跃连接', + closeShortcutActiveDescription: '“{name}” 当前仍处于活跃状态。现在关闭会中断这次连接,确定继续吗?', closeShortcutCloseTab: '关闭标签页', closeShortcutQuitConfirm: '退出应用' } @@ -743,6 +745,8 @@ const enUS: typeof zhCN = { closeShortcutConnectingDescription: '"{name}" is still connecting. Closing it now will interrupt that connection. Continue?', closeShortcutLastActiveTitle: 'Close Current Connection', closeShortcutLastActiveDescription: '"{name}" is the last active connection. Closing it will return you to a new home tab first. Continue?', + closeShortcutActiveTitle: 'Close Active Connection', + closeShortcutActiveDescription: '"{name}" is still active. Closing it now will interrupt that connection. Continue?', closeShortcutCloseTab: 'Close Tab', closeShortcutQuitConfirm: 'Exit App' } diff --git a/apps/desktop/src/renderer/styles/features/confirm-dialog.css b/apps/desktop/src/renderer/styles/features/confirm-dialog.css index 67b7855..7d59bdb 100644 --- a/apps/desktop/src/renderer/styles/features/confirm-dialog.css +++ b/apps/desktop/src/renderer/styles/features/confirm-dialog.css @@ -1,21 +1,122 @@ -/* Confirm Action Dialog - Modern Style */ +/* Confirm Action Dialog - Alert Dialog Style */ .confirm-action-dialog { - width: min(480px, 100%); + width: min(440px, 100%); + padding: 24px; + border-radius: 18px; + background: var(--dialog-surface); + border: 1px solid var(--dialog-border); + box-shadow: var(--dialog-shadow); -webkit-app-region: no-drag; } +.confirm-action-dialog__header { + display: flex; + flex-direction: column; + gap: 10px; +} + +.confirm-action-dialog__title { + color: var(--dialog-title); + font-size: 18px; + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.02em; +} + .confirm-action-dialog__description { - color: var(--text-secondary); - line-height: 1.7; - white-space: pre-wrap; + color: var(--dialog-description); font-size: 14px; - margin-bottom: 4px; - padding: 14px 16px; - background: var(--bg-hover); - border: 1px solid var(--border-light); - border-radius: 10px; + line-height: 1.65; + white-space: pre-wrap; +} + +.confirm-action-dialog__warning { + color: var(--dialog-warning-text); + margin-bottom: 12px; + font-weight: 700; +} + +.confirm-action-dialog__footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid var(--dialog-footer-border); +} + +.confirm-action-dialog__button { + min-width: 92px; + height: 40px; + padding: 0 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; +} + +.confirm-action-dialog__button--secondary { + border: 1px solid var(--dialog-button-secondary-border); + background: var(--dialog-button-secondary-bg); + color: var(--dialog-button-secondary-text); +} + +.confirm-action-dialog__button--secondary:hover:not(:disabled) { + background: var(--dialog-button-secondary-hover-bg); + border-color: var(--dialog-button-secondary-hover-border); +} + +.confirm-action-dialog__button--primary { + border: 1px solid var(--dialog-button-primary-border); + background: var(--dialog-button-primary-bg); + color: var(--dialog-button-primary-text); +} + +.confirm-action-dialog__button--primary:hover:not(:disabled) { + background: var(--dialog-button-primary-hover-bg); + border-color: var(--dialog-button-primary-hover-border); +} + +.confirm-action-dialog__button--danger { + border-color: var(--dialog-button-danger-border); + background: var(--dialog-button-danger-bg); + color: var(--dialog-button-danger-text); } -.confirm-action-dialog__actions { - margin-top: 20px; +.confirm-action-dialog__button--danger:hover:not(:disabled) { + border-color: var(--dialog-button-danger-hover-border); + background: var(--dialog-button-danger-hover-bg); + color: var(--dialog-button-danger-hover-text); +} + +.confirm-action-dialog__button:focus-visible { + outline: none; + box-shadow: var(--dialog-focus-ring); +} + +.confirm-action-dialog__button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +@media (max-width: 640px) { + .confirm-action-dialog { + width: min(100%, 360px); + padding: 20px; + border-radius: 16px; + } + + .confirm-action-dialog__footer { + flex-direction: column-reverse; + align-items: stretch; + } + + .confirm-action-dialog__button { + width: 100%; + } } diff --git a/apps/desktop/src/renderer/styles/themes/default-dark.css b/apps/desktop/src/renderer/styles/themes/default-dark.css index 6b836c0..9cae527 100644 --- a/apps/desktop/src/renderer/styles/themes/default-dark.css +++ b/apps/desktop/src/renderer/styles/themes/default-dark.css @@ -61,6 +61,34 @@ --popover-border: var(--border-light); --popover-shadow: var(--shadow-lg); --confirm-dialog-text: #cfd4dc; + --dialog-surface: var(--bg-card); + --dialog-border: color-mix(in srgb, var(--border-light) 82%, transparent); + --dialog-shadow: + 0 22px 70px rgba(0, 0, 0, 0.45), + 0 1px 0 rgba(255, 255, 255, 0.04) inset; + --dialog-title: var(--text-main); + --dialog-description: var(--text-muted); + --dialog-footer-border: color-mix(in srgb, var(--border-light) 78%, transparent); + --dialog-button-secondary-bg: transparent; + --dialog-button-secondary-border: color-mix(in srgb, var(--border-light) 100%, rgba(255, 255, 255, 0.08)); + --dialog-button-secondary-text: var(--text-main); + --dialog-button-secondary-hover-bg: var(--bg-hover); + --dialog-button-secondary-hover-border: var(--border-dark); + --dialog-button-primary-bg: var(--bg-hover); + --dialog-button-primary-border: var(--border-dark); + --dialog-button-primary-text: var(--text-main); + --dialog-button-primary-hover-bg: var(--bg-active); + --dialog-button-primary-hover-border: var(--border-dark); + --dialog-button-danger-bg: rgba(255, 95, 87, 0.14); + --dialog-button-danger-border: rgba(255, 95, 87, 0.28); + --dialog-button-danger-text: #ff8f88; + --dialog-button-danger-hover-bg: rgba(255, 95, 87, 0.18); + --dialog-button-danger-hover-border: rgba(255, 95, 87, 0.4); + --dialog-button-danger-hover-text: #ffaaa4; + --dialog-focus-ring: + 0 0 0 2px color-mix(in srgb, var(--bg-card) 88%, transparent), + 0 0 0 4px color-mix(in srgb, var(--text-main) 28%, transparent); + --dialog-warning-text: var(--danger); } /* Extracted dark theme overrides */ diff --git a/apps/desktop/src/renderer/styles/themes/default-light.css b/apps/desktop/src/renderer/styles/themes/default-light.css index 4d29f7e..f66ce0d 100644 --- a/apps/desktop/src/renderer/styles/themes/default-light.css +++ b/apps/desktop/src/renderer/styles/themes/default-light.css @@ -49,6 +49,34 @@ --popover-border: var(--border-light); --popover-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); --confirm-dialog-text: #475569; + --dialog-surface: var(--bg-card); + --dialog-border: color-mix(in srgb, var(--border-light) 82%, transparent); + --dialog-shadow: + 0 22px 70px rgba(15, 23, 42, 0.16), + 0 1px 0 rgba(255, 255, 255, 0.82) inset; + --dialog-title: var(--text-main); + --dialog-description: var(--text-muted); + --dialog-footer-border: color-mix(in srgb, var(--border-light) 84%, transparent); + --dialog-button-secondary-bg: transparent; + --dialog-button-secondary-border: var(--border-light); + --dialog-button-secondary-text: var(--text-main); + --dialog-button-secondary-hover-bg: var(--bg-hover); + --dialog-button-secondary-hover-border: var(--border-dark); + --dialog-button-primary-bg: var(--bg-hover); + --dialog-button-primary-border: var(--border-dark); + --dialog-button-primary-text: var(--text-main); + --dialog-button-primary-hover-bg: var(--bg-active); + --dialog-button-primary-hover-border: var(--border-dark); + --dialog-button-danger-bg: rgba(201, 61, 61, 0.08); + --dialog-button-danger-border: rgba(201, 61, 61, 0.24); + --dialog-button-danger-text: #b93838; + --dialog-button-danger-hover-bg: rgba(201, 61, 61, 0.12); + --dialog-button-danger-hover-border: rgba(201, 61, 61, 0.36); + --dialog-button-danger-hover-text: #a12f2f; + --dialog-focus-ring: + 0 0 0 2px color-mix(in srgb, var(--bg-card) 88%, transparent), + 0 0 0 4px color-mix(in srgb, var(--primary) 24%, transparent); + --dialog-warning-text: var(--danger); --terminal-bg: #ffffff; --terminal-text: #111827; From dd0928864eedd57e6e3b91ffa8e189b928c2ca6b Mon Sep 17 00:00:00 2001 From: St0ff3l Date: Fri, 19 Jun 2026 23:48:09 +0800 Subject: [PATCH 14/24] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=82=AC=E6=B5=AE=E5=91=BD=E4=BB=A4=E7=AA=97=E7=9A=84?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=8C=96=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将终端命令输入改为贴合终端区域的悬浮窗样式,避免再像业务面板一样割裂终端与文件区 - 收敛终端悬浮窗的键盘事件作用域,只在终端区域内响应双击 Ctrl/Cmd、Alt 历史和历史面板导航,降低对其他面板与系统快捷键的干扰 - 补齐悬浮窗相关的中英文 i18n 文案,包括占位提示、历史搜索、清空列表、连接切换和文件面板显隐提示 - 将命令历史存储改为按 profile 隔离,避免不同连接之间混用历史记录 - 为悬浮窗补充复制、粘贴、播放、面板显隐等图标,并同步终端容器结构与样式适配 Co-authored-by: Codex --- .../src/renderer/components/TerminalView.tsx | 85 ++- .../src/renderer/features/common/AppIcon.tsx | 26 + .../features/terminal/TerminalDock.tsx | 560 ++++++++++++++++++ .../features/workspace/SessionWorkspace.tsx | 12 +- apps/desktop/src/renderer/i18n.ts | 40 ++ .../src/renderer/styles/features/session.css | 490 +++++++++++++++ 6 files changed, 1207 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/features/terminal/TerminalDock.tsx diff --git a/apps/desktop/src/renderer/components/TerminalView.tsx b/apps/desktop/src/renderer/components/TerminalView.tsx index 075693e..841ce8e 100644 --- a/apps/desktop/src/renderer/components/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/TerminalView.tsx @@ -65,7 +65,7 @@ function encodeBase64Utf8(value: string) { const TERMINAL_TRANSCRIPT_LIMIT = 200_000 const TERMINAL_REMOTE_GUARD_COLS = 2 -const TERMINAL_FIT_GUARD_ROWS = 1 +const TERMINAL_FIT_GUARD_ROWS = 0 const TERMINAL_RESIZE_PIXEL_EPSILON = 2 const TERMINAL_RESIZE_SETTLE_MS = 140 const TERMINAL_RESIZE_OUTPUT_QUIET_MS = 260 @@ -145,6 +145,10 @@ export function TerminalView({ const [hasSelection, setHasSelection] = useState(false) const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const [findOpen, setFindOpen] = useState(false) + const findOpenRef = useRef(findOpen) + useEffect(() => { + findOpenRef.current = findOpen + }, [findOpen]) const [findQuery, setFindQuery] = useState('') const [findMiss, setFindMiss] = useState(false) const [findMatchCount, setFindMatchCount] = useState(0) @@ -509,7 +513,7 @@ export function TerminalView({ const terminal = new Terminal({ fontFamily: '"SF Mono", Menlo, Consolas, monospace', - fontSize: 13, + fontSize: 12, lineHeight: 1.05, cursorBlink: true, allowProposedApi: true, @@ -530,6 +534,48 @@ export function TerminalView({ terminal.loadAddon(webLinksAddon) terminal.unicode.activeVersion = '11' terminal.open(hostRef.current) + terminal.attachCustomKeyEventHandler((event) => { + if (event.type !== 'keydown') { + return true + } + + const isHistoryOpen = document.body.getAttribute('data-history-open') === 'true' + if ( + isHistoryOpen && + (event.key === 'ArrowUp' || + event.key === 'ArrowDown' || + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'Enter' || + event.key === 'Escape') + ) { + return false + } + + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + const matchesCopy = isMac + ? event.metaKey && !event.shiftKey && event.key.toLowerCase() === 'c' + : event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'c' + const matchesPaste = isMac + ? event.metaKey && !event.shiftKey && event.key.toLowerCase() === 'v' + : event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'v' + const matchesFind = isMac + ? event.metaKey && !event.shiftKey && event.key.toLowerCase() === 'f' + : event.ctrlKey && !event.shiftKey && event.key.toLowerCase() === 'f' + + if ( + event.key === 'Control' || + event.key === 'Meta' || + event.key === 'Alt' || + matchesCopy || + matchesPaste || + matchesFind + ) { + return false + } + + return true + }) terminalRef.current = terminal searchAddonRef.current = searchAddon @@ -759,7 +805,11 @@ export function TerminalView({ if (matchesFind) { event.preventDefault() - openFind() + if (findOpenRef.current) { + closeFind() + } else { + openFind() + } return } @@ -769,10 +819,31 @@ export function TerminalView({ } } + const handleFocusTerminal = () => { + terminal.focus() + } + const handleTerminalCopy = () => { + runCopy() + } + const handleTerminalPaste = () => { + void runPaste() + } + const handleTerminalFind = () => { + if (findOpenRef.current) { + closeFind() + } else { + openFind() + } + } + hostRef.current.addEventListener('contextmenu', onContextMenu) window.addEventListener('keydown', onKeyDown) window.addEventListener('focus', onWindowFocus) document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('termdock:focus-terminal', handleFocusTerminal) + window.addEventListener('termdock:terminal-copy', handleTerminalCopy) + window.addEventListener('termdock:terminal-paste', handleTerminalPaste) + window.addEventListener('termdock:terminal-find', handleTerminalFind) // Ask the main process for the actual PTY size once the terminal is mounted. if (!bootedTabs.current.has(tabId)) { @@ -781,6 +852,10 @@ export function TerminalView({ } return () => { + window.removeEventListener('termdock:focus-terminal', handleFocusTerminal) + window.removeEventListener('termdock:terminal-copy', handleTerminalCopy) + window.removeEventListener('termdock:terminal-paste', handleTerminalPaste) + window.removeEventListener('termdock:terminal-find', handleTerminalFind) onDataDispose.dispose() onSelectionDispose.dispose() offData?.() @@ -911,7 +986,7 @@ export function TerminalView({ }, [findCaseSensitive, findOpen, findQuery, findRegex]) return ( - <> +
@@ -980,6 +1055,6 @@ export function TerminalView({ position={contextMenu} /> ) : null} - +
) } diff --git a/apps/desktop/src/renderer/features/common/AppIcon.tsx b/apps/desktop/src/renderer/features/common/AppIcon.tsx index 8c2f65c..69fa66a 100644 --- a/apps/desktop/src/renderer/features/common/AppIcon.tsx +++ b/apps/desktop/src/renderer/features/common/AppIcon.tsx @@ -23,6 +23,11 @@ export type AppIconName = | 'upload' | 'download' | 'flash' + | 'copy' + | 'paste' + | 'chevron-up' + | 'chevron-down' + | 'play' export function AppIcon({ name, @@ -206,6 +211,27 @@ export function AppIcon({ {name === 'flash' ? ( ) : null} + {name === 'copy' ? ( + <> + + + + ) : null} + {name === 'paste' ? ( + <> + + + + ) : null} + {name === 'chevron-up' ? ( + + ) : null} + {name === 'chevron-down' ? ( + + ) : null} + {name === 'play' ? ( + + ) : null} ) } diff --git a/apps/desktop/src/renderer/features/terminal/TerminalDock.tsx b/apps/desktop/src/renderer/features/terminal/TerminalDock.tsx new file mode 100644 index 0000000..fe19bcb --- /dev/null +++ b/apps/desktop/src/renderer/features/terminal/TerminalDock.tsx @@ -0,0 +1,560 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { WorkspaceTab } from '@termdock/core' +import { t } from '../../i18n' +import { AppIcon } from '../common/AppIcon' + +type DockPanel = 'history' | 'options' | null + +type HistoryEntry = { + command: string + createdAt: number +} + +type DockPreferences = { + clearAfterSend: boolean +} + +const HISTORY_LIMIT = 40 +const DEFAULT_PREFERENCES: DockPreferences = { + clearAfterSend: true +} + +function historyStorageKey(profileId: string) { + return `termdock:terminal-dock:history:${profileId}` +} + +function preferencesStorageKey(profileId: string) { + return `termdock:terminal-dock:preferences:${profileId}` +} + +function readStoredJson(key: string, fallback: T) { + try { + const raw = window.localStorage.getItem(key) + if (!raw) { + return fallback + } + return JSON.parse(raw) as T + } catch { + return fallback + } +} + +function normalizePreferences(value: Partial | null | undefined): DockPreferences { + return { + clearAfterSend: value?.clearAfterSend ?? DEFAULT_PREFERENCES.clearAfterSend + } +} + +function isHiddenPath(path: string) { + return path.split('/').some((segment) => segment.startsWith('.') && segment.length > 1) +} + +function formatHistoryTime(timestamp: number) { + const date = new Date(timestamp) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +export function TerminalDock({ + activeTab, + connected, + remotePath, + filePanelHeight, + setFilePanelHeight +}: { + activeTab: WorkspaceTab + connected: boolean + remotePath: string + filePanelHeight?: number + setFilePanelHeight?: (height: number | ((prev: number) => number)) => void +}) { + const [command, setCommand] = useState('') + const [panel, setPanel] = useState(null) + const [history, setHistory] = useState(() => + readStoredJson(historyStorageKey(activeTab.profileId), []) + ) + const [preferences, setPreferences] = useState(() => + normalizePreferences( + readStoredJson | null>(preferencesStorageKey(activeTab.profileId), DEFAULT_PREFERENCES) + ) + ) + const inputRef = useRef(null) + const rootRef = useRef(null) + + const [lastFilePanelHeight, setLastFilePanelHeight] = useState(218) + + const handleToggleConnection = async () => { + if (!window.termdock) return + if (connected) { + await window.termdock.disconnectTab(activeTab.id) + } else { + await window.termdock.reconnectTab(activeTab.id) + } + } + + const handleToggleFilePanel = () => { + if (!setFilePanelHeight) return + if (filePanelHeight === 0) { + setFilePanelHeight(lastFilePanelHeight) + } else { + if (filePanelHeight) { + setLastFilePanelHeight(filePanelHeight) + } + setFilePanelHeight(0) + } + } + + const [historySearch, setHistorySearch] = useState('') + const [activeHistoryIndex, setActiveHistoryIndex] = useState(0) + const [activeTokenIndex, setActiveTokenIndex] = useState(0) + const historySearchInputRef = useRef(null) + + const filteredHistory = useMemo(() => { + if (!historySearch.trim()) { + return history + } + const q = historySearch.toLowerCase() + return history.filter((entry) => entry.command.toLowerCase().includes(q)) + }, [history, historySearch]) + + useEffect(() => { + setActiveHistoryIndex((prev) => { + if (filteredHistory.length === 0) return 0 + return Math.min(prev, filteredHistory.length - 1) + }) + }, [filteredHistory.length]) + + useEffect(() => { + setActiveTokenIndex(0) + }, [activeHistoryIndex]) + + useEffect(() => { + if (panel === 'history') { + setHistorySearch('') + setActiveHistoryIndex(0) + setActiveTokenIndex(0) + historySearchInputRef.current?.focus() + document.body.setAttribute('data-history-open', 'true') + } else { + document.body.removeAttribute('data-history-open') + } + return () => { + document.body.removeAttribute('data-history-open') + } + }, [panel]) + + useEffect(() => { + let lastCtrlPress = 0 + const isEventInTerminalZone = (target: EventTarget | null) => { + if (!(target instanceof Node)) { + return false + } + + const terminalArea = rootRef.current?.parentElement + return Boolean(terminalArea && terminalArea.contains(target)) + } + + const handleGlobalKeyDown = (event: globalThis.KeyboardEvent) => { + const eventInTerminalZone = isEventInTerminalZone(event.target) + if (!eventInTerminalZone && panel !== 'history') { + return + } + + if (event.key === 'Control' || event.key === 'Meta') { + const now = Date.now() + if (!event.repeat && now - lastCtrlPress < 400) { + event.preventDefault() + if (document.activeElement === inputRef.current || document.activeElement === historySearchInputRef.current) { + inputRef.current?.blur() + historySearchInputRef.current?.blur() + window.dispatchEvent(new CustomEvent('termdock:focus-terminal')) + } else { + if (panel === 'history') { + historySearchInputRef.current?.focus() + } else { + inputRef.current?.focus() + } + } + } + lastCtrlPress = now + return + } + + if (event.key === 'Alt' && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.repeat) { + event.preventDefault() + setPanel((prev) => { + const next = prev === 'history' ? null : 'history' + if (next !== 'history') { + window.dispatchEvent(new CustomEvent('termdock:focus-terminal')) + } + return next + }) + return + } + + if (panel === 'history') { + if (event.key === 'Escape') { + event.preventDefault() + setPanel(null) + window.dispatchEvent(new CustomEvent('termdock:focus-terminal')) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + if (filteredHistory.length > 0) { + setActiveHistoryIndex((prev) => (prev - 1 + filteredHistory.length) % filteredHistory.length) + } + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + if (filteredHistory.length > 0) { + setActiveHistoryIndex((prev) => (prev + 1) % filteredHistory.length) + } + return + } + + if (event.key === 'ArrowLeft') { + event.preventDefault() + const targetItem = filteredHistory[activeHistoryIndex] + if (targetItem) { + const tokens = targetItem.command.split(/\s+/).filter(Boolean) + if (tokens.length > 0) { + setActiveTokenIndex((prev) => (prev - 1 + tokens.length) % tokens.length) + } + } + return + } + + if (event.key === 'ArrowRight') { + event.preventDefault() + const targetItem = filteredHistory[activeHistoryIndex] + if (targetItem) { + const tokens = targetItem.command.split(/\s+/).filter(Boolean) + if (tokens.length > 0) { + setActiveTokenIndex((prev) => (prev + 1) % tokens.length) + } + } + return + } + + if (event.key === 'Enter') { + if (event.isComposing) { + return + } + event.preventDefault() + const targetItem = filteredHistory[activeHistoryIndex] + if (targetItem) { + const tokens = targetItem.command.split(/\s+/).filter(Boolean) + const selectedToken = tokens[activeTokenIndex] + if (selectedToken) { + setCommand((prev) => { + const trimmed = prev.trim() + return trimmed ? `${trimmed} ${selectedToken}` : selectedToken + }) + setPanel(null) + window.dispatchEvent(new CustomEvent('termdock:focus-terminal')) + } + } + return + } + } + } + window.addEventListener('keydown', handleGlobalKeyDown) + return () => window.removeEventListener('keydown', handleGlobalKeyDown) + }, [panel, filteredHistory, activeHistoryIndex, activeTokenIndex]) + + const updatePreferences = (updater: (prev: DockPreferences) => DockPreferences) => { + setPreferences((prev) => { + const next = updater(prev) + window.localStorage.setItem(preferencesStorageKey(activeTab.profileId), JSON.stringify(next)) + return next + }) + } + + const canSend = connected && activeTab.sessionType === 'ssh' && command.trim().length > 0 + + const sendCommand = async (nextCommand: string) => { + const trimmed = nextCommand.trim() + if (!trimmed || activeTab.sessionType !== 'ssh' || !connected || !window.termdock?.writeTerminal) { + return + } + + await window.termdock.writeTerminal(activeTab.id, `${trimmed}\r`) + + const now = Date.now() + setHistory((prev) => { + const deduped = prev.filter((entry) => entry.command !== trimmed) + const next = [{ command: trimmed, createdAt: now }, ...deduped].slice(0, HISTORY_LIMIT) + window.localStorage.setItem(historyStorageKey(activeTab.profileId), JSON.stringify(next)) + return next + }) + + if (preferences.clearAfterSend) { + setCommand('') + } + + setPanel(null) + window.requestAnimationFrame(() => inputRef.current?.focus()) + } + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (panel) { + event.preventDefault() + setPanel(null) + return + } + if (command) { + event.preventDefault() + setCommand('') + } + return + } + + if (event.key === 'Enter' && !event.shiftKey) { + if (event.nativeEvent.isComposing) { + return + } + event.preventDefault() + if (canSend) { + void sendCommand(command) + } + } + } + + const handleHistoryAction = async (cmd: string, actionIndex: number) => { + if (actionIndex === 0) { + setPanel(null) + await sendCommand(cmd) + } else if (actionIndex === 1) { + setCommand(cmd) + setPanel(null) + window.requestAnimationFrame(() => inputRef.current?.focus()) + } else if (actionIndex === 2) { + setHistory((prev) => { + const next = prev.filter((entry) => entry.command !== cmd) + window.localStorage.setItem(historyStorageKey(activeTab.profileId), JSON.stringify(next)) + return next + }) + } + } + + const activeItemRef = useRef(null) + + useEffect(() => { + if (panel === 'history' && activeItemRef.current) { + activeItemRef.current.scrollIntoView({ block: 'nearest' }) + } + }, [activeHistoryIndex, panel]) + + const renderHistoryPanel = () => ( +
+
+ {filteredHistory.length ? filteredHistory.map((entry, index) => { + const isActive = activeHistoryIndex === index + const tokens = entry.command.split(/\s+/).filter(Boolean) + return ( +
{ + setActiveHistoryIndex(index) + setActiveTokenIndex(0) + }} + onDoubleClick={() => { + void handleHistoryAction(entry.command, 0) + }} + > +
+ + {isActive ? ( + tokens.map((token, tokenIdx) => ( + { + e.stopPropagation() + setCommand((prev) => { + const trimmed = prev.trim() + return trimmed ? `${trimmed} ${token}` : token + }) + setPanel(null) + window.dispatchEvent(new CustomEvent('termdock:focus-terminal')) + }} + > + {token} + + )) + ) : ( + entry.command + )} + +
+
+ + {formatHistoryTime(entry.createdAt)} + +
+ + + +
+
+
+ ) + }) : ( +
{t.terminalDockHistoryEmpty}
+ )} +
+
+ {t.terminalDockHistoryInsertHint} + +
+
+ setHistorySearch(e.target.value)} + /> +
+
+ ) + + const renderOptionsPanel = () => ( +
+ +
+ ) + + const isMac = window.termdock?.platform === 'darwin' + const placeholderText = isMac ? t.terminalDockPlaceholderMac : t.terminalDockPlaceholderWin + + return ( +
+ {panel === 'history' ? renderHistoryPanel() : null} + {panel === 'options' ? renderOptionsPanel() : null} + {null} +
+ +
+ + + + + + + {setFilePanelHeight ? ( + + ) : null} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/features/workspace/SessionWorkspace.tsx b/apps/desktop/src/renderer/features/workspace/SessionWorkspace.tsx index 8a7e14c..b0ac829 100644 --- a/apps/desktop/src/renderer/features/workspace/SessionWorkspace.tsx +++ b/apps/desktop/src/renderer/features/workspace/SessionWorkspace.tsx @@ -10,6 +10,7 @@ import type { } from '@termdock/core' import { TerminalView } from '../../components/TerminalView' import { FileManager } from '../files/FileManager' +import { TerminalDock } from '../terminal/TerminalDock' export function SessionWorkspace({ activeTab, @@ -96,6 +97,7 @@ export function SessionWorkspace({ const layoutFrameRef = useRef(null) const clampFilePanelHeight = (workspaceHeight: number, nextHeight: number) => { + if (nextHeight === 0) return 0 const minHeight = 25 // Allow it to shrink to just the tabs row height const maxHeight = Math.max(minHeight, workspaceHeight - 160) return Math.min(maxHeight, Math.max(minHeight, nextHeight)) @@ -247,13 +249,21 @@ export function SessionWorkspace({ style={{ '--file-panel-height': `${filePanelHeight}px` } as CSSProperties} > {!isFileOnly ? ( -
+
+
) : null} {!isFileOnly ? ( diff --git a/apps/desktop/src/renderer/i18n.ts b/apps/desktop/src/renderer/i18n.ts index 9659971..3dd305c 100644 --- a/apps/desktop/src/renderer/i18n.ts +++ b/apps/desktop/src/renderer/i18n.ts @@ -121,6 +121,7 @@ const zhCN = { parentFolder: '上级', history: '历史', options: '选项', + minute: '分钟', upload: '上传', download: '下载', reconnect: '重连', @@ -333,6 +334,25 @@ const zhCN = { commandParam: '参数', commandParamHint: '插入动态参数占位', commandDetectedParams: '检测到的参数位', + terminalDockInputLabel: '命令输入', + terminalDockPlaceholder: '输入命令,Enter 发送,Tab 补全路径', + terminalDockHelper: '双击历史回放,Tab 补路径,Esc 清空输入', + terminalDockShortcutHint: '会发送到当前 SSH 会话,不会覆盖终端文字区域', + terminalDockHistoryEmpty: '还没有发送历史', + terminalDockClearAfterSend: '发送后清空', + terminalDockShowHiddenPaths: '显示隐藏路径/目录', + terminalDockClearPathCache: '清空自动补全路径历史', + terminalDockPathCacheTtl: '自动补全路径保留时间', + terminalDockPathHint: 'Tab 补全当前输入里的路径片段', + terminalDockHistoryInsertHint: '按左右方向键选择字段,Enter 插入', + terminalDockClearList: '清空列表', + terminalDockHistorySearchPlaceholder: '搜索历史命令...', + terminalDockPlaceholderMac: '命令输入(双击 Cmd 切换,Option 打开历史,Esc 关闭)', + terminalDockPlaceholderWin: '命令输入(双击 Ctrl 切换,Alt 打开历史,Esc 关闭)', + terminalDockDisconnect: '断开连接', + terminalDockReconnect: '重新连接', + terminalDockShowFilePanel: '显示文件面板', + terminalDockHideFilePanel: '隐藏文件面板', newCommand: '新建命令', settings: '设置', generalSettings: '通用设置', @@ -704,6 +724,26 @@ const enUS: typeof zhCN = { commandParam: 'Param', commandParamHint: 'Insert dynamic placeholders', commandDetectedParams: 'Detected Params', + minute: 'm', + terminalDockInputLabel: 'Command', + terminalDockPlaceholder: 'Type a command, press Enter to send, Tab to complete paths', + terminalDockHelper: 'Double-click history to replay, Tab completes paths, Esc clears the input', + terminalDockShortcutHint: 'Sends to the current SSH session without covering the terminal text area', + terminalDockHistoryEmpty: 'No command history yet', + terminalDockClearAfterSend: 'Clear after send', + terminalDockShowHiddenPaths: 'Show hidden paths/directories', + terminalDockClearPathCache: 'Clear autocomplete paths', + terminalDockPathCacheTtl: 'Path autocomplete retention time', + terminalDockPathHint: 'Tab completes the current path fragment', + terminalDockHistoryInsertHint: 'Use left/right to select a token, press Enter to insert', + terminalDockClearList: 'Clear list', + terminalDockHistorySearchPlaceholder: 'Search command history...', + terminalDockPlaceholderMac: 'Command input (double Cmd to toggle, Option opens history, Esc closes)', + terminalDockPlaceholderWin: 'Command input (double Ctrl to toggle, Alt opens history, Esc closes)', + terminalDockDisconnect: 'Disconnect', + terminalDockReconnect: 'Reconnect', + terminalDockShowFilePanel: 'Show file panel', + terminalDockHideFilePanel: 'Hide file panel', newCommand: 'New Command', settings: 'Settings', generalSettings: 'General Settings', diff --git a/apps/desktop/src/renderer/styles/features/session.css b/apps/desktop/src/renderer/styles/features/session.css index 276abcd..8c89a3b 100644 --- a/apps/desktop/src/renderer/styles/features/session.css +++ b/apps/desktop/src/renderer/styles/features/session.css @@ -40,12 +40,22 @@ border: 0; } +.terminal-view { + position: relative; + height: 100%; + min-height: 0; +} + .terminal-host { box-sizing: border-box; height: 100%; min-height: 0; } +.terminal-area.has-terminal-dock .terminal-host { + padding-bottom: var(--terminal-dock-reserved-height, 44px); +} + .terminal-inner { height: 100%; min-height: 0; @@ -151,11 +161,491 @@ white-space: nowrap; } +.terminal-dock { + --terminal-dock-surface: color-mix(in srgb, var(--bg-card) 85%, transparent); + --terminal-dock-border: var(--border); + --terminal-dock-text-muted: var(--text-muted); + position: absolute; + left: 48px; + right: 48px; + bottom: 10px; + z-index: 24; + pointer-events: none; +} + +.terminal-dock-panel { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + width: min(320px, calc(100vw - 40px)); + border: 1px solid var(--terminal-dock-border); + border-radius: 12px; + padding: 10px 12px; + background: var(--terminal-dock-surface); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + pointer-events: auto; +} + +.terminal-dock-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-size: 11px; +} + +.terminal-dock-panel-head strong { + font-size: 11px; + font-weight: 600; + color: var(--text-main); +} + +.terminal-dock-panel-head span { + font-size: 11px; + color: var(--terminal-dock-text-muted); +} + +.terminal-dock-panel-head button { + font-size: 11px; + color: var(--terminal-dock-text-muted); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + transition: color 0.15s ease; +} + +.terminal-dock-panel-head button:hover { + color: var(--text-main); +} + +.terminal-dock-panel.terminal-dock-history { + left: auto; + right: 0; + width: min(480px, calc(100vw - 40px)); + max-width: none; + padding: 8px 0 10px 0; +} + +.terminal-dock-history-list { + display: flex; + flex-direction: column; + max-height: 180px; + overflow-y: auto; +} + + + +.terminal-dock-history-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 24px; + padding: 4px 12px; + border-bottom: 1px solid var(--border-light); + transition: all 0.15s ease; + cursor: pointer; + flex-shrink: 0; +} + +.terminal-dock-history-wrapper:last-child { + border-bottom: none; +} + +.terminal-dock-history-wrapper:hover { + background: rgba(255, 255, 255, 0.03) !important; +} + +.terminal-dock-history-wrapper.is-active { + background: rgba(255, 255, 255, 0.06) !important; +} + +.terminal-dock-history-left { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; +} + +.terminal-dock-history-left code { + display: flex; + flex-wrap: wrap; + gap: 4px; + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + color: var(--text-main); + word-break: break-all; + white-space: normal; + overflow: visible; +} + +.terminal-dock-history-wrapper:not(.is-active) .terminal-dock-history-left code { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-token { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--text-main) 6%, transparent); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + font-family: "SF Mono", Menlo, Consolas, monospace; +} + +.history-token:hover { + background: color-mix(in srgb, var(--text-main) 15%, transparent); + border-color: var(--border-light); +} + +.history-token.is-selected { + background: var(--selection-bg); + border-color: var(--border-dark); + color: var(--text-main); + font-weight: 600; +} + +.terminal-dock-history-right { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + width: 90px; + position: relative; + height: 24px; +} + +.terminal-dock-history-time { + font-size: 10px; + color: var(--text-muted); + transition: opacity 0.15s ease; +} + +.terminal-dock-history-actions { + display: flex; + align-items: center; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + position: absolute; + right: 0; +} + +.terminal-dock-history-wrapper:hover .terminal-dock-history-actions { + opacity: 1; + pointer-events: auto; +} + +.terminal-dock-history-wrapper:hover .terminal-dock-history-time { + opacity: 0; +} + +.terminal-dock-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; + padding: 0; +} + +.terminal-dock-action-btn:hover { + color: var(--text-main); + background: var(--bg-hover); +} + +.terminal-dock-action-btn .close-icon { + font-size: 10px; + line-height: 1; +} + +.terminal-dock-history-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 4px; + border-top: 1px solid var(--border-light); + margin-top: 4px; +} + +.terminal-dock-history-hint { + font-size: 10px; + color: var(--text-muted); +} + +.terminal-dock-clear-btn { + font-size: 10px; + color: var(--text-muted); + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.terminal-dock-clear-btn:hover { + color: var(--text-main); + background: var(--bg-active); + border-color: var(--border-dark); +} + +.terminal-dock-history-search-wrapper { + margin-top: 6px; + padding: 0 12px; +} + +.terminal-dock-history-search { + width: 100%; + height: 26px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--text-main) 6%, transparent); + color: var(--text-main); + font-size: 11px; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.terminal-dock-history-search:focus { + outline: none; + border-color: var(--border-dark); + background: color-mix(in srgb, var(--text-main) 8%, transparent); +} + +.terminal-dock-suggestion-list button.is-selected { + border-color: var(--border-dark); + background: var(--selection-bg); +} + +.terminal-dock-empty { + font-size: 11px; + color: var(--terminal-dock-text-muted); + text-align: center; + padding: 8px 0; +} + +.terminal-dock-options { + display: grid; + gap: 8px; +} + +.terminal-dock-option-row, +.terminal-dock-option-inline { + display: flex; + align-items: center; + gap: 10px; + min-height: 24px; + font-size: 11px; + color: var(--text-main); +} + +.terminal-dock-option-inline { + justify-content: space-between; +} + +.terminal-dock-option-row input { + margin: 0; + cursor: pointer; +} + +.terminal-dock-ttl-options { + display: flex; + gap: 4px; +} + +.terminal-dock-ttl-options button, +.terminal-dock-option-inline button { + min-height: 22px; + padding: 0 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-hover); + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; +} + +.terminal-dock-ttl-options button:hover, +.terminal-dock-option-inline button:hover { + color: var(--text-main); + background: var(--bg-active); + border-color: var(--border-dark); +} + +.terminal-dock-ttl-options button.is-active { + border-color: var(--border-dark); + background: var(--selection-bg); + color: var(--text-main); +} + +.terminal-dock-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 30px; + padding: 2px 10px; + border: 1px solid var(--terminal-dock-border); + border-radius: 10px; + background: var(--terminal-dock-surface); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + pointer-events: auto; +} + +.terminal-dock-input-shell { + display: flex; + align-items: center; + min-width: 0; + width: 100%; +} + +.terminal-dock-input-shell input { + width: 100%; + min-width: 0; + height: 24px; + padding: 0 4px; + border: none; + background: transparent; + color: var(--text-main); + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; + transition: color 0.15s ease; +} + +.terminal-dock-input-shell input:focus { + outline: none; +} + +.terminal-dock-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.terminal-dock-actions button { + min-width: 48px; + height: 24px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; +} + +.terminal-dock-actions button:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-main); +} + +.terminal-dock-actions button:active:not(:disabled) { + background: var(--bg-active); +} + +.terminal-dock-actions button.is-active { + border-color: var(--border-dark); + background: var(--selection-bg); + color: var(--text-main); +} + +.terminal-dock-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + min-width: 24px !important; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; +} + +.terminal-dock-icon-btn:hover { + background: var(--bg-hover); + color: var(--text-main); +} + +.terminal-dock-icon-btn:active { + background: var(--bg-active); +} + +.terminal-dock-connection.is-connected { + color: var(--success) !important; +} + +.terminal-dock-connection.is-connected:hover { + background: color-mix(in srgb, var(--success) 12%, transparent) !important; + border-color: color-mix(in srgb, var(--success) 35%, transparent) !important; + color: var(--success) !important; +} + +.terminal-dock-connection.is-disconnected { + color: var(--danger) !important; +} + +.terminal-dock-connection.is-disconnected:hover { + background: color-mix(in srgb, var(--danger) 12%, transparent) !important; + border-color: color-mix(in srgb, var(--danger) 35%, transparent) !important; + color: var(--danger) !important; +} + +.terminal-dock-icon-btn .app-icon { + flex: 0 0 auto; +} + +@media (max-width: 980px) { + .terminal-dock { + left: 12px; + right: 12px; + bottom: 12px; + } + + .terminal-dock-bar { + grid-template-columns: 1fr; + min-height: auto; + padding: 6px 10px; + } + + .terminal-dock-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .terminal-dock-panel { + width: min(100%, calc(100vw - 56px)); + } +} + .file-manager { grid-row: 2; display: grid; grid-template-rows: 25px minmax(0, 1fr); min-height: 0; + overflow: hidden; } .session-workspace.file-only .file-manager { From 9aa5383bb8f8766dc6e371d809bf19f18918f101 Mon Sep 17 00:00:00 2001 From: St0ff3l Date: Fri, 19 Jun 2026 23:53:17 +0800 Subject: [PATCH 15/24] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=20file=20edit?= =?UTF-8?q?or=20=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E6=97=A0=E6=84=8F=E4=B9=89=E7=A9=BA=E7=99=BD=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 file-editor-config.ts 里残留的无意义缩进/空白差异 - 保持当前分支工作区干净,避免无关噪音干扰后续评审与提交 Co-authored-by: Codex --- apps/desktop/src/renderer/features/files/file-editor-config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/renderer/features/files/file-editor-config.ts b/apps/desktop/src/renderer/features/files/file-editor-config.ts index 9ebc5d3..aa2e712 100644 --- a/apps/desktop/src/renderer/features/files/file-editor-config.ts +++ b/apps/desktop/src/renderer/features/files/file-editor-config.ts @@ -43,4 +43,3 @@ export function sortEditorLanguages( }) .sort((a, b) => a.label.localeCompare(b.label)) } - From 52a3d0a0ea07e4bc1145d182c40c1bb4c59dccbb Mon Sep 17 00:00:00 2001 From: St0ff3l Date: Sat, 20 Jun 2026 13:11:39 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=8F=91=E9=80=81=E7=9B=AE=E6=A0=87=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=AD=98=E5=82=A8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复发送目标选择器中的 i18n 临时 replace 逻辑,改为正式文案 key,避免后续改文案或扩语言时出现脆弱行为 - 将悬浮窗命令历史从 renderer localStorage 迁移到主进程持久层,新增 command-history.json,和 profiles.json / commands.json 一样落到 userData 目录 - 补齐 workspace service / ipc / preload / renderer 的历史读写链路,并兼容旧版 localStorage 历史自动迁移 - 将命令中心 rememberSelection、sendScope、selectedTabIds 从 localStorage 迁移到主进程持久层,新增 command-send-preferences.json - 补齐对应 core / storage 类型定义,统一发送目标与持久化边界 - 保持 FTP / 纯文件界面不展示命令发送能力,继续只在 SSH 终端场景启用 Co-authored-by: Codex --- .../src/main/ipc/workspace-handlers.ts | 18 + .../main/services/file-profile-repository.ts | 76 +++ .../src/main/services/workspace-service.ts | 18 + apps/desktop/src/preload/preload.cts | 10 + apps/desktop/src/renderer/App.tsx | 170 +++++- .../features/commands/CommandCenter.tsx | 164 ++++-- .../common/SessionSendTargetPicker.tsx | 249 +++++++++ .../features/common/session-send-targets.ts | 51 ++ .../renderer/features/files/FileManager.tsx | 18 +- .../features/terminal/TerminalDock.tsx | 131 ++++- .../features/workspace/SessionWorkspace.tsx | 26 +- .../features/workspace/WorkspaceStage.tsx | 24 +- apps/desktop/src/renderer/i18n.ts | 12 + .../src/renderer/styles/features/commands.css | 497 +++++++++++++++++- .../src/renderer/styles/features/session.css | 1 + packages/core/src/index.ts | 15 + packages/storage/src/index.ts | 28 +- 17 files changed, 1412 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/src/renderer/features/common/SessionSendTargetPicker.tsx create mode 100644 apps/desktop/src/renderer/features/common/session-send-targets.ts diff --git a/apps/desktop/src/main/ipc/workspace-handlers.ts b/apps/desktop/src/main/ipc/workspace-handlers.ts index 074944b..14fa9b6 100644 --- a/apps/desktop/src/main/ipc/workspace-handlers.ts +++ b/apps/desktop/src/main/ipc/workspace-handlers.ts @@ -1,7 +1,9 @@ import { BrowserWindow, ipcMain, type WebContents } from 'electron' import type { + CommandSendPreferences, CommandExecutionOptions, CommandFolder, + TerminalCommandHistoryEntry, CommandTemplateInput, ConnectionFolder, CreateProfileInput @@ -110,6 +112,22 @@ export function registerWorkspaceHandlers(services: IpcServices, options: IpcWin return workspaceService.executeCommandTemplate(tabId, commandId, args, options) }) + ipcMain.handle('workspace:getTerminalCommandHistory', async (_, profileId: string) => { + return workspaceService.getTerminalCommandHistory(profileId) + }) + + ipcMain.handle('workspace:setTerminalCommandHistory', async (_, profileId: string, entries: TerminalCommandHistoryEntry[]) => { + await workspaceService.setTerminalCommandHistory(profileId, entries) + }) + + ipcMain.handle('workspace:getCommandSendPreferences', async () => { + return workspaceService.getCommandSendPreferences() + }) + + ipcMain.handle('workspace:setCommandSendPreferences', async (_, preferences: CommandSendPreferences) => { + await workspaceService.setCommandSendPreferences(preferences) + }) + ipcMain.handle('workspace:openProfile', async (event, profileId: string) => { const snapshot = await workspaceService.openProfile(profileId, event.sender) broadcastSnapshot(snapshot) diff --git a/apps/desktop/src/main/services/file-profile-repository.ts b/apps/desktop/src/main/services/file-profile-repository.ts index 7d3fc9a..7b0ef69 100644 --- a/apps/desktop/src/main/services/file-profile-repository.ts +++ b/apps/desktop/src/main/services/file-profile-repository.ts @@ -3,7 +3,9 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import { safeStorage } from 'electron' import type { + CommandSendPreferences, CommandFolder, + TerminalCommandHistoryEntry, CommandTemplate, CommandTemplateInput, ConnectionFolder, @@ -47,6 +49,8 @@ export class FileProfileRepository implements ProfileRepository { private readonly foldersPath: string private readonly commandFoldersPath: string private readonly commandsPath: string + private readonly commandHistoryPath: string + private readonly commandSendPreferencesPath: string private readonly seedProfiles: ConnectionProfile[] private readonly seedCommandTemplates: CommandTemplate[] private readonly seedCommandFolders: CommandFolder[] @@ -63,6 +67,8 @@ export class FileProfileRepository implements ProfileRepository { this.foldersPath = path.join(baseDir, 'folders.json') this.commandFoldersPath = path.join(baseDir, 'command-folders.json') this.commandsPath = path.join(baseDir, 'commands.json') + this.commandHistoryPath = path.join(baseDir, 'command-history.json') + this.commandSendPreferencesPath = path.join(baseDir, 'command-send-preferences.json') this.seedProfiles = seedProfiles this.seedCommandTemplates = seedCommandTemplates this.seedCommandFolders = seedCommandFolders @@ -321,6 +327,38 @@ export class FileProfileRepository implements ProfileRepository { ))) } + async getTerminalCommandHistory(profileId: string): Promise { + const historyMap = await this.readCommandHistoryMap() + return [...(historyMap[profileId] ?? [])] + } + + async setTerminalCommandHistory(profileId: string, entries: TerminalCommandHistoryEntry[]): Promise { + const historyMap = await this.readCommandHistoryMap() + const nextEntries = entries + .filter((entry) => entry.command.trim().length > 0 && Number.isFinite(entry.createdAt)) + .map((entry) => ({ + command: entry.command, + createdAt: entry.createdAt + })) + + await this.writeCommandHistoryMap({ + ...historyMap, + [profileId]: nextEntries + }) + } + + async getCommandSendPreferences(): Promise { + return this.readCommandSendPreferences() + } + + async setCommandSendPreferences(preferences: CommandSendPreferences): Promise { + await this.writeCommandSendPreferences({ + rememberSelection: preferences.rememberSelection, + sendScope: preferences.sendScope, + selectedTabIds: preferences.selectedTabIds + }) + } + private async ensureFile() { await mkdir(path.dirname(this.filePath), { recursive: true }) try { @@ -343,6 +381,20 @@ export class FileProfileRepository implements ProfileRepository { } catch { await this.writeCommandTemplates(this.seedCommandTemplates) } + try { + await readFile(this.commandHistoryPath, 'utf8') + } catch { + await this.writeCommandHistoryMap({}) + } + try { + await readFile(this.commandSendPreferencesPath, 'utf8') + } catch { + await this.writeCommandSendPreferences({ + rememberSelection: false, + sendScope: 'current', + selectedTabIds: [] + }) + } await this.removeLegacyDemoData() await this.migrateProfileSecrets() @@ -375,6 +427,30 @@ export class FileProfileRepository implements ProfileRepository { ]) } + private async readCommandHistoryMap(): Promise> { + await this.ready + return readJsonFile>(this.commandHistoryPath, {}) + } + + private async writeCommandHistoryMap(historyMap: Record) { + await mkdir(path.dirname(this.commandHistoryPath), { recursive: true }) + await writeFile(this.commandHistoryPath, JSON.stringify(historyMap, null, 2), 'utf8') + } + + private async readCommandSendPreferences(): Promise { + await this.ready + return readJsonFile(this.commandSendPreferencesPath, { + rememberSelection: false, + sendScope: 'current', + selectedTabIds: [] + }) + } + + private async writeCommandSendPreferences(preferences: CommandSendPreferences) { + await mkdir(path.dirname(this.commandSendPreferencesPath), { recursive: true }) + await writeFile(this.commandSendPreferencesPath, JSON.stringify(preferences, null, 2), 'utf8') + } + private async readProfiles(): Promise { await this.ready const content = await readFile(this.filePath, 'utf8') diff --git a/apps/desktop/src/main/services/workspace-service.ts b/apps/desktop/src/main/services/workspace-service.ts index 540bca7..a70e14e 100644 --- a/apps/desktop/src/main/services/workspace-service.ts +++ b/apps/desktop/src/main/services/workspace-service.ts @@ -3,6 +3,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import path from 'node:path' import type { WebContents } from 'electron' import { + type CommandSendPreferences, type CommandExecutionOptions, type CommandTemplateInput, type CommandFolder, @@ -10,6 +11,7 @@ import { type ConnectionLibrarySnapshot, type ConnectionProfile, type CommandExecutionResult, + type TerminalCommandHistoryEntry, type CreateProfileInput, type RemoteFileAccessOptions, type RemoteFileItem, @@ -186,6 +188,22 @@ export class WorkspaceService { return { renderedCommand } } + async getTerminalCommandHistory(profileId: string): Promise { + return this.profileRepository.getTerminalCommandHistory(profileId) + } + + async setTerminalCommandHistory(profileId: string, entries: TerminalCommandHistoryEntry[]): Promise { + await this.profileRepository.setTerminalCommandHistory(profileId, entries) + } + + async getCommandSendPreferences(): Promise { + return this.profileRepository.getCommandSendPreferences() + } + + async setCommandSendPreferences(preferences: CommandSendPreferences): Promise { + await this.profileRepository.setCommandSendPreferences(preferences) + } + async openProfile(profileId: string, sender: WebContents): Promise { const profile = await this.profileRepository.getById(profileId) if (!profile) { diff --git a/apps/desktop/src/preload/preload.cts b/apps/desktop/src/preload/preload.cts index 1cd6d05..319f621 100644 --- a/apps/desktop/src/preload/preload.cts +++ b/apps/desktop/src/preload/preload.cts @@ -1,7 +1,9 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron') as typeof import('electron') import type { + CommandSendPreferences, CommandExecutionOptions, + TerminalCommandHistoryEntry, CommandTemplateInput, CommandFolder, ConnectionFormMode, @@ -98,6 +100,14 @@ const api: TermdockDesktopApi = { ipcRenderer.invoke('workspace:deleteCommandTemplate', commandId), executeCommandTemplate: (tabId: string, commandId: string, args?: string[], options?: CommandExecutionOptions): Promise => ipcRenderer.invoke('workspace:executeCommandTemplate', tabId, commandId, args, options), + getTerminalCommandHistory: (profileId: string): Promise => + ipcRenderer.invoke('workspace:getTerminalCommandHistory', profileId), + setTerminalCommandHistory: (profileId: string, entries: TerminalCommandHistoryEntry[]): Promise => + ipcRenderer.invoke('workspace:setTerminalCommandHistory', profileId, entries), + getCommandSendPreferences: (): Promise => + ipcRenderer.invoke('workspace:getCommandSendPreferences'), + setCommandSendPreferences: (preferences: CommandSendPreferences): Promise => + ipcRenderer.invoke('workspace:setCommandSendPreferences', preferences), openProfile: (profileId: string): Promise => ipcRenderer.invoke('workspace:openProfile', profileId), openProfileFromManager: (profileId: string): Promise => diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index a83c6c1..de3a14b 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -32,6 +32,8 @@ import { FilePermissionModal } from './features/files/FilePermissionModal' import { RootAccessModal } from './features/files/RootAccessModal' import { AppIcon } from './features/common/AppIcon' import { ConfirmActionDialog } from './features/common/ConfirmActionDialog' +import type { SendScope, SessionSendTarget } from './features/common/session-send-targets' +import { resolveSelectedTabIds } from './features/common/session-send-targets' import { TabBar, type OrderedTabEntry, type TabContextTarget } from './features/layout/TabBar' import { TabContextMenu } from './features/layout/TabContextMenu' import { SystemSidebar } from './features/system/SystemSidebar' @@ -64,6 +66,12 @@ type StoredMainTabUiState = { isSystemSidebarCollapsed: boolean } +type TerminalDockSendState = { + scope: SendScope + selectedTabIds: string[] + rememberSelection: boolean +} + function formatSystemInfoTabTitle(sourceTabTitle: string) { return `${t.systemInfoTabTitle} · ${sourceTabTitle || t.untitledTab}` } @@ -425,6 +433,7 @@ export function App() { const [activeLocalTabId, setActiveLocalTabId] = useState(() => initialMainTabUiState.activeLocalTabId) const [nextHomeTabNumber, setNextHomeTabNumber] = useState(() => initialMainTabUiState.nextHomeTabNumber) const [tabOrder, setTabOrder] = useState(() => initialMainTabUiState.tabOrder) + const [terminalDockSendStateByTabId, setTerminalDockSendStateByTabId] = useState>({}) const [draggingTabKey, setDraggingTabKey] = useState(null) const [tabContextMenu, setTabContextMenu] = useState<{ x: number @@ -1240,7 +1249,8 @@ export function App() { commandId: string, args: string[], options: CommandExecutionOptions, - scope: 'current' | 'all-ssh' + scope: SendScope, + selectedTabIds: string[] ) => { if (!desktopApi) { return @@ -1248,11 +1258,8 @@ export function App() { try { setIsBusy(true) - const targetTabs = scope === 'all-ssh' - ? visibleWorkspaceTabs.filter((tab) => tab.sessionType === 'ssh' && tab.status !== 'closed') - : activeTab && activeTab.sessionType === 'ssh' - ? [activeTab] - : [] + const targetIds = resolveSelectedTabIds(scope, activeTab, selectedTabIds, sessionSendTargets) + const targetTabs = visibleWorkspaceTabs.filter((tab) => targetIds.includes(tab.id)) for (const tab of targetTabs) { await desktopApi.executeCommandTemplate(tab.id, commandId, args, options) @@ -1264,6 +1271,69 @@ export function App() { } } + const sendTerminalCommand = async (command: string) => { + if (!desktopApi || !activeTab) { + return + } + + const targetIds = resolveSelectedTabIds( + activeTerminalDockSendState.scope, + activeTab, + activeTerminalDockSendState.selectedTabIds, + sessionSendTargets + ) + + if (!targetIds.length) { + setError(t.commandNoAvailableTargets) + return + } + + try { + for (const tabId of targetIds) { + await desktopApi.writeTerminal(tabId, `${command}\r`) + } + } catch (err) { + reportError(setError, '发送终端命令', err) + throw err + } finally { + if (!activeTerminalDockSendState.rememberSelection && activeTab) { + setTerminalDockSendStateByTabId((prev) => ({ + ...prev, + [activeTab.id]: { + scope: 'current', + selectedTabIds: [], + rememberSelection: false + } + })) + } + } + } + + const updateTerminalDockSendState = ( + updater: (prev: TerminalDockSendState) => TerminalDockSendState + ) => { + if (!activeTab) { + return + } + + setTerminalDockSendStateByTabId((prev) => { + const current = prev[activeTab.id] ?? { + scope: 'current' as SendScope, + selectedTabIds: [], + rememberSelection: false + } + + const next = updater(current) + return { + ...prev, + [activeTab.id]: { + ...next, + selectedTabIds: next.selectedTabIds.filter((tabId) => sessionSendTargets.some((target) => target.tabId === tabId)) + } + } + }) + } + const handleOpenProfile = async (profileId: string) => { if (!desktopApi) { return @@ -2307,6 +2377,69 @@ export function App() { }) .filter((item): item is OrderedTabEntry => item !== null) + const sessionSendTargets = useMemo( + () => + orderedTabs.flatMap((entry, index) => { + if (entry.kind !== 'session' || entry.tab.sessionType !== 'ssh') { + return [] + } + + const session = workspace.sessions[entry.tab.id] + if (!session?.connected) { + return [] + } + + return [{ + tabId: entry.tab.id, + index: index + 1, + title: entry.tab.title, + label: `${index + 1} ${entry.tab.title}`, + isCurrent: entry.tab.id === activeTab?.id + }] + }), + [activeTab?.id, orderedTabs, workspace.sessions] + ) + + const activeTerminalDockSendState = activeTab + ? terminalDockSendStateByTabId[activeTab.id] ?? { + scope: 'current' as SendScope, + selectedTabIds: [], + rememberSelection: false + } + : { + scope: 'current' as SendScope, + selectedTabIds: [], + rememberSelection: false + } + + useEffect(() => { + const validTabIds = new Set(visibleWorkspaceTabs.map((tab) => tab.id)) + setTerminalDockSendStateByTabId((prev) => { + const next = Object.fromEntries( + Object.entries(prev).filter(([tabId]) => validTabIds.has(tabId)) + ) + return Object.keys(next).length === Object.keys(prev).length ? prev : next + }) + }, [visibleWorkspaceTabs]) + + useEffect(() => { + const availableTargetIds = new Set(sessionSendTargets.map((target) => target.tabId)) + setTerminalDockSendStateByTabId((prev) => { + let changed = false + const next = Object.fromEntries( + Object.entries(prev).map(([tabId, state]) => { + const selectedTabIds = state.selectedTabIds.filter((targetTabId) => availableTargetIds.has(targetTabId)) + if (selectedTabIds.length !== state.selectedTabIds.length) { + changed = true + return [tabId, { ...state, selectedTabIds }] + } + return [tabId, state] + }) + ) + return changed ? next : prev + }) + }, [sessionSendTargets]) + const handleOpenRemoteItem = (item: RemoteFileItem) => { if (!desktopApi || !activeTab) { return @@ -2847,7 +2980,9 @@ export function App() { activeProfile={activeProfile} activeSession={activeSession} activeTab={activeTab} - tabs={visibleWorkspaceTabs} + sendTargets={sessionSendTargets} + terminalDockSendScope={activeTerminalDockSendState.scope} + terminalDockSelectedTabIds={activeTerminalDockSendState.selectedTabIds} commandFolders={workspace.commandFolders || []} commandTemplates={workspace.commandTemplates || []} folders={workspace.folders || []} @@ -2862,8 +2997,25 @@ export function App() { onCopyItems={setClipboardItems.bind(null, 'copy')} onCutItems={setClipboardItems.bind(null, 'cut')} onClearCutState={clearCutState} - onExecuteCommand={(commandId, args, options, scope) => { - void executeCommandTemplate(commandId, args, options, scope) + onExecuteCommand={(commandId, args, options, scope, selectedTabIds) => { + void executeCommandTemplate(commandId, args, options, scope, selectedTabIds) + }} + onSendTerminalCommand={sendTerminalCommand} + onTerminalDockSendScopeChange={(scope, rememberSelection) => { + updateTerminalDockSendState((prev) => ({ + ...prev, + scope, + rememberSelection, + selectedTabIds: scope === 'selected-ssh' ? prev.selectedTabIds : [] + })) + }} + onTerminalDockSelectedTabIdsChange={(selectedTabIds, rememberSelection) => { + updateTerminalDockSendState((prev) => ({ + ...prev, + scope: 'selected-ssh', + selectedTabIds, + rememberSelection + })) }} onOpenCommandManager={openCommandManager} profiles={workspace.profiles} diff --git a/apps/desktop/src/renderer/features/commands/CommandCenter.tsx b/apps/desktop/src/renderer/features/commands/CommandCenter.tsx index d2b000b..619fab6 100644 --- a/apps/desktop/src/renderer/features/commands/CommandCenter.tsx +++ b/apps/desktop/src/renderer/features/commands/CommandCenter.tsx @@ -2,16 +2,17 @@ import { useEffect, useMemo, useState, useRef } from 'react' import type { CommandExecutionOptions, CommandFolder, + CommandSendPreferences, CommandTemplate, WorkspaceTab } from '@termdock/core' import { t } from '../../i18n' import { AppIcon } from '../common/AppIcon' import { handleHorizontalWheelScroll } from '../common/horizontal-scroll' +import { SessionSendTargetPicker } from '../common/SessionSendTargetPicker' +import type { SendScope, SessionSendTarget } from '../common/session-send-targets' import { extractCommandParams, groupCommands, sortByOrder } from './command-utils' -type SendScope = 'current' | 'all-ssh' - function getCommandSnippet(command: string) { return command .split('\n') @@ -29,7 +30,7 @@ export function CommandCenter({ commandFolders, commandTemplates, isBusy, - tabs, + sendTargets, onExecute, paneWidth, onPaneWidthChange, @@ -38,8 +39,8 @@ export function CommandCenter({ commandFolders: CommandFolder[] commandTemplates: CommandTemplate[] isBusy: boolean - tabs: WorkspaceTab[] - onExecute(commandId: string, args: string[], options: CommandExecutionOptions, scope: SendScope): void + sendTargets: SessionSendTarget[] + onExecute(commandId: string, args: string[], options: CommandExecutionOptions, scope: SendScope, selectedTabIds: string[]): void paneWidth: number onPaneWidthChange(width: number): void }) { @@ -48,16 +49,15 @@ export function CommandCenter({ () => sortByOrder(commandTemplates.filter((template) => !template.parentId)), [commandTemplates] ) - const sshTabs = useMemo( - () => tabs.filter((tab) => tab.sessionType === 'ssh' && tab.status !== 'closed'), - [tabs] - ) const [activeFolderId, setActiveFolderId] = useState('all') const [selectedCommandId, setSelectedCommandId] = useState(commandTemplates[0]?.id ?? null) const [paramValues, setParamValues] = useState>({}) const [lastRenderedCommand, setLastRenderedCommand] = useState('') const [appendCarriageReturn, setAppendCarriageReturn] = useState(true) + const [preferencesLoaded, setPreferencesLoaded] = useState(false) + const [rememberSelection, setRememberSelection] = useState(false) const [sendScope, setSendScope] = useState('current') + const [selectedTabIds, setSelectedTabIds] = useState([]) const splitRef = useRef(null) const isResizingCommandSplit = useRef(false) @@ -80,8 +80,11 @@ export function CommandCenter({ [commandTemplates, selectedCommandId, visibleTemplates] ) const paramIndexes = selectedTemplate ? extractCommandParams(selectedTemplate.command) : [] - const canRunCurrent = Boolean(activeTab && activeTab.sessionType === 'ssh' && selectedTemplate) - const canRunAny = Boolean(sshTabs.length && selectedTemplate) + const canRunCurrent = Boolean(activeTab && selectedTemplate && sendTargets.some((target) => target.tabId === activeTab.id)) + const canRunAny = Boolean(sendTargets.length && selectedTemplate) + const canRunSelected = Boolean( + selectedTemplate && selectedTabIds.some((tabId) => sendTargets.some((target) => target.tabId === tabId)) + ) useEffect(() => { if (!selectedTemplate && commandTemplates[0]) { @@ -95,6 +98,83 @@ export function CommandCenter({ setLastRenderedCommand('') }, [selectedTemplate?.id]) + useEffect(() => { + setSelectedTabIds((prev) => prev.filter((tabId) => sendTargets.some((target) => target.tabId === tabId))) + }, [sendTargets]) + + useEffect(() => { + let canceled = false + + async function loadPreferences() { + const desktopApi = window.termdock + const legacyRemember = localStorage.getItem('termdock:commands:rememberSelection') === 'true' + const legacyScope = (localStorage.getItem('termdock:commands:sendScope') as SendScope | null) ?? 'current' + const legacySelectedTabIds = (() => { + const saved = localStorage.getItem('termdock:commands:selectedTabIds') + if (!saved) return [] + try { + return JSON.parse(saved) as string[] + } catch { + return [] + } + })() + + if (!desktopApi?.getCommandSendPreferences) { + if (!canceled) { + setRememberSelection(legacyRemember) + setSendScope(legacyRemember ? legacyScope : 'current') + setSelectedTabIds(legacyRemember ? legacySelectedTabIds : []) + setPreferencesLoaded(true) + } + return + } + + const storedPreferences = await desktopApi.getCommandSendPreferences() + const hasStoredPreferences = storedPreferences.rememberSelection + || storedPreferences.sendScope !== 'current' + || storedPreferences.selectedTabIds.length > 0 + const nextPreferences: CommandSendPreferences = hasStoredPreferences + ? storedPreferences + : { + rememberSelection: legacyRemember, + sendScope: legacyRemember ? legacyScope : 'current', + selectedTabIds: legacyRemember ? legacySelectedTabIds : [] + } + + if (!hasStoredPreferences && (legacyRemember || legacySelectedTabIds.length > 0 || legacyScope !== 'current')) { + await desktopApi.setCommandSendPreferences(nextPreferences) + localStorage.removeItem('termdock:commands:rememberSelection') + localStorage.removeItem('termdock:commands:sendScope') + localStorage.removeItem('termdock:commands:selectedTabIds') + } + + if (!canceled) { + setRememberSelection(nextPreferences.rememberSelection) + setSendScope(nextPreferences.rememberSelection ? nextPreferences.sendScope : 'current') + setSelectedTabIds(nextPreferences.rememberSelection ? nextPreferences.selectedTabIds : []) + setPreferencesLoaded(true) + } + } + + void loadPreferences() + + return () => { + canceled = true + } + }, []) + + useEffect(() => { + if (!preferencesLoaded || !window.termdock?.setCommandSendPreferences) { + return + } + + void window.termdock.setCommandSendPreferences({ + rememberSelection, + sendScope: rememberSelection ? sendScope : 'current', + selectedTabIds: rememberSelection ? selectedTabIds : [] + }) + }, [preferencesLoaded, rememberSelection, sendScope, selectedTabIds]) + const handleRun = () => { if (!selectedTemplate) { return @@ -102,7 +182,7 @@ export function CommandCenter({ const args = paramIndexes.map((index) => paramValues[index] ?? '') const rendered = selectedTemplate.command.replace(/\[p#(\d+)\]/g, (_, rawIndex: string) => args[Number(rawIndex) - 1] ?? '') setLastRenderedCommand(rendered) - onExecute(selectedTemplate.id, args, { appendCarriageReturn }, sendScope) + onExecute(selectedTemplate.id, args, { appendCarriageReturn }, sendScope, selectedTabIds) } useEffect(() => { @@ -198,7 +278,7 @@ export function CommandCenter({ - {t.name} + {t.name} @@ -208,11 +288,10 @@ export function CommandCenter({ className={selectedTemplate?.id === template.id ? 'is-selected' : ''} onClick={() => setSelectedCommandId(template.id)} > - - - - - {template.name} + +
+ {template.name} +
))} @@ -244,17 +323,37 @@ export function CommandCenter({ {selectedTemplate ? ( <>
- {selectedTemplate.name} +
+ {selectedTemplate.name} + target.tabId === activeTab.id)?.index ?? '-')) : t.commandSendCurrent} + onScopeChange={setSendScope} + onSelectedTabIdsChange={setSelectedTabIds} + scope={sendScope} + selectedTabIds={selectedTabIds} + targets={sendTargets} + showRememberSelection={true} + rememberSelection={rememberSelection} + onRememberSelectionChange={setRememberSelection} + popover={true} + /> +
-
@@ -292,16 +391,7 @@ export function CommandCenter({ {t.commandRendered} {lastRenderedCommand || selectedTemplate.command}
-
- -
+ ) : (
{t.commandEmpty}
diff --git a/apps/desktop/src/renderer/features/common/SessionSendTargetPicker.tsx b/apps/desktop/src/renderer/features/common/SessionSendTargetPicker.tsx new file mode 100644 index 0000000..80976a9 --- /dev/null +++ b/apps/desktop/src/renderer/features/common/SessionSendTargetPicker.tsx @@ -0,0 +1,249 @@ +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { t } from '../../i18n' +import { AppIcon } from './AppIcon' +import type { SendScope, SessionSendTarget } from './session-send-targets' + +export function SessionSendTargetPicker({ + scope, + selectedTabIds, + targets, + onScopeChange, + onSelectedTabIdsChange, + rememberSelection, + onRememberSelectionChange, + showRememberSelection = false, + currentLabel, + allLabel, + popover = false +}: { + scope: SendScope + selectedTabIds: string[] + targets: SessionSendTarget[] + onScopeChange(scope: SendScope): void + onSelectedTabIdsChange(tabIds: string[]): void + rememberSelection?: boolean + onRememberSelectionChange?: (nextValue: boolean) => void + showRememberSelection?: boolean + currentLabel?: string + allLabel?: string + popover?: boolean +}) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + const wrapperRef = useRef(null) + const dropdownRef = useRef(null) + const [dropdownStyle, setDropdownStyle] = useState({ display: 'none' }) + + const updatePosition = () => { + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect() + const dropdownWidth = 250 + const style: React.CSSProperties = { + position: 'fixed', + zIndex: 9999, + width: `${dropdownWidth}px`, + right: 'auto', + } + + style.left = `${rect.right - dropdownWidth}px` + + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + const shouldOpenUpwards = !popover || (spaceBelow < 280 && spaceAbove > spaceBelow) + + if (shouldOpenUpwards) { + style.bottom = `${window.innerHeight - rect.top + 6}px` + style.top = 'auto' + } else { + style.top = `${rect.bottom + 6}px` + style.bottom = 'auto' + } + + setDropdownStyle(style) + } + } + + useEffect(() => { + if (isOpen) { + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + } else { + setDropdownStyle({ display: 'none' }) + } + + return () => { + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [isOpen, scope, targets, selectedTabIds]) + + useEffect(() => { + if (!isOpen) return + + function handleClickOutside(event: MouseEvent) { + const target = event.target as Node + const clickedInsideTrigger = containerRef.current && containerRef.current.contains(target) + const clickedInsideDropdown = dropdownRef.current && dropdownRef.current.contains(target) + + if (!clickedInsideTrigger && !clickedInsideDropdown) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + const handleScopeSelect = (nextScope: SendScope) => { + onScopeChange(nextScope) + if (nextScope !== 'selected-ssh') { + setIsOpen(false) + } + } + + const currentLabelText = + scope === 'current' + ? (currentLabel ?? t.commandSendCurrent) + : scope === 'all-ssh' + ? (allLabel ?? t.commandSendAll) + : t.commandSendSelected + + return ( +
+
+ {t.commandSendScope} +
+ + + {isOpen && createPortal( +
+
handleScopeSelect('current')} + > + {currentLabel ?? t.commandSendCurrent} +
+
handleScopeSelect('all-ssh')} + > + {allLabel ?? t.commandSendAll} +
+
handleScopeSelect('selected-ssh')} + > + {t.commandSendSelected} +
+ + {scope === 'selected-ssh' && ( +
e.stopPropagation()}> + {targets.length ? ( + <> +
+ +
+ +
+ {targets.map((target) => { + const checked = selectedTabIds.includes(target.tabId) + const currentTag = target.isCurrent ? t.commandCurrentBadge : '' + + return ( + + ) + })} +
+ + ) : ( +
{t.commandNoAvailableTargets}
+ )} +
+ )} +
, + document.body + )} +
+ + {/* Remember Selection on same line for popover mode in select mode */} + {popover && showRememberSelection && scope === 'selected-ssh' && ( + + )} +
+ + {!popover && showRememberSelection ? ( + + ) : null} +
+ ) +} diff --git a/apps/desktop/src/renderer/features/common/session-send-targets.ts b/apps/desktop/src/renderer/features/common/session-send-targets.ts new file mode 100644 index 0000000..dbbdc57 --- /dev/null +++ b/apps/desktop/src/renderer/features/common/session-send-targets.ts @@ -0,0 +1,51 @@ +import type { WorkspaceTab } from '@termdock/core' + +export type SendScope = 'current' | 'all-ssh' | 'selected-ssh' + +export type SessionSendTarget = { + tabId: string + index: number + title: string + label: string + isCurrent: boolean +} + +export function summarizeSendTarget( + scope: SendScope, + selectedTabIds: string[], + targets: SessionSendTarget[], + fallback: string +) { + if (scope === 'current') { + return fallback + } + + if (scope === 'all-ssh') { + return targets.length ? targets.map((target) => String(target.index)).join(', ') : fallback + } + + const selectedTargets = targets.filter((target) => selectedTabIds.includes(target.tabId)) + if (!selectedTargets.length) { + return fallback + } + + return selectedTargets.map((target) => String(target.index)).join(', ') +} + +export function resolveSelectedTabIds( + scope: SendScope, + activeTab: WorkspaceTab | null, + selectedTabIds: string[], + targets: SessionSendTarget[] +) { + if (scope === 'current') { + return activeTab && activeTab.sessionType === 'ssh' ? [activeTab.id] : [] + } + + if (scope === 'all-ssh') { + return targets.map((target) => target.tabId) + } + + const availableIds = new Set(targets.map((target) => target.tabId)) + return selectedTabIds.filter((tabId) => availableIds.has(tabId)) +} diff --git a/apps/desktop/src/renderer/features/files/FileManager.tsx b/apps/desktop/src/renderer/features/files/FileManager.tsx index d967e38..9b66f8e 100644 --- a/apps/desktop/src/renderer/features/files/FileManager.tsx +++ b/apps/desktop/src/renderer/features/files/FileManager.tsx @@ -21,6 +21,7 @@ import { } from '../../app/app-utils' import { t } from '../../i18n' import { AppIcon } from '../common/AppIcon' +import type { SendScope, SessionSendTarget } from '../common/session-send-targets' import { CommandCenter } from '../commands/CommandCenter' import { FileContextMenu } from './FileContextMenu' import { getDisplayFileTypeSortKey } from './file-kind' @@ -133,7 +134,7 @@ function sortRemoteFiles(rows: RemoteFileItem[], sort: RemoteFileSortState) { export function FileManager({ activeSession, activeTab, - tabs, + sendTargets, commandFolders, commandTemplates, isBusy, @@ -170,7 +171,7 @@ export function FileManager({ }: { activeSession: SessionSnapshot activeTab: WorkspaceTab | null - tabs: WorkspaceTab[] + sendTargets: SessionSendTarget[] commandFolders: CommandFolder[] commandTemplates: CommandTemplate[] isBusy: boolean @@ -181,7 +182,7 @@ export function FileManager({ clipboardStatusText: string | null localCutPaths: string[] remoteCutPaths: string[] - onExecuteCommand(commandId: string, args: string[], options: CommandExecutionOptions, scope: 'current' | 'all-ssh'): void + onExecuteCommand(commandId: string, args: string[], options: CommandExecutionOptions, scope: SendScope, selectedTabIds: string[]): void onOpenCommandManager(): void onCopyItems(pane: 'local' | 'remote', items: Array): void onCutItems(pane: 'local' | 'remote', items: Array): void @@ -207,6 +208,7 @@ export function FileManager({ }) { const defaultRemoteSort = { field: 'name', direction: 'asc' } satisfies RemoteFileSortState const isRemoteConnected = activeSession.connected === true + const isSshSession = activeTab?.sessionType === 'ssh' const [activeView, setActiveView] = useState<'file' | 'command'>('file') const [localPaneWidth, setLocalPaneWidth] = useState(214) const [localPathInput, setLocalPathInput] = useState(localPath) @@ -555,12 +557,14 @@ export function FileManager({
- + {isSshSession ? ( + + ) : null}
{activeView === 'file' ? clipboardStatusText || activeSession.remotePath - : `${t.commandQuickLaunch} (${activeTab?.sessionType === 'ssh' ? t.send : t.commandSshOnly})`} + : `${t.commandQuickLaunch} (${isSshSession ? t.send : t.commandSshOnly})`} {activeView === 'file' ? (
@@ -588,13 +592,13 @@ export function FileManager({
)}
- {activeView === 'command' ? ( + {activeView === 'command' && isSshSession ? ( (key: string, fallback: T) { function normalizePreferences(value: Partial | null | undefined): DockPreferences { return { - clearAfterSend: value?.clearAfterSend ?? DEFAULT_PREFERENCES.clearAfterSend + clearAfterSend: value?.clearAfterSend ?? DEFAULT_PREFERENCES.clearAfterSend, + rememberSendTarget: value?.rememberSendTarget ?? DEFAULT_PREFERENCES.rememberSendTarget } } @@ -57,21 +58,29 @@ function formatHistoryTime(timestamp: number) { export function TerminalDock({ activeTab, connected, - remotePath, + sendScope, + selectedTabIds, + sendTargets, filePanelHeight, - setFilePanelHeight + setFilePanelHeight, + onSendCommand, + onSendScopeChange, + onSelectedTabIdsChange }: { activeTab: WorkspaceTab connected: boolean - remotePath: string + sendScope: SendScope + selectedTabIds: string[] + sendTargets: SessionSendTarget[] filePanelHeight?: number setFilePanelHeight?: (height: number | ((prev: number) => number)) => void + onSendCommand(command: string): Promise + onSendScopeChange(scope: SendScope, rememberSelection: boolean): void + onSelectedTabIdsChange(tabIds: string[], rememberSelection: boolean): void }) { const [command, setCommand] = useState('') const [panel, setPanel] = useState(null) - const [history, setHistory] = useState(() => - readStoredJson(historyStorageKey(activeTab.profileId), []) - ) + const [history, setHistory] = useState([]) const [preferences, setPreferences] = useState(() => normalizePreferences( readStoredJson | null>(preferencesStorageKey(activeTab.profileId), DEFAULT_PREFERENCES) @@ -79,9 +88,71 @@ export function TerminalDock({ ) const inputRef = useRef(null) const rootRef = useRef(null) - const [lastFilePanelHeight, setLastFilePanelHeight] = useState(218) + const persistHistory = async (entries: TerminalCommandHistoryEntry[]) => { + if (window.termdock?.setTerminalCommandHistory) { + await window.termdock.setTerminalCommandHistory(activeTab.profileId, entries) + return + } + window.localStorage.setItem(historyStorageKey(activeTab.profileId), JSON.stringify(entries)) + } + + useEffect(() => { + let canceled = false + + async function loadHistory() { + const desktopApi = window.termdock + if (!desktopApi?.getTerminalCommandHistory) { + if (!canceled) { + setHistory(readStoredJson(historyStorageKey(activeTab.profileId), [])) + } + return + } + + const storedHistory = await desktopApi.getTerminalCommandHistory(activeTab.profileId) + const legacyProfileHistory = readStoredJson(historyStorageKey(activeTab.profileId), []) + const legacyGlobalHistory = readStoredJson('termdock:terminal-dock:history:global', []) + const legacyHistory = legacyProfileHistory.length ? legacyProfileHistory : legacyGlobalHistory + const nextHistory = storedHistory.length ? storedHistory : legacyHistory + + if (legacyHistory.length && !storedHistory.length) { + await desktopApi.setTerminalCommandHistory(activeTab.profileId, legacyHistory) + window.localStorage.removeItem(historyStorageKey(activeTab.profileId)) + window.localStorage.removeItem('termdock:terminal-dock:history:global') + } + + if (!canceled) { + setHistory(nextHistory) + } + } + + void loadHistory() + + return () => { + canceled = true + } + }, [activeTab.profileId]) + + useEffect(() => { + if (!panel) return + + function handleClickOutside(event: MouseEvent) { + const target = event.target as Node + const clickedInsideDock = rootRef.current && rootRef.current.contains(target) + const clickedInsideDropdown = (target as HTMLElement).closest && (target as HTMLElement).closest('.custom-select-dropdown') + + if (!clickedInsideDock && !clickedInsideDropdown) { + setPanel(null) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [panel]) + const handleToggleConnection = async () => { if (!window.termdock) return if (connected) { @@ -274,20 +345,26 @@ export function TerminalDock({ } const canSend = connected && activeTab.sessionType === 'ssh' && command.trim().length > 0 + const activeTargetSummary = summarizeSendTarget( + sendScope, + selectedTabIds, + sendTargets, + t.commandSendCurrent + ) const sendCommand = async (nextCommand: string) => { const trimmed = nextCommand.trim() - if (!trimmed || activeTab.sessionType !== 'ssh' || !connected || !window.termdock?.writeTerminal) { + if (!trimmed || activeTab.sessionType !== 'ssh' || !connected) { return } - await window.termdock.writeTerminal(activeTab.id, `${trimmed}\r`) + await onSendCommand(trimmed) const now = Date.now() setHistory((prev) => { const deduped = prev.filter((entry) => entry.command !== trimmed) const next = [{ command: trimmed, createdAt: now }, ...deduped].slice(0, HISTORY_LIMIT) - window.localStorage.setItem(historyStorageKey(activeTab.profileId), JSON.stringify(next)) + void persistHistory(next) return next }) @@ -335,7 +412,7 @@ export function TerminalDock({ } else if (actionIndex === 2) { setHistory((prev) => { const next = prev.filter((entry) => entry.command !== cmd) - window.localStorage.setItem(historyStorageKey(activeTab.profileId), JSON.stringify(next)) + void persistHistory(next) return next }) } @@ -443,7 +520,7 @@ export function TerminalDock({ {t.terminalDockHistoryInsertHint} @@ -471,6 +548,18 @@ export function TerminalDock({ /> {t.terminalDockClearAfterSend} + target.tabId === activeTab.id)?.index ?? '-'))} + onRememberSelectionChange={(nextValue) => updatePreferences((prev) => ({ ...prev, rememberSendTarget: nextValue }))} + onScopeChange={(nextScope) => onSendScopeChange(nextScope, preferences.rememberSendTarget)} + onSelectedTabIdsChange={(tabIds) => onSelectedTabIdsChange(tabIds, preferences.rememberSendTarget)} + rememberSelection={preferences.rememberSendTarget} + scope={sendScope} + selectedTabIds={selectedTabIds} + showRememberSelection + targets={sendTargets} + />
) @@ -509,7 +598,7 @@ export function TerminalDock({ type="button" onClick={() => setPanel((prev) => prev === 'options' ? null : 'options')} > - {t.options} + {`${t.options} · ${activeTargetSummary}`}
) : null} @@ -292,7 +308,7 @@ export function SessionWorkspace({ ): void onCutItems(pane: 'local' | 'remote', items: Array): void onClearCutState(): void - onExecuteCommand(commandId: string, args: string[], options: CommandExecutionOptions, scope: 'current' | 'all-ssh'): void + onExecuteCommand(commandId: string, args: string[], options: CommandExecutionOptions, scope: SendScope, selectedTabIds: string[]): void + onSendTerminalCommand(command: string): Promise + onTerminalDockSendScopeChange(scope: SendScope, rememberSelection: boolean): void + onTerminalDockSelectedTabIdsChange(tabIds: string[], rememberSelection: boolean): void onOpenCommandManager(): void profiles: ConnectionProfile[] onChooseUploadFiles(): void @@ -115,7 +126,9 @@ export function WorkspaceStage({ deleteCommandTemplate(commandId: string): Promise executeCommandTemplate(tabId: string, commandId: string, args?: string[], options?: CommandExecutionOptions): Promise + getTerminalCommandHistory(profileId: string): Promise + setTerminalCommandHistory(profileId: string, entries: TerminalCommandHistoryEntry[]): Promise + getCommandSendPreferences(): Promise + setCommandSendPreferences(preferences: CommandSendPreferences): Promise createProfile(input: CreateProfileInput): Promise updateProfile(profileId: string, input: CreateProfileInput): Promise deleteProfile(profileId: string): Promise diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 4f012a1..63ad6c2 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1,10 +1,12 @@ import type { + CommandSendPreferences, CommandFolder, CommandTemplate, CommandTemplateInput, ConnectionFolder, ConnectionProfile, - CreateProfileInput + CreateProfileInput, + TerminalCommandHistoryEntry } from '@termdock/core' export interface ProfileRepository { @@ -33,6 +35,10 @@ export interface ProfileRepository { getCommandTemplateById(id: string): Promise deleteCommandTemplate(id: string): Promise updateCommandOrder(id: string, newParentId: string | undefined, newOrder: number): Promise + getTerminalCommandHistory(profileId: string): Promise + setTerminalCommandHistory(profileId: string, entries: TerminalCommandHistoryEntry[]): Promise + getCommandSendPreferences(): Promise + setCommandSendPreferences(preferences: CommandSendPreferences): Promise } export class MemoryProfileRepository implements ProfileRepository { @@ -220,6 +226,26 @@ export class MemoryProfileRepository implements ProfileRepository { command.order = newOrder } } + + async getTerminalCommandHistory(_profileId: string): Promise { + return [] + } + + async setTerminalCommandHistory(_profileId: string, _entries: TerminalCommandHistoryEntry[]): Promise { + return + } + + async getCommandSendPreferences(): Promise { + return { + rememberSelection: false, + sendScope: 'current', + selectedTabIds: [] + } + } + + async setCommandSendPreferences(_preferences: CommandSendPreferences): Promise { + return + } } function preserveProfileMetadata(profile: ConnectionProfile, previous: ConnectionProfile): ConnectionProfile { From 2e470317a6d063446e11ab38b0c7021616a1668f Mon Sep 17 00:00:00 2001 From: Flashhhhhhzj <165341332+Flashhhhhhzj@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:07:25 +0800 Subject: [PATCH 17/24] feat: enhance modal UI with improved focus states and button styles --- .codex/skills/ui-ux-pro-max-zh/SKILL.md | 312 +++ .../skills/ui-ux-pro-max-zh/data/_sync_all.py | 414 ++++ .../ui-ux-pro-max-zh/data/app-interface.csv | 31 + .../skills/ui-ux-pro-max-zh/data/charts.csv | 26 + .../skills/ui-ux-pro-max-zh/data/colors.csv | 162 ++ .../skills/ui-ux-pro-max-zh/data/design.csv | 1780 +++++++++++++++ .codex/skills/ui-ux-pro-max-zh/data/draft.csv | 1781 +++++++++++++++ .../ui-ux-pro-max-zh/data/google-fonts.csv | 1924 +++++++++++++++++ .codex/skills/ui-ux-pro-max-zh/data/icons.csv | 106 + .../skills/ui-ux-pro-max-zh/data/landing.csv | 35 + .../skills/ui-ux-pro-max-zh/data/products.csv | 162 ++ .../data/react-performance.csv | 45 + .../ui-ux-pro-max-zh/data/stacks/angular.csv | 51 + .../ui-ux-pro-max-zh/data/stacks/astro.csv | 54 + .../ui-ux-pro-max-zh/data/stacks/flutter.csv | 53 + .../data/stacks/html-tailwind.csv | 56 + .../data/stacks/jetpack-compose.csv | 53 + .../ui-ux-pro-max-zh/data/stacks/laravel.csv | 51 + .../ui-ux-pro-max-zh/data/stacks/nextjs.csv | 53 + .../ui-ux-pro-max-zh/data/stacks/nuxt-ui.csv | 51 + .../ui-ux-pro-max-zh/data/stacks/nuxtjs.csv | 59 + .../data/stacks/react-native.csv | 52 + .../ui-ux-pro-max-zh/data/stacks/react.csv | 54 + .../ui-ux-pro-max-zh/data/stacks/shadcn.csv | 61 + .../ui-ux-pro-max-zh/data/stacks/svelte.csv | 54 + .../ui-ux-pro-max-zh/data/stacks/swiftui.csv | 51 + .../ui-ux-pro-max-zh/data/stacks/threejs.csv | 54 + .../ui-ux-pro-max-zh/data/stacks/vue.csv | 50 + .../skills/ui-ux-pro-max-zh/data/styles.csv | 85 + .../ui-ux-pro-max-zh/data/typography.csv | 75 + .../ui-ux-pro-max-zh/data/ui-reasoning.csv | 162 ++ .../ui-ux-pro-max-zh/data/ux-guidelines.csv | 100 + .../skills/ui-ux-pro-max-zh/scripts/core.py | 267 +++ .../ui-ux-pro-max-zh/scripts/design_system.py | 1144 ++++++++++ .../skills/ui-ux-pro-max-zh/scripts/search.py | 113 + apps/desktop/src/renderer/App.tsx | 77 + .../features/commands/CommandManagerModal.tsx | 18 +- .../connections/ConnectionManagerModal.tsx | 18 +- .../features/settings/SettingsModal.tsx | 18 +- .../features/workspace/HomeWorkspace.tsx | 182 +- .../features/workspace/WorkspaceStage.tsx | 63 +- .../src/renderer/styles/features/commands.css | 86 +- .../src/renderer/styles/features/home.css | 188 +- .../styles/features/modal-components.css | 34 +- .../src/renderer/styles/features/modals.css | 40 +- .../src/renderer/styles/features/overview.css | 91 +- .../renderer/styles/features/quick-links.css | 35 +- .../src/renderer/styles/features/session.css | 102 +- .../renderer/styles/themes/default-dark.css | 39 + .../renderer/styles/themes/default-light.css | 39 + 50 files changed, 10403 insertions(+), 208 deletions(-) create mode 100644 .codex/skills/ui-ux-pro-max-zh/SKILL.md create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/_sync_all.py create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/app-interface.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/charts.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/colors.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/design.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/draft.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/google-fonts.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/icons.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/landing.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/products.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/react-performance.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/angular.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/astro.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/flutter.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/html-tailwind.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/jetpack-compose.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/laravel.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/nextjs.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/nuxt-ui.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/nuxtjs.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/react-native.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/react.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/shadcn.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/svelte.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/swiftui.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/threejs.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/stacks/vue.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/styles.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/typography.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/ui-reasoning.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/data/ux-guidelines.csv create mode 100644 .codex/skills/ui-ux-pro-max-zh/scripts/core.py create mode 100644 .codex/skills/ui-ux-pro-max-zh/scripts/design_system.py create mode 100644 .codex/skills/ui-ux-pro-max-zh/scripts/search.py diff --git a/.codex/skills/ui-ux-pro-max-zh/SKILL.md b/.codex/skills/ui-ux-pro-max-zh/SKILL.md new file mode 100644 index 0000000..c9f8cfd --- /dev/null +++ b/.codex/skills/ui-ux-pro-max-zh/SKILL.md @@ -0,0 +1,312 @@ +--- +name: ui-ux-pro-max-zh +description: 支持可搜索数据库的 UI/UX 设计智能。 +--- +# ui-ux-pro-max-zh + +面向 Web 和移动端应用的全面设计指南。包含跨 16 个技术栈的 67 种 UI 风格、161 个色板、57 组字体配对、99 条 UX 指南以及 25 种图表类型。支持基于优先级的推荐和可搜索数据库。 + + +## 开发前提条件 (Prerequisites) + +检查开发环境是否已安装 Python 3.x: + +```bash +python3 --version || python --version +``` + +如果未安装 Python,请根据您的操作系统执行相应命令安装: + +**macOS:** +```bash +brew install python3 +``` + +**Ubuntu/Debian:** +```bash +sudo apt update && sudo apt install python3 +``` + +**Windows:** +```powershell +winget install Python.Python.3.12 +``` + +--- + +## 使用指南 (How to Use) + +在以下业务开发场景中,可直接调用此技能: + +| 场景 (Scenario) | 触发示例 (Trigger Examples) | 推荐起点 (Start From) | +|----------|-----------------|------------| +| **全新项目 / 页面开发** | "做一个 landing page"、"Build a dashboard" | 步骤 1 → 步骤 2(生成设计系统) | +| **新组件设计与还原** | "Create a pricing card"、"Add a modal" | 步骤 3(专项检索:style, ux) | +| **风格定义 / 调性设计 / 字体配对** | "What style fits a fintech app?"、"推荐配色" | 步骤 2(生成设计系统) | +| **UI 走查与无障碍审查** | "Review this page for UX issues"、"检查无障碍" | 上方的快速参考指南 (Quick Reference) | +| **UI 还原度缺陷及交互异常修复** | "Button hover is broken"、"Layout shifts on load" | 快速参考指南 → 匹配章节调优 | +| **体验升级与性能优化** | "Make this faster"、"Improve mobile experience" | 步骤 3(专项检索:ux, react) | +| **适配暗黑模式** | "Add dark mode support" | 步骤 3(专项检索:style "dark mode") | +| **引入图表与数据可视化** | "Add an analytics dashboard chart" | 步骤 3(专项检索:chart) | +| **特定技术栈的最佳开发实践** | "React performance tips" | 步骤 4(框架指令检索) | + +请遵循以下标准开发流: + +### 步骤 1:深入分析用户需求 + +从用户请求中精准提取以下决策因子: +- **产品定位/类型**:娱乐社交(社交、视频、音乐、游戏)、生产力工具(扫描仪、编辑器、转换器)、效率协作(任务管理、笔记、日历)或复合型平台 +- **目标受众画像**:C 端消费者用户;考虑年龄段、使用场景(通勤、闲暇、工作) +- **风格关键词**:活泼趣味 (playful)、动感活力 (vibrant)、极致极简 (minimal)、暗黑 OLED (dark mode)、内容优先 (content-first)、沉浸式 (immersive) 等 +- **框架技术栈**:确认当前项目所采用的框架与技术栈 + +### 步骤 2:生成设计系统规范(核心前置步骤,必须执行) + +**请务必首先执行 `--design-system` 命令**,以获取包含严密推理逻辑的完整设计系统方案: + +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py " " --design-system [-p "Project Name"] +``` + +底层执行逻辑: +1. **多维度并行检索**:并行检索产品定位 (product)、UI 风格 (style)、色彩搭配 (color)、布局模式 (landing) 以及字体排版 (typography) 数据库 +2. **智能规则推理**:基于 `ui-reasoning.csv` 决策模型,智能匹配并输出最佳方案 +3. **输出标准化设计系统**:提供版式布局、视觉风格、色板、字体调性及动效交互说明 +4. **输出反模式避坑清单**:列出当前行业需规避的交互/视觉雷区 + +**示例:** +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa" +``` + +### 步骤 2b:持久化设计系统(全局主配置 + 页面覆盖模式) + +若需保存生成的配置以支持跨会话的**层级上下文检索**,请追加 `--persist` 参数: + +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "" --design-system --persist -p "Project Name" +``` + +这将在项目根目录下自动创建: +- `design-system/MASTER.md` —— 全局唯一事实来源 (Single Source of Truth,包含全局通用规范) +- `design-system/pages/` —— 存放特定页面覆盖规则的文件夹 + +**生成特定页面覆盖规则示例:** +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "" --design-system --persist -p "Project Name" --page "dashboard" +``` + +这还会额外创建: +- `design-system/pages/dashboard.md` —— 仅针对仪表盘页面的局部覆盖规范(只声明偏离 Master 的差异部分) + +**层级上下文检索机制:** +1. AI 在构建特定页面(如 Checkout)时,会首先检索 `design-system/pages/checkout.md` 是否存在 +2. 如果该页面配置文件存在,其局部规则将**覆盖 (override)** 全局 `MASTER.md` 的设定 +3. 若不存在特定页面配置文件,则默认以 `design-system/MASTER.md` 为全局唯一依据 + +**上下文感知检索提示词模板 (Context-Aware Prompt):** +``` +I am building the [Page Name] page. Please read design-system/MASTER.md. +Also check if design-system/pages/[page-name].md exists. +If the page file exists, prioritize its rules. +If not, use the Master rules exclusively. +Now, generate the code... +``` + +### 步骤 3:补充性细节检索(按需执行) + +在设计系统大框架下,若需针对特定交互细节进行微调,可运行以下命令检索: + +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "" --domain [-n ] +``` + +**补充检索场景:** + +| 需求 | 检索领域 (Domain) | 示例 (Example) | +|------|--------|---------| +| 产品原型与排版模式 | `product` | `--domain product "entertainment social"` | +| 视觉风格深度检索 | `style` | `--domain style "glassmorphism dark"` | +| 配色/色板精选 | `color` | `--domain color "entertainment vibrant"` | +| 字体组合/字体配对 | `typography` | `--domain typography "playful modern"` | +| 图表展现与库推荐 | `chart` | `--domain chart "real-time dashboard"` | +| UX 交互与无障碍合规 | `ux` | `--domain ux "animation accessibility"` | +| 落地页布局与 CTA 策略 | `landing` | `--domain landing "hero social-proof"` | +| 开发框架性能调优 | `react` | `--domain react "rerender memo list"` | +| 平台交互规范 (iOS/Android) | `web` | `--domain web "accessibilityLabel touch safe-areas"` | +| AI 提示词与核心变量 | `prompt` | `--domain prompt "minimalism"` | + +### 步骤 4:特定技术栈规范适配 + +检索针对特定框架底层实现的高质量代码模板与性能建议: + +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "" --stack +``` + +--- + +## 快捷检索参考表 + +### 检索领域 (Domains) + +| 领域 (Domain) | 用途 | 示例关键词 | +|--------|---------|------------------| +| `product` | 产品类型模式推荐建议 | SaaS, e-commerce, portfolio, healthcare, beauty, service | +| `style` | UI 设计风格、颜色、特效特征 | glassmorphism, minimalism, dark mode, brutalism | +| `typography` | 经典字体搭配、Google Fonts 导入推荐 | elegant, playful, professional, modern | +| `color` | 匹配行业受众的产品色板推荐 | saas, ecommerce, healthcare, beauty, fintech, service | +| `landing` | 落地页页面结构与 CTA 转化策略 | hero, hero-centric, testimonial, pricing, social-proof | +| `chart` | 推荐的数据图表类型与可视化图表库 | trend, comparison, timeline, funnel, pie | +| `ux` | 交互设计优秀实践与反模式防坑指南 | animation, accessibility, z-index, loading | +| `react` | React 生态框架的前端渲染性能调优 | waterfall, bundle, suspense, memo, rerender, cache | +| `web` | 主流端平台设计与无障碍接口规范 | accessibilityLabel, touch targets, safe areas, Dynamic Type | +| `prompt` | 生成特定风格视觉的 AI 绘图提示词 | (style name) | + +--- + +## 标准工作流实战演练 + +**用户请求**:"帮我设计一个 AI 搜索首页" + +### 步骤 1:分析需求 +- 产品定位:工具类(AI 智能搜索终端) +- 目标受众:追求极致效率、即用即走的 C 端用户 +- 风格关键词:前沿、极简、内容优先、暗色模式 +- 技术栈:根据项目配置或默认使用 HTML + Tailwind + +### 步骤 2:生成设计系统规范(核心前置步骤,必须执行) + +```bash +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search" +``` + +**输出**:获得一份量身定制的设计系统方案,包含推荐布局、视觉风格特征、语义配色、字体排版、核心交互动效以及避坑指南。 + +### 步骤 3:补充性细节检索(按需执行) + +```bash +# 检索极简与纯黑 OLED 模式的设计规范细节 +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "minimalism dark mode" --domain style + +# 检索关于搜索过渡和加载状态的 UX 优秀动效实践 +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "search loading animation" --domain ux +``` + +### 步骤 4:特定开发框架的规范检索 + +```bash +# 检索特定技术栈下的输入性能与列表渲染最佳实践 +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "input performance" --stack react +``` + +**最后阶段**:将设计系统规范与检索出的最佳交互细节融会贯通,产出高质量前端代码。 + +--- + +## 输出格式 (Output Formats) + +`--design-system` 命令行标志支持以下两种输出格式: + +```bash +# ASCII 艺术框(默认)- 最适合终端显示与命令行直观阅读 +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "fintech crypto" --design-system + +# Markdown 格式 - 格式更规整,最适合保存为文档记录或传给 AI 上下文 +python3 .codex/skills/ui-ux-pro-max-zh/scripts/search.py "fintech crypto" --design-system -f markdown +``` + +--- + +## 高阶开发与提问技巧 + +### 提问与检索策略 + +- **使用多维度复合关键词** —— 结合“产品类别 + 垂直行业 + 视觉基调 + 信息密度”进行检索,例如:`"entertainment social vibrant content-dense"`,而不是随意地检索一个 `"app"` +- **横向测试相似关键词** —— `"playful neon"` → `"vibrant dark"` → `"content-first minimal"` +- **采用漏斗式分析** —— 首先运行 `--design-system` 构建底层规范,随后用 `--domain` 深入研究有疑问的局部维度 +- **明确绑定技术栈** —— 检索时追加 `--stack `,以获取针对该框架最优雅的组件封装与渲染逻辑 + +### 常见设计/交互痛点排查与方案 + +| 问题 | 解决方案 | +|---------|------------| +| **视觉失焦(风格或配色拿捏不准)** | 尝试微调检索关键词重新运行 `--design-system` 进行设计推演 | +| **暗黑模式文本对比度不合规** | 参阅快速参考指南第 1 节:`color-dark-mode` + `color-accessible-pairs` 优化明度 | +| **动画与交互手感生硬** | 参阅快速参考指南第 7 节:`spring-physics` + `easing` + `exit-faster-than-enter` 曲线 | +| **表单填报交互繁琐、报错不友好** | 参阅快速参考指南第 8 节:`inline-validation` + `error-clarity` + `focus-management` 重构 | +| **导航路径混乱、返回状态丢失** | 参阅快速参考指南第 9 节:`nav-hierarchy` + `bottom-nav-limit` + `back-behavior` 保留现场 | +| **移动端小屏布局拥挤或溢出** | 参阅快速参考指南第 5 节:`mobile-first` + `breakpoint-consistency` 优雅折行 | +| **列表滚动掉帧或交互响应迟钝** | 参阅快速参考指南第 3 节:`virtualize-lists` + `main-thread-budget` + `debounce-throttle` 优化 | + +### 上线前 UX 质量走查清单 (Pre-delivery Checklist) + +- 在具体实现前,建议检索 `--domain ux "animation accessibility z-index loading"` 作为防错预走查 +- 交付前,务必严格核对快速参考指南的 **§1–§3** 章节(CRITICAL & HIGH 级别),这是产品可用性的底线 +- 至少在 375px(移动端黄金分辨率)和横屏设备下进行界面极限测试 +- 开启系统 **减弱动画 (reduced-motion)** 以及 **系统字体最大字号 (Dynamic Type)**,验证布局是否错乱或被截断 +- 亮/暗色模式下的前背景色对比度必须使用专业对比度工具双向校验,不要凭主观推测 +- 交互按钮的物理热区面积至少达 44pt,且操作入口在系统级边缘(如状态栏/手势条)绝无重合或干扰 + +--- + +## 高质量 UI 开发通用铁律 + +以下是极易被忽略、但会导致界面显得极其廉价和不专业的细节硬伤: + +### 图标与视觉资产规范 + +- 默认图标库使用 **Phosphor (`@phosphor-icons/react`)**。`src/ui-ux-pro-max-zh/data/icons.csv` 中列出的只是常用推荐图标,不是完整集合。 +- 当推荐表中找不到合适的图标时: + - **优先继续从 Phosphor 的完整图标集中选择任何语义更贴切的图标**; + - 如果 Phosphor 也没有理想选项,可以使用 **Heroicons (`@heroicons/react`)** 作为备选,注意保持风格一致(线性/填充、笔画粗细、圆角风格)。 + +| 规范准则 (Rule) | 优秀实践 (Do) | 应规避的反模式 (Avoid) | 核心设计考量 (Why It Matters) | +|------|----------|--------|----------------| +| **禁止将 Emoji 用于结构化图标** | 统一使用高阶矢量图标(如 Phosphor、Heroicons 或平台专有矢量图标库)。 | 在侧边栏导航、设置表单或重要交互控件中胡乱堆叠 Emoji(如 🎨 🚀 ⚙️)。 | Emoji 极其依赖客户端系统预装字库,渲染效果千差万别且无法被设计 Token 所控制。 | +| **纯矢量化资产原则** | 全量使用可无限缩放、完美兼容主题切换的 SVG 图标或平台级矢量 XML 格式。 | 使用 PNG、JPG 等拉伸时会产生锯齿、模糊且无法变色的位图图标。 | 确保在高分视网膜屏幕下的极致清晰度,以及无缝切换亮/暗主题的能力。 | +| **按压状态过渡稳定性** | 点击/按压态仅使用颜色深浅、不透明度 (Opacity) 或投影高度 (Elevation) 的过渡,绝对不改变组件本身的边框物理大小。 | 在按压或悬停时,使用会改变物理宽高、导致周围元素被挤压或抖动的形变 (Layout Shift)。 | 避免造成交互过程中的页面视觉抖动,保障移动端交互的极其平滑,提升感知质量。 | +| **品牌资产规范使用** | 统一使用官方矢量品牌资产,严格遵循其标志安全边界与配色指南。 | 主观臆断或手动绘制 Logo、未经授权随意为品牌图标重新配色或强行拉伸比例。 | 确保品牌呈现的严肃与规范,防范法务纠纷及合规性风险。 | +| **图标尺寸系统化** | 将图标高度与宽度抽象并定义为统一的设计 Token(如 `icon-sm`、`icon-md`=24px、`icon-lg`)。 | 在同一界面中随意混用 18px、21px、25px、29px 等混乱的任意像素尺寸。 | 维持产品设计系统的整体韵律感,建立严谨的视觉阶梯。 | +| **图标描边线宽一致性** | 在同一个视觉信息层级内,所有图标必须采用相同的线宽描边(如统一使用 1.5px 或 2px)。 | 随意拼凑不同线宽、粗细交织的图标集合。 | 描边粗细不一会严重割裂页面的精致度,降低产品的整体品质感。 | +| **图标填充/线性风格纯净度** | 在同一视觉层级或同一组功能区(如底部 Tab 栏)内,必须统一使用同一种图标风格(要么全部线性,要么全部填充)。 | 在同一功能区域内,随性混用填充型与线性图标(如首页是线性,搜索页是填充)。 | 维护图标系统的整体调性与视觉规律性。 | +| **触控热区最小面积限制** | 确保可交互按钮物理热区面积至少达 44×44pt;如果图标视觉过小,必须使用负外边距或 hitSlop 属性扩大实际热区。 | 使用直接暴露的小图标而未做任何热区扩展,导致用户反复点击失败。 | 保障不同手部尺寸用户在运动、颠簸场景下的触碰成功率,满足 A11y 规范。 | +| **图标文字基线精准对齐** | 图标必须与同级排版的文本基线(Baseline)或中心线严格对齐,并保持统一的水平外边距。 | 图标高度偏高或偏低,产生杂乱无章的视错觉。 | 细微的视觉失衡是摧毁高级感的主要元凶,必须做到像素级严谨对齐。 | +| **图形对比度无障碍审查** | 遵循 WCAG 对比度底线:常规功能图标对比度 ≥4.5:1,大型图形化装饰性符号对比度 ≥3:1。 | 采用过淡的灰色图标,导致在阳光直射下完全看不清。 | 确保在各种极端照度和不同设备屏幕上的卓越可读性。 | + +### 主流端交互规范原则 + +| 规范法则 (Rule) | 正面实践 (Do) | 反面模式 (Don't) | +|------|----|----- | +| **即时触控反馈 (Tap feedback)** | 必须在触控后的 80–150ms 内给出明确的涟漪、明度微调或透明度过渡等响应 | 点击后界面如一潭死水,无任何视觉或动效响应 | +| **动效时间把控 (Animation timing)** | 微交互时长限定在 150–300ms 左右,且必须配置符合物理特性的非线性曲线 | 动效瞬间硬切 (0ms) 或采用超过 500ms 的沉重拖沓动画 | +| **朗读焦点顺序 (Accessibility focus)** | 确保屏幕阅读器 (Screen Reader) 的焦点流向与页面视觉布局顺序绝对匹配 | 焦点焦点随意跳动或将无关的装饰元素朗读给视障用户 | +| **禁用态状态表达** | 应用淡化透明度、鼠标禁用标识,并通过 `disabled` 属性禁止一切点击触发 | 按钮看起来依然可用但点击后无任何反应,使用户困惑是否发生卡顿 | +| **扩大最小交互区域** | 确保移动端触控目标热区宽与高均 ≥44pt,对微小图标通过属性扩充交互热区 | 使用过小的像素点,逼迫用户像做外科手术一样精准点击 | +| **规避多重手势冲突** | 每一个物理区域仅设计一种主流手势操作,防止嵌套滚动或边缘划动手势拦截系统手势 | 在支持滑动的卡片组件内,强行嵌入横向滚动的次级数据列表 | +| **无障碍语义声明** | 规范使用原生交互控件(如 Button、Pressable),配置准确的 accessibility role 角色 | 随意使用普通的无语义容器(如 Div、View)来包裹关键交互入口 | + +### 亮/暗色模式对比度调优 + +| 规范法则 (Rule) | 正面实践 (Do) | 反面模式 (Don't) | +|------|----|----- | +| **卡片/卡板层级感 (Surface readability)** | 善用投影高度、高光边框或微弱明度差将卡片面板与底层背景界定分明 | 卡片完全融入背景,导致页面沦为平铺直叙的一张白板 | +| **浅色模式文本易读性** | 确保浅色主题下的正文段落与背景的对比度 ≥4.5:1 | 在白底上使用过淡的浅灰色文字,伤害视力 | +| **暗黑模式文本易读性** | 确保深色主题下主要文本对比度 ≥4.5:1,次要说明文字对比度 ≥3:1 | 暗色主题下的文本对比度不足,导致文字几乎隐形 | +| **边框与分割线辨识度** | 确保分割线和细边框在浅色与暗色主题下均有匹配的明暗度对比 | 只针对默认主题调优,在暗黑模式下边框彻底消失 | +| **交互状态双向适配** | 针对亮/暗两套主题,均设计高水准的 hover、focus、disabled 及 active 交互态 | 仅针对其中一个主题优化了状态,导致另一主题下交互态难以辨认 | +| **Token 化色彩管理** | 在文字、背景和边框上全量引入语义化色彩 Token,实现主题一键切换 | 在组件样式中大量写死 Hex 颜色,使得主题适配维护成本巨大 | +| **半透明遮罩对比调优** | 模态弹窗的底层 Scrim 半透明度应设在 40%–60% 之间,以提供充足的前景聚焦力 | 遮罩过淡导致底层内容与弹窗文本发生视觉打架,严重干扰阅读 | + +### 布局与间距节奏规范 + +| 规范法则 (Rule) | 正面实践 (Do) | 反面模式 (Don't) | +|------|----|----- | +| **安全区域对齐 (Safe-area)** | 针对页眉、底栏及全局悬浮 CTA 栏,强制注入顶部与底部安全区域 padding | 悬浮底栏把刘海屏或系统底部手势条死死挡住,阻断操作 | +| **避开系统交互热区** | 为系统状态栏和导航指示条预留充足的高度避让,严防手势冲突 | 让可交互的按钮与系统自带的手势滑轨边缘重叠,触发误触 | +| **统一多端限制宽度** | 依据设备屏幕尺寸,采用预设的最大安全内容容器宽度(如大屏 PC 限制在 1200px 左右) | 页面布局在宽屏下无限延伸扩展,导致视觉重心涣散 | +| **4/8pt 间距韵律系统** | 内边距、外边距、网格空隙等,一律采用统一的 4/8/16/24/32/48px 等阶梯节奏 | 随心所欲使用 7px、13px、19px、31px 等无规律的间距数值 | +| **控制段落最大排版宽度** | 在宽屏平板及 PC 上,限制单行文字最大排版宽度,保证视线移动舒适 | 段落文字横跨整屏,导致用户每读完一行都需要长距离移动眼球寻找下一行 | +| **垂直间距分明层级** | 页面各区块之间使用鲜明的、拉开差距的垂直间距(如 24px/48px/64px) | 区块区块之间没有清晰的空隙,使页面显得局促而杂乱 | +| **响应式水平槽宽** | 随着设备屏幕宽度增加,按比例增加两侧的水平页面边距 (Page Gutters) | 在平板和移动端上都顽固使用相同极窄的 12px 边距 | +| **底部滚动安全占位** | 凡是页面底部有固定粘性底栏时,必须为列表容器最底端提供相等的 Padding 占位 | 用户滑动到列表最底部时,最后一个条目被粘性底栏永久遮挡 | diff --git a/.codex/skills/ui-ux-pro-max-zh/data/_sync_all.py b/.codex/skills/ui-ux-pro-max-zh/data/_sync_all.py new file mode 100644 index 0000000..37f7c3a --- /dev/null +++ b/.codex/skills/ui-ux-pro-max-zh/data/_sync_all.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +""" +Sync colors.csv and ui-reasoning.csv with the updated products.csv (161 entries). +- Remove deleted product types +- Rename mismatched entries +- Add new entries for missing product types +- Keep colors.csv aligned 1:1 with products.csv +- Renumber everything +""" +import csv, os, json + +BASE = os.path.dirname(os.path.abspath(__file__)) + +# ─── Color derivation helpers ──────────────────────────────────────────────── +def h2r(h): + h = h.lstrip("#") + return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) + +def r2h(r, g, b): + return f"#{max(0,min(255,int(r))):02X}{max(0,min(255,int(g))):02X}{max(0,min(255,int(b))):02X}" + +def lum(h): + r, g, b = [x/255.0 for x in h2r(h)] + r, g, b = [(x/12.92 if x<=0.03928 else ((x+0.055)/1.055)**2.4) for x in (r, g, b)] + return 0.2126*r + 0.7152*g + 0.0722*b + +def is_dark(bg): + return lum(bg) < 0.18 + +def on_color(bg): + return "#FFFFFF" if lum(bg) < 0.4 else "#0F172A" + +def blend(a, b, f=0.15): + ra, ga, ba = h2r(a) + rb, gb, bb = h2r(b) + return r2h(ra+(rb-ra)*f, ga+(gb-ga)*f, ba+(bb-ba)*f) + +def shift(h, n): + r, g, b = h2r(h) + return r2h(r+n, g+n, b+n) + +def derive_row(pt, pri, sec, acc, bg, notes=""): + """Generate full 16-token color row from 4 base colors.""" + dark = is_dark(bg) + fg = "#FFFFFF" if dark else "#0F172A" + on_pri = on_color(pri) + on_sec = on_color(sec) + on_acc = on_color(acc) + card = shift(bg, 10) if dark else "#FFFFFF" + card_fg = "#FFFFFF" if dark else "#0F172A" + muted = blend(bg, pri, 0.08) if dark else blend("#FFFFFF", pri, 0.06) + muted_fg = "#94A3B8" if dark else "#64748B" + border = f"rgba(255,255,255,0.08)" if dark else blend("#FFFFFF", pri, 0.12) + destr = "#DC2626" + on_destr = "#FFFFFF" + ring = pri + return [pt, pri, on_pri, sec, on_sec, acc, on_acc, bg, fg, card, card_fg, muted, muted_fg, border, destr, on_destr, ring, notes] + +# ─── Rename maps ───────────────────────────────────────────────────────────── +COLOR_RENAMES = { + "Quantum Computing": "Quantum Computing Interface", + "Biohacking / Longevity": "Biohacking / Longevity App", + "Autonomous Systems": "Autonomous Drone Fleet Manager", + "Generative AI Art": "Generative Art Platform", + "Spatial / Vision OS": "Spatial Computing OS / App", + "Climate Tech": "Sustainable Energy / Climate Tech", +} +UI_RENAMES = { + "Architecture/Interior": "Architecture / Interior", + "Autonomous Drone Fleet": "Autonomous Drone Fleet Manager", + "B2B SaaS Enterprise": "B2B Service", + "Biohacking/Longevity App": "Biohacking / Longevity App", + "Biotech/Life Sciences": "Biotech / Life Sciences", + "Developer Tool/IDE": "Developer Tool / IDE", + "Education": "Educational App", + "Fintech (Banking)": "Fintech/Crypto", + "Government/Public": "Government/Public Service", + "Home Services": "Home Services (Plumber/Electrician)", + "Micro-Credentials/Badges": "Micro-Credentials/Badges Platform", + "Music/Entertainment": "Music Streaming", + "Quantum Computing": "Quantum Computing Interface", + "Real Estate": "Real Estate/Property", + "Remote Work/Collaboration": "Remote Work/Collaboration Tool", + "Restaurant/Food": "Restaurant/Food Service", + "SaaS Dashboard": "Analytics Dashboard", + "Space Tech/Aerospace": "Space Tech / Aerospace", + "Spatial Computing OS": "Spatial Computing OS / App", + "Startup Landing": "Micro SaaS", + "Sustainable Energy/Climate": "Sustainable Energy / Climate Tech", + "Travel/Tourism": "Travel/Tourism Agency", + "Wellness/Mental Health": "Mental Health App", +} + +REMOVE_TYPES = { + "Service Landing Page", "Sustainability/ESG Platform", + "Cleaning Service", "Coffee Shop", + "Consulting Firm", "Conference/Webinar Platform", +} + +# ─── New color definitions: (primary, secondary, accent, bg, notes) ────────── +# Grouped by category for clarity. Each tuple generates a full 16-token row. +NEW_COLORS = { + # ── Old #97-#116 that never got colors ── + "Todo & Task Manager": ("#2563EB","#3B82F6","#059669","#F8FAFC","Functional blue + progress green"), + "Personal Finance Tracker": ("#1E40AF","#3B82F6","#059669","#0F172A","Trust blue + profit green on dark"), + "Chat & Messaging App": ("#2563EB","#6366F1","#059669","#FFFFFF","Messenger blue + online green"), + "Notes & Writing App": ("#78716C","#A8A29E","#D97706","#FFFBEB","Warm ink + amber accent on cream"), + "Habit Tracker": ("#D97706","#F59E0B","#059669","#FFFBEB","Streak amber + habit green"), + "Food Delivery / On-Demand": ("#EA580C","#F97316","#2563EB","#FFF7ED","Appetizing orange + trust blue"), + "Ride Hailing / Transportation":("#1E293B","#334155","#2563EB","#0F172A","Map dark + route blue"), + "Recipe & Cooking App": ("#9A3412","#C2410C","#059669","#FFFBEB","Warm terracotta + fresh green"), + "Meditation & Mindfulness": ("#7C3AED","#8B5CF6","#059669","#FAF5FF","Calm lavender + mindful green"), + "Weather App": ("#0284C7","#0EA5E9","#F59E0B","#F0F9FF","Sky blue + sun amber"), + "Diary & Journal App": ("#92400E","#A16207","#6366F1","#FFFBEB","Warm journal brown + ink violet"), + "CRM & Client Management": ("#2563EB","#3B82F6","#059669","#F8FAFC","Professional blue + deal green"), + "Inventory & Stock Management":("#334155","#475569","#059669","#F8FAFC","Industrial slate + stock green"), + "Flashcard & Study Tool": ("#7C3AED","#8B5CF6","#059669","#FAF5FF","Study purple + correct green"), + "Booking & Appointment App": ("#0284C7","#0EA5E9","#059669","#F0F9FF","Calendar blue + available green"), + "Invoice & Billing Tool": ("#1E3A5F","#2563EB","#059669","#F8FAFC","Navy professional + paid green"), + "Grocery & Shopping List": ("#059669","#10B981","#D97706","#ECFDF5","Fresh green + food amber"), + "Timer & Pomodoro": ("#DC2626","#EF4444","#059669","#0F172A","Focus red on dark + break green"), + "Parenting & Baby Tracker": ("#EC4899","#F472B6","#0284C7","#FDF2F8","Soft pink + trust blue"), + "Scanner & Document Manager": ("#1E293B","#334155","#2563EB","#F8FAFC","Document grey + scan blue"), + # ── A. Utility / Productivity ── + "Calendar & Scheduling App": ("#2563EB","#3B82F6","#059669","#F8FAFC","Calendar blue + event green"), + "Password Manager": ("#1E3A5F","#334155","#059669","#0F172A","Vault dark blue + secure green"), + "Expense Splitter / Bill Split":("#059669","#10B981","#DC2626","#F8FAFC","Balance green + owe red"), + "Voice Recorder & Memo": ("#DC2626","#EF4444","#2563EB","#FFFFFF","Recording red + waveform blue"), + "Bookmark & Read-Later": ("#D97706","#F59E0B","#2563EB","#FFFBEB","Warm amber + link blue"), + "Translator App": ("#2563EB","#0891B2","#EA580C","#F8FAFC","Global blue + teal + accent orange"), + "Calculator & Unit Converter": ("#EA580C","#F97316","#2563EB","#1C1917","Operation orange on dark"), + "Alarm & World Clock": ("#D97706","#F59E0B","#6366F1","#0F172A","Time amber + night indigo on dark"), + "File Manager & Transfer": ("#2563EB","#3B82F6","#D97706","#F8FAFC","Folder blue + file amber"), + "Email Client": ("#2563EB","#3B82F6","#DC2626","#FFFFFF","Inbox blue + priority red"), + # ── B. Games ── + "Casual Puzzle Game": ("#EC4899","#8B5CF6","#F59E0B","#FDF2F8","Cheerful pink + reward gold"), + "Trivia & Quiz Game": ("#2563EB","#7C3AED","#F59E0B","#EFF6FF","Quiz blue + gold leaderboard"), + "Card & Board Game": ("#15803D","#166534","#D97706","#0F172A","Felt green + gold on dark"), + "Idle & Clicker Game": ("#D97706","#F59E0B","#7C3AED","#FFFBEB","Coin gold + prestige purple"), + "Word & Crossword Game": ("#15803D","#059669","#D97706","#FFFFFF","Word green + letter amber"), + "Arcade & Retro Game": ("#DC2626","#2563EB","#22C55E","#0F172A","Neon red+blue on dark + score green"), + # ── C. Creator Tools ── + "Photo Editor & Filters": ("#7C3AED","#6366F1","#0891B2","#0F172A","Editor violet + filter cyan on dark"), + "Short Video Editor": ("#EC4899","#DB2777","#2563EB","#0F172A","Video pink on dark + timeline blue"), + "Drawing & Sketching Canvas": ("#7C3AED","#8B5CF6","#0891B2","#1C1917","Canvas purple + tool teal on dark"), + "Music Creation & Beat Maker": ("#7C3AED","#6366F1","#22C55E","#0F172A","Studio purple + waveform green on dark"), + "Meme & Sticker Maker": ("#EC4899","#F59E0B","#2563EB","#FFFFFF","Viral pink + comedy yellow + share blue"), + "AI Photo & Avatar Generator": ("#7C3AED","#6366F1","#EC4899","#FAF5FF","AI purple + generation pink"), + "Link-in-Bio Page Builder": ("#2563EB","#7C3AED","#EC4899","#FFFFFF","Brand blue + creator purple"), + # ── D. Personal Life ── + "Wardrobe & Outfit Planner": ("#BE185D","#EC4899","#D97706","#FDF2F8","Fashion rose + gold accent"), + "Plant Care Tracker": ("#15803D","#059669","#D97706","#F0FDF4","Nature green + sun yellow"), + "Book & Reading Tracker": ("#78716C","#92400E","#D97706","#FFFBEB","Book brown + page amber"), + "Couple & Relationship App": ("#BE185D","#EC4899","#DC2626","#FDF2F8","Romance rose + love red"), + "Family Calendar & Chores": ("#2563EB","#059669","#D97706","#F8FAFC","Family blue + chore green"), + "Mood Tracker": ("#7C3AED","#6366F1","#D97706","#FAF5FF","Mood purple + insight amber"), + "Gift & Wishlist": ("#DC2626","#D97706","#EC4899","#FFF1F2","Gift red + gold + surprise pink"), + # ── E. Health ── + "Running & Cycling GPS": ("#EA580C","#F97316","#059669","#0F172A","Energetic orange + pace green on dark"), + "Yoga & Stretching Guide": ("#6B7280","#78716C","#0891B2","#F5F5F0","Sage neutral + calm teal"), + "Sleep Tracker": ("#4338CA","#6366F1","#7C3AED","#0F172A","Night indigo + dream violet on dark"), + "Calorie & Nutrition Counter": ("#059669","#10B981","#EA580C","#ECFDF5","Healthy green + macro orange"), + "Period & Cycle Tracker": ("#BE185D","#EC4899","#7C3AED","#FDF2F8","Blush rose + fertility lavender"), + "Medication & Pill Reminder": ("#0284C7","#0891B2","#DC2626","#F0F9FF","Medical blue + alert red"), + "Water & Hydration Reminder": ("#0284C7","#06B6D4","#0891B2","#F0F9FF","Refreshing blue + water cyan"), + "Fasting & Intermittent Timer":("#6366F1","#4338CA","#059669","#0F172A","Fasting indigo on dark + eating green"), + # ── F. Social ── + "Anonymous Community / Confession":("#475569","#334155","#0891B2","#0F172A","Protective grey + subtle teal on dark"), + "Local Events & Discovery": ("#EA580C","#F97316","#2563EB","#FFF7ED","Event orange + map blue"), + "Study Together / Virtual Coworking":("#2563EB","#3B82F6","#059669","#F8FAFC","Focus blue + session green"), + # ── G. Education ── + "Coding Challenge & Practice": ("#22C55E","#059669","#D97706","#0F172A","Code green + difficulty amber on dark"), + "Kids Learning (ABC & Math)": ("#2563EB","#F59E0B","#EC4899","#EFF6FF","Learning blue + play yellow + fun pink"), + "Music Instrument Learning": ("#DC2626","#9A3412","#D97706","#FFFBEB","Musical red + warm amber"), + # ── H. Transport ── + "Parking Finder": ("#2563EB","#059669","#DC2626","#F0F9FF","Available blue/green + occupied red"), + "Public Transit Guide": ("#2563EB","#0891B2","#EA580C","#F8FAFC","Transit blue + line colors"), + "Road Trip Planner": ("#EA580C","#0891B2","#D97706","#FFF7ED","Adventure orange + map teal"), + # ── I. Safety & Lifestyle ── + "VPN & Privacy Tool": ("#1E3A5F","#334155","#22C55E","#0F172A","Shield dark + connected green"), + "Emergency SOS & Safety": ("#DC2626","#EF4444","#2563EB","#FFF1F2","Alert red + safety blue"), + "Wallpaper & Theme App": ("#7C3AED","#EC4899","#2563EB","#FAF5FF","Aesthetic purple + trending pink"), + "White Noise & Ambient Sound": ("#475569","#334155","#4338CA","#0F172A","Ambient grey + deep indigo on dark"), + "Home Decoration & Interior Design":("#78716C","#A8A29E","#D97706","#FAF5F2","Interior warm grey + gold accent"), +} + +# ─── 1. REBUILD colors.csv ─────────────────────────────────────────────────── +def rebuild_colors(): + src = os.path.join(BASE, "colors.csv") + with open(src, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + headers = reader.fieldnames + existing = list(reader) + + # Build lookup: Product Type -> row data + color_map = {} + for row in existing: + pt = row.get("Product Type", "").strip() + if not pt: + continue + # Remove deleted types + if pt in REMOVE_TYPES: + print(f" [colors] REMOVE: {pt}") + continue + # Rename mismatched types + if pt in COLOR_RENAMES: + new_name = COLOR_RENAMES[pt] + print(f" [colors] RENAME: {pt} → {new_name}") + row["Product Type"] = new_name + pt = new_name + color_map[pt] = row + + # Read products.csv to get the correct order + with open(os.path.join(BASE, "products.csv"), newline="", encoding="utf-8") as f: + products = list(csv.DictReader(f)) + + # Build final rows in products.csv order + final_rows = [] + added = 0 + for i, prod in enumerate(products, 1): + pt = prod["Product Type"] + if pt in color_map: + row = color_map[pt] + row["No"] = str(i) + final_rows.append(row) + elif pt in NEW_COLORS: + pri, sec, acc, bg, notes = NEW_COLORS[pt] + new_row = derive_row(pt, pri, sec, acc, bg, notes) + d = dict(zip(headers, [str(i)] + new_row)) + final_rows.append(d) + added += 1 + else: + print(f" [colors] WARNING: No color data for '{pt}' - using defaults") + new_row = derive_row(pt, "#2563EB", "#3B82F6", "#059669", "#F8FAFC", "Auto-generated default") + d = dict(zip(headers, [str(i)] + new_row)) + final_rows.append(d) + added += 1 + + # Write + with open(src, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=headers) + writer.writeheader() + writer.writerows(final_rows) + + product_count = len(products) + print(f"\n ✅ colors.csv: {len(final_rows)} rows ({product_count} products)") + print(f" Added: {added} new color rows") + +# ─── 2. REBUILD ui-reasoning.csv ───────────────────────────────────────────── +def derive_ui_reasoning(prod): + """Generate ui-reasoning row from products.csv row.""" + pt = prod["Product Type"] + style = prod.get("Primary Style Recommendation", "") + landing = prod.get("Landing Page Pattern", "") + color_focus = prod.get("Color Palette Focus", "") + considerations = prod.get("Key Considerations", "") + keywords = prod.get("Keywords", "") + + # Typography mood derived from style + typo_map = { + "Minimalism": "Professional + Clean hierarchy", + "Glassmorphism": "Modern + Clear hierarchy", + "Brutalism": "Bold + Oversized + Monospace", + "Claymorphism": "Playful + Rounded + Friendly", + "Dark Mode": "High contrast + Light on dark", + "Neumorphism": "Subtle + Soft + Monochromatic", + "Flat Design": "Bold + Clean + Sans-serif", + "Vibrant": "Energetic + Bold + Large", + "Aurora": "Elegant + Gradient-friendly", + "AI-Native": "Conversational + Minimal chrome", + "Organic": "Warm + Humanist + Natural", + "Motion": "Dynamic + Hierarchy-shifting", + "Accessible": "Large + High contrast + Clear", + "Soft UI": "Modern + Accessible + Balanced", + "Trust": "Professional + Serif accents", + "Swiss": "Grid-based + Mathematical + Helvetica", + "3D": "Immersive + Spatial + Variable", + "Retro": "Nostalgic + Monospace + Neon", + "Cyberpunk": "Terminal + Monospace + Neon", + "Pixel": "Retro + Blocky + 8-bit", + } + typo_mood = "Professional + Clear hierarchy" + for key, val in typo_map.items(): + if key.lower() in style.lower(): + typo_mood = val + break + + # Key effects from style + eff_map = { + "Glassmorphism": "Backdrop blur (10-20px) + Translucent overlays", + "Neumorphism": "Dual shadows (light+dark) + Soft press 150ms", + "Claymorphism": "Multi-layer shadows + Spring bounce + Soft press 200ms", + "Brutalism": "No transitions + Hard borders + Instant feedback", + "Dark Mode": "Subtle glow + Neon accents + High contrast", + "Flat Design": "Color shift hover + Fast 150ms transitions + No shadows", + "Minimalism": "Subtle hover 200ms + Smooth transitions + Clean", + "Motion-Driven": "Scroll animations + Parallax + Page transitions", + "Micro-interactions": "Haptic feedback + Small 50-100ms animations", + "Vibrant": "Large section gaps 48px+ + Color shift hover + Scroll-snap", + "Aurora": "Flowing gradients 8-12s + Color morphing", + "AI-Native": "Typing indicator + Streaming text + Context reveal", + "Organic": "Rounded 16-24px + Natural shadows + Flowing SVG", + "Soft UI": "Improved shadows + Modern 200-300ms + Focus visible", + "3D": "WebGL/Three.js + Parallax 3-5 layers + Physics 300-400ms", + "Trust": "Clear focus rings + Badge hover + Metric pulse", + "Accessible": "Focus rings 3-4px + ARIA + Reduced motion", + } + key_effects = "Subtle hover (200ms) + Smooth transitions" + for key, val in eff_map.items(): + if key.lower() in style.lower(): + key_effects = val + break + + # Decision rules + rules = {} + if "dark" in style.lower() or "oled" in style.lower(): + rules["if_light_mode_needed"] = "provide-theme-toggle" + if "glass" in style.lower(): + rules["if_low_performance"] = "fallback-to-flat" + if "conversion" in landing.lower(): + rules["if_conversion_focused"] = "add-urgency-colors" + if "social" in landing.lower(): + rules["if_trust_needed"] = "add-testimonials" + if "data" in keywords.lower() or "dashboard" in keywords.lower(): + rules["if_data_heavy"] = "prioritize-data-density" + if not rules: + rules["if_ux_focused"] = "prioritize-clarity" + rules["if_mobile"] = "optimize-touch-targets" + + # Anti-patterns + anti_patterns = [] + if "minimalism" in style.lower() or "minimal" in style.lower(): + anti_patterns.append("Excessive decoration") + if "dark" in style.lower(): + anti_patterns.append("Pure white backgrounds") + if "flat" in style.lower(): + anti_patterns.append("Complex shadows + 3D effects") + if "vibrant" in style.lower(): + anti_patterns.append("Muted colors + Low energy") + if "accessible" in style.lower(): + anti_patterns.append("Color-only indicators") + if not anti_patterns: + anti_patterns = ["Inconsistent styling", "Poor contrast ratios"] + anti_str = " + ".join(anti_patterns[:2]) + + return { + "UI_Category": pt, + "Recommended_Pattern": landing, + "Style_Priority": style, + "Color_Mood": color_focus, + "Typography_Mood": typo_mood, + "Key_Effects": key_effects, + "Decision_Rules": json.dumps(rules), + "Anti_Patterns": anti_str, + "Severity": "HIGH" + } + + +def rebuild_ui_reasoning(): + src = os.path.join(BASE, "ui-reasoning.csv") + with open(src, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + headers = reader.fieldnames + existing = list(reader) + + # Build lookup + ui_map = {} + for row in existing: + cat = row.get("UI_Category", "").strip() + if not cat: + continue + if cat in REMOVE_TYPES: + print(f" [ui-reason] REMOVE: {cat}") + continue + if cat in UI_RENAMES: + new_name = UI_RENAMES[cat] + print(f" [ui-reason] RENAME: {cat} → {new_name}") + row["UI_Category"] = new_name + cat = new_name + ui_map[cat] = row + + with open(os.path.join(BASE, "products.csv"), newline="", encoding="utf-8") as f: + products = list(csv.DictReader(f)) + + final_rows = [] + added = 0 + for i, prod in enumerate(products, 1): + pt = prod["Product Type"] + if pt in ui_map: + row = ui_map[pt] + row["No"] = str(i) + final_rows.append(row) + else: + row = derive_ui_reasoning(prod) + row["No"] = str(i) + final_rows.append(row) + added += 1 + + with open(src, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=headers) + writer.writeheader() + writer.writerows(final_rows) + + print(f"\n ✅ ui-reasoning.csv: {len(final_rows)} rows") + print(f" Added: {added} new reasoning rows") + + +# ─── MAIN ──────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + print("=== Rebuilding colors.csv ===") + rebuild_colors() + print("\n=== Rebuilding ui-reasoning.csv ===") + rebuild_ui_reasoning() + print("\n🎉 Done!") diff --git a/.codex/skills/ui-ux-pro-max-zh/data/app-interface.csv b/.codex/skills/ui-ux-pro-max-zh/data/app-interface.csv new file mode 100644 index 0000000..ccacf8a --- /dev/null +++ b/.codex/skills/ui-ux-pro-max-zh/data/app-interface.csv @@ -0,0 +1,31 @@ +No,Category,Issue,Keywords,Platform,Description,Do,Don't,Code Example Good,Code Example Bad,Severity +1,Accessibility,Icon Button Labels,icon button accessibilityLabel,iOS/Android/React Native,仅图标按钮必须公开可访问的标签,在图标按钮上设置 accessibilityLabel 或 label 属性,没有可访问名称的图标按钮,"",,Critical +2,Accessibility,Form Control Labels,form input label accessibilityLabel,iOS/Android/React Native,所有输入都必须具有可见标签和可访问性标签,将文本标签与输入配对并设置 accessibilityLabel,仅带有占位符的输入,"Email","",Critical +3,Accessibility,Role & Traits,accessibilityRole accessibilityTraits,iOS/Android/React Native,交互元素必须暴露正确的角色/特征,使用 accessibilityRole/按钮/链接/复选框等。,依赖没有角色的通用视图,"Submit",Submit,High +4,Accessibility,Dynamic Updates,accessibilityLiveRegion announce,iOS/Android/React Native,应向屏幕阅读器宣布异步状态更新,使用 accessibilityLiveRegion 或 announceForAccessibility,静默更新文本,不通知,"{status}",{status},Medium +5,Accessibility,Decorative Icons,accessible={false} importantForAccessibility,iOS/Android/React Native,装饰图标应该对屏幕阅读器隐藏,将装饰图标标记为无障碍不友好,让屏幕阅读器读取每个图标,"",,Medium +6,Touch,Touch Target Size,touch 44x44 hitSlop,iOS/Android/React Native,主要触摸目标必须至少为 44x44pt,增加 hitSlop 或 padding 以满足最小值,带有小触摸区域的小图标,,"",Critical +7,Touch,Touch Spacing,touch spacing gap 8px,iOS/Android/React Native,相邻触摸目标需要足够的间距,可触摸对象之间至少保持 8dp 的间距,许多按钮没有间隙地聚集在一起,