mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +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",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/merge": "^6.12.0",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -429,6 +430,19 @@
|
||||
"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": {
|
||||
"version": "6.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/merge": "^6.12.0",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"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_prev": "Previous",
|
||||
"search_next": "Next",
|
||||
"diff": {
|
||||
"title": "Review Changes",
|
||||
"current": "Current",
|
||||
"modified": "Modified",
|
||||
"confirm": "Confirm Save",
|
||||
"no_changes": "No changes detected"
|
||||
},
|
||||
"tabs": {
|
||||
"visual": "Visual Editor",
|
||||
"source": "Source File Editor"
|
||||
|
||||
@@ -925,6 +925,13 @@
|
||||
"search_no_results": "Нет результатов",
|
||||
"search_prev": "Назад",
|
||||
"search_next": "Вперёд",
|
||||
"diff": {
|
||||
"title": "Обзор изменений",
|
||||
"current": "Текущая",
|
||||
"modified": "Изменённая",
|
||||
"confirm": "Подтвердить",
|
||||
"no_changes": "Изменений не обнаружено"
|
||||
},
|
||||
"tabs": {
|
||||
"visual": "Визуальный редактор",
|
||||
"source": "Редактор файла"
|
||||
|
||||
@@ -922,6 +922,13 @@
|
||||
"search_no_results": "无结果",
|
||||
"search_prev": "上一个",
|
||||
"search_next": "下一个",
|
||||
"diff": {
|
||||
"title": "确认变更",
|
||||
"current": "当前配置",
|
||||
"modified": "修改后",
|
||||
"confirm": "确认保存",
|
||||
"no_changes": "未检测到变更"
|
||||
},
|
||||
"tabs": {
|
||||
"visual": "可视化编辑",
|
||||
"source": "源文件编辑"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||
import { DiffModal } from '@/components/config/DiffModal';
|
||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||
import { configFileApi } from '@/services/api/configFile';
|
||||
@@ -53,6 +54,9 @@ export function ConfigPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [diffModalOpen, setDiffModalOpen] = useState(false);
|
||||
const [serverYaml, setServerYaml] = useState('');
|
||||
const [mergedYaml, setMergedYaml] = useState('');
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -73,6 +77,9 @@ export function ConfigPage() {
|
||||
const data = await configFileApi.fetchConfigYaml();
|
||||
setContent(data);
|
||||
setDirty(false);
|
||||
setDiffModalOpen(false);
|
||||
setServerYaml(data);
|
||||
setMergedYaml(data);
|
||||
loadVisualValuesFromYaml(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||
@@ -86,17 +93,20 @@ export function ConfigPage() {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleConfirmSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const previousCommercialMode = readCommercialModeFromYaml(content);
|
||||
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
||||
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
|
||||
const previousCommercialMode = readCommercialModeFromYaml(serverYaml);
|
||||
const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
|
||||
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
|
||||
await configFileApi.saveConfigYaml(nextContent);
|
||||
|
||||
await configFileApi.saveConfigYaml(mergedYaml);
|
||||
const latestContent = await configFileApi.fetchConfigYaml();
|
||||
setDirty(false);
|
||||
setDiffModalOpen(false);
|
||||
setContent(latestContent);
|
||||
setServerYaml(latestContent);
|
||||
setMergedYaml(latestContent);
|
||||
loadVisualValuesFromYaml(latestContent);
|
||||
showNotification(t('config_management.save_success'), 'success');
|
||||
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) => {
|
||||
setContent(value);
|
||||
setDirty(true);
|
||||
@@ -326,7 +363,7 @@ export function ConfigPage() {
|
||||
type="button"
|
||||
className={styles.floatingActionButton}
|
||||
onClick={handleSave}
|
||||
disabled={disableControls || loading || saving || !isDirty}
|
||||
disabled={disableControls || loading || saving || !isDirty || diffModalOpen}
|
||||
title={t('config_management.save')}
|
||||
aria-label={t('config_management.save')}
|
||||
>
|
||||
@@ -474,6 +511,14 @@ export function ConfigPage() {
|
||||
</Card>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
<DiffModal
|
||||
open={diffModalOpen}
|
||||
original={serverYaml}
|
||||
modified={mergedYaml}
|
||||
onConfirm={handleConfirmSave}
|
||||
onCancel={() => setDiffModalOpen(false)}
|
||||
loading={saving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user