feat(config): add YAML diff confirmation before save

This commit is contained in:
Supra4E8C
2026-02-16 22:04:55 +08:00
parent 470ff51579
commit b7794a91b4
8 changed files with 262 additions and 6 deletions

14
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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;
}
}

View 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>
);
}

View File

@@ -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"

View File

@@ -925,6 +925,13 @@
"search_no_results": "Нет результатов",
"search_prev": "Назад",
"search_next": "Вперёд",
"diff": {
"title": "Обзор изменений",
"current": "Текущая",
"modified": "Изменённая",
"confirm": "Подтвердить",
"no_changes": "Изменений не обнаружено"
},
"tabs": {
"visual": "Визуальный редактор",
"source": "Редактор файла"

View File

@@ -922,6 +922,13 @@
"search_no_results": "无结果",
"search_prev": "上一个",
"search_next": "下一个",
"diff": {
"title": "确认变更",
"current": "当前配置",
"modified": "修改后",
"confirm": "确认保存",
"no_changes": "未检测到变更"
},
"tabs": {
"visual": "可视化编辑",
"source": "源文件编辑"

View File

@@ -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>
);
}