Spaces:
Running
Running
| <html lang="zh-CN" class="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>智能数据炼油厂 (Smart Data Refinery)</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| colors: { | |
| primary: '#3b82f6', | |
| secondary: '#10b981', | |
| dark: '#111827', | |
| darker: '#0f172a', | |
| panel: '#1e293b' | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| [v-cloak] { display: none ; } | |
| .glass { | |
| background: rgba(30, 41, 59, 0.7); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| ::-webkit-scrollbar { width: 8px; height: 8px; } | |
| ::-webkit-scrollbar-track { background: #1e293b; } | |
| ::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #64748b; } | |
| </style> | |
| </head> | |
| <body class="bg-darker text-gray-200 min-h-screen flex flex-col"> | |
| <div id="app" class="flex flex-col h-screen" v-cloak> | |
| <!-- Header --> | |
| <header class="h-16 border-b border-gray-700 bg-panel flex items-center justify-between px-6 shrink-0"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 rounded bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center font-bold text-white">D</div> | |
| <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400">智能数据炼油厂</h1> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <button @click="loadDemoData" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 rounded text-sm font-medium transition flex items-center gap-2" :disabled="loading"> | |
| <span>🧪 加载演示数据</span> | |
| </button> | |
| <button @click="exportData('csv')" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 rounded text-sm font-medium transition flex items-center gap-2" :disabled="!filename"> | |
| <span>导出 CSV</span> | |
| </button> | |
| <button @click="exportData('json')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 rounded text-sm font-medium transition flex items-center gap-2" :disabled="!filename"> | |
| <span>导出 JSON</span> | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-1 flex overflow-hidden"> | |
| <!-- Sidebar (Pipeline) --> | |
| <aside class="w-80 bg-panel border-r border-gray-700 flex flex-col shrink-0"> | |
| <div class="p-4 border-b border-gray-700"> | |
| <h2 class="font-semibold text-gray-300 mb-2">处理流水线 (Pipeline)</h2> | |
| <div class="text-xs text-gray-500">按顺序执行以下操作</div> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-4 space-y-3"> | |
| <div v-if="operations.length === 0" class="text-center text-gray-500 py-10 border-2 border-dashed border-gray-700 rounded-lg"> | |
| 暂无操作 | |
| </div> | |
| <div v-for="(op, index) in operations" :key="index" class="bg-dark p-3 rounded border border-gray-600 relative group"> | |
| <button @click="removeOperation(index)" class="absolute top-2 right-2 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition">✕</button> | |
| <div class="text-sm font-bold text-blue-400 mb-1">${ getOpName(op.type) }</div> | |
| <!-- Dynamic Params Display --> | |
| <div class="text-xs text-gray-400 space-y-1"> | |
| <div v-if="op.type === 'filter'"> | |
| ${ op.params.column } ${ op.params.operator } ${ op.params.value } | |
| </div> | |
| <div v-if="op.type === 'fillna'"> | |
| ${ op.params.subset ? op.params.subset : '所有列' } -> ${ op.params.method || op.params.value } | |
| </div> | |
| <div v-if="op.type === 'drop_duplicates'"> | |
| ${ op.params.subset ? '依据: ' + op.params.subset : '完全重复' } | |
| </div> | |
| <div v-if="op.type === 'sort'"> | |
| ${ op.params.column } (${ op.params.ascending ? '升序' : '降序' }) | |
| </div> | |
| <div v-if="op.type === 'select_columns'"> | |
| 保留: ${ op.params.columns.join(', ') } | |
| </div> | |
| <div v-if="op.type === 'rename'"> | |
| 重命名: ${ JSON.stringify(op.params.mapping) } | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add Operation Button --> | |
| <div class="p-4 border-t border-gray-700 bg-panel"> | |
| <button @click="showAddOpModal = true" class="w-full py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition" :disabled="!filename"> | |
| + 添加操作 | |
| </button> | |
| </div> | |
| </aside> | |
| <!-- Main Area --> | |
| <div class="flex-1 flex flex-col bg-darker overflow-hidden relative"> | |
| <!-- Upload / Empty State --> | |
| <div v-if="!filename" class="absolute inset-0 flex items-center justify-center z-10 bg-darker/90 backdrop-blur-sm"> | |
| <div | |
| class="w-96 h-64 border-2 border-dashed border-gray-600 rounded-xl flex flex-col items-center justify-center cursor-pointer hover:border-blue-500 hover:bg-blue-500/5 transition group" | |
| @click="triggerFileInput" | |
| @dragover.prevent | |
| @drop.prevent="handleDrop" | |
| > | |
| <input type="file" ref="fileInput" class="hidden" @change="handleFileSelect" accept=".csv,.json,.xlsx"> | |
| <div class="text-4xl mb-4 group-hover:scale-110 transition">📂</div> | |
| <div class="text-lg font-medium text-gray-300">点击或拖拽上传文件</div> | |
| <div class="text-sm text-gray-500 mt-2">支持 CSV, JSON, Excel (< 16MB)</div> | |
| </div> | |
| </div> | |
| <!-- Data Table --> | |
| <div class="flex-1 overflow-auto p-0 relative"> | |
| <div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-darker/50 z-20"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> | |
| </div> | |
| <table v-if="previewData" class="w-full text-left border-collapse"> | |
| <thead class="bg-panel sticky top-0 z-10 shadow-md"> | |
| <tr> | |
| <th v-for="col in previewColumns" :key="col" class="p-3 text-xs font-medium text-gray-400 uppercase tracking-wider border-b border-gray-700 whitespace-nowrap"> | |
| ${ col } | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-gray-800"> | |
| <tr v-for="(row, idx) in previewData" :key="idx" class="hover:bg-gray-800/50 transition"> | |
| <td v-for="col in previewColumns" :key="col" class="p-3 text-sm text-gray-300 whitespace-nowrap border-r border-gray-800 last:border-r-0"> | |
| ${ row[col] } | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Footer Stats --> | |
| <div class="h-10 bg-panel border-t border-gray-700 flex items-center px-4 gap-6 text-xs text-gray-400 shrink-0"> | |
| <div v-if="stats"> | |
| <span>行数: <span class="text-white">${ stats.rows }</span></span> | |
| <span class="ml-4">列数: <span class="text-white">${ stats.columns }</span></span> | |
| <span class="ml-4">缺失值: <span class="text-yellow-500">${ stats.missing_values }</span></span> | |
| <span class="ml-4">重复行: <span class="text-red-500">${ stats.duplicates }</span></span> | |
| </div> | |
| <div class="ml-auto"> | |
| <span v-if="filename" class="text-blue-400">${ filename }</span> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Add Operation Modal --> | |
| <div v-if="showAddOpModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"> | |
| <div class="bg-panel border border-gray-600 rounded-lg w-[500px] shadow-2xl p-6"> | |
| <h3 class="text-lg font-bold mb-4 text-white">添加操作</h3> | |
| <div class="mb-4"> | |
| <label class="block text-sm text-gray-400 mb-1">操作类型</label> | |
| <select v-model="newOp.type" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500"> | |
| <option value="filter">筛选 (Filter)</option> | |
| <option value="sort">排序 (Sort)</option> | |
| <option value="fillna">填充缺失值 (Fill NA)</option> | |
| <option value="drop_duplicates">去重 (Drop Duplicates)</option> | |
| <option value="select_columns">选择列 (Select Columns)</option> | |
| <option value="rename">重命名列 (Rename)</option> | |
| </select> | |
| </div> | |
| <!-- Dynamic Inputs based on Type --> | |
| <div class="space-y-3 mb-6"> | |
| <!-- Filter --> | |
| <div v-if="newOp.type === 'filter'"> | |
| <select v-model="newOp.params.column" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white mb-2"> | |
| <option v-for="col in previewColumns" :value="col">${ col }</option> | |
| </select> | |
| <div class="flex gap-2 mb-2"> | |
| <select v-model="newOp.params.operator" class="w-1/3 bg-dark border border-gray-600 rounded px-3 py-2 text-white"> | |
| <option value="==">等于</option> | |
| <option value="!=">不等于</option> | |
| <option value=">">大于</option> | |
| <option value="<">小于</option> | |
| <option value="contains">包含</option> | |
| </select> | |
| <input v-model="newOp.params.value" placeholder="值" class="w-2/3 bg-dark border border-gray-600 rounded px-3 py-2 text-white"> | |
| </div> | |
| </div> | |
| <!-- Sort --> | |
| <div v-if="newOp.type === 'sort'"> | |
| <select v-model="newOp.params.column" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white mb-2"> | |
| <option v-for="col in previewColumns" :value="col">${ col }</option> | |
| </select> | |
| <label class="flex items-center gap-2 text-sm text-gray-300"> | |
| <input type="checkbox" v-model="newOp.params.ascending"> 升序 (Ascending) | |
| </label> | |
| </div> | |
| <!-- FillNA --> | |
| <div v-if="newOp.type === 'fillna'"> | |
| <select v-model="newOp.params.subset" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white mb-2"> | |
| <option value="">所有列</option> | |
| <option v-for="col in previewColumns" :value="col">${ col }</option> | |
| </select> | |
| <div class="flex gap-2"> | |
| <input v-model="newOp.params.value" placeholder="填充值 (e.g. 0, Unknown)" class="flex-1 bg-dark border border-gray-600 rounded px-3 py-2 text-white"> | |
| <select v-model="newOp.params.method" class="w-1/3 bg-dark border border-gray-600 rounded px-3 py-2 text-white"> | |
| <option value="">指定值</option> | |
| <option value="ffill">前向填充</option> | |
| <option value="bfill">后向填充</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- Drop Duplicates --> | |
| <div v-if="newOp.type === 'drop_duplicates'"> | |
| <select v-model="newOp.params.subset" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white mb-2"> | |
| <option value="">所有列 (完全重复)</option> | |
| <option v-for="col in previewColumns" :value="col">${ col }</option> | |
| </select> | |
| </div> | |
| <!-- Select Columns --> | |
| <div v-if="newOp.type === 'select_columns'"> | |
| <div class="h-32 overflow-y-auto border border-gray-600 rounded p-2 bg-dark"> | |
| <label v-for="col in previewColumns" :key="col" class="flex items-center gap-2 text-sm text-gray-300 mb-1"> | |
| <input type="checkbox" :value="col" v-model="newOp.params.columns"> ${ col } | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Rename --> | |
| <div v-if="newOp.type === 'rename'"> | |
| <select v-model="tempRenameCol" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white mb-2"> | |
| <option v-for="col in previewColumns" :value="col">${ col }</option> | |
| </select> | |
| <input v-model="tempRenameVal" placeholder="新列名" class="w-full bg-dark border border-gray-600 rounded px-3 py-2 text-white"> | |
| </div> | |
| </div> | |
| <div class="flex justify-end gap-3"> | |
| <button @click="showAddOpModal = false" class="px-4 py-2 text-gray-400 hover:text-white transition">取消</button> | |
| <button @click="addOperation" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-white font-medium transition">确认添加</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, reactive } = Vue | |
| createApp({ | |
| delimiters: ['${', '}'], | |
| setup() { | |
| const filename = ref('') | |
| const previewData = ref(null) | |
| const previewColumns = ref([]) | |
| const stats = ref(null) | |
| const operations = ref([]) | |
| const loading = ref(false) | |
| const showAddOpModal = ref(false) | |
| // Add Op Form | |
| const newOp = reactive({ | |
| type: 'filter', | |
| params: { | |
| columns: [], // for select_columns | |
| ascending: true | |
| } | |
| }) | |
| const tempRenameCol = ref('') | |
| const tempRenameVal = ref('') | |
| const fileInput = ref(null) | |
| const triggerFileInput = () => fileInput.value.click() | |
| const handleFileSelect = (e) => { | |
| const file = e.target.files[0] | |
| if (file) uploadFile(file) | |
| } | |
| const handleDrop = (e) => { | |
| const file = e.dataTransfer.files[0] | |
| if (file) uploadFile(file) | |
| } | |
| const loadDemoData = async () => { | |
| loading.value = true | |
| try { | |
| const res = await axios.post('/api/load_demo') | |
| filename.value = res.data.filename | |
| previewData.value = res.data.preview.data | |
| previewColumns.value = res.data.preview.columns | |
| stats.value = res.data.preview.stats | |
| operations.value = [] | |
| } catch (e) { | |
| alert('Demo load failed: ' + (e.response?.data?.error || e.message)) | |
| } finally { | |
| loading.value = false | |
| } | |
| } | |
| const uploadFile = async (file) => { | |
| // Backend limit is 50MB now, frontend warning at 50MB | |
| if (file.size > 50 * 1024 * 1024) { | |
| alert('文件过大,建议小于 50MB') | |
| } | |
| const formData = new FormData() | |
| formData.append('file', file) | |
| loading.value = true | |
| try { | |
| const res = await axios.post('/api/upload', formData) | |
| filename.value = res.data.filename | |
| previewData.value = res.data.preview.data | |
| previewColumns.value = res.data.preview.columns | |
| stats.value = res.data.preview.stats | |
| operations.value = [] // Reset operations | |
| } catch (e) { | |
| alert('Upload failed: ' + (e.response?.data?.error || e.message)) | |
| } finally { | |
| loading.value = false | |
| } | |
| } | |
| const addOperation = () => { | |
| const op = JSON.parse(JSON.stringify(newOp)) // Deep copy | |
| // Specific logic fixes | |
| if (op.type === 'rename') { | |
| if (!tempRenameCol.value || !tempRenameVal.value) return | |
| op.params.mapping = { [tempRenameCol.value]: tempRenameVal.value } | |
| } | |
| if (op.type === 'select_columns' && op.params.columns.length === 0) return | |
| operations.value.push(op) | |
| showAddOpModal.value = false | |
| // Reset specialized params | |
| newOp.params = { columns: [], ascending: true } | |
| tempRenameCol.value = '' | |
| tempRenameVal.value = '' | |
| // Trigger process | |
| processPipeline() | |
| } | |
| const removeOperation = (index) => { | |
| operations.value.splice(index, 1) | |
| processPipeline() | |
| } | |
| const processPipeline = async () => { | |
| loading.value = true | |
| try { | |
| const res = await axios.post('/api/process', { | |
| filename: filename.value, | |
| operations: operations.value | |
| }) | |
| previewData.value = res.data.preview.data | |
| previewColumns.value = res.data.preview.columns | |
| stats.value = res.data.preview.stats | |
| } catch (e) { | |
| alert('Processing failed: ' + (e.response?.data?.error || e.message)) | |
| } finally { | |
| loading.value = false | |
| } | |
| } | |
| const exportData = async (format) => { | |
| try { | |
| const res = await axios.post('/api/export', { | |
| filename: filename.value, | |
| operations: operations.value, | |
| format: format | |
| }, { responseType: 'blob' }) | |
| const url = window.URL.createObjectURL(new Blob([res.data])) | |
| const link = document.createElement('a') | |
| link.href = url | |
| link.setAttribute('download', `processed_${filename.value.split('.')[0]}.${format}`) | |
| document.body.appendChild(link) | |
| link.click() | |
| } catch (e) { | |
| alert('Export failed') | |
| } | |
| } | |
| const getOpName = (type) => { | |
| const map = { | |
| 'filter': '筛选 (Filter)', | |
| 'sort': '排序 (Sort)', | |
| 'fillna': '填充缺失 (Fill NA)', | |
| 'drop_duplicates': '去重 (Dedupe)', | |
| 'select_columns': '列选择 (Select)', | |
| 'rename': '重命名 (Rename)' | |
| } | |
| return map[type] || type | |
| } | |
| return { | |
| filename, previewData, previewColumns, stats, operations, loading, | |
| showAddOpModal, newOp, tempRenameCol, tempRenameVal, fileInput, | |
| triggerFileInput, handleFileSelect, handleDrop, | |
| addOperation, removeOperation, exportData, getOpName, | |
| loadDemoData | |
| } | |
| } | |
| }).mount('#app') | |
| </script> | |
| </body> | |
| </html> | |