From b4fc85b57b247fb49a9d6765b981f4e0adf0829e Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 15 Apr 2026 00:05:00 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style(proxy-settings):=20sticky?= =?UTF-8?q?=20pill=20SaveBar=20+=20instant=20enable=20toggle=20(#13821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ”– 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 --- .agents/skills/code-review/SKILL.md | 2 +- .agents/skills/react/SKILL.md | 3 + CLAUDE.md | 2 +- package.json | 2 +- .../settings/proxy/features/ProxyForm.tsx | 146 ++++++------------ .../settings/proxy/features/SaveBar.tsx | 132 ++++++++++++++++ .../settings/proxy/features/useProxyDirty.ts | 28 ++++ src/store/electron/actions/settings.ts | 11 +- 8 files changed, 212 insertions(+), 114 deletions(-) create mode 100644 src/routes/(main)/settings/proxy/features/SaveBar.tsx create mode 100644 src/routes/(main)/settings/proxy/features/useProxyDirty.ts diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 073240f052..ac7be5e327 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -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 diff --git a/.agents/skills/react/SKILL.md b/.agents/skills/react/SKILL.md index 4fa6202c37..05086b2cc9 100644 --- a/.agents/skills/react/SKILL.md +++ b/.agents/skills/react/SKILL.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 77f406b938..57b3d2ffa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/package.json b/package.json index 05dc9d24d7..84bdeb5c89 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/routes/(main)/settings/proxy/features/ProxyForm.tsx b/src/routes/(main)/settings/proxy/features/ProxyForm.tsx index 311235da49..432444fd97 100644 --- a/src/routes/(main)/settings/proxy/features/ProxyForm.tsx +++ b/src/routes/(main)/settings/proxy/features/ProxyForm.tsx @@ -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(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) => { + 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 ; @@ -118,7 +108,6 @@ const ProxyForm = () => { valuePropName: 'checked', }, ], - extra: loading && , title: t('proxy.enable'), }; @@ -149,7 +138,6 @@ const ProxyForm = () => { name: 'proxyPort', }, ], - extra: loading && , title: t('proxy.basicSettings'), }; @@ -179,7 +167,6 @@ const ProxyForm = () => { ] : []), ], - extra: loading && , title: t('proxy.authSettings'), }; @@ -187,54 +174,28 @@ const ProxyForm = () => { children: [ { children: ( - - - setTestUrl(e.target.value)} - /> - - - {/* Test result display */} - {!testResult ? null : testResult.success ? ( - - {t('proxy.testSuccessWithTime', { time: testResult.responseTime })} - - } - /> - ) : ( - - {t('proxy.testFailed')}: {testResult.message} - - } - /> - )} - + + setTestUrl(e.target.value)} + /> + + ), desc: t('proxy.testDescription'), label: t('proxy.testUrl'), minWidth: undefined, }, ], - extra: loading && , title: t('proxy.connectionTest'), }; return ( - + <>
{ onValuesChange={handleValuesChange} {...FORM_STYLE} /> - - {hasUnsavedChanges && ( - - {t('proxy.unsavedChanges')} - - )} - - - - - - + + ); }; diff --git a/src/routes/(main)/settings/proxy/features/SaveBar.tsx b/src/routes/(main)/settings/proxy/features/SaveBar.tsx new file mode 100644 index 0000000000..eed9292927 --- /dev/null +++ b/src/routes/(main)/settings/proxy/features/SaveBar.tsx @@ -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(({ isDirty, isSaving, onReset, onSave }) => { + const { t } = useTranslation('electron'); + + return ( + + {isDirty && ( + +
+ + {t('proxy.unsavedChanges')} + + +
+
+ )} +
+ ); +}); + +SaveBar.displayName = 'SaveBar'; + +export default SaveBar; diff --git a/src/routes/(main)/settings/proxy/features/useProxyDirty.ts b/src/routes/(main)/settings/proxy/features/useProxyDirty.ts new file mode 100644 index 0000000000..360f9738a6 --- /dev/null +++ b/src/routes/(main)/settings/proxy/features/useProxyDirty.ts @@ -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 }; +}; diff --git a/src/store/electron/actions/settings.ts b/src/store/electron/actions/settings.ts index 54d15d6bfa..e022ab2feb 100644 --- a/src/store/electron/actions/settings.ts +++ b/src/store/electron/actions/settings.ts @@ -42,15 +42,8 @@ export class ElectronSettingsActionImpl { }; setProxySettings = async (values: Partial): Promise => { - 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 => {