mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
💄 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:
parent
fd0d846975
commit
b4fc85b57b
8 changed files with 212 additions and 114 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,54 +174,28 @@ const ProxyForm = () => {
|
|||
children: [
|
||||
{
|
||||
children: (
|
||||
<Flexbox gap={8}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder={t('proxy.testUrlPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={testUrl}
|
||||
onChange={(e) => setTestUrl(e.target.value)}
|
||||
/>
|
||||
<Button loading={isTesting} type="default" onClick={handleTest}>
|
||||
{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>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder={t('proxy.testUrlPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
value={testUrl}
|
||||
onChange={(e) => setTestUrl(e.target.value)}
|
||||
/>
|
||||
<Button loading={isTesting} type="default" onClick={handleTest}>
|
||||
{t('proxy.testButton')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
),
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
132
src/routes/(main)/settings/proxy/features/SaveBar.tsx
Normal file
132
src/routes/(main)/settings/proxy/features/SaveBar.tsx
Normal 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;
|
||||
28
src/routes/(main)/settings/proxy/features/useProxyDirty.ts
Normal file
28
src/routes/(main)/settings/proxy/features/useProxyDirty.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
await desktopSettingsService.setSettings(values);
|
||||
await this.#get().refreshProxySettings();
|
||||
};
|
||||
|
||||
updateDesktopHotkey = async (id: string, accelerator: string): Promise<ShortcutUpdateResult> => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue