mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
feat: add visual configuration editor and YAML handling
- Implemented a new hook `useVisualConfig` for managing visual configuration state and YAML parsing. - Added types for visual configuration in `visualConfig.ts`. - Enhanced `ConfigPage` to support switching between visual and source editors. - Introduced floating action buttons for save and reload actions. - Updated translations for tab labels in English and Chinese. - Styled the configuration page with new tab and floating action button styles.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(var(--config-action-bar-height, 0px) + #{$spacing-lg});
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -21,6 +22,49 @@
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
margin-bottom: $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
@include button-reset;
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -242,6 +286,130 @@
|
||||
}
|
||||
}
|
||||
|
||||
.floatingActionContainer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: auto;
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.floatingActionList {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
max-width: inherit;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
text-align: center;
|
||||
max-width: min(360px, 52vw);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
@include button-reset;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) {
|
||||
.floatingActionList {
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.floatingActionContainer {
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.floatingActionList {
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.floatingStatus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floatingActionButton {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
@@ -7,17 +8,35 @@ import { keymap } from '@codemirror/view';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconChevronDown, IconChevronUp, IconSearch } from '@/components/ui/icons';
|
||||
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||
import { configFileApi } from '@/services/api/configFile';
|
||||
import styles from './ConfigPage.module.scss';
|
||||
|
||||
type ConfigEditorTab = 'visual' | 'source';
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
|
||||
const {
|
||||
visualValues,
|
||||
visualDirty,
|
||||
loadVisualValuesFromYaml,
|
||||
applyVisualChangesToYaml,
|
||||
setVisualValues
|
||||
} = useVisualConfig();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ConfigEditorTab>(() => {
|
||||
const saved = localStorage.getItem('config-management:tab');
|
||||
if (saved === 'visual' || saved === 'source') return saved;
|
||||
return 'visual';
|
||||
});
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -31,8 +50,10 @@ export function ConfigPage() {
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const floatingControlsRef = useRef<HTMLDivElement>(null);
|
||||
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const floatingActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isDirty = dirty || visualDirty;
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -41,13 +62,14 @@ export function ConfigPage() {
|
||||
const data = await configFileApi.fetchConfigYaml();
|
||||
setContent(data);
|
||||
setDirty(false);
|
||||
loadVisualValuesFromYaml(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, [loadVisualValuesFromYaml, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
@@ -56,8 +78,11 @@ export function ConfigPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await configFileApi.saveConfigYaml(content);
|
||||
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
||||
await configFileApi.saveConfigYaml(nextContent);
|
||||
setDirty(false);
|
||||
setContent(nextContent);
|
||||
loadVisualValuesFromYaml(nextContent);
|
||||
showNotification(t('config_management.save_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
@@ -72,6 +97,23 @@ export function ConfigPage() {
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback((tab: ConfigEditorTab) => {
|
||||
if (tab === activeTab) return;
|
||||
|
||||
if (tab === 'source') {
|
||||
const nextContent = applyVisualChangesToYaml(content);
|
||||
if (nextContent !== content) {
|
||||
setContent(nextContent);
|
||||
setDirty(true);
|
||||
}
|
||||
} else {
|
||||
loadVisualValuesFromYaml(content);
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
localStorage.setItem('config-management:tab', tab);
|
||||
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]);
|
||||
|
||||
// Search functionality
|
||||
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
||||
if (!query || !editorRef.current?.view) return;
|
||||
@@ -173,6 +215,8 @@ export function ConfigPage() {
|
||||
|
||||
// Keep floating controls from covering editor content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (activeTab !== 'source') return;
|
||||
|
||||
const controlsEl = floatingControlsRef.current;
|
||||
const wrapperEl = editorWrapperRef.current;
|
||||
if (!controlsEl || !wrapperEl) return;
|
||||
@@ -192,6 +236,31 @@ export function ConfigPage() {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', updatePadding);
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const actionsEl = floatingActionsRef.current;
|
||||
if (!actionsEl) return;
|
||||
|
||||
const updatePadding = () => {
|
||||
const height = actionsEl.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty('--config-action-bar-height', `${height}px`);
|
||||
};
|
||||
|
||||
updatePadding();
|
||||
window.addEventListener('resize', updatePadding);
|
||||
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding);
|
||||
ro?.observe(actionsEl);
|
||||
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', updatePadding);
|
||||
document.documentElement.style.removeProperty('--config-action-bar-height');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CodeMirror extensions
|
||||
@@ -208,131 +277,181 @@ export function ConfigPage() {
|
||||
if (loading) return t('config_management.status_loading');
|
||||
if (error) return t('config_management.status_load_failed');
|
||||
if (saving) return t('config_management.status_saving');
|
||||
if (dirty) return t('config_management.status_dirty');
|
||||
if (isDirty) return t('config_management.status_dirty');
|
||||
return t('config_management.status_loaded');
|
||||
};
|
||||
|
||||
const getStatusClass = () => {
|
||||
if (error) return styles.error;
|
||||
if (dirty) return styles.modified;
|
||||
if (isDirty) return styles.modified;
|
||||
if (!loading && !saving) return styles.saved;
|
||||
return '';
|
||||
};
|
||||
|
||||
const floatingActions = (
|
||||
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
|
||||
<div className={styles.floatingActionList}>
|
||||
<div className={`${styles.floatingStatus} ${styles.status} ${getStatusClass()}`}>{getStatusText()}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
title={t('config_management.reload')}
|
||||
aria-label={t('config_management.reload')}
|
||||
>
|
||||
<IconRefreshCw size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={handleSave}
|
||||
disabled={disableControls || loading || saving || !isDirty}
|
||||
title={t('config_management.save')}
|
||||
aria-label={t('config_management.save')}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
{isDirty && <span className={styles.dirtyDot} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||
<p className={styles.description}>{t('config_management.description')}</p>
|
||||
|
||||
<div className={styles.tabBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('visual')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
|
||||
onClick={() => handleTabChange('source')}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className={styles.configCard}>
|
||||
<div className={styles.content}>
|
||||
{/* Editor */}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<div className={styles.editorWrapper} ref={editorWrapperRef}>
|
||||
{/* Floating search controls */}
|
||||
<div className={styles.floatingControls} ref={floatingControlsRef}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('config_management.search_placeholder', {
|
||||
defaultValue: '搜索配置内容...'
|
||||
})}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.searchInput}
|
||||
rightElement={
|
||||
<div className={styles.searchRight}>
|
||||
{searchQuery && lastSearchedQuery === searchQuery && (
|
||||
<span className={styles.searchCount}>
|
||||
{searchResults.total > 0
|
||||
? `${searchResults.current} / ${searchResults.total}`
|
||||
: t('config_management.search_no_results', { defaultValue: '无结果' })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
onClick={() => executeSearch('next')}
|
||||
disabled={!searchQuery || disableControls || loading}
|
||||
title={t('config_management.search_button', { defaultValue: '搜索' })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handlePrevMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_prev', { defaultValue: '上一个' })}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleNextMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_next', { defaultValue: '下一个' })}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true
|
||||
}}
|
||||
|
||||
{activeTab === 'visual' ? (
|
||||
<VisualConfigEditor
|
||||
values={visualValues}
|
||||
disabled={disableControls || loading}
|
||||
onChange={setVisualValues}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.editorWrapper} ref={editorWrapperRef}>
|
||||
{/* Floating search controls */}
|
||||
<div className={styles.floatingControls} ref={floatingControlsRef}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('config_management.search_placeholder', {
|
||||
defaultValue: '搜索配置内容...'
|
||||
})}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.searchInput}
|
||||
rightElement={
|
||||
<div className={styles.searchRight}>
|
||||
{searchQuery && lastSearchedQuery === searchQuery && (
|
||||
<span className={styles.searchCount}>
|
||||
{searchResults.total > 0
|
||||
? `${searchResults.current} / ${searchResults.total}`
|
||||
: t('config_management.search_no_results', { defaultValue: '无结果' })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
onClick={() => executeSearch('next')}
|
||||
disabled={!searchQuery || disableControls || loading}
|
||||
title={t('config_management.search_button', { defaultValue: '搜索' })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handlePrevMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_prev', { defaultValue: '上一个' })}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleNextMatch}
|
||||
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
|
||||
title={t('config_management.search_next', { defaultValue: '下一个' })}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
extensions={extensions}
|
||||
theme={resolvedTheme}
|
||||
editable={!disableControls && !loading}
|
||||
placeholder={t('config_management.editor_placeholder')}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: false,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
searchKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: false,
|
||||
lintKeymap: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className={styles.controls}>
|
||||
<span className={`${styles.status} ${getStatusClass()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="secondary" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
{t('config_management.reload')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={disableControls || loading || !dirty}>
|
||||
{t('config_management.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user