Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Mock Data Master - 虚拟数据生成大师</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://unpkg.com/@phosphor-icons/web"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| .scrollbar-hide::-webkit-scrollbar { display: none; } | |
| .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } | |
| /* Custom scrollbar for table */ | |
| .custom-scrollbar::-webkit-scrollbar { width: 8px; height: 8px; } | |
| .custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #9ca3af; } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 text-gray-800 h-screen flex flex-col overflow-hidden"> | |
| <div id="app" class="flex flex-col h-full"> | |
| <!-- Header --> | |
| <header class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm z-10 shrink-0"> | |
| <div class="flex items-center gap-3"> | |
| <div class="bg-gradient-to-br from-blue-500 to-indigo-600 text-white p-2.5 rounded-lg shadow-md"> | |
| <i class="ph ph-database text-xl"></i> | |
| </div> | |
| <div> | |
| <h1 class="font-bold text-xl text-gray-900 tracking-tight">Mock Data Master</h1> | |
| <p class="text-xs text-gray-500 font-medium">虚拟数据生成大师</p> | |
| </div> | |
| </div> | |
| <!-- <div class="flex items-center gap-4"> | |
| <a href="https://github.com/duqing2026" target="_blank" class="text-gray-400 hover:text-gray-900 transition"> | |
| <i class="ph ph-github-logo text-2xl"></i> | |
| </a> | |
| </div> --> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-1 flex overflow-hidden"> | |
| <!-- Left Panel: Configuration --> | |
| <div class="w-1/3 min-w-[380px] max-w-[500px] bg-white border-r border-gray-200 flex flex-col z-0 shadow-[4px_0_24px_rgba(0,0,0,0.02)]"> | |
| <!-- Top Controls --> | |
| <div class="p-5 border-b border-gray-100 bg-gray-50/50 space-y-4"> | |
| <div class="flex items-center justify-between"> | |
| <h2 class="font-semibold text-gray-700 flex items-center gap-2"> | |
| <i class="ph ph-faders"></i> 模型配置 | |
| </h2> | |
| <button @click="resetSchema" class="text-xs text-red-500 hover:text-red-700 flex items-center gap-1 px-2 py-1 rounded hover:bg-red-50 transition"> | |
| <i class="ph ph-arrow-counter-clockwise"></i> 重置 | |
| </button> | |
| </div> | |
| <!-- Presets --> | |
| <div class="flex gap-2 items-center"> | |
| <label class="text-xs font-medium text-gray-500 whitespace-nowrap">快速模板:</label> | |
| <select @change="applyTemplate($event.target.value)" class="flex-1 px-2 py-1.5 border border-gray-300 rounded text-sm focus:border-blue-500 outline-none bg-white"> | |
| <option value="">-- 选择模板 --</option> | |
| <option value="user">用户 (User)</option> | |
| <option value="ecommerce">电商产品 (E-commerce)</option> | |
| <option value="order">订单 (Order)</option> | |
| <option value="article">文章 (Article)</option> | |
| </select> | |
| </div> | |
| <!-- Row Count --> | |
| <div class="flex items-center gap-4"> | |
| <label class="text-sm font-medium text-gray-600">生成行数</label> | |
| <div class="flex items-center flex-1 gap-2"> | |
| <input type="range" v-model.number="rowCount" min="1" max="1000" class="flex-1 accent-blue-600 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> | |
| <input type="number" v-model.number="rowCount" min="1" max="1000" class="w-20 px-3 py-1.5 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none text-sm text-center font-mono"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Fields List --> | |
| <div class="flex-1 overflow-y-auto p-5 space-y-3 custom-scrollbar"> | |
| <div v-for="(field, index) in schema" :key="index" class="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition group relative animate-fadeIn"> | |
| <div class="flex items-start gap-3"> | |
| <div class="flex items-center justify-center w-6 h-6 rounded-full bg-gray-100 text-gray-400 text-xs font-mono mt-1"> | |
| {{ index + 1 }} | |
| </div> | |
| <div class="flex-1 space-y-2"> | |
| <div class="flex gap-2"> | |
| <input type="text" v-model="field.name" placeholder="字段名 (如: username)" class="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm focus:border-blue-500 outline-none font-mono text-gray-700 placeholder-gray-400"> | |
| <select v-model="field.type" class="w-40 px-2 py-1.5 border border-gray-300 rounded text-sm focus:border-blue-500 outline-none bg-gray-50 text-gray-700"> | |
| <option v-for="type in availableTypes" :value="type.value">{{ type.label }}</option> | |
| </select> | |
| </div> | |
| <!-- Extra options for integer --> | |
| <div v-if="field.type === 'integer'" class="flex gap-2 items-center text-xs text-gray-500 pl-1 bg-gray-50 p-1.5 rounded border border-gray-100"> | |
| <span class="font-medium">范围:</span> | |
| <input type="number" v-model.number="field.min" class="w-16 border border-gray-300 rounded px-1 py-0.5 text-center focus:border-blue-500 outline-none"> | |
| <span class="text-gray-400">-</span> | |
| <input type="number" v-model.number="field.max" class="w-16 border border-gray-300 rounded px-1 py-0.5 text-center focus:border-blue-500 outline-none"> | |
| </div> | |
| </div> | |
| <button @click="removeField(index)" class="text-gray-400 hover:text-red-500 p-1.5 rounded hover:bg-red-50 transition opacity-0 group-hover:opacity-100"> | |
| <i class="ph ph-trash text-lg"></i> | |
| </button> | |
| </div> | |
| <div class="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 rounded-l-lg opacity-0 group-hover:opacity-100 transition"></div> | |
| </div> | |
| <button @click="addField" class="w-full py-3 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-500 hover:text-blue-500 hover:bg-blue-50 transition flex items-center justify-center gap-2 font-medium group"> | |
| <i class="ph ph-plus-circle text-xl group-hover:scale-110 transition"></i> 添加字段 | |
| </button> | |
| </div> | |
| <!-- Generate Button --> | |
| <div class="p-5 border-t border-gray-200 bg-white"> | |
| <button @click="generateData" :disabled="loading" class="w-full py-3.5 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white rounded-xl font-bold shadow-lg shadow-blue-200/50 transition transform active:scale-[0.99] flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"> | |
| <span v-if="loading" class="animate-spin"><i class="ph ph-spinner text-xl"></i></span> | |
| <span v-else class="flex items-center gap-2"><i class="ph ph-lightning text-xl"></i> 立即生成数据</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Right Panel: Preview & Export --> | |
| <div class="flex-1 flex flex-col bg-gray-50/50 overflow-hidden relative"> | |
| <!-- Toolbar --> | |
| <div class="px-6 py-3 border-b border-gray-200 bg-white flex items-center justify-between shrink-0 h-[65px]"> | |
| <div class="flex items-center gap-3"> | |
| <div class="bg-gray-100 p-1.5 rounded-md text-gray-500"> | |
| <i class="ph ph-table text-lg"></i> | |
| </div> | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-semibold text-gray-700">数据预览</span> | |
| <span class="text-xs text-gray-500" v-if="generatedData.length">共 {{ generatedData.length }} 条记录</span> | |
| </div> | |
| </div> | |
| <div class="flex gap-2" v-if="generatedData.length > 0"> | |
| <button @click="exportCSV" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm hover:bg-gray-50 text-gray-700 flex items-center gap-2 transition shadow-sm bg-white"> | |
| <i class="ph ph-file-csv text-green-600 text-lg"></i> CSV | |
| </button> | |
| <button @click="exportJSON" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm hover:bg-gray-50 text-gray-700 flex items-center gap-2 transition shadow-sm bg-white"> | |
| <i class="ph ph-brackets-curly text-yellow-600 text-lg"></i> JSON | |
| </button> | |
| <button @click="exportSQL" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm hover:bg-gray-50 text-gray-700 flex items-center gap-2 transition shadow-sm bg-white"> | |
| <i class="ph ph-database text-blue-600 text-lg"></i> SQL | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Table Content --> | |
| <div class="flex-1 overflow-auto p-6 relative custom-scrollbar"> | |
| <div v-if="loading" class="absolute inset-0 bg-white/50 backdrop-blur-sm z-10 flex items-center justify-center"> | |
| <div class="bg-white p-4 rounded-xl shadow-xl flex items-center gap-3 border border-gray-100"> | |
| <i class="ph ph-spinner animate-spin text-blue-600 text-2xl"></i> | |
| <span class="text-gray-600 font-medium">正在生成数据...</span> | |
| </div> | |
| </div> | |
| <div v-if="generatedData.length === 0 && !loading" class="h-full flex flex-col items-center justify-center text-gray-400 select-none"> | |
| <div class="bg-gray-100 p-6 rounded-full mb-4"> | |
| <i class="ph ph-magic-wand text-4xl text-gray-300"></i> | |
| </div> | |
| <p class="text-lg font-medium text-gray-500">准备就绪</p> | |
| <p class="text-sm">配置左侧字段模型,点击生成按钮开始</p> | |
| </div> | |
| <div v-else class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden min-w-full inline-block align-middle"> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full text-sm text-left border-collapse"> | |
| <thead class="bg-gray-50 text-gray-600 font-semibold sticky top-0 z-10 shadow-sm"> | |
| <tr> | |
| <th class="px-4 py-3 w-16 text-center border-b border-gray-200 bg-gray-50">#</th> | |
| <th v-for="col in schema" :key="col.name" class="px-4 py-3 whitespace-nowrap border-b border-gray-200 bg-gray-50 text-xs uppercase tracking-wider">{{ col.name }}</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-gray-100"> | |
| <tr v-for="(row, idx) in generatedData" :key="idx" class="hover:bg-blue-50/30 transition group"> | |
| <td class="px-4 py-3 text-center text-gray-400 font-mono text-xs border-r border-transparent group-hover:border-blue-100 bg-gray-50/30">{{ idx + 1 }}</td> | |
| <td v-for="col in schema" :key="col.name" class="px-4 py-3 whitespace-nowrap text-gray-700 max-w-xs truncate font-mono text-xs"> | |
| <span v-if="row[col.name] === null" class="text-gray-300 italic">null</span> | |
| <span v-else>{{ formatValue(row[col.name]) }}</span> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- SQL Modal --> | |
| <div v-if="showSQLModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fadeIn"> | |
| <div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] border border-gray-200 overflow-hidden transform transition-all scale-100"> | |
| <div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50"> | |
| <div class="flex items-center gap-2"> | |
| <div class="bg-blue-100 p-1.5 rounded text-blue-600"><i class="ph ph-database"></i></div> | |
| <h3 class="font-bold text-lg text-gray-800">导出 SQL</h3> | |
| </div> | |
| <button @click="showSQLModal = false" class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-200 transition"><i class="ph ph-x text-xl"></i></button> | |
| </div> | |
| <div class="p-4 bg-gray-50 border-b border-gray-200 flex gap-4 items-center"> | |
| <div class="flex items-center gap-2 flex-1"> | |
| <label class="text-sm font-medium text-gray-600">表名:</label> | |
| <input type="text" v-model="tableName" placeholder="users" class="border border-gray-300 rounded px-3 py-1.5 text-sm outline-none focus:border-blue-500 font-mono w-48"> | |
| </div> | |
| </div> | |
| <div class="flex-1 overflow-auto p-0 bg-[#1e1e1e] relative group"> | |
| <button @click="copyToClipboard(sqlContent)" class="absolute top-4 right-4 bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded text-xs backdrop-blur-md transition flex items-center gap-2 border border-white/10 z-10"> | |
| <i class="ph ph-copy"></i> 复制 | |
| </button> | |
| <pre class="p-6 text-gray-300 font-mono text-xs leading-relaxed overflow-auto h-full"><code class="language-sql">{{ sqlContent }}</code></pre> | |
| </div> | |
| <div class="p-4 border-t border-gray-100 flex justify-end gap-3 bg-white"> | |
| <button @click="showSQLModal = false" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg text-sm font-medium transition">取消</button> | |
| <button @click="downloadSQL" class="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-md shadow-blue-200 text-sm font-medium transition flex items-center gap-2"> | |
| <i class="ph ph-download-simple"></i> 下载 .sql 文件 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted, watch } = Vue; | |
| createApp({ | |
| setup() { | |
| // Initial Default Schema | |
| const defaultSchema = [ | |
| { name: 'id', type: 'integer', min: 1, max: 1000 }, | |
| { name: 'username', type: 'name' }, | |
| { name: 'email', type: 'email' }, | |
| { name: 'created_at', type: 'date_this_year' } | |
| ]; | |
| // Templates | |
| const templates = { | |
| user: [ | |
| { name: 'id', type: 'integer', min: 1, max: 10000 }, | |
| { name: 'full_name', type: 'name' }, | |
| { name: 'email', type: 'email' }, | |
| { name: 'phone', type: 'phone_number' }, | |
| { name: 'address', type: 'address' }, | |
| { name: 'is_active', type: 'boolean' } | |
| ], | |
| ecommerce: [ | |
| { name: 'product_id', type: 'uuid' }, | |
| { name: 'product_name', type: 'text' }, | |
| { name: 'price', type: 'integer', min: 10, max: 5000 }, | |
| { name: 'color', type: 'color' }, | |
| { name: 'image', type: 'image_url' }, | |
| { name: 'in_stock', type: 'boolean' } | |
| ], | |
| order: [ | |
| { name: 'order_no', type: 'uuid' }, | |
| { name: 'customer_name', type: 'name' }, | |
| { name: 'amount', type: 'integer', min: 100, max: 10000 }, | |
| { name: 'status', type: 'boolean' }, | |
| { name: 'order_date', type: 'date_this_year' } | |
| ], | |
| article: [ | |
| { name: 'title', type: 'text' }, | |
| { name: 'author', type: 'name' }, | |
| { name: 'content', type: 'paragraph' }, | |
| { name: 'publish_date', type: 'date_this_year' }, | |
| { name: 'cover_image', type: 'image_url' } | |
| ] | |
| }; | |
| const schema = ref(defaultSchema); | |
| const availableTypes = ref([]); | |
| const rowCount = ref(20); | |
| const generatedData = ref([]); | |
| const loading = ref(false); | |
| const showSQLModal = ref(false); | |
| const sqlContent = ref(''); | |
| const tableName = ref('users'); | |
| // Persistence | |
| const loadState = () => { | |
| const saved = localStorage.getItem('mockDataMaster_schema'); | |
| if (saved) { | |
| try { | |
| const parsed = JSON.parse(saved); | |
| schema.value = parsed.schema || defaultSchema; | |
| rowCount.value = parsed.rowCount || 20; | |
| } catch (e) { console.error('Load failed', e); } | |
| } | |
| }; | |
| const saveState = () => { | |
| localStorage.setItem('mockDataMaster_schema', JSON.stringify({ | |
| schema: schema.value, | |
| rowCount: rowCount.value | |
| })); | |
| }; | |
| // Watch for changes to save | |
| watch([schema, rowCount], saveState, { deep: true }); | |
| const applyTemplate = (tplName) => { | |
| if (templates[tplName]) { | |
| schema.value = JSON.parse(JSON.stringify(templates[tplName])); | |
| } | |
| }; | |
| const resetSchema = () => { | |
| if(confirm('确定要重置所有配置吗?')) { | |
| schema.value = JSON.parse(JSON.stringify(defaultSchema)); | |
| rowCount.value = 20; | |
| generatedData.value = []; | |
| } | |
| }; | |
| const fetchTypes = async () => { | |
| try { | |
| const res = await fetch('/api/types'); | |
| availableTypes.value = await res.json(); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }; | |
| const addField = () => { | |
| schema.value.push({ name: 'new_field', type: 'text' }); | |
| // Scroll to bottom after Vue updates DOM | |
| setTimeout(() => { | |
| const container = document.querySelector('.custom-scrollbar'); | |
| if (container) container.scrollTop = container.scrollHeight; | |
| }, 10); | |
| }; | |
| const removeField = (index) => { | |
| schema.value.splice(index, 1); | |
| }; | |
| const generateData = async () => { | |
| if (schema.value.length === 0) return; | |
| loading.value = true; | |
| try { | |
| const res = await fetch('/api/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| count: rowCount.value, | |
| schema: schema.value | |
| }) | |
| }); | |
| if (!res.ok) throw new Error('Network response was not ok'); | |
| generatedData.value = await res.json(); | |
| } catch (e) { | |
| alert('生成失败,请检查网络或配置'); | |
| console.error(e); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| const formatValue = (val) => { | |
| if (val === true) return 'TRUE'; | |
| if (val === false) return 'FALSE'; | |
| return val; | |
| }; | |
| // Export functions | |
| const escapeCSV = (val) => { | |
| if (val === null || val === undefined) return ''; | |
| let str = String(val); | |
| if (str.includes(',') || str.includes('"') || str.includes('\n')) { | |
| str = `"${str.replace(/"/g, '""')}"`; | |
| } | |
| return str; | |
| }; | |
| const exportCSV = () => { | |
| if (!generatedData.value.length) return; | |
| const headers = schema.value.map(f => f.name).join(','); | |
| const rows = generatedData.value.map(row => | |
| schema.value.map(f => escapeCSV(row[f.name])).join(',') | |
| ).join('\n'); | |
| const csvContent = `${headers}\n${rows}`; | |
| downloadFile(csvContent, 'mock_data.csv', 'text/csv;charset=utf-8;'); | |
| }; | |
| const exportJSON = () => { | |
| if (!generatedData.value.length) return; | |
| downloadFile(JSON.stringify(generatedData.value, null, 2), 'mock_data.json', 'application/json'); | |
| }; | |
| const generateSQL = () => { | |
| if (!generatedData.value.length) return ''; | |
| const table = tableName.value || 'table_name'; | |
| const cols = schema.value.map(f => f.name).join(', '); | |
| const stmts = generatedData.value.map(row => { | |
| const vals = schema.value.map(f => { | |
| const val = row[f.name]; | |
| if (val === null || val === undefined) return 'NULL'; | |
| if (typeof val === 'number') return val; | |
| if (typeof val === 'boolean') return val ? 1 : 0; | |
| // Escape single quotes for SQL | |
| return `'${String(val).replace(/'/g, "''")}'`; | |
| }).join(', '); | |
| return `INSERT INTO ${table} (${cols}) VALUES (${vals});`; | |
| }).join('\n'); | |
| return stmts; | |
| }; | |
| const exportSQL = () => { | |
| sqlContent.value = generateSQL(); | |
| showSQLModal.value = true; | |
| }; | |
| const downloadSQL = () => { | |
| const sql = generateSQL(); | |
| downloadFile(sql, `${tableName.value}.sql`, 'text/sql'); | |
| }; | |
| const downloadFile = (content, fileName, mimeType) => { | |
| const blob = new Blob([content], { type: mimeType }); | |
| const link = document.createElement('a'); | |
| link.href = URL.createObjectURL(blob); | |
| link.download = fileName; | |
| link.click(); | |
| URL.revokeObjectURL(link.href); | |
| }; | |
| const copyToClipboard = async (text) => { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| alert('已复制到剪贴板'); | |
| } catch (err) { | |
| console.error('Failed to copy: ', err); | |
| } | |
| }; | |
| onMounted(() => { | |
| fetchTypes(); | |
| loadState(); | |
| // Generate initial data if empty | |
| if (generatedData.value.length === 0) { | |
| generateData(); | |
| } | |
| }); | |
| return { | |
| schema, availableTypes, rowCount, generatedData, loading, | |
| addField, removeField, generateData, formatValue, | |
| exportCSV, exportJSON, exportSQL, | |
| showSQLModal, sqlContent, tableName, downloadSQL, | |
| applyTemplate, resetSchema, copyToClipboard | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| <style> | |
| /* Animation utilities */ | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(5px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .animate-fadeIn { animation: fadeIn 0.3s ease-out; } | |
| </style> | |
| </body> | |
| </html> | |