duqing2026's picture
Update index.html
d987640
<!DOCTYPE html>
<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>