fix: unify settings center layout

This commit is contained in:
lofyer
2026-08-10 22:30:32 +08:00
parent 7d15e83153
commit 2cc76fc960
11 changed files with 430 additions and 428 deletions
+4 -3
View File
@@ -508,9 +508,10 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 - 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
- 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。 - 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。 - 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在当前内容区顶部右侧;主保存操作在最右侧,测试等次操作排列在其左侧 - 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。 - 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
- 自动生效的设置不为了显示成功文案而长期占用页脚,通过应用通知提供短期反馈。加载保存错误仍需保留可处理的上下文。 - 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载保存和测试错误显示在分类页头下方,并保留可处理的上下文。
## 14. 文案规则 ## 14. 文案规则
@@ -560,7 +561,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。 - [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
- [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。 - [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。
- [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。 - [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
- [ ] 设置中心移除全局操作页脚,将未提交配置的保存与测试操作统一放到当前内容区顶部右侧 - [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知
### 15.5 验收 ### 15.5 验收
+36 -31
View File
@@ -1,7 +1,6 @@
import { import {
FlaskConical, FlaskConical,
FolderOpen, FolderOpen,
MessageSquare,
Save, Save,
Smartphone, Smartphone,
Unplug Unplug
@@ -34,6 +33,7 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives' import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
type ChannelDraft = { type ChannelDraft = {
enabled: boolean enabled: boolean
@@ -1218,40 +1218,44 @@ export function ChannelSettingsSection({
!dingtalkProject !dingtalkProject
) { ) {
return ( return (
<div className="settings-section"> <>
<p className={error ? 'settings-warning' : 'settings-empty'}> <SettingsCategoryHeader
{error ?? '正在读取消息通道设置…'} category="channels"
</p> error={error}
</div> headingId="channel-settings-heading"
/>
{!error && (
<div className="settings-section">
<p className="settings-empty"></p>
</div>
)}
</>
) )
} }
return ( return (
<section <>
aria-labelledby="channel-settings-heading" <SettingsCategoryHeader
className="settings-section channel-settings" actions={
> <button
<div className="settings-section__title settings-section__title--actions"> className="primary-button"
<MessageSquare aria-hidden="true" size={17} /> disabled={busy}
<div> onClick={() => void save()}
<strong id="channel-settings-heading"></strong> type="button"
<small> >
<Save aria-hidden="true" size={13} />
</small> {busy ? '保存中…' : '保存通道设置'}
</div> </button>
<button }
className="primary-button" category="channels"
disabled={busy} error={error}
onClick={() => void save()} headingId="channel-settings-heading"
type="button" />
> <section
<Save aria-hidden="true" size={13} /> aria-label="消息通道配置"
{busy ? '保存中…' : '保存通道设置'} className="settings-section channel-settings"
</button> >
</div>
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>} {snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
{error && <p className="settings-warning" role="alert">{error}</p>}
<div className="channel-settings__tabs"> <div className="channel-settings__tabs">
<PageTabs <PageTabs
@@ -1325,6 +1329,7 @@ export function ChannelSettingsSection({
/> />
)} )}
</div> </div>
</section> </section>
</>
) )
} }
+23 -22
View File
@@ -29,6 +29,7 @@ import type {
RuntimeTarget RuntimeTarget
} from '../../shared/capability-contracts' } from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
import { SettingsCategoryHeader } from './SettingsPrimitives'
const runtimeLabels: Record<RuntimeTarget, string> = { const runtimeLabels: Record<RuntimeTarget, string> = {
model: '模型', model: '模型',
@@ -318,25 +319,26 @@ export function McpSettingsSection(): React.JSX.Element {
} }
return ( return (
<div className="settings-section"> <>
<div className="settings-section__title settings-section__title--actions"> <SettingsCategoryHeader
<Network size={17} /> actions={
<div> <button
<strong> MCP</strong> className="secondary-button"
<small> MCP MCP Server</small> disabled={Boolean(busy) || Boolean(editor)}
</div> onClick={(event) =>
<button openEditor({ ...emptyEditor }, event.currentTarget)
className="secondary-button" }
disabled={Boolean(busy) || Boolean(editor)} type="button"
onClick={(event) => >
openEditor({ ...emptyEditor }, event.currentTarget) <Plus aria-hidden="true" size={14} />
} Server
type="button" </button>
> }
<Plus size={14} /> category="mcp"
Server error={!editor ? error : undefined}
</button> headingId="mcp-settings-heading"
</div> />
<section aria-label="MCP 配置" className="settings-section">
<p className="settings-notice"> <p className="settings-notice">
MCP Execute MCP Execute
@@ -348,8 +350,6 @@ export function McpSettingsSection(): React.JSX.Element {
访 访
GoodBuddy GoodBuddy
</p> </p>
{error && !editor && <p className="settings-warning">{error}</p>}
<section <section
aria-labelledby="computer-capabilities-heading" aria-labelledby="computer-capabilities-heading"
className="mcp-tool-section" className="mcp-tool-section"
@@ -1108,6 +1108,7 @@ export function McpSettingsSection(): React.JSX.Element {
) )
})} })}
</div> </div>
</div> </section>
</>
) )
} }
@@ -1,4 +1,3 @@
import { Sparkles } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { import type {
ApplicationSettings, ApplicationSettings,
@@ -6,6 +5,7 @@ import type {
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts' import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import { SegmentedControl } from './WorkspacePrimitives' import { SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
type PlatformFeaturesSettingsSectionProps = { type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void onMagicNotesEnabledChange: (enabled: boolean) => void
@@ -108,17 +108,13 @@ export function PlatformFeaturesSettingsSection({
} }
return ( return (
<section <>
aria-labelledby="platform-features-heading" <SettingsCategoryHeader
className="settings-section" category="platform-features"
> error={error}
<div className="settings-section__title"> headingId="platform-features-heading"
<Sparkles aria-hidden="true" size={17} /> />
<div> <section aria-label="平台功能选项" className="settings-section">
<strong id="platform-features-heading"></strong>
<small> GoodBuddy </small>
</div>
</div>
<article className="capability-card"> <article className="capability-card">
<div className="capability-card__header"> <div className="capability-card__header">
<div> <div>
@@ -175,11 +171,7 @@ export function PlatformFeaturesSettingsSection({
</small> </small>
</div> </div>
</article> </article>
{error && ( </section>
<p className="settings-warning" role="alert"> </>
{error}
</p>
)}
</section>
) )
} }
+52 -7
View File
@@ -476,6 +476,7 @@ describe('SettingsPanel runtime files', () => {
magicNoteCommentMode: 'after-save-auto' magicNoteCommentMode: 'after-save-auto'
}) })
) )
expect( expect(
screen.getByRole('button', { name: '长评 + 要点' }) screen.getByRole('button', { name: '长评 + 要点' })
).toHaveAttribute('aria-pressed', 'true') ).toHaveAttribute('aria-pressed', 'true')
@@ -532,7 +533,7 @@ describe('SettingsPanel runtime files', () => {
).toBeNull() ).toBeNull()
}) })
it('places explicit configuration actions at the top of the content', () => { it('uses one category header for titles and explicit actions', () => {
render( render(
<SettingsPanel <SettingsPanel
{...heartbeatSettingsProps} {...heartbeatSettingsProps}
@@ -548,18 +549,24 @@ describe('SettingsPanel runtime files', () => {
name: '设置中心' name: '设置中心'
}) })
const content = screen.getByRole('tabpanel') const content = screen.getByRole('tabpanel')
const toolbar = content.querySelector( const categoryHeader = content.querySelector(
'.settings-panel__content-toolbar' '.settings-category-header'
) )
expect(toolbar).toBe(content.firstElementChild) expect(categoryHeader).toBe(content.firstElementChild)
expect( 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: '保存设置' name: '保存设置'
}) })
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
within(toolbar as HTMLElement).getByRole('button', { within(categoryHeader as HTMLElement).getByRole('button', {
name: '保存并测试 OpenCode' name: '保存并测试 OpenCode'
}) })
).toBeInTheDocument() ).toBeInTheDocument()
@@ -571,6 +578,35 @@ describe('SettingsPanel runtime files', () => {
expect( expect(
screen.queryByRole('button', { name: '保存设置' }) screen.queryByRole('button', { name: '保存设置' })
).not.toBeInTheDocument() ).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(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
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', () => { 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 () => { it('tests the selected model instead of a selected Continue Runtime', async () => {
const onNotify = vi.fn()
getRuntime.mockResolvedValueOnce({ getRuntime.mockResolvedValueOnce({
...runtimeSettings, ...runtimeSettings,
provider: 'continue', provider: 'continue',
@@ -1405,6 +1442,7 @@ describe('SettingsPanel runtime files', () => {
open open
onClearLocalData={vi.fn(async () => {})} onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()} onClose={vi.fn()}
onNotify={onNotify}
onSaved={vi.fn()} onSaved={vi.fn()}
/> />
) )
@@ -1419,7 +1457,14 @@ describe('SettingsPanel runtime files', () => {
expect(testModelConnection).toHaveBeenCalledWith(modelProfileId) expect(testModelConnection).toHaveBeenCalledWith(modelProfileId)
) )
expect(testRuntime).not.toHaveBeenCalled() 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 () => { it('shows an actionable model error without Electron IPC prefixes', async () => {
+100 -270
View File
@@ -1,5 +1,4 @@
import { import {
Check,
FolderOpen, FolderOpen,
KeyRound, KeyRound,
LockKeyhole, LockKeyhole,
@@ -39,6 +38,11 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection' import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection' import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives' import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
settingsCategoryList,
type SettingsCategoryId
} from './settings-categories'
import type { AppearanceTheme } from './theme' import type { AppearanceTheme } from './theme'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import type { import type {
@@ -46,18 +50,6 @@ import type {
EmbeddingSettingsSnapshot EmbeddingSettingsSnapshot
} from '../../shared/embedding-contracts' } from '../../shared/embedding-contracts'
type SettingsTab =
| 'appearance'
| 'platform-features'
| 'model'
| 'runtime'
| 'security'
| 'automation'
| 'channels'
| 'roles'
| 'skills'
| 'mcp'
| 'about'
type ModelType = 'llm' | 'embedding' | 'speech' type ModelType = 'llm' | 'embedding' | 'speech'
type AgentRuntimeType = RuntimeConfigActionInput['runtime'] type AgentRuntimeType = RuntimeConfigActionInput['runtime']
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & { type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
@@ -66,19 +58,7 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
clearApiKey: boolean clearApiKey: boolean
} }
const settingsTabs: readonly SettingsTab[] = [ const settingsTabs = settingsCategoryList.map(({ id }) => id)
'appearance',
'platform-features',
'model',
'runtime',
'security',
'automation',
'channels',
'roles',
'skills',
'mcp',
'about'
]
type SettingsPanelProps = { type SettingsPanelProps = {
open: boolean open: boolean
@@ -253,7 +233,7 @@ export function SettingsPanel({
presentation = 'modal', presentation = 'modal',
onClose, onClose,
onSaved, onSaved,
onNotify, onNotify = () => {},
onClearLocalData, onClearLocalData,
heartbeats, heartbeats,
onCreateHeartbeat, onCreateHeartbeat,
@@ -333,13 +313,12 @@ export function SettingsPanel({
const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] = const [embeddingDiagnosticRunning, setEmbeddingDiagnosticRunning] =
useState(false) useState(false)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [saved, setSaved] = useState(false)
const [connectionResult, setConnectionResult] = useState<string>()
const [confirmingClear, setConfirmingClear] = useState(false) const [confirmingClear, setConfirmingClear] = useState(false)
const [clearingLocalData, setClearingLocalData] = useState(false) const [clearingLocalData, setClearingLocalData] = useState(false)
const [detection, setDetection] = useState<AgentRuntimeDetection>() const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false) const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] = useState<SettingsTab>('runtime') const [activeTab, setActiveTab] =
useState<SettingsCategoryId>('runtime')
const [modelType, setModelType] = useState<ModelType>('llm') const [modelType, setModelType] = useState<ModelType>('llm')
const [agentRuntimeType, setAgentRuntimeType] = const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode') useState<AgentRuntimeType>('opencode')
@@ -349,12 +328,16 @@ export function SettingsPanel({
activeTab === 'runtime' || activeTab === 'runtime' ||
activeTab === 'security' || activeTab === 'security' ||
activeTab === 'roles' activeTab === 'roles'
const showContentActions = const categoryRendersOwnHeader =
configurationTab || Boolean(error) || saved activeTab === 'platform-features' ||
activeTab === 'channels' ||
activeTab === 'skills' ||
activeTab === 'mcp' ||
activeTab === 'about'
const handleTabKeyDown = ( const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>, event: React.KeyboardEvent<HTMLButtonElement>,
tab: SettingsTab tab: SettingsCategoryId
): void => { ): void => {
const currentIndex = settingsTabs.indexOf(tab) const currentIndex = settingsTabs.indexOf(tab)
let nextIndex: number | undefined let nextIndex: number | undefined
@@ -374,6 +357,7 @@ export function SettingsPanel({
} }
event.preventDefault() event.preventDefault()
const nextTab = settingsTabs[nextIndex]! const nextTab = settingsTabs[nextIndex]!
setError(undefined)
setActiveTab(nextTab) setActiveTab(nextTab)
event.currentTarget.parentElement event.currentTarget.parentElement
?.querySelector<HTMLButtonElement>( ?.querySelector<HTMLButtonElement>(
@@ -390,8 +374,6 @@ export function SettingsPanel({
.getRuntime() .getRuntime()
.then((value) => { .then((value) => {
setError(undefined) setError(undefined)
setSaved(false)
setConnectionResult(undefined)
setConfirmingClear(false) setConfirmingClear(false)
setClearingLocalData(false) setClearingLocalData(false)
setModelType('llm') setModelType('llm')
@@ -496,10 +478,11 @@ export function SettingsPanel({
onClose() onClose()
} }
const save = async (): Promise<RuntimeSettings | undefined> => { const save = async (
notifySuccess = true
): Promise<RuntimeSettings | undefined> => {
setSaving(true) setSaving(true)
setError(undefined) setError(undefined)
setSaved(false)
try { try {
const defaultProfile = const defaultProfile =
modelProfiles.find( modelProfiles.find(
@@ -605,8 +588,14 @@ export function SettingsPanel({
) )
} }
} }
setSaved(true)
onSaved(value) onSaved(value)
if (notifySuccess) {
onNotify({
tone: 'success',
message: '设置已保存',
dedupeKey: 'runtime-settings-saved'
})
}
return value return value
} catch (reason) { } catch (reason) {
setError(settingsErrorMessage(reason, '保存设置失败')) setError(settingsErrorMessage(reason, '保存设置失败'))
@@ -620,8 +609,7 @@ export function SettingsPanel({
const testingModel = activeTab === 'model' && modelType === 'llm' const testingModel = activeTab === 'model' && modelType === 'llm'
const profileId = selectedModelProfileId const profileId = selectedModelProfileId
setTesting(true) setTesting(true)
setConnectionResult(undefined) const savedSettings = await save(false)
const savedSettings = await save()
if (!savedSettings) { if (!savedSettings) {
setTesting(false) setTesting(false)
return return
@@ -644,11 +632,16 @@ export function SettingsPanel({
if (!status.available) { if (!status.available) {
throw new Error(status.detail) throw new Error(status.detail)
} }
setConnectionResult( onNotify({
status.capability === 'image-generation' tone: 'success',
? status.detail message:
: `连接成功:${status.label}` status.capability === 'image-generation'
) ? status.detail
: `连接成功:${status.label}`,
dedupeKey: testingModel
? 'model-connection-tested'
: `runtime-connection-tested-${agentRuntimeType}`
})
} catch (reason) { } catch (reason) {
setError( setError(
settingsErrorMessage( settingsErrorMessage(
@@ -671,7 +664,7 @@ export function SettingsPanel({
setEmbeddingDiagnostic(undefined) setEmbeddingDiagnostic(undefined)
setError(undefined) setError(undefined)
try { try {
if (!(await save())) { if (!(await save(false))) {
return return
} }
const diagnostic = await embeddings.diagnose() const diagnostic = await embeddings.diagnose()
@@ -692,7 +685,7 @@ export function SettingsPanel({
} }
setError(undefined) setError(undefined)
try { try {
if (!(await save())) { if (!(await save(false))) {
return return
} }
const indexStatus = await embeddings.rebuild() const indexStatus = await embeddings.rebuild()
@@ -993,182 +986,28 @@ export function SettingsPanel({
className="settings-tabs" className="settings-tabs"
role="tablist" role="tablist"
> >
<button {settingsCategoryList.map((category) => (
aria-controls="settings-panel-appearance" <button
aria-label="外观" aria-controls={`settings-panel-${category.id}`}
aria-selected={activeTab === 'appearance'} aria-label={category.label}
id="settings-tab-appearance" aria-selected={activeTab === category.id}
onClick={() => setActiveTab('appearance')} id={`settings-tab-${category.id}`}
onKeyDown={(event) => key={category.id}
handleTabKeyDown(event, 'appearance') onClick={() => {
} setError(undefined)
role="tab" setActiveTab(category.id)
tabIndex={activeTab === 'appearance' ? 0 : -1} }}
type="button" onKeyDown={(event) =>
> handleTabKeyDown(event, category.id)
<strong></strong> }
<small></small> role="tab"
</button> tabIndex={activeTab === category.id ? 0 : -1}
<button type="button"
aria-controls="settings-panel-platform-features" >
aria-label="平台功能" <strong>{category.label}</strong>
aria-selected={activeTab === 'platform-features'} <small>{category.navigationDescription}</small>
id="settings-tab-platform-features" </button>
onClick={() => setActiveTab('platform-features')} ))}
onKeyDown={(event) =>
handleTabKeyDown(event, 'platform-features')
}
role="tab"
tabIndex={activeTab === 'platform-features' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-model"
aria-label="模型连接"
aria-selected={activeTab === 'model'}
id="settings-tab-model"
onClick={() => setActiveTab('model')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'model')
}
role="tab"
tabIndex={activeTab === 'model' ? 0 : -1}
type="button"
>
<strong></strong>
<small>LLM</small>
</button>
<button
aria-controls="settings-panel-runtime"
aria-label="Agent Runtime"
aria-selected={activeTab === 'runtime'}
id="settings-tab-runtime"
onClick={() => setActiveTab('runtime')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'runtime')
}
role="tab"
tabIndex={activeTab === 'runtime' ? 0 : -1}
type="button"
>
<strong>Agent Runtime</strong>
<small>OpenCodeContinue </small>
</button>
<button
aria-controls="settings-panel-security"
aria-label="安全与数据"
aria-selected={activeTab === 'security'}
id="settings-tab-security"
onClick={() => setActiveTab('security')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'security')
}
role="tab"
tabIndex={activeTab === 'security' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-automation"
aria-label="自动化"
aria-selected={activeTab === 'automation'}
id="settings-tab-automation"
onClick={() => setActiveTab('automation')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'automation')
}
role="tab"
tabIndex={activeTab === 'automation' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-channels"
aria-label="消息通道"
aria-selected={activeTab === 'channels'}
id="settings-tab-channels"
onClick={() => setActiveTab('channels')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'channels')
}
role="tab"
tabIndex={activeTab === 'channels' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-roles"
aria-label="角色与提示词"
aria-selected={activeTab === 'roles'}
id="settings-tab-roles"
onClick={() => setActiveTab('roles')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'roles')
}
role="tab"
tabIndex={activeTab === 'roles' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-skills"
aria-label="Skills"
aria-selected={activeTab === 'skills'}
id="settings-tab-skills"
onClick={() => setActiveTab('skills')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'skills')
}
role="tab"
tabIndex={activeTab === 'skills' ? 0 : -1}
type="button"
>
<strong>Skills</strong>
<small></small>
</button>
<button
aria-controls="settings-panel-mcp"
aria-label="MCP"
aria-selected={activeTab === 'mcp'}
id="settings-tab-mcp"
onClick={() => setActiveTab('mcp')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'mcp')
}
role="tab"
tabIndex={activeTab === 'mcp' ? 0 : -1}
type="button"
>
<strong>MCP</strong>
<small></small>
</button>
<button
aria-controls="settings-panel-about"
aria-label="关于与更新"
aria-selected={activeTab === 'about'}
id="settings-tab-about"
onClick={() => setActiveTab('about')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'about')
}
role="tab"
tabIndex={activeTab === 'about' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
</nav> </nav>
<div <div
@@ -1178,52 +1017,43 @@ export function SettingsPanel({
ref={settingsBodyRef} ref={settingsBodyRef}
role="tabpanel" role="tabpanel"
> >
{showContentActions && ( {!categoryRendersOwnHeader && (
<div className="settings-panel__content-toolbar"> <SettingsCategoryHeader
<div className="settings-feedback"> actions={
{error && ( configurationTab ? (
<span className="settings-error" role="alert"> <>
{error} {(activeTab === 'runtime' ||
</span> (activeTab === 'model' && modelType === 'llm')) && (
)} <button
{saved && ( className="secondary-button"
<span className="settings-success" role="status"> disabled={saving || testing}
<Check aria-hidden="true" size={14} /> onClick={() => void testConnection()}
{connectionResult ?? '设置已保存'} type="button"
</span> >
)} {testing
</div> ? '测试中…'
{configurationTab && ( : activeTab === 'model'
<div className="settings-panel__content-actions"> ? '保存并测试模型'
{(activeTab === 'runtime' || : agentRuntimeType === 'opencode'
(activeTab === 'model' && modelType === 'llm')) && ( ? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button>
)}
<button <button
className="secondary-button" className="primary-button"
disabled={saving || testing} disabled={saving || testing}
onClick={() => void testConnection()} onClick={() => void save()}
type="button" type="button"
> >
{testing <Save aria-hidden="true" size={13} />
? '测试中…' {saving ? '保存中…' : '保存设置'}
: activeTab === 'model'
? '保存并测试模型'
: agentRuntimeType === 'opencode'
? '保存并测试 OpenCode'
: '保存并测试 Continue'}
</button> </button>
)} </>
<button ) : undefined
className="primary-button" }
disabled={saving || testing} category={activeTab}
onClick={() => void save()} error={error}
type="button" />
>
<Save aria-hidden="true" size={13} />
{saving ? '保存中…' : '保存设置'}
</button>
</div>
)}
</div>
)} )}
{activeTab === 'appearance' && ( {activeTab === 'appearance' && (
<div className="settings-section appearance-settings"> <div className="settings-section appearance-settings">
@@ -2215,7 +2045,8 @@ export function SettingsPanel({
{activeTab === 'security' && ( {activeTab === 'security' && (
<> <>
<label className="field"> <div className="settings-section">
<label className="field">
<span>Runtime OS </span> <span>Runtime OS </span>
<select <select
aria-label="Runtime OS 沙箱" aria-label="Runtime OS 沙箱"
@@ -2235,8 +2066,8 @@ export function SettingsPanel({
bubblewrap Linux bubblewrap Linux
OpenCode Runtime Continue OpenCode Runtime Continue
</small> </small>
</label> </label>
<label className="field"> <label className="field">
<span></span> <span></span>
<select <select
aria-label="直连模型工具安全策略" aria-label="直连模型工具安全策略"
@@ -2258,7 +2089,8 @@ export function SettingsPanel({
OpenCode OpenCode
Continue 使 Continue 使
</small> </small>
</label> </label>
</div>
<div className="settings-section settings-section--danger"> <div className="settings-section settings-section--danger">
<div> <div>
@@ -2284,8 +2116,6 @@ export function SettingsPanel({
onClick={() => { onClick={() => {
setClearingLocalData(true) setClearingLocalData(true)
setError(undefined) setError(undefined)
setSaved(false)
setConnectionResult(undefined)
void onClearLocalData() void onClearLocalData()
.then(() => { .then(() => {
setConfirmingClear(false) setConfirmingClear(false)
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import {
settingsCategories,
type SettingsCategoryId
} from './settings-categories'
export function SettingsCategoryHeader({
actions,
category,
error,
headingId = `settings-category-${category}`
}: {
actions?: ReactNode
category: SettingsCategoryId
error?: string
headingId?: string
}): React.JSX.Element {
const definition = settingsCategories[category]
return (
<header className="settings-category-header">
<div className="settings-category-header__content">
<h2 id={headingId}>{definition.label}</h2>
<p>{definition.description}</p>
</div>
{actions && (
<div className="settings-category-header__actions">
{actions}
</div>
)}
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
</header>
)
}
+41 -37
View File
@@ -1,10 +1,11 @@
import { BookOpen, Download, Trash2 } from 'lucide-react' import { Download, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { import type {
CapabilityAssignments, CapabilityAssignments,
CapabilitySnapshot, CapabilitySnapshot,
RuntimeTarget RuntimeTarget
} from '../../shared/capability-contracts' } from '../../shared/capability-contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
const runtimeLabels: Record<RuntimeTarget, string> = { const runtimeLabels: Record<RuntimeTarget, string> = {
model: '模型', model: '模型',
@@ -56,46 +57,48 @@ export function SkillsSettingsSection(): React.JSX.Element {
} }
return ( return (
<div className="settings-section"> <>
<div className="settings-section__title settings-section__title--actions"> <SettingsCategoryHeader
<BookOpen size={17} /> actions={
<div> <>
<strong>Skills</strong> <button
<small>OpenCode Continue</small> className="secondary-button"
</div> disabled={Boolean(busy)}
<button onClick={() =>
className="secondary-button" void run('import', () =>
disabled={Boolean(busy)} window.goodbuddy.capabilities.importSkill('directory')
onClick={() => )
void run('import', () => }
window.goodbuddy.capabilities.importSkill('directory') type="button"
) >
} <Download aria-hidden="true" size={14} />
type="button" Skill
> </button>
<Download size={14} /> <button
Skill className="secondary-button"
</button> disabled={Boolean(busy)}
<button onClick={() =>
className="secondary-button" void run('import', () =>
disabled={Boolean(busy)} window.goodbuddy.capabilities.importSkill('zip')
onClick={() => )
void run('import', () => }
window.goodbuddy.capabilities.importSkill('zip') type="button"
) >
} <Download aria-hidden="true" size={14} />
type="button" Skill ZIP
> </button>
<Download size={14} /> </>
Skill ZIP }
</button> category="skills"
</div> error={error}
headingId="skills-settings-heading"
/>
<section aria-label="Skills 列表" className="settings-section">
<p className="settings-notice"> <p className="settings-notice">
Skill Runtime Skill Runtime
Skill OpenCode Continue Skill OpenCode Continue
</p> </p>
{error && <p className="settings-warning">{error}</p>}
{!snapshot && !error && <p className="settings-empty"> Skills</p>} {!snapshot && !error && <p className="settings-empty"> Skills</p>}
<div className="capability-list"> <div className="capability-list">
{snapshot?.skills.map((skill) => ( {snapshot?.skills.map((skill) => (
@@ -174,6 +177,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
</article> </article>
))} ))}
</div> </div>
</div> </section>
</>
) )
} }
+14 -18
View File
@@ -1,10 +1,11 @@
import { ExternalLink, Info, RefreshCw } from 'lucide-react' import { ExternalLink, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { import type {
ApplicationSettings, ApplicationSettings,
VersionCheckResult VersionCheckResult
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type { AppInfo } from '../../shared/contracts' import type { AppInfo } from '../../shared/contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) { if (bytes >= 1024 * 1024 * 1024) {
@@ -106,17 +107,16 @@ export function UpdateSettingsSection(): React.JSX.Element {
} }
return ( return (
<section <>
aria-labelledby="update-settings-heading" <SettingsCategoryHeader
className="settings-section update-settings" category="about"
> error={error}
<div className="settings-section__title"> headingId="update-settings-heading"
<Info aria-hidden="true" size={17} /> />
<div> <section
<strong id="update-settings-heading"></strong> aria-label="更新设置"
<small> GoodBuddy GitHub Release</small> className="settings-section update-settings"
</div> >
</div>
<article className="capability-card"> <article className="capability-card">
<div className="capability-card__header"> <div className="capability-card__header">
@@ -165,11 +165,6 @@ export function UpdateSettingsSection(): React.JSX.Element {
</div> </div>
</article> </article>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
{result && ( {result && (
<article <article
aria-live="polite" aria-live="polite"
@@ -202,6 +197,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
</p> </p>
</article> </article>
)} )}
</section> </section>
</>
) )
} }
+76
View File
@@ -0,0 +1,76 @@
export const settingsCategoryList = [
{
id: 'appearance',
label: '外观',
navigationDescription: '亮色、暗色与系统主题',
description: '亮色、暗色与系统主题'
},
{
id: 'platform-features',
label: '平台功能',
navigationDescription: '功能入口与工作区能力',
description: '控制 GoodBuddy 工作区中显示的功能入口'
},
{
id: 'model',
label: '模型连接',
navigationDescription: 'LLM、向量模型与凭据',
description: 'LLM、向量模型与凭据'
},
{
id: 'runtime',
label: 'Agent Runtime',
navigationDescription: 'OpenCode、Continue 与工作区',
description: 'OpenCode、Continue 与工作区'
},
{
id: 'security',
label: '安全与数据',
navigationDescription: '工具策略与本地隐私',
description: '工具策略与本地隐私'
},
{
id: 'automation',
label: '自动化',
navigationDescription: '智能心跳与周期回顾',
description: '智能心跳与周期回顾'
},
{
id: 'channels',
label: '消息通道',
navigationDescription: '微信、企业微信与钉钉',
description: '配置连接、工作目录、消息处理后端与默认模式'
},
{
id: 'roles',
label: '角色与提示词',
navigationDescription: '角色、说明与系统提示词',
description: '角色、说明与系统提示词'
},
{
id: 'skills',
label: 'Skills',
navigationDescription: '内置与自定义能力',
description: '支持直连模型、OpenCode 和 Continue'
},
{
id: 'mcp',
label: 'MCP',
navigationDescription: '工具服务与凭据',
description: '查看内置工具、内置 MCP,并管理外部 MCP Server'
},
{
id: 'about',
label: '关于与更新',
navigationDescription: '版本检查与下载页',
description: '只检查 GoodBuddy 官方 GitHub Release,不自动下载安装'
}
] as const
export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id']
export const settingsCategories = Object.fromEntries(
settingsCategoryList.map((category) => [category.id, category])
) as Record<SettingsCategoryId, SettingsCategoryDefinition>
+37 -22
View File
@@ -4065,15 +4065,34 @@ button > svg * {
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.settings-panel__content-toolbar { .settings-category-header {
display: flex; display: grid;
min-height: var(--control-height); min-width: 0;
align-items: center; align-items: start;
justify-content: flex-end; grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-3); gap: var(--space-3) var(--space-4);
} }
.settings-panel__content-actions { .settings-category-header__content {
min-width: 0;
}
.settings-category-header__content h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
font-weight: 650;
line-height: 1.4;
}
.settings-category-header__content p {
margin: var(--space-1) 0 0;
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.settings-category-header__actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@@ -4081,13 +4100,19 @@ button > svg * {
gap: var(--space-2); gap: var(--space-2);
} }
.settings-panel__content-actions .primary-button { .settings-category-header__actions .primary-button,
.settings-category-header__actions .secondary-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-2); gap: var(--space-2);
} }
.settings-category-header > .settings-warning {
margin: 0;
grid-column: 1 / -1;
}
.settings-page .settings-tabs button { .settings-page .settings-tabs button {
min-height: 58px; min-height: 58px;
padding: 10px 12px; padding: 10px 12px;
@@ -5567,12 +5592,6 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--success) !important; color: var(--success) !important;
} }
.settings-feedback {
flex: 1;
min-width: 0;
font-size: 10px;
}
.settings-error { .settings-error {
color: var(--danger); color: var(--danger);
} }
@@ -8773,17 +8792,13 @@ details.settings-section > :not(summary) + :not(summary) {
flex-basis: 100%; flex-basis: 100%;
} }
.settings-panel__content-toolbar { .settings-category-header {
align-items: stretch; grid-template-columns: 1fr;
flex-wrap: wrap;
} }
.settings-feedback { .settings-category-header__actions {
flex-basis: 100%;
}
.settings-panel__content-actions {
width: 100%; width: 100%;
justify-content: flex-start;
} }
.mcp-editor { .mcp-editor {