mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 10:40:50 +08:00
feat(config): add YAML diff confirmation before save
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -429,6 +430,19 @@
|
|||||||
"crelt": "^1.0.5"
|
"crelt": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/merge": {
|
||||||
|
"version": "6.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.0.tgz",
|
||||||
|
"integrity": "sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"style-mod": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/search": {
|
"node_modules/@codemirror/search": {
|
||||||
"version": "6.5.11",
|
"version": "6.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
72
src/components/config/DiffModal.module.scss
Normal file
72
src/components/config/DiffModal.module.scss
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.diffModal {
|
||||||
|
:global(.modal-body) {
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnLabels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeRoot {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
:global(.cm-mergeView) {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.cm-mergeViewEditors),
|
||||||
|
:global(.cm-mergeViewEditor),
|
||||||
|
:global(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.cm-scroller) {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.cm-gutters) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.cm-mergeGap) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.content {
|
||||||
|
height: 65vh;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/components/config/DiffModal.tsx
Normal file
103
src/components/config/DiffModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
|
import { MergeView } from '@codemirror/merge';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useThemeStore } from '@/stores';
|
||||||
|
import styles from './DiffModal.module.scss';
|
||||||
|
|
||||||
|
type DiffModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiffModal({
|
||||||
|
open,
|
||||||
|
original,
|
||||||
|
modified,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading = false
|
||||||
|
}: DiffModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const mergeContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mergeViewRef = useRef<MergeView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !mergeContainerRef.current) return;
|
||||||
|
|
||||||
|
const mountEl = mergeContainerRef.current;
|
||||||
|
mountEl.innerHTML = '';
|
||||||
|
|
||||||
|
const commonExtensions = [
|
||||||
|
yaml(),
|
||||||
|
EditorState.readOnly.of(true),
|
||||||
|
EditorView.editable.of(false),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': { height: '100%' },
|
||||||
|
'.cm-scroller': {
|
||||||
|
fontFamily: "'Consolas', 'Monaco', 'Menlo', monospace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ dark: resolvedTheme === 'dark' }
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
const view = new MergeView({
|
||||||
|
parent: mountEl,
|
||||||
|
a: { doc: original, extensions: commonExtensions },
|
||||||
|
b: { doc: modified, extensions: commonExtensions },
|
||||||
|
orientation: 'a-b',
|
||||||
|
highlightChanges: true,
|
||||||
|
gutter: true
|
||||||
|
});
|
||||||
|
mergeViewRef.current = view;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
if (mergeViewRef.current === view) {
|
||||||
|
mergeViewRef.current = null;
|
||||||
|
}
|
||||||
|
mountEl.innerHTML = '';
|
||||||
|
};
|
||||||
|
}, [modified, open, original, resolvedTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('config_management.diff.title')}
|
||||||
|
onClose={onCancel}
|
||||||
|
width="min(1200px, 90vw)"
|
||||||
|
className={styles.diffModal}
|
||||||
|
closeDisabled={loading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} loading={loading} disabled={loading}>
|
||||||
|
{t('config_management.diff.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.columnLabels}>
|
||||||
|
<span>{t('config_management.diff.current')}</span>
|
||||||
|
<span>{t('config_management.diff.modified')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.mergeRoot} ref={mergeContainerRef} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -922,6 +922,13 @@
|
|||||||
"search_no_results": "No results",
|
"search_no_results": "No results",
|
||||||
"search_prev": "Previous",
|
"search_prev": "Previous",
|
||||||
"search_next": "Next",
|
"search_next": "Next",
|
||||||
|
"diff": {
|
||||||
|
"title": "Review Changes",
|
||||||
|
"current": "Current",
|
||||||
|
"modified": "Modified",
|
||||||
|
"confirm": "Confirm Save",
|
||||||
|
"no_changes": "No changes detected"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"visual": "Visual Editor",
|
"visual": "Visual Editor",
|
||||||
"source": "Source File Editor"
|
"source": "Source File Editor"
|
||||||
|
|||||||
@@ -925,6 +925,13 @@
|
|||||||
"search_no_results": "Нет результатов",
|
"search_no_results": "Нет результатов",
|
||||||
"search_prev": "Назад",
|
"search_prev": "Назад",
|
||||||
"search_next": "Вперёд",
|
"search_next": "Вперёд",
|
||||||
|
"diff": {
|
||||||
|
"title": "Обзор изменений",
|
||||||
|
"current": "Текущая",
|
||||||
|
"modified": "Изменённая",
|
||||||
|
"confirm": "Подтвердить",
|
||||||
|
"no_changes": "Изменений не обнаружено"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"visual": "Визуальный редактор",
|
"visual": "Визуальный редактор",
|
||||||
"source": "Редактор файла"
|
"source": "Редактор файла"
|
||||||
|
|||||||
@@ -922,6 +922,13 @@
|
|||||||
"search_no_results": "无结果",
|
"search_no_results": "无结果",
|
||||||
"search_prev": "上一个",
|
"search_prev": "上一个",
|
||||||
"search_next": "下一个",
|
"search_next": "下一个",
|
||||||
|
"diff": {
|
||||||
|
"title": "确认变更",
|
||||||
|
"current": "当前配置",
|
||||||
|
"modified": "修改后",
|
||||||
|
"confirm": "确认保存",
|
||||||
|
"no_changes": "未检测到变更"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"visual": "可视化编辑",
|
"visual": "可视化编辑",
|
||||||
"source": "源文件编辑"
|
"source": "源文件编辑"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
||||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||||
|
import { DiffModal } from '@/components/config/DiffModal';
|
||||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||||
import { configFileApi } from '@/services/api/configFile';
|
import { configFileApi } from '@/services/api/configFile';
|
||||||
@@ -53,6 +54,9 @@ export function ConfigPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [diffModalOpen, setDiffModalOpen] = useState(false);
|
||||||
|
const [serverYaml, setServerYaml] = useState('');
|
||||||
|
const [mergedYaml, setMergedYaml] = useState('');
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -73,6 +77,9 @@ export function ConfigPage() {
|
|||||||
const data = await configFileApi.fetchConfigYaml();
|
const data = await configFileApi.fetchConfigYaml();
|
||||||
setContent(data);
|
setContent(data);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
setDiffModalOpen(false);
|
||||||
|
setServerYaml(data);
|
||||||
|
setMergedYaml(data);
|
||||||
loadVisualValuesFromYaml(data);
|
loadVisualValuesFromYaml(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
@@ -86,17 +93,20 @@ export function ConfigPage() {
|
|||||||
loadConfig();
|
loadConfig();
|
||||||
}, [loadConfig]);
|
}, [loadConfig]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleConfirmSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const previousCommercialMode = readCommercialModeFromYaml(content);
|
const previousCommercialMode = readCommercialModeFromYaml(serverYaml);
|
||||||
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
|
||||||
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
|
|
||||||
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
|
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
|
||||||
await configFileApi.saveConfigYaml(nextContent);
|
|
||||||
|
await configFileApi.saveConfigYaml(mergedYaml);
|
||||||
const latestContent = await configFileApi.fetchConfigYaml();
|
const latestContent = await configFileApi.fetchConfigYaml();
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
setDiffModalOpen(false);
|
||||||
setContent(latestContent);
|
setContent(latestContent);
|
||||||
|
setServerYaml(latestContent);
|
||||||
|
setMergedYaml(latestContent);
|
||||||
loadVisualValuesFromYaml(latestContent);
|
loadVisualValuesFromYaml(latestContent);
|
||||||
showNotification(t('config_management.save_success'), 'success');
|
showNotification(t('config_management.save_success'), 'success');
|
||||||
if (commercialModeChanged) {
|
if (commercialModeChanged) {
|
||||||
@@ -110,6 +120,33 @@ export function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const nextMergedYaml = applyVisualChangesToYaml(content);
|
||||||
|
const latestServerYaml = await configFileApi.fetchConfigYaml();
|
||||||
|
|
||||||
|
if (latestServerYaml === nextMergedYaml) {
|
||||||
|
setDirty(false);
|
||||||
|
setContent(latestServerYaml);
|
||||||
|
setServerYaml(latestServerYaml);
|
||||||
|
setMergedYaml(nextMergedYaml);
|
||||||
|
loadVisualValuesFromYaml(latestServerYaml);
|
||||||
|
showNotification(t('config_management.diff.no_changes'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerYaml(latestServerYaml);
|
||||||
|
setMergedYaml(nextMergedYaml);
|
||||||
|
setDiffModalOpen(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = useCallback((value: string) => {
|
const handleChange = useCallback((value: string) => {
|
||||||
setContent(value);
|
setContent(value);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
@@ -326,7 +363,7 @@ export function ConfigPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.floatingActionButton}
|
className={styles.floatingActionButton}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={disableControls || loading || saving || !isDirty}
|
disabled={disableControls || loading || saving || !isDirty || diffModalOpen}
|
||||||
title={t('config_management.save')}
|
title={t('config_management.save')}
|
||||||
aria-label={t('config_management.save')}
|
aria-label={t('config_management.save')}
|
||||||
>
|
>
|
||||||
@@ -474,6 +511,14 @@ export function ConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||||
|
<DiffModal
|
||||||
|
open={diffModalOpen}
|
||||||
|
original={serverYaml}
|
||||||
|
modified={mergedYaml}
|
||||||
|
onConfirm={handleConfirmSave}
|
||||||
|
onCancel={() => setDiffModalOpen(false)}
|
||||||
|
loading={saving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user