diff --git a/README.md b/README.md index b6bbe94..4ae47df 100644 --- a/README.md +++ b/README.md @@ -446,4 +446,63 @@ api.logs.filter({ user, type, status }); --- -*最后更新:2026-03-30* +## 对话输入框布局 + +### 布局结构 + +对话输入框采用响应式水平布局,根据屏幕宽度自动调整组件排列方式。 + +**桌面端(>= 768px)**: +- 水平布局:模型选择器 → 输入区域 → 工具按钮 → 发送按钮 +- 模型选择器:标准模式(显示完整模型名称,宽度 160px) +- 分隔方式:右侧垂直边框 + +**平板端(480-768px)**: +- 水平布局:模型选择器 → 输入区域 → 工具按钮 → 发送按钮 +- 模型选择器:紧凑模式(仅显示图标,宽度 100px) +- 工具按钮:上传文件、代码块 +- 分隔方式:右侧垂直边框 + +**移动端(< 480px)**: +- 垂直布局:模型选择器在上,输入区域在下 +- 模型选择器:标准模式(显示完整模型名称,宽度 160px) +- 分隔方式:底部水平边框 + +### ModelSelector 组件 + +ModelSelector 组件支持 `variant` 属性,控制显示模式: + +```jsx + +``` + +**variant 属性值**: +- `standard`:标准模式,显示完整模型名称 +- `compact`:紧凑模式,仅显示图标 + +### 响应式断点 + +| 断点名称 | 屏幕宽度 | 布局模式 | 模型选择器 | 宽度 | +|---------|---------|---------|-----------|------| +| 桌面端 | >= 768px | 水平 | 标准 | 160px | +| 平板端 | 480-768px | 水平 | 紧凑 | 100px | +| 移动端 | < 480px | 垂直 | 标准 | 160px | + +### 工具按钮 + +- 工具按钮横向排列在输入区域右侧(水平布局)或下方(垂直布局) +- 当前工具按钮:上传文件、代码块 +- 工具按钮右侧显示分隔边框(水平布局) + +### 下拉菜单方向 + +- 默认向下展开 +- 如果下方空间不足,自动向上展开 + +--- + +*最后更新:2026-04-10* diff --git a/openspec/specs/model-selector/spec.md b/openspec/specs/model-selector/spec.md index 0356d98..d033982 100644 --- a/openspec/specs/model-selector/spec.md +++ b/openspec/specs/model-selector/spec.md @@ -1,14 +1,14 @@ ## ADDED Requirements -### Requirement: 融合式模型选择器触发器 +### Requirement: 独立式模型选择器触发器 -系统 SHALL 在对话输入框的顶部集成模型选择器触发器,显示当前选中的模型名称和默认标记。 +系统 SHALL 在对话输入框的左侧集成模型选择器触发器,显示当前选中的模型名称。 #### Scenario: 显示模型选择器触发器 - **WHEN** 用户访问聊天页面 -- **THEN** 系统在聊天输入框顶部显示模型选择器触发器 -- **THEN** 触发器显示模型图标、模型名称、默认标记(如果适用)和下拉箭头 -- **THEN** 触发器与输入框融合为一个整体 +- **THEN** 系统在聊天输入框左侧显示模型选择器触发器 +- **THEN** 触发器显示模型图标、模型名称和下拉箭头 +- **THEN** 触发器作为独立组件显示在输入框左侧 #### Scenario: 显示默认标记 - **WHEN** 当前选中的模型是默认模型(`isDefault = true`) @@ -30,6 +30,16 @@ - **THEN** 每个分组显示分组标题和该层级的模型列表 - **THEN** 展开时有平滑的动画效果 +#### Scenario: 默认向下展开 +- **WHEN** 用户点击模型选择器触发器且下方空间充足(> 350px) +- **THEN** 下拉列表向下展开 +- **THEN** 下拉列表显示在触发器下方 + +#### Scenario: 向上展开 +- **WHEN** 触发器下方空间不足以显示完整下拉列表(< 350px)且上方空间充足 +- **THEN** 下拉列表向上展开 +- **THEN** 下拉列表显示在触发器上方 + #### Scenario: 分组标题显示 - **WHEN** 下拉列表展开 - **THEN** 平台模型分组显示标题"📍 平台模型" @@ -88,17 +98,87 @@ ### Requirement: 模型选择器样式 -系统 SHALL 使用融合式设计,模型选择器与输入框风格一致,圆角、边框、阴影统一。 +系统 SHALL 支持标准显示和紧凑显示两种模式,在水平布局中与输入框风格协调。 -#### Scenario: 融合式设计 -- **WHEN** 模型选择器展开 -- **THEN** 触发器和下拉列表形成一个整体 -- **THEN** 触发器使用浅灰色背景,圆角顶部 16px -- **THEN** 下拉列表使用白色背景,圆角底部 12px -- **THEN** 边框颜色和阴影与输入框一致 +#### Scenario: 标准显示模式 +- **WHEN** 模型选择器使用标准模式(桌面端) +- **THEN** 触发器使用最小宽度 160px +- **THEN** 触发器右侧显示垂直分隔边框 +- **THEN** 触发器和输入框形成水平排列 + +#### Scenario: 紧凑显示模式 +- **WHEN** 模型选择器使用紧凑模式(平板端) +- **THEN** 触发器使用最小宽度 120px +- **THEN** 触发器显示为图标型(仅显示模型图标,隐藏模型名称) +- **THEN** 触发器右侧显示垂直分隔边框 #### Scenario: 响应式设计 -- **WHEN** 用户使用移动设备访问 -- **THEN** 模型选择器自适应屏幕宽度 -- **THEN** 触发器使用较小的内边距和字体大小 -- **THEN** 下拉列表最大高度调整为 280px +- **WHEN** 用户使用桌面设备访问(屏幕宽度 >= 768px) +- **THEN** 模型选择器使用标准显示模式 +- **THEN** 触发器显示模型图标和完整模型名称 +- **THEN** 触发器宽度为 160px + +- **WHEN** 用户使用平板设备访问(屏幕宽度 480-768px) +- **THEN** 模型选择器使用紧凑显示模式 +- **THEN** 触发器显示为图标型(隐藏模型名称) +- **THEN** 触发器宽度为 100px + +- **WHEN** 用户使用移动设备访问(屏幕宽度 < 480px) +- **THEN** 模型选择器回退到垂直布局 +- **THEN** 触发器位于输入框上方 +- **THEN** 触发器显示模型图标和完整模型名称 +- **THEN** 触发器宽度为 160px + +### Requirement: 紧凑型模型选择器 + +系统 SHALL 支持紧凑型显示模式,通过 `variant` 属性控制。 + +#### Scenario: 启用紧凑模式 +- **WHEN** 模型选择器组件接收 `variant="compact"` 属性 +- **THEN** 触发器应用紧凑型样式 +- **THEN** 触发器最小宽度为 120px +- **THEN** 触发器显示模型图标和下拉箭头,隐藏模型名称 + +#### Scenario: 紧凑模式下的下拉展开 +- **WHEN** 用户点击紧凑型触发器 +- **THEN** 系统展开下拉列表 +- **THEN** 下拉列表使用固定定位(position: fixed) +- **THEN** 下拉列表根据触发器位置和可用空间自动调整展开方向 + +### Requirement: 水平布局输入框 + +系统 SHALL 支持水平布局的对话输入框,模型选择器位于输入框左侧。 + +#### Scenario: 桌面端水平布局 +- **WHEN** 用户在桌面端访问(屏幕宽度 > 768px) +- **THEN** 输入框容器使用水平 flex 布局 +- **THEN** 从左到右依次显示:模型选择器、输入区域、工具按钮组、发送按钮 +- **THEN** 模型选择器和输入框之间显示垂直分隔边框 + +#### Scenario: 平板端水平布局 +- **WHEN** 用户在平板端访问(屏幕宽度 480-768px) +- **THEN** 输入框容器使用水平 flex 布局 +- **THEN** 从左到右依次显示:紧凑型模型选择器、输入区域、工具按钮组、发送按钮 +- **THEN** 工具按钮组限制最多显示 4 个按钮 +- **THEN** 紧凑型模型选择器和输入框之间显示垂直分隔边框 + +#### Scenario: 移动端垂直布局 +- **WHEN** 用户在移动端访问(屏幕宽度 < 480px) +- **THEN** 输入框容器使用垂直 flex 布局 +- **THEN** 从上到下依次显示:模型选择器、输入区域、工具按钮组、发送按钮 +- **THEN** 模型选择器和输入框之间显示水平分隔边框 + +### Requirement: 工具按钮优化 + +系统 SHALL 在水平布局中优化工具按钮的显示方式。 + +#### Scenario: 工具按钮横向排列 +- **WHEN** 输入框使用水平布局(桌面端/平板端) +- **THEN** 工具按钮横向排列在输入区域右侧 +- **THEN** 工具按钮限制最多显示 4 个 +- **THEN** 超出 4 个的按钮不显示 + +#### Scenario: 工具按钮纵向排列 +- **WHEN** 输入框使用垂直布局(移动端) +- **THEN** 工具按钮纵向排列在输入区域下方 +- **THEN** 工具按钮横向排列,限制最多显示 4 个 diff --git a/openspec/specs/responsive-chat-input/spec.md b/openspec/specs/responsive-chat-input/spec.md new file mode 100644 index 0000000..fd4c53f --- /dev/null +++ b/openspec/specs/responsive-chat-input/spec.md @@ -0,0 +1,110 @@ +# Capability: responsive-chat-input + +## Purpose + +系统 SHALL 支持响应式对话输入框布局,根据屏幕宽度自动切换水平/垂直布局模式,优化在不同设备上的用户体验。 + +## ADDED Requirements + +### Requirement: 响应式布局切换 + +系统 SHALL 根据屏幕宽度自动切换对话输入框的布局模式。 + +#### Scenario: 桌面端水平布局 +- **WHEN** 用户在桌面端访问(屏幕宽度 >= 768px) +- **THEN** 系统应用水平布局模式 +- **THEN** 输入框容器使用 `flex-direction: row` +- **THEN** 组件从左到右依次排列:模型选择器、输入区域、工具按钮组、发送按钮 + +#### Scenario: 平板端水平布局 +- **WHEN** 用户在平板端访问(屏幕宽度 480-768px) +- **THEN** 系统应用水平布局模式 +- **THEN** 输入框容器使用 `flex-direction: row` +- **THEN** 模型选择器使用紧凑型显示 +- **THEN** 工具按钮组显示当前可用的工具按钮 + +#### Scenario: 移动端垂直布局 +- **WHEN** 用户在移动端访问(屏幕宽度 < 480px) +- **THEN** 系统应用垂直布局模式 +- **THEN** 输入框容器使用 `flex-direction: column` +- **THEN** 组件从上到下依次排列:模型选择器、输入区域、工具按钮组、发送按钮 +- **THEN** 模型选择器使用标准显示模式 + +### Requirement: 模型选择器布局适配 + +系统 SHALL 在不同布局模式下调整模型选择器的显示样式。 + +#### Scenario: 水平布局中的模型选择器 +- **WHEN** 输入框使用水平布局(桌面端/平板端) +- **THEN** 模型选择器位于输入框左侧 +- **THEN** 模型选择器右侧显示垂直分隔边框(1px solid) +- **THEN** 桌面端(>= 768px)使用标准显示模式(宽度 160px) +- **THEN** 平板端(480-768px)使用紧凑显示模式(宽度 100px,图标型) + +#### Scenario: 垂直布局中的模型选择器 +- **WHEN** 输入框使用垂直布局(移动端) +- **THEN** 模型选择器位于输入框上方 +- **THEN** 模型选择器底部显示水平分隔边框(1px solid) +- **THEN** 使用标准显示模式(显示完整模型名称) + +### Requirement: 输入区域布局适配 + +系统 SHALL 在不同布局模式下调整输入区域的尺寸和样式。 + +#### Scenario: 水平布局中的输入区域 +- **WHEN** 输入框使用水平布局(桌面端/平板端) +- **THEN** 输入区域占据剩余水平空间(`flex: 1`) +- **THEN** 输入区域高度固定或使用自动高度 + +#### Scenario: 垂直布局中的输入区域 +- **WHEN** 输入框使用垂直布局(移动端) +- **THEN** 输入区域占据垂直空间,高度自适应内容 +- **THEN** 输入区域宽度占满容器宽度 + +### Requirement: 工具按钮组布局适配 + +系统 SHALL 在不同布局模式下调整工具按钮组的排列方式。 + +#### Scenario: 水平布局中的工具按钮 +- **WHEN** 输入框使用水平布局(桌面端/平板端) +- **THEN** 工具按钮组横向排列在输入区域右侧 +- **THEN** 工具按钮包括:上传文件、代码块 +- **THEN** 每个按钮使用图标型显示 +- **THEN** 工具按钮右侧使用垂直分隔边框(1px solid) + +#### Scenario: 垂直布局中的工具按钮 +- **WHEN** 输入框使用垂直布局(移动端) +- **THEN** 工具按钮组横向排列在输入区域下方 +- **THEN** 工具按钮包括:上传文件、代码块 +- **THEN** 每个按钮使用图标型显示 +- **THEN** 工具按钮组不显示右侧分隔边框 + +### Requirement: 发送按钮布局适配 + +系统 SHALL 在不同布局模式下调整发送按钮的位置和样式。 + +#### Scenario: 水平布局中的发送按钮 +- **WHEN** 输入框使用水平布局(桌面端/平板端) +- **THEN** 发送按钮位于工具按钮组右侧 +- **THEN** 发送按钮与工具按钮组之间保持适当间距 + +#### Scenario: 垂直布局中的发送按钮 +- **WHEN** 输入框使用垂直布局(移动端) +- **THEN** 发送按钮位于工具按钮组下方或与工具按钮组并排 +- **THEN** 发送按钮占据合适宽度 + +### Requirement: 视觉分隔适配 + +系统 SHALL 在不同布局模式下使用适当的视觉分隔方式。 + +#### Scenario: 水平布局中的垂直分隔 +- **WHEN** 输入框使用水平布局 +- **THEN** 模型选择器和输入框之间显示垂直分隔边框 +- **THEN** 分隔边框颜色与输入框边框一致 +- **THEN** 分隔边框宽度为 1px + +#### Scenario: 垂直布局中的水平分隔 +- **WHEN** 输入框使用垂直布局 +- **THEN** 模型选择器和输入框之间显示水平分隔边框 +- **THEN** 分隔边框颜色与输入框边框一致 +- **THEN** 分隔边框宽度为 1px diff --git a/src/pages/console/ChatPage.jsx b/src/pages/console/ChatPage.jsx index 459ef36..3f7acf3 100644 --- a/src/pages/console/ChatPage.jsx +++ b/src/pages/console/ChatPage.jsx @@ -4,8 +4,9 @@ import { getChatScenes } from '../../data/conversations.js'; import { FiPaperclip, FiCode, FiSend, FiChevronDown } from 'react-icons/fi'; import { api } from '../../services/api.js'; -function ModelSelector({ selectedModel, onSelectModel }) { +function ModelSelector({ selectedModel, onSelectModel, variant = 'standard' }) { const [open, setOpen] = useState(false); + const [dropdownDirection, setDropdownDirection] = useState('down'); const ref = useRef(null); const platformModels = api.admin.modelConfigs.list().map(c => ({ @@ -45,13 +46,49 @@ function ModelSelector({ selectedModel, onSelectModel }) { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + useEffect(() => { + if (open && ref.current) { + const rect = ref.current.getBoundingClientRect(); + const dropdownHeight = 350; + const spaceBelow = window.innerHeight - rect.bottom; + + if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) { + setDropdownDirection('up'); + } else { + setDropdownDirection('down'); + } + } + }, [open]); + const handleSelect = (model) => { onSelectModel(model); setOpen(false); }; + const getDropdownPosition = () => { + if (!ref.current) return {}; + + const rect = ref.current.getBoundingClientRect(); + const dropdownHeight = 350; + const spaceBelow = window.innerHeight - rect.bottom; + + if (dropdownDirection === 'down') { + return { + top: rect.bottom, + left: rect.left, + width: rect.width, + }; + } else { + return { + bottom: window.innerHeight - rect.top, + left: rect.left, + width: rect.width, + }; + } + }; + return ( -
+
setOpen(!open)} @@ -60,9 +97,8 @@ function ModelSelector({ selectedModel, onSelectModel }) { - {selectedModel?.name || '选择模型'} - {selectedModel?.isDefault && ( - 默认 + {variant !== 'compact' && ( + {selectedModel?.name || '选择模型'} )}
@@ -71,23 +107,23 @@ function ModelSelector({ selectedModel, onSelectModel }) {
{open && ( -
- {groups.map(group => ( -
-
{group.title}
- {group.models.map(model => ( -
handleSelect(model)} - > - {model.name} - {model.isDefault && ( - 默认 - )} +
+ {groups.map(group => ( +
+
{group.title}
+ {group.models.map(model => ( +
handleSelect(model)} + > + {model.name} +
+ ))}
- ))} -
))}
)} @@ -101,6 +137,7 @@ function ChatPage() { const chatScenes = getChatScenes(); const html = chatScenes[currentScene] || ''; const chatMessagesRef = useRef(null); + const [isCompact, setIsCompact] = useState(false); const defaultPlatformModel = api.admin.modelConfigs.list().find(c => c.isActive); const defaultProjectModel = api.consoleModels.project.list().find(c => c.isActive); @@ -135,6 +172,16 @@ function ChatPage() { }; }, [scene, html]); + useEffect(() => { + const checkScreenSize = () => { + setIsCompact(window.innerWidth >= 480 && window.innerWidth < 768); + }; + + checkScreenSize(); + window.addEventListener('resize', checkScreenSize); + return () => window.removeEventListener('resize', checkScreenSize); + }, []); + return (
@@ -143,10 +190,11 @@ function ChatPage() {
-
+