소스 검색

feat: updaate

jerry 2 달 전
부모
커밋
85d45fde2e
5개의 변경된 파일619개의 추가작업 그리고 7개의 파일을 삭제
  1. 65 0
      package-lock.json
  2. 7 6
      package.json
  3. 429 0
      src/app/admin/configurations/page.tsx
  4. 2 1
      src/components/admin/AdminSidebar.tsx
  5. 116 0
      src/components/common/JsonConfigEditor.tsx

+ 65 - 0
package-lock.json

@@ -8,6 +8,7 @@
             "name": "textskin-frontend",
             "version": "0.1.0",
             "dependencies": {
+                "@monaco-editor/react": "^4.7.0",
                 "axios": "^1.6.7",
                 "date-fns": "^4.1.0",
                 "lucide-react": "^0.330.0",
@@ -173,6 +174,27 @@
                 "@jridgewell/sourcemap-codec": "^1.4.14"
             }
         },
+        "node_modules/@monaco-editor/loader": {
+            "version": "1.7.0",
+            "resolved": "https://registry.npmmirror.com/@monaco-editor/loader/-/loader-1.7.0.tgz",
+            "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+            "dependencies": {
+                "state-local": "^1.0.6"
+            }
+        },
+        "node_modules/@monaco-editor/react": {
+            "version": "4.7.0",
+            "resolved": "https://registry.npmmirror.com/@monaco-editor/react/-/react-4.7.0.tgz",
+            "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+            "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/@next/env": {
             "version": "14.1.0",
             "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz",
@@ -472,6 +494,13 @@
                 "csstype": "^3.2.2"
             }
         },
+        "node_modules/@types/trusted-types": {
+            "version": "2.0.7",
+            "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+            "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+            "optional": true,
+            "peer": true
+        },
         "node_modules/@types/use-sync-external-store": {
             "version": "0.0.6",
             "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -1368,6 +1397,15 @@
                 "node": ">=6.0.0"
             }
         },
+        "node_modules/dompurify": {
+            "version": "3.2.7",
+            "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz",
+            "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+            "peer": true,
+            "optionalDependencies": {
+                "@types/trusted-types": "^2.0.7"
+            }
+        },
         "node_modules/dunder-proto": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2236,6 +2274,18 @@
                 "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
             }
         },
+        "node_modules/marked": {
+            "version": "14.0.0",
+            "resolved": "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz",
+            "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+            "peer": true,
+            "bin": {
+                "marked": "bin/marked.js"
+            },
+            "engines": {
+                "node": ">= 18"
+            }
+        },
         "node_modules/math-intrinsics": {
             "version": "1.1.0",
             "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2310,6 +2360,16 @@
                 "node": "*"
             }
         },
+        "node_modules/monaco-editor": {
+            "version": "0.55.1",
+            "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.55.1.tgz",
+            "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
+            "peer": true,
+            "dependencies": {
+                "dompurify": "3.2.7",
+                "marked": "14.0.0"
+            }
+        },
         "node_modules/ms": {
             "version": "2.1.3",
             "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -3037,6 +3097,11 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/state-local": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmmirror.com/state-local/-/state-local-1.0.7.tgz",
+            "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
+        },
         "node_modules/streamsearch": {
             "version": "1.1.0",
             "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",

+ 7 - 6
package.json

@@ -9,6 +9,7 @@
         "lint": "next lint"
     },
     "dependencies": {
+        "@monaco-editor/react": "^4.7.0",
         "axios": "^1.6.7",
         "date-fns": "^4.1.0",
         "lucide-react": "^0.330.0",
@@ -26,10 +27,10 @@
         "@typescript-eslint/eslint-plugin": "^8.57.0",
         "@typescript-eslint/parser": "^8.57.0",
         "autoprefixer": "^10.0.1",
-    "eslint": "^8.57.1",
-    "postcss": "^8",
-    "tailwindcss": "^3.3.0",
-    "typescript": "5.9.3",
-    "eslint-plugin-react-hooks": "file:tools/eslint-plugin-react-hooks"
-  }
+        "eslint": "^8.57.1",
+        "eslint-plugin-react-hooks": "file:tools/eslint-plugin-react-hooks",
+        "postcss": "^8",
+        "tailwindcss": "^3.3.0",
+        "typescript": "5.9.3"
+    }
 }

+ 429 - 0
src/app/admin/configurations/page.tsx

@@ -0,0 +1,429 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import api from '@/lib/api';
+import JsonConfigEditor from '@/components/common/JsonConfigEditor';
+import {
+  Edit3,
+  FileText,
+  Loader2,
+  Plus,
+  RefreshCw,
+  Save,
+  Search,
+  SlidersHorizontal,
+  X,
+  AlertCircle,
+} from 'lucide-react';
+
+interface DynamicConfiguration {
+  id?: number;
+  config_key: string;
+  config_value: string;
+  description: string;
+  type: string;
+}
+
+type ConfigurationForm = DynamicConfiguration;
+
+const emptyForm: ConfigurationForm = {
+  config_key: '',
+  config_value: '',
+  description: '',
+  type: '',
+};
+
+function normalizeConfig(item: Partial<DynamicConfiguration>): DynamicConfiguration {
+  return {
+    id: item.id,
+    config_key: item.config_key || '',
+    config_value:
+      typeof item.config_value === 'string'
+        ? item.config_value
+        : JSON.stringify(item.config_value ?? ''),
+    description: item.description || '',
+    type: item.type || '',
+  };
+}
+
+export default function AdminConfigurationsPage() {
+  const [configs, setConfigs] = useState<DynamicConfiguration[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [submitting, setSubmitting] = useState(false);
+  const [keyword, setKeyword] = useState('');
+  const [keyLookup, setKeyLookup] = useState('');
+  const [isModalOpen, setModalOpen] = useState(false);
+  const [editingConfig, setEditingConfig] = useState<DynamicConfiguration | null>(null);
+  const [form, setForm] = useState<ConfigurationForm>(emptyForm);
+  const [valueJsonError, setValueJsonError] = useState<string | null>(null);
+
+  const filteredConfigs = useMemo(() => {
+    const query = keyword.trim().toLowerCase();
+    if (!query) return configs;
+
+    return configs.filter((item) =>
+      [item.config_key, item.config_value, item.description, item.type]
+        .filter(Boolean)
+        .some((value) => value.toLowerCase().includes(query))
+    );
+  }, [configs, keyword]);
+
+  const fetchConfigs = async () => {
+    setLoading(true);
+    try {
+      const res = await api.get('/api/dynamic-configurations/all');
+      const list = res.data?.data || [];
+      setConfigs(Array.isArray(list) ? list.map(normalizeConfig) : []);
+    } catch (e: any) {
+      console.error(e);
+      alert('读取配置失败: ' + (e.response?.data?.message || e.message || '未知错误'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchConfigs();
+  }, []);
+
+  const openCreate = () => {
+    setEditingConfig(null);
+    setForm(emptyForm);
+    setValueJsonError(null);
+    setModalOpen(true);
+  };
+
+  const openEdit = (config: DynamicConfiguration) => {
+    setEditingConfig(config);
+    
+    // 打开时,尝试格式化 JSON
+    let displayValue = config.config_value;
+    try {
+      displayValue = JSON.stringify(JSON.parse(config.config_value), null, 2);
+    } catch (e) {
+      // 解析失败说明不是标准的纯 JSON,保持原样
+    }
+    
+    setForm({ ...config, config_value: displayValue });
+    setValueJsonError(null);
+    setModalOpen(true);
+  };
+
+  const closeModal = () => {
+    if (submitting) return;
+    setModalOpen(false);
+    setEditingConfig(null);
+    setForm(emptyForm);
+    setValueJsonError(null);
+  };
+
+  const handleLookupByKey = async () => {
+    const targetKey = keyLookup.trim();
+    if (!targetKey) {
+      alert('请输入要查询的配置 Key');
+      return;
+    }
+
+    setLoading(true);
+    try {
+      const res = await api.get(`/api/dynamic-configurations/key/${encodeURIComponent(targetKey)}`);
+      const item = res.data?.data;
+      setConfigs(item ? [normalizeConfig(item)] : []);
+      setKeyword('');
+    } catch (e: any) {
+      console.error(e);
+      alert('查询失败: ' + (e.response?.data?.message || e.message || '未知错误'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+
+    const payload: DynamicConfiguration = {
+      config_key: form.config_key.trim(),
+      config_value: form.config_value,
+      description: form.description.trim(),
+      type: form.type.trim(),
+    };
+
+    if (!payload.config_key) {
+      alert('配置 Key 不能为空');
+      return;
+    }
+
+    if (valueJsonError) {
+      alert('配置值 JSON 语法错误,请修复后再保存');
+      return;
+    }
+
+    setSubmitting(true);
+    try {
+      if (editingConfig?.config_key) {
+        await api.put(
+          `/api/dynamic-configurations/key/${encodeURIComponent(editingConfig.config_key)}`,
+          payload
+        );
+      } else {
+        await api.post('/api/dynamic-configurations', payload);
+      }
+
+      alert(editingConfig ? '配置已更新' : '配置已创建');
+      closeModal();
+      fetchConfigs();
+    } catch (e: any) {
+      console.error(e);
+      alert('保存失败: ' + (e.response?.data?.message || e.message || '未知错误'));
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') handleLookupByKey();
+  };
+
+  const handleValueValidityChange = useCallback((isValid: boolean, error: string | null) => {
+    setValueJsonError(isValid ? null : error || 'JSON 语法错误');
+  }, []);
+
+  return (
+    <div className="p-4 md:p-6">
+      <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-6">
+        <div>
+          <h1 className="text-2xl font-bold text-slate-800">动态配置管理</h1>
+          <p className="text-sm text-slate-500 mt-1">维护系统运行时配置项、业务开关和 JSON 参数</p>
+        </div>
+
+        <div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
+          <div className="relative w-full sm:w-64">
+            <input
+              type="text"
+              placeholder="本地筛选 key/type/描述..."
+              className="w-full pl-9 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition"
+              value={keyword}
+              onChange={(e) => setKeyword(e.target.value)}
+            />
+            <Search size={16} className="absolute left-3 top-2.5 text-gray-400" />
+          </div>
+
+          <button
+            onClick={fetchConfigs}
+            className="flex items-center justify-center gap-2 px-4 py-2 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 text-slate-700 font-medium active:scale-95 transition"
+          >
+            <RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> 刷新
+          </button>
+
+          <button
+            onClick={openCreate}
+            className="flex items-center justify-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 font-medium shadow-sm active:scale-95 transition"
+          >
+            <Plus size={16} /> 新增配置
+          </button>
+        </div>
+      </div>
+
+      <div className="bg-white border border-slate-200 rounded-lg p-4 mb-4">
+        <div className="flex flex-col md:flex-row md:items-center gap-3">
+          <div className="flex items-center gap-2 text-sm font-semibold text-slate-700 md:w-36">
+            <FileText size={16} className="text-blue-600" />
+            按 Key 精确读取
+          </div>
+          <input
+            type="text"
+            placeholder="例如 EXCHANGE_RATES 或 test-key"
+            className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition"
+            value={keyLookup}
+            onChange={(e) => setKeyLookup(e.target.value)}
+            onKeyDown={handleKeyDown}
+          />
+          <button
+            onClick={handleLookupByKey}
+            className="flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition"
+          >
+            <Search size={16} /> 查询
+          </button>
+        </div>
+      </div>
+
+      <div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
+        {/* 去掉了 Value 列,调整了各列的比例让 Key 和 Description 有更大空间 */}
+        <div className="hidden md:grid grid-cols-[80px_2fr_1fr_3fr_100px] gap-4 px-4 py-3 bg-slate-50 border-b border-slate-200 text-xs font-bold text-slate-500 uppercase">
+          <div>ID</div>
+          <div>Key</div>
+          <div>Type</div>
+          <div>Description</div>
+          <div className="text-right">Actions</div>
+        </div>
+
+        {loading ? (
+          <div className="flex items-center justify-center py-16 text-slate-400">
+            <Loader2 size={28} className="animate-spin mr-2" />
+            正在读取配置...
+          </div>
+        ) : filteredConfigs.length === 0 ? (
+          <div className="flex flex-col items-center justify-center py-16 text-slate-400">
+            <SlidersHorizontal size={32} className="mb-2" />
+            暂无配置数据
+          </div>
+        ) : (
+          <div className="divide-y divide-slate-100">
+            {filteredConfigs.map((item) => (
+              <div
+                key={`${item.id || 'new'}-${item.config_key}`}
+                className="grid grid-cols-1 md:grid-cols-[80px_2fr_1fr_3fr_100px] gap-3 md:gap-4 px-4 py-4 hover:bg-slate-50 transition items-center"
+              >
+                <div className="text-sm text-slate-500">
+                  <span className="md:hidden text-xs font-semibold text-slate-400 mr-2">ID</span>
+                  {item.id ?? '-'}
+                </div>
+                <div className="min-w-0">
+                  <div className="md:hidden text-xs font-semibold text-slate-400 mb-1">Key</div>
+                  <code className="text-sm font-semibold text-blue-700 break-all">{item.config_key}</code>
+                </div>
+                <div>
+                  <div className="md:hidden text-xs font-semibold text-slate-400 mb-1">Type</div>
+                  <span className="inline-flex max-w-full px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium break-all">
+                    {item.type || '未分类'}
+                  </span>
+                </div>
+                <div className="min-w-0 text-sm text-slate-600 break-words">
+                  <div className="md:hidden text-xs font-semibold text-slate-400 mb-1">Description</div>
+                  {item.description || <span className="text-slate-400">无描述</span>}
+                </div>
+                
+                <div className="flex md:justify-end">
+                  <button
+                    onClick={() => openEdit(item)}
+                    className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition"
+                  >
+                    <Edit3 size={14} /> 编辑
+                  </button>
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+
+      {isModalOpen && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
+          <div className="bg-white rounded-xl shadow-2xl w-full max-w-[1400px] h-[95vh] overflow-hidden flex flex-col">
+            
+            {/* Header: 压缩高度 */}
+            <div className="px-5 py-3 border-b flex items-center justify-between bg-white shrink-0">
+              <div className="flex items-center gap-3">
+                <h3 className="font-bold text-lg text-slate-800">
+                  {editingConfig ? '编辑动态配置' : '新增动态配置'}
+                </h3>
+                {editingConfig && (
+                  <span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-md font-mono">
+                    {editingConfig.config_key}
+                  </span>
+                )}
+              </div>
+              <button onClick={closeModal} className="p-2 hover:bg-slate-100 rounded-full transition text-slate-500">
+                <X size={20} />
+              </button>
+            </div>
+
+            {/* Body: 左右分栏 */}
+            <form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
+              <div className="flex-1 flex flex-col lg:flex-row min-h-0 bg-slate-50/50">
+                
+                {/* 左侧:JSON 编辑器,占据绝对主力空间 */}
+                <div className="flex-1 flex flex-col min-h-0 p-4">
+                  <div className="flex-1 min-h-0 border border-slate-200 shadow-sm rounded-lg overflow-hidden bg-white">
+                    <JsonConfigEditor
+                      type={form.type}
+                      value={form.config_value}
+                      onChange={(configValue) => setForm({ ...form, config_value: configValue })}
+                      onValidityChange={handleValueValidityChange}
+                    />
+                  </div>
+                </div>
+
+                {/* 右侧:将非核心配置移至侧边栏,宽度固定,方便查看描述和填写字段 */}
+                <div className="w-full lg:w-[320px] bg-white border-t lg:border-t-0 lg:border-l border-slate-200 p-5 shrink-0 overflow-y-auto">
+                  <h4 className="text-sm font-bold text-slate-800 mb-4 pb-2 border-b border-slate-100">基础属性</h4>
+                  
+                  <div className="space-y-5">
+                    <label className="block">
+                      <span className="block text-sm font-semibold text-slate-700 mb-1.5">配置 Key</span>
+                      <input
+                        type="text"
+                        value={form.config_key}
+                        onChange={(e) => setForm({ ...form, config_key: e.target.value })}
+                        className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition bg-slate-50 font-mono"
+                        placeholder="CONFIG_KEY"
+                        required
+                        disabled={!!editingConfig}
+                      />
+                      <p className="text-xs text-slate-400 mt-1">唯一键,供后端读取使用</p>
+                    </label>
+
+                    <label className="block">
+                      <span className="block text-sm font-semibold text-slate-700 mb-1.5">类型 (Type)</span>
+                      <input
+                        type="text"
+                        value={form.type}
+                        onChange={(e) => setForm({ ...form, type: e.target.value })}
+                        className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition font-mono"
+                        placeholder="json / string / ..."
+                      />
+                    </label>
+
+                    <label className="block">
+                      <span className="block text-sm font-semibold text-slate-700 mb-1.5">用途描述</span>
+                      {/* 右侧空间充足,改用 textarea 方便填写详细的说明 */}
+                      <textarea
+                        rows={5}
+                        value={form.description}
+                        onChange={(e) => setForm({ ...form, description: e.target.value })}
+                        className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition resize-none leading-relaxed"
+                        placeholder="该配置的用途、结构说明及注意事项..."
+                      />
+                    </label>
+                  </div>
+                </div>
+
+              </div>
+
+              {/* Footer: 压缩高度并贴边 */}
+              <div className="px-5 py-3 border-t bg-white flex items-center justify-between shrink-0">
+                <div className="text-sm font-medium text-red-600 flex items-center gap-2">
+                   {valueJsonError && (
+                     <>
+                        <AlertCircle size={16} /> 
+                        {valueJsonError}
+                     </>
+                   )}
+                </div>
+                
+                <div className="flex gap-3">
+                  <button
+                    type="button"
+                    onClick={closeModal}
+                    className="px-5 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition font-medium"
+                    disabled={submitting}
+                  >
+                    取消
+                  </button>
+                  <button
+                    type="submit"
+                    disabled={submitting || Boolean(valueJsonError)}
+                    className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-sm transition disabled:opacity-70 disabled:cursor-not-allowed font-medium"
+                  >
+                    {submitting ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
+                    保存配置
+                  </button>
+                </div>
+              </div>
+            </form>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 2 - 1
src/components/admin/AdminSidebar.tsx

@@ -7,7 +7,7 @@ import {
   LayoutDashboard, ShoppingBag, LifeBuoy, Settings, LogOut, 
   Activity, CreditCard, Users, ChevronLeft, ChevronRight, 
   Plane, LucideIcon, CalendarClock, LayoutGrid, CheckCheck,
-  Menu, X, Server, UserCog, Network
+  Menu, X, Server, UserCog, Network, SlidersHorizontal
 } from 'lucide-react';
 
 interface MenuItem {
@@ -31,6 +31,7 @@ export default function AdminSidebar() {
     { name: '订单管理', href: '/admin/orders', icon: ShoppingBag },
     { name: '支付配置', href: '/admin/payments', icon: CreditCard },
     { name: '商品配置', href: '/admin/products', icon: Settings },
+    { name: '动态配置', href: '/admin/configurations', icon: SlidersHorizontal },
     { name: '系统任务', href: '/admin/tasks', icon: Activity },
     { name: 'VISAMETRIC', href: '/admin/visametric', icon: CalendarClock },
     { name: 'Worker 状态', href: '/admin/workers', icon: Server },

+ 116 - 0
src/components/common/JsonConfigEditor.tsx

@@ -0,0 +1,116 @@
+'use client';
+
+import { useRef } from 'react';
+import Editor, { useMonaco } from '@monaco-editor/react';
+import { Braces, Minimize2 } from 'lucide-react';
+
+interface JsonConfigEditorProps {
+  value: string;
+  onChange: (value: string) => void;
+  onValidityChange?: (isValid: boolean, error: string | null) => void;
+  type?: string; // 用于判断是否强制为普通字符串
+}
+
+export default function JsonConfigEditor({ value, onChange, onValidityChange, type }: JsonConfigEditorProps) {
+  const monaco = useMonaco();
+  const editorRef = useRef<any>(null);
+
+  // 简单判断是否像 JSON
+  const isJsonLike = value.trim().startsWith('{') || value.trim().startsWith('[');
+  const language = type === 'json' || isJsonLike ? 'json' : 'plaintext';
+
+  const handleEditorDidMount = (editor: any) => {
+    editorRef.current = editor;
+  };
+
+  // 接收 Monaco 内部的语法校验结果
+  const handleValidate = (markers: any[]) => {
+    // severity === 8 代表 Error
+    const errors = markers.filter((marker) => marker.severity === 8);
+    if (errors.length > 0) {
+      const firstError = errors[0];
+      onValidityChange?.(
+        false, 
+        `第 ${firstError.startLineNumber} 行: ${firstError.message}`
+      );
+    } else {
+      onValidityChange?.(true, null);
+    }
+  };
+
+  const formatJson = () => {
+    if (editorRef.current) {
+      // 触发 Monaco 内置的格式化
+      editorRef.current.getAction('editor.action.formatDocument').run();
+    } else {
+      // 备用方案
+      try {
+        onChange(JSON.stringify(JSON.parse(value), null, 2));
+      } catch (e) {}
+    }
+  };
+
+  const compactJson = () => {
+    try {
+      onChange(JSON.stringify(JSON.parse(value)));
+    } catch (e) {
+      alert('JSON 语法有误,无法压缩');
+    }
+  };
+
+  return (
+    <div className="flex flex-col h-full space-y-2">
+      {/* 工具栏 */}
+      <div className="flex items-center justify-between">
+        <div className="text-xs text-slate-500">
+          {language === 'json' ? (
+            <span className="text-blue-600 font-medium">JSON 模式</span>
+          ) : (
+            <span className="text-amber-600 font-medium">纯文本模式 (Plaintext)</span>
+          )}
+          <span className="ml-2 text-slate-400">支持 VS Code 快捷键 (如 Ctrl+F 搜索, Alt+Shift+F 格式化)</span>
+        </div>
+        
+        <div className="flex gap-2">
+          <button
+            type="button"
+            onClick={formatJson}
+            className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
+          >
+            <Braces size={14} /> 格式化
+          </button>
+          <button
+            type="button"
+            onClick={compactJson}
+            className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
+          >
+            <Minimize2 size={14} /> 压缩
+          </button>
+        </div>
+      </div>
+
+      {/* 编辑器主体 */}
+      <div className="flex-1 border border-slate-200 rounded-lg overflow-hidden min-h-[400px]">
+        <Editor
+          height="100%"
+          language={language}
+          theme="vs-dark" // 如果你喜欢浅色,可以改为 "light"
+          value={value}
+          onChange={(val) => onChange(val || '')}
+          onValidate={handleValidate}
+          onMount={handleEditorDidMount}
+          options={{
+            minimap: { enabled: true, scale: 0.75 }, // 开启右侧代码缩略图,大 JSON 必备
+            scrollBeyondLastLine: false,
+            wordWrap: 'on',
+            folding: true, // 开启代码折叠
+            formatOnPaste: true,
+            fontSize: 13,
+            fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
+            tabSize: 2,
+          }}
+        />
+      </div>
+    </div>
+  );
+}