Skip to content

Commit da0a21a

Browse files
committed
fix: guard settings category render errors
1 parent 60d8211 commit da0a21a

3 files changed

Lines changed: 100 additions & 6 deletions

File tree

apps/dsa-web/src/pages/SettingsPage.tsx

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from 'react';
2-
import { useEffect, useRef, useState } from 'react';
2+
import { Component, useEffect, useRef, useState } from 'react';
33
import { useAuth, useSystemConfig } from '../hooks';
44
import { createParsedApiError, getParsedApiError, type ParsedApiError } from '../api/error';
55
import { systemConfigApi } from '../api/systemConfig';
@@ -17,7 +17,7 @@ import {
1717
SettingsSectionCard,
1818
} from '../components/settings';
1919
import { WEB_BUILD_INFO } from '../utils/constants';
20-
import { getCategoryDescriptionZh } from '../utils/systemConfigI18n';
20+
import { getCategoryDescriptionZh, getCategoryTitleZh } from '../utils/systemConfigI18n';
2121
import type { SystemConfigCategory } from '../types/systemConfig';
2222

2323
type DesktopWindow = Window & {
@@ -71,6 +71,64 @@ type DesktopUpdateNotice = {
7171
actionKind?: 'release' | 'install';
7272
};
7373

74+
type SettingsContentErrorBoundaryProps = {
75+
categoryTitle: string;
76+
resetKey: string;
77+
children: React.ReactNode;
78+
};
79+
80+
type SettingsContentErrorBoundaryState = {
81+
hasError: boolean;
82+
message: string;
83+
};
84+
85+
class SettingsContentErrorBoundary extends Component<
86+
SettingsContentErrorBoundaryProps,
87+
SettingsContentErrorBoundaryState
88+
> {
89+
state: SettingsContentErrorBoundaryState = {
90+
hasError: false,
91+
message: '',
92+
};
93+
94+
static getDerivedStateFromError(error: unknown): SettingsContentErrorBoundaryState {
95+
return {
96+
hasError: true,
97+
message: error instanceof Error ? error.message : 'Unknown settings render error',
98+
};
99+
}
100+
101+
componentDidUpdate(previousProps: SettingsContentErrorBoundaryProps) {
102+
if (previousProps.resetKey !== this.props.resetKey && this.state.hasError) {
103+
this.setState({ hasError: false, message: '' });
104+
}
105+
}
106+
107+
componentDidCatch(error: unknown, info: unknown) {
108+
console.error('Settings category render failed', error, info);
109+
}
110+
111+
render() {
112+
if (!this.state.hasError) {
113+
return this.props.children;
114+
}
115+
116+
const detail = this.state.message ? `错误信息:${this.state.message}。` : '';
117+
return (
118+
<SettingsSectionCard
119+
title="当前分类加载失败"
120+
description="该设置分类出现运行时异常,页面已保留其它导航与操作。"
121+
>
122+
<SettingsAlert
123+
title={`${this.props.categoryTitle} 设置暂时无法显示`}
124+
message={`${detail}请记录当前点击的设置分类,并提供 release 版本、Windows 版本和 desktop.log 继续排查。`}
125+
variant="error"
126+
/>
127+
</SettingsSectionCard>
128+
);
129+
}
130+
}
131+
74132
function trimDesktopRuntimeString(value: unknown) {
75133
return typeof value === 'string' ? value.trim() : '';
76134
}
@@ -308,7 +366,12 @@ const SettingsPage: React.FC = () => {
308366
};
309367
}, [canCheckDesktopUpdate, desktopRuntimeApi]);
310368

311-
const rawActiveItems = itemsByCategory[activeCategory] || [];
369+
const safeCategories = Array.isArray(categories) ? categories : [];
370+
const safeItemsByCategory =
371+
itemsByCategory && typeof itemsByCategory === 'object' ? itemsByCategory : {};
372+
const rawActiveItems = Array.isArray(safeItemsByCategory[activeCategory])
373+
? safeItemsByCategory[activeCategory]
374+
: [];
312375
const rawActiveItemMap = new Map(rawActiveItems.map((item) => [item.key, String(item.value ?? '')]));
313376
const hasConfiguredChannels = Boolean((rawActiveItemMap.get('LLM_CHANNELS') || '').trim());
314377
const hasLitellmConfig = Boolean((rawActiveItemMap.get('LITELLM_CONFIG') || '').trim());
@@ -365,6 +428,7 @@ const SettingsPage: React.FC = () => {
365428
: rawActiveItems;
366429
const isEnvBackupAllowed = isDesktopRuntime || authEnabled;
367430
const envBackupActionDisabled = isLoading || isSaving || isExportingEnv || isImportingEnv || !isEnvBackupAllowed;
431+
const activeCategoryTitle = getCategoryTitleZh(activeCategory as SystemConfigCategory, activeCategory);
368432

369433
const downloadEnvBackup = async () => {
370434
setEnvBackupActionError(null);
@@ -554,14 +618,18 @@ const SettingsPage: React.FC = () => {
554618
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[280px_1fr]">
555619
<aside className="lg:sticky lg:top-4 lg:self-start">
556620
<SettingsCategoryNav
557-
categories={categories}
558-
itemsByCategory={itemsByCategory}
621+
categories={safeCategories}
622+
itemsByCategory={safeItemsByCategory}
559623
activeCategory={activeCategory}
560624
onSelect={setActiveCategory}
561625
/>
562626
</aside>
563627

564628
<section className="space-y-4">
629+
<SettingsContentErrorBoundary
630+
categoryTitle={activeCategoryTitle}
631+
resetKey={activeCategory}
632+
>
565633
{activeCategory === 'system' ? <AuthSettingsCard /> : null}
566634
{activeCategory === 'system' ? (
567635
<SettingsSectionCard
@@ -783,6 +851,7 @@ const SettingsPage: React.FC = () => {
783851
className="settings-surface-panel settings-border-strong border-none bg-transparent shadow-none"
784852
/>
785853
)}
854+
</SettingsContentErrorBoundary>
786855
</section>
787856
</div>
788857
)}

apps/dsa-web/src/pages/__tests__/SettingsPage.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
applyPartialUpdate,
2222
refreshAfterExternalSave,
2323
refreshStatus,
24+
settingsFieldThrowKey,
2425
useAuthMock,
2526
useSystemConfigMock,
2627
webBuildInfoMock,
@@ -41,6 +42,7 @@ const {
4142
applyPartialUpdate: vi.fn(),
4243
refreshAfterExternalSave: vi.fn(),
4344
refreshStatus: vi.fn(),
45+
settingsFieldThrowKey: { value: '' },
4446
useAuthMock: vi.fn(),
4547
useSystemConfigMock: vi.fn(),
4648
webBuildInfoMock: {
@@ -139,7 +141,12 @@ vi.mock('../../components/settings', () => ({
139141
))}
140142
</nav>
141143
),
142-
SettingsField: ({ item }: { item: { key: string } }) => <div>{item.key}</div>,
144+
SettingsField: ({ item }: { item: { key: string } }) => {
145+
if (settingsFieldThrowKey.value === item.key) {
146+
throw new Error(`boom:${item.key}`);
147+
}
148+
return <div>{item.key}</div>;
149+
},
143150
SettingsLoading: () => <div>loading</div>,
144151
SettingsSectionCard: ({
145152
title,
@@ -383,6 +390,7 @@ describe('SettingsPage', () => {
383390
refreshStatus,
384391
});
385392
useSystemConfigMock.mockReturnValue(buildSystemConfigState());
393+
settingsFieldThrowKey.value = '';
386394
delete (window as { dsaDesktop?: unknown }).dsaDesktop;
387395
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
388396
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
@@ -635,6 +643,22 @@ describe('SettingsPage', () => {
635643
expect(screen.getByText('WECHAT_WEBHOOK_URL')).toBeInTheDocument();
636644
});
637645

646+
it('keeps settings page usable when a category field throws during render', () => {
647+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
648+
settingsFieldThrowKey.value = 'WECHAT_WEBHOOK_URL';
649+
useSystemConfigMock.mockReturnValue(buildSystemConfigState({ activeCategory: 'notification' }));
650+
651+
render(<SettingsPage />);
652+
653+
expect(screen.getByRole('heading', { name: '系统设置' })).toBeInTheDocument();
654+
expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
655+
expect(screen.getByText(/ /)).toBeInTheDocument();
656+
expect(screen.getByText(/desktop\.log/)).toBeInTheDocument();
657+
expect(screen.getByText(/boom:WECHAT_WEBHOOK_URL/)).toBeInTheDocument();
658+
659+
consoleErrorSpy.mockRestore();
660+
});
661+
638662
it('renders env backup actions outside desktop runtime', () => {
639663
render(<SettingsPage />);
640664

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
4040
- [修复] 修正分析报告 API 构建策略点位时数值字段未归一为字符串的问题,避免策略价格触发响应 DTO 类型校验失败。
4141
- [修复] Docker 启动入口自动修复 `data` / `logs` / `reports` 挂载目录权限并降权运行,文档化的 Compose `exec` 手动命令显式使用 `dsa` 用户,避免普通部署需要手动 `chown` / `chmod`
4242
- [修复] Web 首页大盘复盘结果改由主内容滚动区承载,避免 loading 切换到长结果后下方报告区域被截断或无法继续滚动。
43+
- [修复] Web/桌面设置页为当前分类增加局部运行时异常兜底,避免通知或 Agent 设置渲染异常时整页黑屏,并提示收集桌面端日志。
4344

4445
## [3.16.0] - 2026-05-10
4546

0 commit comments

Comments
 (0)