feat(settings): 统一设置中心内页与代理交互

- 将主题设置、AI 设置和关于页纳入设置中心详情页,补齐返回和取消交互

- 重构全局代理设置为草稿应用模式,新增常用预设、URL 测试入口和测试结果展示

- 按参考样式重做关于页版本与项目入口布局,并收紧顶部品牌卡尺寸

- 提取 AISettingsContent 供设置中心内嵌复用,控制详情页内部滚动边界

- 补齐多语言文案和设置中心静态回归断言
This commit is contained in:
Syngnat
2026-07-08 17:37:05 +08:00
parent b94168957e
commit da895ea502
10 changed files with 2446 additions and 1195 deletions

View File

@@ -7,10 +7,15 @@ const appSource = readFileSync(
'utf8',
);
const aiSettingsModalSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/AISettingsModal.tsx', import.meta.url)),
'utf8',
);
describe('settings center layout', () => {
it('uses the same split navigation shell as the tool center', () => {
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';");
expect(appSource).toContain("type SettingsCenterPaneKey = 'language' | 'sidebar-metadata' | 'proxy' | 'web-auth';");
expect(appSource).toContain("type SettingsCenterPaneKey = 'language' | 'theme' | 'sidebar-metadata' | 'proxy' | 'web-auth' | 'ai' | 'about-go-navi';");
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
@@ -49,4 +54,64 @@ describe('settings center layout', () => {
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'web-auth')");
expect(appSource).toContain("<WebAuthSettingsPanel");
});
it('adds global proxy connection testing controls', () => {
expect(appSource).toContain("const DEFAULT_GLOBAL_PROXY_TEST_URL = 'https://api.github.com/';");
expect(appSource).toContain('const [proxyTestUrl, setProxyTestUrl]');
expect(appSource).toContain('const [proxyTesting, setProxyTesting]');
expect(appSource).toContain('const [proxyTestResult, setProxyTestResult]');
expect(appSource).toContain('handleTestGlobalProxyDraft');
expect(appSource).toContain('TestGlobalProxyConnection');
expect(appSource).toContain('https://github.com/Syngnat/GoNavi/releases/latest');
expect(appSource).toContain("t('app.proxy.test.action')");
expect(appSource).toContain("t('app.proxy.test.target_placeholder')");
});
it('adds cancel and back actions to settings center detail panes', () => {
expect(appSource).toContain('const handleBackFromSettingsCenterPane = useCallback(() => {');
expect(appSource).toContain('const handleCancelSettingsCenterPane = useCallback(() => {');
expect(appSource).toContain('onClick={handleCancelSettingsCenterPane}');
expect(appSource).toContain("t('common.cancel')");
expect(appSource).toContain('onClick={handleBackFromSettingsCenterPane}');
expect(appSource).toContain("t('common.back_to_previous')");
});
it('opens theme, AI, and about entries inside settings center detail panes', () => {
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'theme')");
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'ai')");
expect(appSource).toContain("handleOpenSettingsCenterPane('about', 'about-go-navi')");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'theme')");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'ai')");
expect(appSource).toContain('<AISettingsContent');
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'about-go-navi')");
expect(appSource).toContain('renderSettingsCenterAboutPane()');
});
it('renders the settings center about page with the reference card layout', () => {
expect(appSource).toContain('const renderSettingsCenterAboutPane = () => {');
expect(appSource).toContain('const renderSettingsCenterAboutProjectEntry = ({');
expect(appSource).toContain("padding: '18px 22px'");
expect(appSource).toContain('width: 64');
expect(appSource).toContain('height: 64');
expect(appSource).toContain('minWidth: 260');
expect(appSource).toContain("t('app.about.version_update.title')");
expect(appSource).toContain("t('app.about.project.github.title')");
expect(appSource).toContain("t('app.about.project.issues.title')");
expect(appSource).toContain("t('app.about.project.releases.title')");
expect(appSource).toContain('const renderSettingsCenterAboutFooter = () => (');
expect(appSource).toContain("t('app.about.last_checked_at', { time: aboutLastCheckedAt })");
expect(appSource).toContain('renderAboutUpdateActions()');
});
it('keeps embedded split-pane settings stable at scroll boundaries', () => {
expect(appSource).toContain('const isSettingsCenterContainedScrollPane =');
expect(appSource).toContain("activeSettingsCenterPane?.key === 'theme' || activeSettingsCenterPane?.key === 'ai'");
expect(appSource).toContain('const settingsCenterDetailBodyStyle: React.CSSProperties = isSettingsCenterContainedScrollPane');
expect(appSource).toContain("overflowY: 'hidden'");
expect(appSource).toContain('style={settingsCenterDetailBodyStyle}');
expect(appSource).toContain("boxSizing: 'border-box'");
expect(appSource).toContain("overscrollBehavior: 'contain'");
expect(aiSettingsModalSource).toContain("boxSizing: 'border-box'");
expect(aiSettingsModalSource).toContain("overscrollBehavior: 'contain'");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,14 @@ interface AISettingsModalProps {
onBeforeExternalMCPUse?: () => Promise<void>;
}
export interface AISettingsContentProps {
active: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
onBeforeExternalMCPUse?: () => Promise<void>;
}
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
running: false,
addr: '127.0.0.1:8765',
@@ -80,7 +88,7 @@ const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
};
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => {
export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => {
const { t } = useI18n();
const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({
...DEFAULT_MCP_HTTP_SERVER_STATUS,
@@ -251,16 +259,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
} catch (e) { console.warn('Failed to load AI config', e); }
}, [defaultMCPHTTPServerStatus, resolveAIService, syncMCPClientStatuses]);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => { if (active) void loadConfig(); }, [active, loadConfig]);
useEffect(() => {
if (open) {
if (active) {
resetMCPClientSelectionTouched();
}
}, [open, resetMCPClientSelectionTouched]);
}, [active, resetMCPClientSelectionTouched]);
useEffect(() => {
if (!open || !focusProviderId) {
if (!active || !focusProviderId) {
return;
}
if (!providers.some((provider) => provider.id === focusProviderId)) {
@@ -268,7 +276,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}
setActiveSection('providers');
setActiveProviderId(focusProviderId);
}, [focusProviderId, open, providers]);
}, [active, focusProviderId, providers]);
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
setEditingProvider(session.editingProvider as AIProviderConfig | null);
@@ -285,16 +293,11 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
applyProviderEditorSession(buildClosedProviderEditorSession());
}, [applyProviderEditorSession]);
const handleModalClose = useCallback(() => {
resetProviderEditorSession();
onClose();
}, [onClose, resetProviderEditorSession]);
useEffect(() => {
if (!open) {
if (!active) {
resetProviderEditorSession();
}
}, [open, resetProviderEditorSession]);
}, [active, resetProviderEditorSession]);
const handleAddProvider = () => {
const preset = findPreset('openai');
applyProviderEditorSession(buildAddProviderEditorSession({
@@ -707,6 +710,148 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
});
};
return (
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative', boxSizing: 'border-box' }}>
{messageContextHolder}
<AISettingsSidebar
activeSection={activeSection}
darkMode={darkMode}
overlayTheme={overlayTheme}
onSelectSection={setActiveSection}
/>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (
<AISettingsProvidersSection
providers={providers}
activeProviderId={activeProviderId}
editingProvider={editingProvider}
isEditing={isEditing}
form={form}
providerPresets={localizedProviderPresets}
watchedPresetKey={watchedPresetKey}
watchedApiFormat={watchedApiFormat}
loading={loading}
testStatus={testStatus}
primaryPasswordVisible={primaryPasswordVisible}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
resolveProviderPreset={matchLocalizedProviderPreset}
resolvePresetByKey={findLocalizedPreset}
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
onSetActiveProvider={handleSetActive}
onCancelEdit={resetProviderEditorSession}
onPresetChange={handlePresetChange}
onTestProvider={handleTestProvider}
onSaveProvider={handleSaveProvider}
/>
)}
{activeSection === 'safety' && (
<AISettingsSafetySection
safetyLevel={safetyLevel}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
onChange={handleSafetyChange}
/>
)}
{activeSection === 'context' && (
<AISettingsContextSection
contextLevel={contextLevel}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
onChange={handleContextChange}
/>
)}
{activeSection === 'mcp' && (
<AISettingsMCPSection
mcpClientStatuses={mcpClientStatuses}
selectedMCPClient={selectedMCPClient}
selectedMCPClientStatus={selectedMCPClientStatus}
selectedMCPClientCommandText={selectedMCPClientCommandText}
mcpHTTPServerStatus={mcpHTTPServerStatus}
mcpHTTPServerDraft={mcpHTTPServerDraft}
mcpServers={mcpServers}
mcpTools={mcpTools}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
mcpHTTPServerLoading={mcpHTTPServerLoading}
onUpdateHTTPServerDraft={handleUpdateMCPHTTPServerDraft}
onToggleHTTPServer={handleToggleMCPHTTPServer}
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
onSelectClient={handleSelectMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
onInstallSelectedClient={handleInstallSelectedMCPClient}
onAddServer={handleAddMCPServer}
onUpdateServerDraft={updateMCPServerDraft}
onTestServer={handleTestMCPServer}
onSaveServer={handleSaveMCPServer}
onDeleteServer={handleDeleteMCPServer}
/>
)}
{activeSection === 'skills' && (
<AISettingsSkillsSection
skills={skills}
skillRequiredToolOptions={skillRequiredToolOptions}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
onAddSkill={handleAddSkill}
onUpdateSkillDraft={updateSkillDraft}
onSaveSkill={handleSaveSkill}
onDeleteSkill={handleDeleteSkill}
/>
)}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
/>
)}
{activeSection === 'prompts' && (
<AISettingsPromptsSection
builtinPrompts={builtinPrompts}
userPromptSettings={userPromptSettings}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
loading={loading}
onChangeUserPrompt={(key, value) => setUserPromptSettings((prev) => ({
...prev,
[key]: value,
}))}
onSave={handleSaveUserPromptSettings}
/>
)}
</div>
</div>
);
};
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => {
const { t } = useI18n();
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
@@ -731,7 +876,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
}
open={open}
onCancel={handleModalClose}
onCancel={onClose}
footer={null}
width={820}
styles={{
@@ -740,142 +885,13 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
}}
>
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
{messageContextHolder}
<AISettingsSidebar
activeSection={activeSection}
darkMode={darkMode}
overlayTheme={overlayTheme}
onSelectSection={setActiveSection}
/>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (
<AISettingsProvidersSection
providers={providers}
activeProviderId={activeProviderId}
editingProvider={editingProvider}
isEditing={isEditing}
form={form}
providerPresets={localizedProviderPresets}
watchedPresetKey={watchedPresetKey}
watchedApiFormat={watchedApiFormat}
loading={loading}
testStatus={testStatus}
primaryPasswordVisible={primaryPasswordVisible}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
resolveProviderPreset={matchLocalizedProviderPreset}
resolvePresetByKey={findLocalizedPreset}
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
onSetActiveProvider={handleSetActive}
onCancelEdit={resetProviderEditorSession}
onPresetChange={handlePresetChange}
onTestProvider={handleTestProvider}
onSaveProvider={handleSaveProvider}
/>
)}
{activeSection === 'safety' && (
<AISettingsSafetySection
safetyLevel={safetyLevel}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
onChange={handleSafetyChange}
/>
)}
{activeSection === 'context' && (
<AISettingsContextSection
contextLevel={contextLevel}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
onChange={handleContextChange}
/>
)}
{activeSection === 'mcp' && (
<AISettingsMCPSection
mcpClientStatuses={mcpClientStatuses}
selectedMCPClient={selectedMCPClient}
selectedMCPClientStatus={selectedMCPClientStatus}
selectedMCPClientCommandText={selectedMCPClientCommandText}
mcpHTTPServerStatus={mcpHTTPServerStatus}
mcpHTTPServerDraft={mcpHTTPServerDraft}
mcpServers={mcpServers}
mcpTools={mcpTools}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
mcpHTTPServerLoading={mcpHTTPServerLoading}
onUpdateHTTPServerDraft={handleUpdateMCPHTTPServerDraft}
onToggleHTTPServer={handleToggleMCPHTTPServer}
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
onSelectClient={handleSelectMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
onInstallSelectedClient={handleInstallSelectedMCPClient}
onAddServer={handleAddMCPServer}
onUpdateServerDraft={updateMCPServerDraft}
onTestServer={handleTestMCPServer}
onSaveServer={handleSaveMCPServer}
onDeleteServer={handleDeleteMCPServer}
/>
)}
{activeSection === 'skills' && (
<AISettingsSkillsSection
skills={skills}
skillRequiredToolOptions={skillRequiredToolOptions}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
onAddSkill={handleAddSkill}
onUpdateSkillDraft={updateSkillDraft}
onSaveSkill={handleSaveSkill}
onDeleteSkill={handleDeleteSkill}
/>
)}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
/>
)}
{activeSection === 'prompts' && (
<AISettingsPromptsSection
builtinPrompts={builtinPrompts}
userPromptSettings={userPromptSettings}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
loading={loading}
onChangeUserPrompt={(key, value) => setUserPromptSettings((prev) => ({
...prev,
[key]: value,
}))}
onSave={handleSaveUserPromptSettings}
/>
)}
</div>
</div>
<AISettingsContent
active={open}
darkMode={darkMode}
overlayTheme={overlayTheme}
focusProviderId={focusProviderId}
onBeforeExternalMCPUse={onBeforeExternalMCPUse}
/>
</Modal>
);
};

View File

@@ -344,6 +344,11 @@ describe("i18n catalog", () => {
"data_grid.column_settings.show_all",
"data_grid.column_settings.hide_all",
"data_grid.column_settings.search_columns_placeholder",
"data_grid.column_settings.global_hidden_columns",
"data_grid.column_settings.global_hidden_columns_help",
"data_grid.column_settings.global_hidden_columns_apply",
"data_grid.column_settings.global_hidden_columns_add_current",
"data_grid.column_settings.global_hidden_columns_clear",
"data_grid.column_settings.remember_column_order",
"data_grid.column_settings.remember_hidden_columns",
"data_grid.column_settings.reset_order",