diff --git a/DESIGN.md b/DESIGN.md index 6cec3e4..a8af97c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -508,9 +508,10 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 - 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。 - 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。 -- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在当前内容区顶部右侧;主保存操作在最右侧,测试等次操作排列在其左侧。 +- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。 +- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。 - 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。 -- 自动生效的设置不为了显示成功文案而长期占用页脚,通过应用通知提供短期反馈。加载或保存错误仍需保留可处理的上下文。 +- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。 ## 14. 文案规则 @@ -560,7 +561,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。 - [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。 - [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。 -- [ ] 设置中心移除全局操作页脚,将未提交配置的保存与测试操作统一放到当前内容区顶部右侧。 +- [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。 ### 15.5 验收 diff --git a/src/renderer/src/ChannelSettingsSection.tsx b/src/renderer/src/ChannelSettingsSection.tsx index f0e80d9..e68bc85 100644 --- a/src/renderer/src/ChannelSettingsSection.tsx +++ b/src/renderer/src/ChannelSettingsSection.tsx @@ -1,7 +1,6 @@ import { FlaskConical, FolderOpen, - MessageSquare, Save, Smartphone, Unplug @@ -34,6 +33,7 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract import type { AppNotificationInput } from './notifications' import { trapTabFocus } from './dialog-focus' import { PageTabs, SegmentedControl } from './WorkspacePrimitives' +import { SettingsCategoryHeader } from './SettingsPrimitives' type ChannelDraft = { enabled: boolean @@ -1218,40 +1218,44 @@ export function ChannelSettingsSection({ !dingtalkProject ) { return ( -
-

- {error ?? '正在读取消息通道设置…'} -

-
+ <> + + {!error && ( +
+

正在读取消息通道设置…

+
+ )} + ) } return ( -
-
-
- + <> + void save()} + type="button" + > +
+ ) } diff --git a/src/renderer/src/McpSettingsSection.tsx b/src/renderer/src/McpSettingsSection.tsx index c60f01d..15ce8fe 100644 --- a/src/renderer/src/McpSettingsSection.tsx +++ b/src/renderer/src/McpSettingsSection.tsx @@ -29,6 +29,7 @@ import type { RuntimeTarget } from '../../shared/capability-contracts' import { trapTabFocus } from './dialog-focus' +import { SettingsCategoryHeader } from './SettingsPrimitives' const runtimeLabels: Record = { model: '模型', @@ -318,25 +319,26 @@ export function McpSettingsSection(): React.JSX.Element { } return ( -
-
- -
- 工具与 MCP - 查看内置工具、内置 MCP 并管理外部 MCP Server -
- -
+ <> + + openEditor({ ...emptyEditor }, event.currentTarget) + } + type="button" + > +
+ + ) } diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx index 9c3f390..0b24692 100644 --- a/src/renderer/src/PlatformFeaturesSettingsSection.tsx +++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx @@ -1,4 +1,3 @@ -import { Sparkles } from 'lucide-react' import { useEffect, useState } from 'react' import type { ApplicationSettings, @@ -6,6 +5,7 @@ import type { } from '../../shared/application-settings-contracts' import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts' import { SegmentedControl } from './WorkspacePrimitives' +import { SettingsCategoryHeader } from './SettingsPrimitives' type PlatformFeaturesSettingsSectionProps = { onMagicNotesEnabledChange: (enabled: boolean) => void @@ -108,17 +108,13 @@ export function PlatformFeaturesSettingsSection({ } return ( -
-
-
+ <> + +
@@ -175,11 +171,7 @@ export function PlatformFeaturesSettingsSection({
- {error && ( -

- {error} -

- )} -
+
+ ) } diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 1210dc0..d9490e9 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -476,6 +476,7 @@ describe('SettingsPanel runtime files', () => { magicNoteCommentMode: 'after-save-auto' }) ) + expect( screen.getByRole('button', { name: '长评 + 要点' }) ).toHaveAttribute('aria-pressed', 'true') @@ -532,7 +533,7 @@ describe('SettingsPanel runtime files', () => { ).toBeNull() }) - it('places explicit configuration actions at the top of the content', () => { + it('uses one category header for titles and explicit actions', () => { render( { name: '设置中心' }) const content = screen.getByRole('tabpanel') - const toolbar = content.querySelector( - '.settings-panel__content-toolbar' + const categoryHeader = content.querySelector( + '.settings-category-header' ) - expect(toolbar).toBe(content.firstElementChild) + expect(categoryHeader).toBe(content.firstElementChild) expect( - within(toolbar as HTMLElement).getByRole('button', { + within(categoryHeader as HTMLElement).getByRole('heading', { + level: 2, + name: 'Agent Runtime' + }) + ).toBeInTheDocument() + expect( + within(categoryHeader as HTMLElement).getByRole('button', { name: '保存设置' }) ).toBeInTheDocument() expect( - within(toolbar as HTMLElement).getByRole('button', { + within(categoryHeader as HTMLElement).getByRole('button', { name: '保存并测试 OpenCode' }) ).toBeInTheDocument() @@ -571,6 +578,35 @@ describe('SettingsPanel runtime files', () => { expect( screen.queryByRole('button', { name: '保存设置' }) ).not.toBeInTheDocument() + expect( + screen.getByRole('heading', { level: 2, name: '外观' }) + ).toBeInTheDocument() + }) + + it('routes save success through the transient app notification', async () => { + const onNotify = vi.fn() + render( + {})} + onClose={vi.fn()} + onNotify={onNotify} + onSaved={vi.fn()} + /> + ) + + await screen.findByDisplayValue('C:\\Workspace') + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + + await waitFor(() => + expect(onNotify).toHaveBeenCalledWith({ + tone: 'success', + message: '设置已保存', + dedupeKey: 'runtime-settings-saved' + }) + ) + expect(screen.queryByText('设置已保存')).not.toBeInTheDocument() }) it('uses one first-level heading for the settings page', () => { @@ -1386,6 +1422,7 @@ describe('SettingsPanel runtime files', () => { }) it('tests the selected model instead of a selected Continue Runtime', async () => { + const onNotify = vi.fn() getRuntime.mockResolvedValueOnce({ ...runtimeSettings, provider: 'continue', @@ -1405,6 +1442,7 @@ describe('SettingsPanel runtime files', () => { open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} + onNotify={onNotify} onSaved={vi.fn()} /> ) @@ -1419,7 +1457,14 @@ describe('SettingsPanel runtime files', () => { expect(testModelConnection).toHaveBeenCalledWith(modelProfileId) ) expect(testRuntime).not.toHaveBeenCalled() - expect(await screen.findByText('连接成功:sonnet-5')).toBeInTheDocument() + await waitFor(() => + expect(onNotify).toHaveBeenCalledWith({ + tone: 'success', + message: '连接成功:sonnet-5', + dedupeKey: 'model-connection-tested' + }) + ) + expect(screen.queryByText('连接成功:sonnet-5')).not.toBeInTheDocument() }) it('shows an actionable model error without Electron IPC prefixes', async () => { diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index dbfcaa7..68d1c6b 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -1,5 +1,4 @@ import { - Check, FolderOpen, KeyRound, LockKeyhole, @@ -39,6 +38,11 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti import { SpeechModelSettingsSection } from './SpeechModelSettingsSection' import { EmbeddingSettingsSection } from './EmbeddingSettingsSection' import { PageHeader, SegmentedControl } from './WorkspacePrimitives' +import { SettingsCategoryHeader } from './SettingsPrimitives' +import { + settingsCategoryList, + type SettingsCategoryId +} from './settings-categories' import type { AppearanceTheme } from './theme' import type { AppNotificationInput } from './notifications' import type { @@ -46,18 +50,6 @@ import type { EmbeddingSettingsSnapshot } from '../../shared/embedding-contracts' -type SettingsTab = - | 'appearance' - | 'platform-features' - | 'model' - | 'runtime' - | 'security' - | 'automation' - | 'channels' - | 'roles' - | 'skills' - | 'mcp' - | 'about' type ModelType = 'llm' | 'embedding' | 'speech' type AgentRuntimeType = RuntimeConfigActionInput['runtime'] type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & { @@ -66,19 +58,7 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & { clearApiKey: boolean } -const settingsTabs: readonly SettingsTab[] = [ - 'appearance', - 'platform-features', - 'model', - 'runtime', - 'security', - 'automation', - 'channels', - 'roles', - 'skills', - 'mcp', - 'about' -] +const settingsTabs = settingsCategoryList.map(({ id }) => id) type SettingsPanelProps = { open: boolean @@ -253,7 +233,7 @@ export function SettingsPanel({ presentation = 'modal', onClose, onSaved, - onNotify, + onNotify = () => {}, onClearLocalData, heartbeats, onCreateHeartbeat, @@ -333,13 +313,12 @@ export function SettingsPanel({ const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] = useState(false) const [error, setError] = useState() - const [saved, setSaved] = useState(false) - const [connectionResult, setConnectionResult] = useState() const [confirmingClear, setConfirmingClear] = useState(false) const [clearingLocalData, setClearingLocalData] = useState(false) const [detection, setDetection] = useState() const [detecting, setDetecting] = useState(false) - const [activeTab, setActiveTab] = useState('runtime') + const [activeTab, setActiveTab] = + useState('runtime') const [modelType, setModelType] = useState('llm') const [agentRuntimeType, setAgentRuntimeType] = useState('opencode') @@ -349,12 +328,16 @@ export function SettingsPanel({ activeTab === 'runtime' || activeTab === 'security' || activeTab === 'roles' - const showContentActions = - configurationTab || Boolean(error) || saved + const categoryRendersOwnHeader = + activeTab === 'platform-features' || + activeTab === 'channels' || + activeTab === 'skills' || + activeTab === 'mcp' || + activeTab === 'about' const handleTabKeyDown = ( event: React.KeyboardEvent, - tab: SettingsTab + tab: SettingsCategoryId ): void => { const currentIndex = settingsTabs.indexOf(tab) let nextIndex: number | undefined @@ -374,6 +357,7 @@ export function SettingsPanel({ } event.preventDefault() const nextTab = settingsTabs[nextIndex]! + setError(undefined) setActiveTab(nextTab) event.currentTarget.parentElement ?.querySelector( @@ -390,8 +374,6 @@ export function SettingsPanel({ .getRuntime() .then((value) => { setError(undefined) - setSaved(false) - setConnectionResult(undefined) setConfirmingClear(false) setClearingLocalData(false) setModelType('llm') @@ -496,10 +478,11 @@ export function SettingsPanel({ onClose() } - const save = async (): Promise => { + const save = async ( + notifySuccess = true + ): Promise => { setSaving(true) setError(undefined) - setSaved(false) try { const defaultProfile = modelProfiles.find( @@ -605,8 +588,14 @@ export function SettingsPanel({ ) } } - setSaved(true) onSaved(value) + if (notifySuccess) { + onNotify({ + tone: 'success', + message: '设置已保存', + dedupeKey: 'runtime-settings-saved' + }) + } return value } catch (reason) { setError(settingsErrorMessage(reason, '保存设置失败')) @@ -620,8 +609,7 @@ export function SettingsPanel({ const testingModel = activeTab === 'model' && modelType === 'llm' const profileId = selectedModelProfileId setTesting(true) - setConnectionResult(undefined) - const savedSettings = await save() + const savedSettings = await save(false) if (!savedSettings) { setTesting(false) return @@ -644,11 +632,16 @@ export function SettingsPanel({ if (!status.available) { throw new Error(status.detail) } - setConnectionResult( - status.capability === 'image-generation' - ? status.detail - : `连接成功:${status.label}` - ) + onNotify({ + tone: 'success', + message: + status.capability === 'image-generation' + ? status.detail + : `连接成功:${status.label}`, + dedupeKey: testingModel + ? 'model-connection-tested' + : `runtime-connection-tested-${agentRuntimeType}` + }) } catch (reason) { setError( settingsErrorMessage( @@ -671,7 +664,7 @@ export function SettingsPanel({ setEmbeddingDiagnostic(undefined) setError(undefined) try { - if (!(await save())) { + if (!(await save(false))) { return } const diagnostic = await embeddings.diagnose() @@ -692,7 +685,7 @@ export function SettingsPanel({ } setError(undefined) try { - if (!(await save())) { + if (!(await save(false))) { return } const indexStatus = await embeddings.rebuild() @@ -993,182 +986,28 @@ export function SettingsPanel({ className="settings-tabs" role="tablist" > - - - - - - - - - - - + {settingsCategoryList.map((category) => ( + + ))}
- {showContentActions && ( -
-
- {error && ( - - {error} - - )} - {saved && ( - - - )} -
- {configurationTab && ( -
- {(activeTab === 'runtime' || - (activeTab === 'model' && modelType === 'llm')) && ( + {!categoryRendersOwnHeader && ( + + {(activeTab === 'runtime' || + (activeTab === 'model' && modelType === 'llm')) && ( + + )} - )} - -
- )} -
+ + ) : undefined + } + category={activeTab} + error={error} + /> )} {activeTab === 'appearance' && (
@@ -2215,7 +2045,8 @@ export function SettingsPanel({ {activeTab === 'security' && ( <> -