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