💄 style(proxy-settings): sticky pill SaveBar + instant enable toggle (#13821)

* 🔖 chore(release): release version v2.1.49 [skip ci]

* 💄 style(proxy-settings): sticky pill SaveBar + instant enable toggle

- Split enableProxy into instant-apply (no save required)
- Floating pill SaveBar fixed bottom-center, visible only when dirty
- Test connection feedback moved to toast (@lobehub/ui)
- Refresh style guidance: prefer createStaticStyles + cssVar

Fixes LOBE-7071

* 🐛 fix(proxy-settings): rollback enable toggle on save failure, preserve in-progress edits

---------

Co-authored-by: lobehubbot <i@lobehub.com>
This commit is contained in:
Innei 2026-04-15 00:05:00 +08:00 committed by GitHub
parent fd0d846975
commit b4fc85b57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 212 additions and 114 deletions

View file

@ -46,7 +46,7 @@ description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs,
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
- Copy-pasted blocks with slight variation — extract into shared function
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
- Use `antd-style` token system, not hardcoded colors
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
### Database

View file

@ -6,6 +6,9 @@ description: React component development guide. Use when working with React comp
# React Component Writing Guide
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents

View file

@ -6,7 +6,7 @@ Guidelines for using Claude Code in this LobeHub repository.
- Next.js 16 + React 19 + TypeScript
- SPA inside Next.js with `react-router-dom`
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS**prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
- react-i18next for i18n; zustand for state management
- SWR for data fetching; TRPC for type-safe backend
- Drizzle ORM with PostgreSQL; Vitest for testing

View file

@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.1.48",
"version": "2.1.49",
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",

View file

@ -2,21 +2,17 @@
import { type NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { type FormGroupItemType } from '@lobehub/ui';
import { Alert, Flexbox, Form, Icon, Skeleton } from '@lobehub/ui';
import { Form, Skeleton, toast } from '@lobehub/ui';
import { Button, Form as AntdForm, Input, Radio, Space, Switch } from 'antd';
import { Loader2Icon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FORM_STYLE } from '@/const/layoutTokens';
import { desktopSettingsService } from '@/services/electron/settings';
import { useElectronStore } from '@/store/electron';
interface ProxyTestResult {
message?: string;
responseTime?: number;
success: boolean;
}
import SaveBar from './SaveBar';
import { useProxyDirty } from './useProxyDirty';
const ProxyForm = () => {
const { t } = useTranslation('electron');
@ -24,9 +20,6 @@ const ProxyForm = () => {
const [testUrl, setTestUrl] = useState('https://www.google.com');
const [isTesting, setIsTesting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loading, setLoading] = useState(false);
const isEnableProxy = AntdForm.useWatch('enableProxy', form);
const proxyRequireAuth = AntdForm.useWatch('proxyRequireAuth', form);
@ -37,28 +30,35 @@ const ProxyForm = () => {
]);
const { data: proxySettings, isLoading } = useGetProxySettings();
const { isDirty } = useProxyDirty(form, proxySettings);
const initializedRef = useRef(false);
useEffect(() => {
if (proxySettings) {
if (proxySettings && !initializedRef.current) {
form.setFieldsValue(proxySettings);
setHasUnsavedChanges(false);
initializedRef.current = true;
}
}, [form, proxySettings]);
// Listen for form value changes
const handleValuesChange = useCallback(() => {
setLoading(true);
setHasUnsavedChanges(true);
setTestResult(null); // Clear the previous test result
setLoading(false);
}, []);
const handleValuesChange = useCallback(
(changed: Partial<NetworkProxySettings>) => {
if ('enableProxy' in changed) {
const next = changed.enableProxy;
setProxySettings({ enableProxy: next }).catch((error) => {
form.setFieldsValue({ enableProxy: !next });
const message = error instanceof Error ? error.message : String(error);
toast.error(t('proxy.saveFailed', { error: message }));
});
}
},
[form, setProxySettings, t],
);
// Save configuration
const handleSave = useCallback(async () => {
try {
setIsSaving(true);
const values = await form.validateFields();
await setProxySettings(values);
setHasUnsavedChanges(false);
} catch {
// validation error
} finally {
@ -66,43 +66,33 @@ const ProxyForm = () => {
}
}, [form, setProxySettings]);
// Reset configuration
const handleReset = useCallback(() => {
if (proxySettings) {
form.setFieldsValue(proxySettings);
setHasUnsavedChanges(false);
setTestResult(null);
}
if (proxySettings) form.setFieldsValue(proxySettings);
}, [form, proxySettings]);
// Test proxy configuration
const handleTest = useCallback(async () => {
try {
setIsTesting(true);
setTestResult(null);
// Validate form and get current configuration
const values = await form.validateFields();
const config: NetworkProxySettings = {
...proxySettings,
...values,
};
// Use the new testProxyConfig method to test the proxy being configured by the user
const result = await desktopSettingsService.testProxyConfig(config, testUrl);
setTestResult(result);
if (result.success) {
toast.success(t('proxy.testSuccessWithTime', { time: result.responseTime }));
} else {
toast.error(`${t('proxy.testFailed')}: ${result.message ?? ''}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const result: ProxyTestResult = {
message: errorMessage,
success: false,
};
setTestResult(result);
toast.error(`${t('proxy.testFailed')}: ${errorMessage}`);
} finally {
setIsTesting(false);
}
}, [proxySettings, testUrl, form]);
}, [proxySettings, testUrl, form, t]);
if (isLoading) return <Skeleton active paragraph={{ rows: 5 }} title={false} />;
@ -118,7 +108,6 @@ const ProxyForm = () => {
valuePropName: 'checked',
},
],
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
title: t('proxy.enable'),
};
@ -149,7 +138,6 @@ const ProxyForm = () => {
name: 'proxyPort',
},
],
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
title: t('proxy.basicSettings'),
};
@ -179,7 +167,6 @@ const ProxyForm = () => {
]
: []),
],
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
title: t('proxy.authSettings'),
};
@ -187,7 +174,6 @@ const ProxyForm = () => {
children: [
{
children: (
<Flexbox gap={8}>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder={t('proxy.testUrlPlaceholder')}
@ -199,42 +185,17 @@ const ProxyForm = () => {
{t('proxy.testButton')}
</Button>
</Space.Compact>
{/* Test result display */}
{!testResult ? null : testResult.success ? (
<Alert
closable
type={'success'}
title={
<Flexbox horizontal align="center" gap={8}>
{t('proxy.testSuccessWithTime', { time: testResult.responseTime })}
</Flexbox>
}
/>
) : (
<Alert
closable
type={'error'}
variant={'outlined'}
title={
<Flexbox horizontal align="center" gap={8}>
{t('proxy.testFailed')}: {testResult.message}
</Flexbox>
}
/>
)}
</Flexbox>
),
desc: t('proxy.testDescription'),
label: t('proxy.testUrl'),
minWidth: undefined,
},
],
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
title: t('proxy.connectionTest'),
};
return (
<Flexbox gap={24}>
<>
<Form
collapsible={false}
form={form}
@ -245,27 +206,8 @@ const ProxyForm = () => {
onValuesChange={handleValuesChange}
{...FORM_STYLE}
/>
<Flexbox align="end" justify="flex-end">
{hasUnsavedChanges && (
<span style={{ color: 'var(--ant-color-warning)', marginBottom: 8 }}>
{t('proxy.unsavedChanges')}
</span>
)}
<Flexbox horizontal gap={8}>
<Button
disabled={!hasUnsavedChanges}
loading={isSaving}
type="primary"
onClick={handleSave}
>
{t('proxy.saveButton')}
</Button>
<Button disabled={!hasUnsavedChanges || isSaving} onClick={handleReset}>
{t('proxy.resetButton')}
</Button>
</Flexbox>
</Flexbox>
</Flexbox>
<SaveBar isDirty={isDirty} isSaving={isSaving} onReset={handleReset} onSave={handleSave} />
</>
);
};

View file

@ -0,0 +1,132 @@
'use client';
import { Button } from 'antd';
import { createStaticStyles } from 'antd-style';
import { AnimatePresence, m } from 'motion/react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
pointer-events: none;
position: fixed;
z-index: 1000;
inset-block-end: 24px;
inset-inline-start: 50%;
transform: translateX(-50%);
`,
pill: css`
pointer-events: auto;
display: inline-flex;
gap: 8px;
align-items: center;
padding-block: 6px;
padding-inline: 16px 6px;
border-radius: 999px;
font-size: 13px;
color: ${cssVar.colorTextLightSolid};
background: ${cssVar.colorBgSpotlight};
box-shadow:
0 8px 28px rgb(0 0 0 / 22%),
0 2px 6px rgb(0 0 0 / 14%);
`,
dot: css`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${cssVar.colorWarning};
`,
message: css`
opacity: 0.92;
`,
resetButton: css`
height: 28px;
padding-block: 0;
padding-inline: 12px;
border-radius: 999px;
color: rgb(255 255 255 / 85%);
background: transparent;
&:hover {
color: #fff !important;
background: rgb(255 255 255 / 8%) !important;
}
`,
saveButton: css`
height: 28px;
padding-block: 0;
padding-inline: 14px;
border: none;
border-radius: 999px;
font-weight: 500;
color: ${cssVar.colorBgSpotlight} !important;
background: #fff !important;
&:hover {
background: rgb(255 255 255 / 92%) !important;
}
`,
}));
interface SaveBarProps {
isDirty: boolean;
isSaving: boolean;
onReset: () => void;
onSave: () => void;
}
const SaveBar = memo<SaveBarProps>(({ isDirty, isSaving, onReset, onSave }) => {
const { t } = useTranslation('electron');
return (
<AnimatePresence>
{isDirty && (
<m.div
animate={{ opacity: 1, y: 0 }}
aria-live="polite"
className={styles.container}
exit={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 16 }}
role="status"
transition={{ duration: 0.18, ease: 'easeOut' }}
>
<div className={styles.pill}>
<span className={styles.dot} />
<span className={styles.message}>{t('proxy.unsavedChanges')}</span>
<Button
className={styles.resetButton}
disabled={isSaving}
size="small"
type="text"
onClick={onReset}
>
{t('proxy.resetButton')}
</Button>
<Button
className={styles.saveButton}
loading={isSaving}
size="small"
type="primary"
onClick={onSave}
>
{t('proxy.saveButton')}
</Button>
</div>
</m.div>
)}
</AnimatePresence>
);
});
SaveBar.displayName = 'SaveBar';
export default SaveBar;

View file

@ -0,0 +1,28 @@
import { type NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { Form as AntdForm, type FormInstance } from 'antd';
import { useMemo } from 'react';
const WATCH_FIELDS: readonly (keyof NetworkProxySettings)[] = [
'proxyType',
'proxyServer',
'proxyPort',
'proxyRequireAuth',
'proxyUsername',
'proxyPassword',
];
const normalize = (v: unknown) => (v === undefined || v === null ? '' : v);
export const useProxyDirty = (
form: FormInstance,
saved: NetworkProxySettings | undefined,
): { isDirty: boolean } => {
const values = AntdForm.useWatch([], form);
const isDirty = useMemo(() => {
if (!saved || !values) return false;
return WATCH_FIELDS.some((key) => normalize(values[key]) !== normalize(saved[key]));
}, [values, saved]);
return { isDirty };
};

View file

@ -42,15 +42,8 @@ export class ElectronSettingsActionImpl {
};
setProxySettings = async (values: Partial<NetworkProxySettings>): Promise<void> => {
try {
// Update settings
await desktopSettingsService.setSettings(values);
// Refresh state
await this.#get().refreshProxySettings();
} catch (error) {
console.error('Proxy settings update failed:', error);
}
};
updateDesktopHotkey = async (id: string, accelerator: string): Promise<ShortcutUpdateResult> => {