Skip to content

Commit 05b4b85

Browse files
Activer007EchoingFootsteps
authored andcommitted
feat: [issue ZhuLinsen#1199 PR1] add settings field help dialog infrastructure (ZhuLinsen#1204)
* feat: add settings field help infrastructure * fix: avoid online fallback in bot name routing test Resolve natural-language stock-name candidates through deterministic local partial matches before invoking the broader name resolver. This keeps common aliases like 茅台 on the fast local path and prevents offline CI from waiting on AkShare network fallback. Guard the async dispatcher test with an assertion that AkShare fallback is not called for the local alias case. * test: isolate schedule time provider failure case The schedule-time provider failure test could fail when SCHEDULE_TIME was present in the process environment before importing main. In that case _INITIAL_PROCESS_ENV marks it as an explicit override, the provider returns the env value, and ConfigManager.read_config_map is never called, so the expected RuntimeError is not raised. Patch _INITIAL_PROCESS_ENV in the test to model the intended no-process-override scenario and keep the assertion independent of the shell environment used by scripts/ci_gate.sh. * fix: improve settings help dialog accessibility Trap keyboard focus inside the settings help dialog while it is open and return focus to the trigger on close. Keep the backdrop click target out of the tab order and cover the focus loop behavior in the settings field test. * feat: add help entry and multilingual support for system settings page * feat: add maintenance guidelines for settings help documentation * fix: clarify WebUI bind settings and toast visibility * chore: remove trailing blank line from settings help
1 parent 7a4872a commit 05b4b85

19 files changed

Lines changed: 862 additions & 17 deletions

api/v1/schemas/system_config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ class SystemConfigOption(BaseModel):
3030
value: str
3131

3232

33+
class SystemConfigDocLink(BaseModel):
34+
"""Documentation link metadata for field help panels."""
35+
36+
label: str
37+
href: str
38+
39+
3340
class SystemConfigFieldSchema(BaseModel):
3441
"""Metadata schema for a single config field."""
3542

@@ -46,6 +53,10 @@ class SystemConfigFieldSchema(BaseModel):
4653
options: List[str | SystemConfigOption] = Field(default_factory=list)
4754
validation: Dict[str, Any] = Field(default_factory=dict)
4855
display_order: int
56+
help_key: Optional[str] = Field(None, description="Stable localization key for detailed help content")
57+
examples: List[str] = Field(default_factory=list, description="Safe example values for help panels")
58+
docs: List[SystemConfigDocLink] = Field(default_factory=list, description="Related documentation links")
59+
warning_codes: List[str] = Field(default_factory=list, description="Stable warning identifiers for help panels")
4960

5061

5162
class SystemConfigCategorySchema(BaseModel):

apps/dsa-web/src/components/settings/SettingsAlert.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type React from 'react';
22
import { Button, InlineAlert } from '../common';
3+
import { cn } from '../../utils/cn';
34

45
interface SettingsAlertProps {
56
title: string;
67
message: string;
78
variant?: 'error' | 'success' | 'warning';
9+
presentation?: 'inline' | 'toast';
810
actionLabel?: string;
911
onAction?: () => void;
1012
className?: string;
@@ -16,20 +18,35 @@ const variantMap: Record<NonNullable<SettingsAlertProps['variant']>, 'danger' |
1618
warning: 'warning',
1719
};
1820

21+
const toastHighlightStyle = [
22+
'relative overflow-hidden bg-card/95 text-foreground shadow-soft-card-strong backdrop-blur-sm',
23+
'before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1.5',
24+
'before:bg-gradient-to-r before:from-cyan/80 before:via-primary/70 before:to-purple/70',
25+
].join(' ');
26+
27+
const toastVariantStyles: Record<NonNullable<SettingsAlertProps['variant']>, string> = {
28+
error: toastHighlightStyle,
29+
success: toastHighlightStyle,
30+
warning: toastHighlightStyle,
31+
};
32+
1933
export const SettingsAlert: React.FC<SettingsAlertProps> = ({
2034
title,
2135
message,
2236
variant = 'error',
37+
presentation = 'inline',
2338
actionLabel,
2439
onAction,
2540
className = '',
2641
}) => {
42+
const presentationClassName = presentation === 'toast' ? toastVariantStyles[variant] : '';
43+
2744
return (
2845
<InlineAlert
2946
title={title}
3047
message={message}
3148
variant={variantMap[variant]}
32-
className={className}
49+
className={cn(presentationClassName, className)}
3350
action={actionLabel && onAction ? (
3451
<Button
3552
type="button"

apps/dsa-web/src/components/settings/SettingsField.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState } from 'react';
22
import type React from 'react';
3-
import { Badge, Button, Select, Input, Tooltip } from '../common';
3+
import { Badge, Button, Select, Input } from '../common';
44
import type { ConfigValidationIssue, SystemConfigFieldSchema, SystemConfigItem } from '../../types/systemConfig';
55
import { getFieldDescriptionZh, getFieldTitleZh } from '../../utils/systemConfigI18n';
66
import { cn } from '../../utils/cn';
7+
import { SettingsHelpButton } from './SettingsHelpButton';
78

89
function normalizeSelectOptions(options: SystemConfigFieldSchema['options'] = []) {
910
return options.map((option) => {
@@ -215,6 +216,12 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
215216
<label className="text-sm font-semibold text-foreground" htmlFor={controlId}>
216217
{title}
217218
</label>
219+
<SettingsHelpButton
220+
fieldKey={item.key}
221+
title={title}
222+
schema={schema}
223+
description={description}
224+
/>
218225
{schema?.isSensitive ? (
219226
<Badge variant="history" size="sm">
220227
敏感
@@ -228,11 +235,9 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
228235
</div>
229236

230237
{description ? (
231-
<Tooltip content={description}>
232-
<p className="mb-3 inline-flex max-w-full text-xs leading-5 text-muted-text">
233-
{description}
234-
</p>
235-
</Tooltip>
238+
<p className="mb-3 max-w-full text-xs leading-5 text-muted-text">
239+
{description}
240+
</p>
236241
) : null}
237242

238243
<div>
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { CircleHelp, ExternalLink, X } from 'lucide-react';
2+
import { useEffect, useId, useRef, useState } from 'react';
3+
import type React from 'react';
4+
import { createPortal } from 'react-dom';
5+
import type { SystemConfigFieldSchema } from '../../types/systemConfig';
6+
import { getSettingsHelpContent } from '../../locales/settingsHelp';
7+
import { cn } from '../../utils/cn';
8+
import { Tooltip } from '../common';
9+
10+
interface SettingsHelpButtonProps {
11+
fieldKey: string;
12+
title: string;
13+
schema?: SystemConfigFieldSchema;
14+
description?: string;
15+
}
16+
17+
const FOCUSABLE_SELECTOR = [
18+
'a[href]',
19+
'button:not([disabled])',
20+
'textarea:not([disabled])',
21+
'input:not([disabled])',
22+
'select:not([disabled])',
23+
'[tabindex]:not([tabindex="-1"])',
24+
].join(',');
25+
26+
function getFocusableElements(container: HTMLElement): HTMLElement[] {
27+
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
28+
}
29+
30+
function hasItems<T>(items: T[] | undefined): items is T[] {
31+
return Boolean(items?.length);
32+
}
33+
34+
function HelpSection({
35+
title,
36+
children,
37+
}: {
38+
title: string;
39+
children: React.ReactNode;
40+
}) {
41+
if (!children) {
42+
return null;
43+
}
44+
45+
return (
46+
<section className="space-y-2">
47+
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-text">{title}</h3>
48+
{children}
49+
</section>
50+
);
51+
}
52+
53+
function HelpList({ items }: { items?: string[] }) {
54+
if (!hasItems(items)) {
55+
return null;
56+
}
57+
58+
return (
59+
<ul className="space-y-1.5 text-sm leading-6 text-secondary-text">
60+
{items.map((item) => (
61+
<li className="flex gap-2" key={item}>
62+
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-cyan/70" />
63+
<span>{item}</span>
64+
</li>
65+
))}
66+
</ul>
67+
);
68+
}
69+
70+
function CodeExamples({ examples }: { examples?: string[] }) {
71+
if (!hasItems(examples)) {
72+
return null;
73+
}
74+
75+
return (
76+
<div className="space-y-2">
77+
{examples.map((example) => (
78+
<code
79+
className="block whitespace-pre-wrap break-words rounded-lg border border-border/70 bg-background/70 px-3 py-2 font-mono text-xs leading-5 text-foreground"
80+
key={example}
81+
>
82+
{example}
83+
</code>
84+
))}
85+
</div>
86+
);
87+
}
88+
89+
export const SettingsHelpButton: React.FC<SettingsHelpButtonProps> = ({
90+
fieldKey,
91+
title,
92+
schema,
93+
description,
94+
}) => {
95+
const help = getSettingsHelpContent(schema?.helpKey, description);
96+
const [open, setOpen] = useState(false);
97+
const buttonRef = useRef<HTMLButtonElement | null>(null);
98+
const dialogRef = useRef<HTMLDivElement | null>(null);
99+
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
100+
const titleId = useId();
101+
const examples = schema?.examples ?? [];
102+
const docs = schema?.docs?.length ? schema.docs : help?.docs ?? [];
103+
104+
useEffect(() => {
105+
if (!open) {
106+
return;
107+
}
108+
109+
const focusDialogStart = () => {
110+
closeButtonRef.current?.focus();
111+
};
112+
113+
const handleKeyDown = (event: KeyboardEvent) => {
114+
if (event.key === 'Escape') {
115+
setOpen(false);
116+
return;
117+
}
118+
119+
if (event.key !== 'Tab') {
120+
return;
121+
}
122+
123+
const dialog = dialogRef.current;
124+
if (!dialog) {
125+
return;
126+
}
127+
128+
const focusableElements = getFocusableElements(dialog);
129+
if (!focusableElements.length) {
130+
event.preventDefault();
131+
dialog.focus();
132+
return;
133+
}
134+
135+
const firstElement = focusableElements[0];
136+
const lastElement = focusableElements[focusableElements.length - 1];
137+
const activeElement = document.activeElement;
138+
139+
if (event.shiftKey) {
140+
if (!activeElement || !dialog.contains(activeElement) || activeElement === firstElement) {
141+
event.preventDefault();
142+
lastElement.focus();
143+
}
144+
return;
145+
}
146+
147+
if (!activeElement || !dialog.contains(activeElement) || activeElement === lastElement) {
148+
event.preventDefault();
149+
firstElement.focus();
150+
}
151+
};
152+
153+
document.addEventListener('keydown', handleKeyDown);
154+
const previousOverflow = document.body.style.overflow;
155+
const triggerButton = buttonRef.current;
156+
document.body.style.overflow = 'hidden';
157+
focusDialogStart();
158+
159+
return () => {
160+
document.removeEventListener('keydown', handleKeyDown);
161+
document.body.style.overflow = previousOverflow;
162+
triggerButton?.focus();
163+
};
164+
}, [open]);
165+
166+
if (!help) {
167+
return null;
168+
}
169+
170+
return (
171+
<>
172+
<Tooltip content="查看配置说明">
173+
<span className="inline-flex">
174+
<button
175+
ref={buttonRef}
176+
type="button"
177+
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-transparent text-muted-text transition-colors hover:border-[var(--settings-border)] hover:bg-[var(--settings-surface-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15"
178+
aria-label={`查看 ${title} 配置说明`}
179+
aria-expanded={open}
180+
aria-controls={open ? titleId : undefined}
181+
onClick={() => setOpen(true)}
182+
>
183+
<CircleHelp aria-hidden="true" className="h-4 w-4" />
184+
</button>
185+
</span>
186+
</Tooltip>
187+
188+
{open && typeof document !== 'undefined'
189+
? createPortal(
190+
<div className="fixed inset-0 z-[140] flex items-end bg-background/25 backdrop-blur-sm sm:items-center sm:justify-center">
191+
<button
192+
type="button"
193+
className="absolute inset-0 cursor-default"
194+
aria-label="关闭配置说明"
195+
tabIndex={-1}
196+
onClick={() => setOpen(false)}
197+
/>
198+
<div
199+
ref={dialogRef}
200+
role="dialog"
201+
aria-modal="true"
202+
aria-labelledby={titleId}
203+
tabIndex={-1}
204+
className={cn(
205+
'relative flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-2xl border border-border/80 bg-card shadow-soft-card-strong',
206+
'sm:max-w-2xl sm:rounded-2xl',
207+
)}
208+
>
209+
<div className="h-1 w-full bg-gradient-to-r from-cyan/80 via-primary/70 to-purple/70" />
210+
<div className="flex items-start justify-between gap-4 border-b border-border/60 px-5 py-4">
211+
<div className="min-w-0">
212+
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-text">
213+
{fieldKey}
214+
</p>
215+
<h2 id={titleId} className="mt-1 text-lg font-semibold text-foreground">
216+
{help.title || title}
217+
</h2>
218+
{help.summary ? (
219+
<p className="mt-2 text-sm leading-6 text-secondary-text">{help.summary}</p>
220+
) : null}
221+
</div>
222+
<button
223+
ref={closeButtonRef}
224+
type="button"
225+
onClick={() => setOpen(false)}
226+
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-card/80 text-secondary-text transition-colors hover:bg-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15"
227+
aria-label="关闭配置说明"
228+
>
229+
<X aria-hidden="true" className="h-4 w-4" />
230+
</button>
231+
</div>
232+
233+
<div className="space-y-5 overflow-y-auto px-5 py-5">
234+
<HelpSection title="用途">
235+
{help.usage ? <p className="text-sm leading-6 text-secondary-text">{help.usage}</p> : null}
236+
</HelpSection>
237+
238+
<HelpSection title="取值说明">
239+
<HelpList items={help.valueNotes} />
240+
</HelpSection>
241+
242+
<HelpSection title="配置样例">
243+
<CodeExamples examples={examples} />
244+
</HelpSection>
245+
246+
<HelpSection title="影响范围">
247+
<HelpList items={help.impact} />
248+
</HelpSection>
249+
250+
<HelpSection title="注意事项">
251+
<HelpList items={help.notes} />
252+
</HelpSection>
253+
254+
{hasItems(docs) ? (
255+
<HelpSection title="相关文档">
256+
<div className="flex flex-wrap gap-2">
257+
{docs.map((doc) => (
258+
<a
259+
className="inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-xs text-secondary-text transition-colors hover:bg-hover hover:text-foreground"
260+
href={doc.href}
261+
key={`${doc.label}-${doc.href}`}
262+
rel="noreferrer"
263+
target="_blank"
264+
>
265+
<span>{doc.label}</span>
266+
<ExternalLink aria-hidden="true" className="h-3.5 w-3.5" />
267+
</a>
268+
))}
269+
</div>
270+
</HelpSection>
271+
) : null}
272+
</div>
273+
</div>
274+
</div>,
275+
document.body,
276+
)
277+
: null}
278+
</>
279+
);
280+
};

0 commit comments

Comments
 (0)