SchemaManager.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import api from '@/lib/api';
  4. import { Loader2, Plus, Save, X, FileJson, List, Edit3 } from 'lucide-react';
  5. import JsonEditor from '@/components/common/JsonEditor';
  6. interface SchemaManagerProps {
  7. isOpen: boolean;
  8. onClose: () => void;
  9. }
  10. interface SchemaItem {
  11. id: number;
  12. name: string;
  13. schema_json: string;
  14. description?: string;
  15. }
  16. export default function SchemaManager({ isOpen, onClose }: SchemaManagerProps) {
  17. const [schemas, setSchemas] = useState<SchemaItem[]>([]);
  18. const [loading, setLoading] = useState(false);
  19. // 移动端 Tab 状态: 'list' | 'editor'
  20. const [activeTab, setActiveTab] = useState<'list' | 'editor'>('list');
  21. // 编辑模式状态
  22. const [editingId, setEditingId] = useState<number | null>(null);
  23. const [formData, setFormData] = useState({
  24. name: '',
  25. description: '',
  26. schema_json: '{\n "type": "object",\n "properties": {\n "field_name": { "type": "string", "title": "示例字段" }\n }\n}'
  27. });
  28. useEffect(() => {
  29. if (isOpen) {
  30. fetchSchemas();
  31. setActiveTab('list'); // 打开时默认显示列表
  32. }
  33. }, [isOpen]);
  34. const fetchSchemas = async () => {
  35. setLoading(true);
  36. try {
  37. const res = await api.get('/api/vas/schema/list');
  38. setSchemas(Array.isArray(res.data.data) ? res.data.data : []);
  39. } catch (e) {
  40. console.warn("API Error, using mock schema data");
  41. setSchemas([]);
  42. } finally {
  43. setLoading(false);
  44. }
  45. };
  46. const handleSave = async () => {
  47. if (!formData.name) return alert("名称必填");
  48. try {
  49. JSON.parse(formData.schema_json); // 校验 JSON
  50. if (editingId) {
  51. await api.post(`/api/vas/schema/update`, formData, {params: {"id": editingId}});
  52. } else {
  53. await api.post('/api/vas/schema/create', formData);
  54. }
  55. alert("保存成功");
  56. fetchSchemas();
  57. // 保存后逻辑:
  58. // 如果是新建,保存后清空表单并留再编辑页,或者切回列表
  59. // 这里选择保留在当前页,但如果是移动端可以考虑切回列表
  60. if (!editingId) {
  61. setFormData({ name: '', description: '', schema_json: '{}' });
  62. }
  63. } catch (e: any) {
  64. alert("保存失败: " + (e.message || "JSON 格式错误"));
  65. }
  66. };
  67. const handleCreateNew = () => {
  68. setEditingId(null);
  69. setFormData({ name: '', description: '', schema_json: '{}' });
  70. setActiveTab('editor'); // 移动端自动切换到编辑器 tab
  71. };
  72. const handleEdit = (item: SchemaItem) => {
  73. setEditingId(item.id);
  74. setFormData({
  75. name: item.name,
  76. description: item.description || '',
  77. schema_json: typeof item.schema_json === 'string' ? item.schema_json : JSON.stringify(item.schema_json, null, 2)
  78. });
  79. setActiveTab('editor'); // 移动端自动切换到编辑器 tab
  80. };
  81. const handleDelete = async (id: number) => {
  82. if(!confirm("删除 Schema 可能导致关联商品失效,确定删除吗?")) return;
  83. try {
  84. await api.delete('/api/vas/schema/delete', {params: {"id": id}});
  85. fetchSchemas();
  86. if (editingId === id) {
  87. setEditingId(null);
  88. setFormData({ name: '', description: '', schema_json: '{}' });
  89. setActiveTab('list'); // 删除后切回列表
  90. }
  91. } catch (e) { alert("删除失败"); }
  92. };
  93. if (!isOpen) return null;
  94. return (
  95. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-0 md:p-4">
  96. {/*
  97. 容器:
  98. - 移动端:全屏 (w-full h-full),无圆角
  99. - 桌面端:固定大小 (max-w-4xl h-[85vh]),有圆角
  100. */}
  101. <div className="bg-white w-full h-full md:w-full md:max-w-4xl md:h-[85vh] md:rounded-xl shadow-2xl flex flex-col overflow-hidden animate-in zoom-in duration-200">
  102. {/* Header */}
  103. <div className="px-4 py-3 md:px-6 md:py-4 border-b flex justify-between items-center bg-slate-50 flex-shrink-0">
  104. <h3 className="font-bold text-gray-900 text-lg flex items-center gap-2">
  105. <FileJson className="text-purple-600" /> 表单 Schema 管理
  106. </h3>
  107. <button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1">
  108. <X size={24} />
  109. </button>
  110. </div>
  111. {/* Mobile Tabs (仅移动端显示) */}
  112. <div className="flex md:hidden border-b border-gray-200 flex-shrink-0">
  113. <button
  114. onClick={() => setActiveTab('list')}
  115. className={`flex-1 py-3 text-sm font-medium flex items-center justify-center gap-2 ${
  116. activeTab === 'list' ? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50' : 'text-gray-500'
  117. }`}
  118. >
  119. <List size={16} /> 列表
  120. </button>
  121. <button
  122. onClick={() => setActiveTab('editor')}
  123. className={`flex-1 py-3 text-sm font-medium flex items-center justify-center gap-2 ${
  124. activeTab === 'editor' ? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50' : 'text-gray-500'
  125. }`}
  126. >
  127. <Edit3 size={16} /> {editingId ? '编辑' : '新建'}
  128. </button>
  129. </div>
  130. {/* Content Body */}
  131. <div className="flex flex-1 overflow-hidden relative">
  132. {/*
  133. 左侧:列表
  134. - 移动端:根据 activeTab === 'list' 显示/隐藏
  135. - 桌面端:md:flex md:w-1/3
  136. */}
  137. <div className={`
  138. flex-col bg-slate-50 border-r border-slate-200 overflow-y-auto p-4
  139. ${activeTab === 'list' ? 'flex w-full' : 'hidden'}
  140. md:flex md:w-1/3
  141. `}>
  142. <button
  143. onClick={handleCreateNew}
  144. className="w-full mb-4 py-2.5 border-2 border-dashed border-blue-300 text-blue-600 rounded-lg hover:bg-blue-50 flex items-center justify-center gap-2 font-bold text-sm transition"
  145. >
  146. <Plus size={16} /> 新建 Schema
  147. </button>
  148. <div className="space-y-2">
  149. {loading && <div className="text-center py-4 text-gray-400"><Loader2 className="animate-spin inline mr-2"/> 加载中...</div>}
  150. {!loading && schemas.length === 0 && <div className="text-center py-10 text-gray-400 text-xs">暂无数据</div>}
  151. {schemas.map(s => (
  152. <div
  153. key={s.id}
  154. onClick={() => handleEdit(s)}
  155. className={`p-3 rounded-lg cursor-pointer border transition ${
  156. editingId === s.id
  157. ? 'bg-white border-blue-500 shadow-md ring-1 ring-blue-500'
  158. : 'bg-white border-slate-200 hover:border-blue-300'
  159. }`}
  160. >
  161. <div className="font-bold text-slate-800 text-sm">{s.name}</div>
  162. <div className="text-xs text-slate-500 truncate mt-1">ID: {s.id}</div>
  163. </div>
  164. ))}
  165. </div>
  166. </div>
  167. {/*
  168. 右侧:编辑区
  169. - 移动端:根据 activeTab === 'editor' 显示/隐藏
  170. - 桌面端:md:flex md:w-2/3
  171. */}
  172. <div className={`
  173. flex-col p-4 md:p-6 overflow-y-auto w-full
  174. ${activeTab === 'editor' ? 'flex' : 'hidden'}
  175. md:flex md:w-2/3
  176. `}>
  177. <h4 className="text-lg font-bold mb-4 border-b pb-2 flex justify-between items-center">
  178. <span>{editingId ? `编辑 Schema #${editingId}` : '创建新 Schema'}</span>
  179. {editingId && (
  180. <button onClick={() => handleDelete(editingId)} className="text-red-500 hover:text-red-700 text-xs flex items-center gap-1 md:hidden">
  181. <Trash2 size={14} /> 删除
  182. </button>
  183. )}
  184. </h4>
  185. <div className="space-y-4">
  186. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  187. <div>
  188. <label className="block text-sm font-medium mb-1 text-slate-700">名称 (Name) <span className="text-red-500">*</span></label>
  189. <input
  190. type="text"
  191. className="w-full border border-slate-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
  192. value={formData.name}
  193. onChange={e => setFormData({...formData, name: e.target.value})}
  194. placeholder="e.g. Japan Visa Form"
  195. />
  196. </div>
  197. <div>
  198. <label className="block text-sm font-medium mb-1 text-slate-700">描述 (Desc)</label>
  199. <input
  200. type="text"
  201. className="w-full border border-slate-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
  202. value={formData.description}
  203. onChange={e => setFormData({...formData, description: e.target.value})}
  204. placeholder="简短描述用途"
  205. />
  206. </div>
  207. </div>
  208. {/* JSON Editor */}
  209. <div className="flex-1 min-h-[300px]">
  210. <JsonEditor
  211. label="Schema JSON (定义动态表单结构)"
  212. value={formData.schema_json}
  213. onChange={(val) => setFormData({...formData, schema_json: val})}
  214. height="h-[400px] md:h-80" // 移动端稍微高一点
  215. placeholder='{"type": "object", ...}'
  216. />
  217. </div>
  218. <div className="flex justify-end gap-3 pt-4 border-t mt-auto">
  219. {editingId && (
  220. <button onClick={() => handleDelete(editingId)} className="text-red-600 px-4 text-sm hover:underline hidden md:block">删除</button>
  221. )}
  222. <button onClick={handleSave} className="bg-blue-600 text-white px-6 py-2.5 rounded-lg font-bold shadow hover:bg-blue-700 flex items-center gap-2 transition w-full md:w-auto justify-center">
  223. <Save size={18} /> 保存配置
  224. </button>
  225. </div>
  226. </div>
  227. </div>
  228. </div>
  229. </div>
  230. </div>
  231. );
  232. }