diff --git a/package-lock.json b/package-lock.json index 63c4c2b..f9bf2b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b8d6a4d..d03590d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/config/DiffModal.module.scss b/src/components/config/DiffModal.module.scss new file mode 100644 index 0000000..02b7b6e --- /dev/null +++ b/src/components/config/DiffModal.module.scss @@ -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; + } +} diff --git a/src/components/config/DiffModal.tsx b/src/components/config/DiffModal.tsx new file mode 100644 index 0000000..7665083 --- /dev/null +++ b/src/components/config/DiffModal.tsx @@ -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(null); + const mergeViewRef = useRef(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 ( + + + + + } + > +
+
+ {t('config_management.diff.current')} + {t('config_management.diff.modified')} +
+
+
+ + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index de56b53..249ada1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dc20afe..96363d1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -925,6 +925,13 @@ "search_no_results": "Нет результатов", "search_prev": "Назад", "search_next": "Вперёд", + "diff": { + "title": "Обзор изменений", + "current": "Текущая", + "modified": "Изменённая", + "confirm": "Подтвердить", + "no_changes": "Изменений не обнаружено" + }, "tabs": { "visual": "Визуальный редактор", "source": "Редактор файла" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 29b2f32..9195771 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -922,6 +922,13 @@ "search_no_results": "无结果", "search_prev": "上一个", "search_next": "下一个", + "diff": { + "title": "确认变更", + "current": "当前配置", + "modified": "修改后", + "confirm": "确认保存", + "no_changes": "未检测到变更" + }, "tabs": { "visual": "可视化编辑", "source": "源文件编辑" diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 0191c31..671edb9 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -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() { {typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null} + setDiffModalOpen(false)} + loading={saving} + />
); }