feat: use local Monaco editor for config values

This commit is contained in:
2026-08-30 10:16:23 +08:00
parent b94e46d966
commit b0f8d21d67
7 changed files with 220 additions and 14 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Editor, { DiffEditor } from '@monaco-editor/react';
import { import {
Package, FolderTree, Globe, SlidersHorizontal, Plus, Pencil, Trash2, Package, FolderTree, Globe, SlidersHorizontal, Plus, Pencil, Trash2,
Search, X, UploadCloud, History, RotateCcw, AlertTriangle, Check, Search, X, UploadCloud, History, RotateCcw, AlertTriangle, Check,
@@ -40,6 +41,12 @@ const TYPE_BADGE = {
json: 'bg-amber-50 text-amber-600', json: 'bg-amber-50 text-amber-600',
xml: 'bg-violet-50 text-violet-600', xml: 'bg-violet-50 text-violet-600',
}; };
const MONACO_LANGUAGE = {
properties: 'ini',
yaml: 'yaml',
json: 'json',
xml: 'xml',
};
/* ========================================================================= /* =========================================================================
* 种子数据 * 种子数据
@@ -109,22 +116,83 @@ const initialReleases = [
/* ========================================================================= /* =========================================================================
* 通用小组件 * 通用小组件
* ========================================================================= */ * ========================================================================= */
function Field({ label, required, children }) { function Field({ label, required, children, composite = false }) {
return ( const content = (
<label className="block"> <>
<span className="block text-xs font-medium text-slate-500 mb-1"> <span className="block text-xs font-medium text-slate-500 mb-1">
{label} {label}
{required && <span className="text-rose-500 ml-0.5">*</span>} {required && <span className="text-rose-500 ml-0.5">*</span>}
</span> </span>
{children} {children}
</label> </>
); );
// Monaco 等复合控件内部包含自己的 textarea / 可聚焦元素。
// 不能包在 <label> 中,否则浏览器会在点击后执行 label 的默认聚焦行为,
// 抢走 Monaco 自己刚获得的焦点,表现为“点击编辑器立即失焦”。
return composite ? <div className="block">{content}</div> : <label className="block">{content}</label>;
} }
function inputCls(disabled) { function inputCls(disabled) {
return `w-full rounded-lg border px-3 py-1.5 text-sm outline-none transition ${ return `w-full rounded-lg border px-3 py-1.5 text-sm outline-none transition ${
disabled ? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed' : 'border-slate-200 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100' disabled ? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed' : 'border-slate-200 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100'
}`; }`;
} }
function ConfigValueEditor({ mode, namespaceType, value, releasedValue, onChange }) {
const language = MONACO_LANGUAGE[namespaceType] || 'plaintext';
const commonOptions = {
automaticLayout: true,
fontSize: 13,
lineHeight: 20,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
tabSize: 2,
insertSpaces: true,
padding: { top: 12, bottom: 12 },
};
return (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-100">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[11px] text-slate-400">
<span className={`rounded px-1.5 py-0.5 font-mono ${TYPE_BADGE[namespaceType] || TYPE_BADGE.properties}`}>
{namespaceType || 'properties'}
</span>
<span>{mode === 'edit' ? '左侧:已发布值 · 右侧:当前编辑值' : '编辑器语法跟随命名空间格式'}</span>
</div>
{mode === 'edit' ? (
<DiffEditor
key={`diff-${namespaceType}`}
height="300px"
original={releasedValue ?? ''}
modified={value ?? ''}
originalLanguage={language}
modifiedLanguage={language}
theme="vs"
options={{
...commonOptions,
renderSideBySide: true,
originalEditable: false,
readOnly: false,
}}
onMount={(diffEditor) => {
const modifiedEditor = diffEditor.getModifiedEditor();
modifiedEditor.onDidChangeModelContent(() => onChange(modifiedEditor.getValue()));
}}
/>
) : (
<Editor
key={`editor-${namespaceType}`}
height="260px"
language={language}
value={value ?? ''}
theme="vs"
options={commonOptions}
onChange={(nextValue) => onChange(nextValue ?? '')}
/>
)}
</div>
);
}
function EmptyHint({ text }) { function EmptyHint({ text }) {
return <div className="bg-white rounded-xl border border-dashed border-slate-300 p-12 text-center text-sm text-slate-400">{text}</div>; return <div className="bg-white rounded-xl border border-dashed border-slate-300 p-12 text-center text-sm text-slate-400">{text}</div>;
} }
@@ -199,7 +267,7 @@ function ConfirmDialog({ confirm, onCancel }) {
* 新建 / 编辑 弹窗(应用、命名空间、环境、配置项 共用) * 新建 / 编辑 弹窗(应用、命名空间、环境、配置项 共用)
* ========================================================================= */ * ========================================================================= */
function EntityModal({ modal, apps, onClose, onSubmit }) { function EntityModal({ modal, apps, onClose, onSubmit }) {
const { entityType, mode, data } = modal; const { entityType, mode, data, namespaceType = 'properties' } = modal;
const [form, setForm] = useState(() => data || {}); const [form, setForm] = useState(() => data || {});
const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
@@ -217,7 +285,7 @@ function EntityModal({ modal, apps, onClose, onSubmit }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<form onClick={(e) => e.stopPropagation()} onSubmit={handleSubmit} className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden"> <form onClick={(e) => e.stopPropagation()} onSubmit={handleSubmit} className={`bg-white rounded-xl shadow-xl w-full overflow-hidden ${entityType === 'config' ? 'max-w-4xl' : 'max-w-md'}`}>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between"> <div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">{titles[entityType]}</h3> <h3 className="text-sm font-semibold text-slate-800">{titles[entityType]}</h3>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600"> <button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600">
@@ -295,8 +363,14 @@ function EntityModal({ modal, apps, onClose, onSubmit }) {
<Field label="Key" required> <Field label="Key" required>
<input required disabled={mode === 'edit'} value={form.key || ''} onChange={(e) => set('key', e.target.value)} placeholder="如 server.port" className={inputCls(mode === 'edit') + ' font-mono'} /> <input required disabled={mode === 'edit'} value={form.key || ''} onChange={(e) => set('key', e.target.value)} placeholder="如 server.port" className={inputCls(mode === 'edit') + ' font-mono'} />
</Field> </Field>
<Field label="Value" required> <Field label="Value" required composite>
<textarea required value={form.value || ''} onChange={(e) => set('value', e.target.value)} rows={4} className={inputCls() + ' font-mono'} /> <ConfigValueEditor
mode={mode}
namespaceType={namespaceType}
value={form.value || ''}
releasedValue={form.releasedValue}
onChange={(value) => set('value', value)}
/>
</Field> </Field>
<Field label="备注"> <Field label="备注">
<input value={form.comment || ''} onChange={(e) => set('comment', e.target.value)} className={inputCls()} /> <input value={form.comment || ''} onChange={(e) => set('comment', e.target.value)} className={inputCls()} />
@@ -1339,8 +1413,15 @@ export default function ConfigCenter() {
} }
/* ---------- 配置项 CRUD ---------- */ /* ---------- 配置项 CRUD ---------- */
function openAddConfig() { if (!selectedNsId) return; setModal({ entityType: 'config', mode: 'add', data: {} }); } function openAddConfig() {
function openEditConfig(c) { setModal({ entityType: 'config', mode: 'edit', data: { ...c } }); } if (!selectedNsId) return;
const namespaceType = namespaces.find((item) => item.id === selectedNsId)?.type || 'properties';
setModal({ entityType: 'config', mode: 'add', namespaceType, data: {} });
}
function openEditConfig(c) {
const namespaceType = namespaces.find((item) => item.id === c.nsId)?.type || 'properties';
setModal({ entityType: 'config', mode: 'edit', namespaceType, data: { ...c } });
}
async function toggleDeleteConfig(c) { async function toggleDeleteConfig(c) {
try { try {
if (c.pendingDelete) await api.restoreConfigItem(c.id); if (c.pendingDelete) await api.restoreConfigItem(c.id);

72
web/package-lock.json generated
View File

@@ -8,16 +8,17 @@
"name": "configcenter-web", "name": "configcenter-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"autoprefixer": "latest", "autoprefixer": "latest",
"lucide-react": "latest", "lucide-react": "latest",
"monaco-editor": "^0.56.0",
"postcss": "latest", "postcss": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "latest" "vite": "latest"
}, }
"devDependencies": {}
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
@@ -66,6 +67,29 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -374,6 +398,13 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
@@ -640,6 +671,15 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.416", "version": "1.5.416",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz",
@@ -1127,6 +1167,18 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -1149,6 +1201,16 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/monaco-editor": {
"version": "0.56.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
"integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
"license": "MIT",
"dependencies": {
"dompurify": "3.4.8",
"marked": "14.0.0"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -1564,6 +1626,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",

View File

@@ -9,14 +9,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"autoprefixer": "latest", "autoprefixer": "latest",
"lucide-react": "latest", "lucide-react": "latest",
"monaco-editor": "^0.56.0",
"postcss": "latest", "postcss": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "latest" "vite": "latest"
}, }
"devDependencies": {}
} }

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './monaco.js';
import ConfigCenter from '../../ConfigCenter.jsx'; import ConfigCenter from '../../ConfigCenter.jsx';
import './index.css'; import './index.css';

View File

@@ -0,0 +1,39 @@
let monacoInstance = null;
function cancelable(promise) {
let canceled = false;
const wrapped = new Promise((resolve, reject) => {
promise.then(
(value) => {
if (canceled) reject({ type: 'cancelation', msg: 'operation is manually canceled' });
else resolve(value);
},
reject,
);
});
wrapped.cancel = () => { canceled = true; };
return wrapped;
}
const loader = {
config(config) {
if (config?.monaco) monacoInstance = config.monaco;
},
init() {
if (!monacoInstance && typeof window !== 'undefined' && window.monaco?.editor) {
monacoInstance = window.monaco;
}
if (!monacoInstance) {
return cancelable(Promise.reject(new Error('Local Monaco instance is not configured')));
}
return cancelable(Promise.resolve(monacoInstance));
},
__getMonacoInstance() {
return monacoInstance;
},
};
export { loader };
export default loader;

14
web/src/monaco.js Normal file
View File

@@ -0,0 +1,14 @@
import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/language/json/json.worker?worker';
// 完全使用随前端产物打包的 Monaco不依赖 jsDelivr 等公网 CDN。
self.MonacoEnvironment = {
getWorker(_moduleId, label) {
if (label === 'json') return new JsonWorker();
return new EditorWorker();
},
};
loader.config({ monaco });

View File

@@ -8,6 +8,8 @@ export default defineConfig({
alias: { alias: {
react: fileURLToPath(new URL('./node_modules/react', import.meta.url)), react: fileURLToPath(new URL('./node_modules/react', import.meta.url)),
'lucide-react': fileURLToPath(new URL('./node_modules/lucide-react', import.meta.url)), 'lucide-react': fileURLToPath(new URL('./node_modules/lucide-react', import.meta.url)),
'@monaco-editor/react': fileURLToPath(new URL('./node_modules/@monaco-editor/react', import.meta.url)),
'@monaco-editor/loader': fileURLToPath(new URL('./src/monaco-loader-local.js', import.meta.url)),
}, },
}, },
server: { server: {