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?
|
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
|
||||||
- Copy-pasted blocks with slight variation — extract into shared function
|
- Copy-pasted blocks with slight variation — extract into shared function
|
||||||
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
|
- `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
|
### Database
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ description: React component development guide. Use when working with React comp
|
||||||
# React Component Writing Guide
|
# React Component Writing Guide
|
||||||
|
|
||||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
- 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`)
|
- 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
|
- 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
|
- 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
|
- Next.js 16 + React 19 + TypeScript
|
||||||
- SPA inside Next.js with `react-router-dom`
|
- 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
|
- react-i18next for i18n; zustand for state management
|
||||||
- SWR for data fetching; TRPC for type-safe backend
|
- SWR for data fetching; TRPC for type-safe backend
|
||||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@lobehub/lobehub",
|
"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.",
|
"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": [
|
"keywords": [
|
||||||
"framework",
|
"framework",
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,17 @@
|
||||||
|
|
||||||
import { type NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
import { type NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||||
import { type FormGroupItemType } from '@lobehub/ui';
|
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 { Button, Form as AntdForm, Input, Radio, Space, Switch } from 'antd';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FORM_STYLE } from '@/const/layoutTokens';
|
import { FORM_STYLE } from '@/const/layoutTokens';
|
||||||
import { desktopSettingsService } from '@/services/electron/settings';
|
import { desktopSettingsService } from '@/services/electron/settings';
|
||||||
import { useElectronStore } from '@/store/electron';
|
import { useElectronStore } from '@/store/electron';
|
||||||
|
|
||||||
interface ProxyTestResult {
|
import SaveBar from './SaveBar';
|
||||||
message?: string;
|
import { useProxyDirty } from './useProxyDirty';
|
||||||
responseTime?: number;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProxyForm = () => {
|
const ProxyForm = () => {
|
||||||
const { t } = useTranslation('electron');
|
const { t } = useTranslation('electron');
|
||||||
|
|
@ -24,9 +20,6 @@ const ProxyForm = () => {
|
||||||
const [testUrl, setTestUrl] = useState('https://www.google.com');
|
const [testUrl, setTestUrl] = useState('https://www.google.com');
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [isSaving, setIsSaving] = 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 isEnableProxy = AntdForm.useWatch('enableProxy', form);
|
||||||
const proxyRequireAuth = AntdForm.useWatch('proxyRequireAuth', form);
|
const proxyRequireAuth = AntdForm.useWatch('proxyRequireAuth', form);
|
||||||
|
|
@ -37,28 +30,35 @@ const ProxyForm = () => {
|
||||||
]);
|
]);
|
||||||
const { data: proxySettings, isLoading } = useGetProxySettings();
|
const { data: proxySettings, isLoading } = useGetProxySettings();
|
||||||
|
|
||||||
|
const { isDirty } = useProxyDirty(form, proxySettings);
|
||||||
|
|
||||||
|
const initializedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (proxySettings) {
|
if (proxySettings && !initializedRef.current) {
|
||||||
form.setFieldsValue(proxySettings);
|
form.setFieldsValue(proxySettings);
|
||||||
setHasUnsavedChanges(false);
|
initializedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [form, proxySettings]);
|
}, [form, proxySettings]);
|
||||||
|
|
||||||
// Listen for form value changes
|
const handleValuesChange = useCallback(
|
||||||
const handleValuesChange = useCallback(() => {
|
(changed: Partial<NetworkProxySettings>) => {
|
||||||
setLoading(true);
|
if ('enableProxy' in changed) {
|
||||||
setHasUnsavedChanges(true);
|
const next = changed.enableProxy;
|
||||||
setTestResult(null); // Clear the previous test result
|
setProxySettings({ enableProxy: next }).catch((error) => {
|
||||||
setLoading(false);
|
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 () => {
|
const handleSave = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
await setProxySettings(values);
|
await setProxySettings(values);
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
// validation error
|
// validation error
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -66,43 +66,33 @@ const ProxyForm = () => {
|
||||||
}
|
}
|
||||||
}, [form, setProxySettings]);
|
}, [form, setProxySettings]);
|
||||||
|
|
||||||
// Reset configuration
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
if (proxySettings) {
|
if (proxySettings) form.setFieldsValue(proxySettings);
|
||||||
form.setFieldsValue(proxySettings);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
setTestResult(null);
|
|
||||||
}
|
|
||||||
}, [form, proxySettings]);
|
}, [form, proxySettings]);
|
||||||
|
|
||||||
// Test proxy configuration
|
|
||||||
const handleTest = useCallback(async () => {
|
const handleTest = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setTestResult(null);
|
|
||||||
|
|
||||||
// Validate form and get current configuration
|
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const config: NetworkProxySettings = {
|
const config: NetworkProxySettings = {
|
||||||
...proxySettings,
|
...proxySettings,
|
||||||
...values,
|
...values,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the new testProxyConfig method to test the proxy being configured by the user
|
|
||||||
const result = await desktopSettingsService.testProxyConfig(config, testUrl);
|
const result = await desktopSettingsService.testProxyConfig(config, testUrl);
|
||||||
|
if (result.success) {
|
||||||
setTestResult(result);
|
toast.success(t('proxy.testSuccessWithTime', { time: result.responseTime }));
|
||||||
|
} else {
|
||||||
|
toast.error(`${t('proxy.testFailed')}: ${result.message ?? ''}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
const result: ProxyTestResult = {
|
toast.error(`${t('proxy.testFailed')}: ${errorMessage}`);
|
||||||
message: errorMessage,
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
setTestResult(result);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
}
|
}
|
||||||
}, [proxySettings, testUrl, form]);
|
}, [proxySettings, testUrl, form, t]);
|
||||||
|
|
||||||
if (isLoading) return <Skeleton active paragraph={{ rows: 5 }} title={false} />;
|
if (isLoading) return <Skeleton active paragraph={{ rows: 5 }} title={false} />;
|
||||||
|
|
||||||
|
|
@ -118,7 +108,6 @@ const ProxyForm = () => {
|
||||||
valuePropName: 'checked',
|
valuePropName: 'checked',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
|
|
||||||
title: t('proxy.enable'),
|
title: t('proxy.enable'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -149,7 +138,6 @@ const ProxyForm = () => {
|
||||||
name: 'proxyPort',
|
name: 'proxyPort',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
|
|
||||||
title: t('proxy.basicSettings'),
|
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'),
|
title: t('proxy.authSettings'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -187,54 +174,28 @@ const ProxyForm = () => {
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
children: (
|
children: (
|
||||||
<Flexbox gap={8}>
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
<Input
|
||||||
<Input
|
placeholder={t('proxy.testUrlPlaceholder')}
|
||||||
placeholder={t('proxy.testUrlPlaceholder')}
|
style={{ flex: 1 }}
|
||||||
style={{ flex: 1 }}
|
value={testUrl}
|
||||||
value={testUrl}
|
onChange={(e) => setTestUrl(e.target.value)}
|
||||||
onChange={(e) => setTestUrl(e.target.value)}
|
/>
|
||||||
/>
|
<Button loading={isTesting} type="default" onClick={handleTest}>
|
||||||
<Button loading={isTesting} type="default" onClick={handleTest}>
|
{t('proxy.testButton')}
|
||||||
{t('proxy.testButton')}
|
</Button>
|
||||||
</Button>
|
</Space.Compact>
|
||||||
</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'),
|
desc: t('proxy.testDescription'),
|
||||||
label: t('proxy.testUrl'),
|
label: t('proxy.testUrl'),
|
||||||
minWidth: undefined,
|
minWidth: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
|
|
||||||
title: t('proxy.connectionTest'),
|
title: t('proxy.connectionTest'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flexbox gap={24}>
|
<>
|
||||||
<Form
|
<Form
|
||||||
collapsible={false}
|
collapsible={false}
|
||||||
form={form}
|
form={form}
|
||||||
|
|
@ -245,27 +206,8 @@ const ProxyForm = () => {
|
||||||
onValuesChange={handleValuesChange}
|
onValuesChange={handleValuesChange}
|
||||||
{...FORM_STYLE}
|
{...FORM_STYLE}
|
||||||
/>
|
/>
|
||||||
<Flexbox align="end" justify="flex-end">
|
<SaveBar isDirty={isDirty} isSaving={isSaving} onReset={handleReset} onSave={handleSave} />
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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> => {
|
setProxySettings = async (values: Partial<NetworkProxySettings>): Promise<void> => {
|
||||||
try {
|
await desktopSettingsService.setSettings(values);
|
||||||
// Update settings
|
await this.#get().refreshProxySettings();
|
||||||
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> => {
|
updateDesktopHotkey = async (id: string, accelerator: string): Promise<ShortcutUpdateResult> => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue