diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index f6609e3ba..d253980f8 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -28,10 +28,12 @@ import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" import { WecomForm } from "@/components/channels/channel-forms/wecom-form" import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { useGateway } from "@/hooks/use-gateway" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" interface ChannelConfigPageProps { @@ -296,21 +298,34 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [enabled, setEnabled] = useState(false) const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) const arrayFieldFlushersRef = useRef(new Map()) + const loadRequestIdRef = useRef(0) + + const resetPageState = useCallback(() => { + arrayFieldFlushersRef.current.clear() + setChannel(null) + setBaseConfig({}) + setEditConfig({}) + setConfiguredSecrets([]) + setEnabled(false) + setFetchError("") + setServerError("") + setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) + }, []) const loadData = useCallback( async (silent = false) => { + const requestId = loadRequestIdRef.current + 1 + loadRequestIdRef.current = requestId if (!silent) setLoading(true) try { const catalog = await getChannelsCatalog() + if (loadRequestIdRef.current !== requestId) return const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { - setChannel(null) - setBaseConfig({}) - setEditConfig({}) - setConfiguredSecrets([]) - setEnabled(false) + resetPageState() setFetchError( t("channels.page.notFound", { name: channelName, @@ -320,6 +335,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { } const channelConfig = await getChannelConfig(channelName) + if (loadRequestIdRef.current !== requestId) return const raw = asRecord(channelConfig.config) const normalized = normalizeConfig(matched, raw) @@ -332,18 +348,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) } catch (e) { + if (loadRequestIdRef.current !== requestId) return setConfiguredSecrets([]) setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { - if (!silent) setLoading(false) + if (!silent && loadRequestIdRef.current === requestId) { + setLoading(false) + } } }, - [channelName, t], + [channelName, resetPageState, t], ) useEffect(() => { + resetPageState() + setLoading(true) loadData() - }, [loadData]) + }, [loadData, resetPageState]) const previousGatewayStatusRef = useRef(gatewayState) useEffect(() => { @@ -359,6 +380,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return isConfigured(channel, editConfig, configuredSecrets) }, [channel, configuredSecrets, editConfig]) + const isDirty = useMemo(() => { + if (loading || !channel || channel.name !== channelName) return false + const basePayload = buildSavePayload( + channel, + buildEditConfig(channel.name, baseConfig), + asBool(baseConfig.enabled), + ) + const currentPayload = buildSavePayload(channel, editConfig, enabled) + return JSON.stringify(basePayload) !== JSON.stringify(currentPayload) + }, [baseConfig, channel, channelName, editConfig, enabled, loading]) + const docsUrl = useMemo(() => { if (!channel) return "" if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" @@ -479,6 +511,13 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }, }) await loadData() + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("channels.page.saveSuccess"), + channelDisplayName, + gateway?.restartRequired === true, + ) } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") @@ -674,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {

{serverError}

)} + {isDirty && ( + + )} +
- -
diff --git a/web/frontend/src/components/config-change-notice.tsx b/web/frontend/src/components/config-change-notice.tsx new file mode 100644 index 000000000..27e5eed7d --- /dev/null +++ b/web/frontend/src/components/config-change-notice.tsx @@ -0,0 +1,48 @@ +import { + IconAlertCircle, + IconDeviceFloppy, + IconRefresh, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +interface ConfigChangeNoticeProps { + kind: "save" | "restart" + title: string + description?: string + className?: string +} + +export function ConfigChangeNotice({ + kind, + title, + description, + className, +}: ConfigChangeNoticeProps) { + const Icon = + kind === "restart" + ? IconRefresh + : kind === "save" + ? IconDeviceFloppy + : IconAlertCircle + + return ( +
+ +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index cc1a4624e..0b5665640 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -15,6 +15,7 @@ import { setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { AgentDefaultsSection, CronSection, @@ -36,6 +37,7 @@ import { import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" export function ConfigPage() { @@ -334,8 +336,13 @@ export function ConfigPage() { queryClient.setQueryData(["system", "autostart"], status) } - toast.success(t("pages.config.save_success")) - void refreshGatewayState({ force: true }) + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) } catch (err) { toast.error( err instanceof Error ? err.message : t("pages.config.save_error"), @@ -433,8 +440,12 @@ export function ConfigPage() { {isDirty && (
-
- {t("pages.config.unsaved_changes")} +
+
{actionButtons}
diff --git a/web/frontend/src/components/config/raw-config-page.tsx b/web/frontend/src/components/config/raw-config-page.tsx index f8f987651..c06e5fe41 100644 --- a/web/frontend/src/components/config/raw-config-page.tsx +++ b/web/frontend/src/components/config/raw-config-page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { launcherFetch } from "@/api/http" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { PageHeader } from "@/components/page-header" import { AlertDialog, @@ -20,6 +21,7 @@ import { } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" export function RawConfigPage() { @@ -49,7 +51,6 @@ export function RawConfigPage() { } }, onSuccess: (_, submittedConfig) => { - toast.success(t("pages.config.save_success")) try { const savedConfig = JSON.parse(submittedConfig) setLastSavedConfig(savedConfig) @@ -58,7 +59,14 @@ export function RawConfigPage() { } catch { queryClient.invalidateQueries({ queryKey: ["config"] }) } - void refreshGatewayState({ force: true }) + void refreshGatewayState({ force: true }).then((gateway) => { + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) + }) }, onError: () => { toast.error(t("pages.config.save_error")) @@ -141,9 +149,12 @@ export function RawConfigPage() { ) : (
{isDirty && ( -
- {t("pages.config.unsaved_changes")} -
+ )}