Browse Source

优化前端页面

lxylxy123321 1 tuần trước cách đây
mục cha
commit
06a515a649

+ 2 - 2
.gitignore

@@ -1,11 +1,11 @@
 # 前端依赖
 frontend/node_modules/
-
+frontend/dist/
 # 后端运行时数据
 backend/data/
 *.db
 
-
+.claude/
 
 # Python
 __pycache__/

+ 2 - 2
docker-compose.yml

@@ -10,8 +10,8 @@ services:
     ports:
       - "8010:8010"
     volumes:
-      # 持久化数据和模型
-      - ./data:/root/Fine-tuning/backend/data
+      # 持久化数据和模型(使用绝对路径,避免重建容器后数据丢失)
+      - ./backend/data:/root/Fine-tuning/backend/data
     env_file:
       - ./backend/.env.docker
     environment:

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 8
frontend/dist/assets/index-BuI1P6s7.js


+ 0 - 12
frontend/dist/index.html

@@ -1,12 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-  <head>
-    <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>PEFT Fine-Tuning Platform</title>
-    <script type="module" crossorigin src="/assets/index-BuI1P6s7.js"></script>
-  </head>
-  <body>
-    <div id="root"></div>
-  </body>
-</html>

+ 20 - 14
frontend/src/App.tsx

@@ -1,24 +1,30 @@
+import { lazy, Suspense } from 'react'
 import { Routes, Route } from 'react-router-dom'
 import { Layout } from './components/layout/Layout'
-import { Dashboard } from './pages/Dashboard'
-import { Models } from './pages/Models'
-import { Datasets } from './pages/Datasets'
-import { Training } from './pages/Training'
-import { Evaluation } from './pages/Evaluation'
-import { Deployment } from './pages/Deployment'
-import { Inference } from './pages/Inference'
+
+const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
+const Models = lazy(() => import('./pages/Models').then(m => ({ default: m.Models })))
+const Datasets = lazy(() => import('./pages/Datasets').then(m => ({ default: m.Datasets })))
+const Training = lazy(() => import('./pages/Training').then(m => ({ default: m.Training })))
+const Evaluation = lazy(() => import('./pages/Evaluation').then(m => ({ default: m.Evaluation })))
+const Deployment = lazy(() => import('./pages/Deployment').then(m => ({ default: m.Deployment })))
+const Inference = lazy(() => import('./pages/Inference').then(m => ({ default: m.Inference })))
+
+function PageFallback() {
+  return <div style={{ padding: 24, color: '#999' }}>加载中...</div>
+}
 
 export default function App() {
   return (
     <Layout>
       <Routes>
-        <Route path="/" element={<Dashboard />} />
-        <Route path="/models" element={<Models />} />
-        <Route path="/datasets" element={<Datasets />} />
-        <Route path="/training" element={<Training />} />
-        <Route path="/evaluation" element={<Evaluation />} />
-        <Route path="/deployment" element={<Deployment />} />
-        <Route path="/inference" element={<Inference />} />
+        <Route path="/" element={<Suspense fallback={<PageFallback />}><Dashboard /></Suspense>} />
+        <Route path="/models" element={<Suspense fallback={<PageFallback />}><Models /></Suspense>} />
+        <Route path="/datasets" element={<Suspense fallback={<PageFallback />}><Datasets /></Suspense>} />
+        <Route path="/training" element={<Suspense fallback={<PageFallback />}><Training /></Suspense>} />
+        <Route path="/evaluation" element={<Suspense fallback={<PageFallback />}><Evaluation /></Suspense>} />
+        <Route path="/deployment" element={<Suspense fallback={<PageFallback />}><Deployment /></Suspense>} />
+        <Route path="/inference" element={<Suspense fallback={<PageFallback />}><Inference /></Suspense>} />
       </Routes>
     </Layout>
   )

+ 11 - 11
frontend/src/pages/Dashboard.tsx

@@ -1,6 +1,16 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, memo } from 'react'
 import api from '../api/client'
 
+const StatCard = memo(function StatCard({ title, value, desc }: { title: string; value: number; desc: string }) {
+  return (
+    <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
+      <div style={{ fontSize: 13, color: '#666' }}>{title}</div>
+      <div style={{ fontSize: 32, fontWeight: 700, margin: '8px 0' }}>{value}</div>
+      <div style={{ fontSize: 12, color: '#999' }}>{desc}</div>
+    </div>
+  )
+})
+
 export function Dashboard() {
   const [models, setModels] = useState(0)
   const [datasets, setDatasets] = useState(0)
@@ -30,13 +40,3 @@ export function Dashboard() {
     </div>
   )
 }
-
-function StatCard({ title, value, desc }: { title: string; value: number; desc: string }) {
-  return (
-    <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-      <div style={{ fontSize: 13, color: '#666' }}>{title}</div>
-      <div style={{ fontSize: 32, fontWeight: 700, margin: '8px 0' }}>{value}</div>
-      <div style={{ fontSize: 12, color: '#999' }}>{desc}</div>
-    </div>
-  )
-}

+ 21 - 11
frontend/src/pages/Datasets.tsx

@@ -1,6 +1,25 @@
-import { useState, useRef } from 'react'
+import { useState, useRef, memo } from 'react'
 import api, { DatasetInfo } from '../api/client'
 
+const DatasetRow = memo(function DatasetRow({ d, onPreview, onDelete }: {
+  d: DatasetInfo
+  onPreview: (id: string) => void
+  onDelete: (id: string) => void
+}) {
+  return (
+    <tr style={{ borderBottom: '1px solid #eee' }}>
+      <td style={{ padding: '8px 0' }}>{d.name}</td>
+      <td>{d.format}</td>
+      <td>{d.record_count}</td>
+      <td>{d.created_at}</td>
+      <td>
+        <button onClick={() => onPreview(d.id)} style={{ marginRight: 8, padding: '2px 8px', cursor: 'pointer' }}>预览</button>
+        <button onClick={() => onDelete(d.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>删除</button>
+      </td>
+    </tr>
+  )
+})
+
 export function Datasets() {
   const [datasets, setDatasets] = useState<DatasetInfo[]>([])
   const [uploading, setUploading] = useState(false)
@@ -143,16 +162,7 @@ export function Datasets() {
             </thead>
             <tbody>
               {datasets.map(d => (
-                <tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
-                  <td style={{ padding: '8px 0' }}>{d.name}</td>
-                  <td>{d.format}</td>
-                  <td>{d.record_count}</td>
-                  <td>{d.created_at}</td>
-                  <td>
-                    <button onClick={() => handlePreview(d.id)} style={{ marginRight: 8, padding: '2px 8px', cursor: 'pointer' }}>预览</button>
-                    <button onClick={() => handleDelete(d.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>删除</button>
-                  </td>
-                </tr>
+                <DatasetRow key={d.id} d={d} onPreview={handlePreview} onDelete={handleDelete} />
               ))}
             </tbody>
           </table>

+ 26 - 16
frontend/src/pages/Models.tsx

@@ -1,6 +1,30 @@
-import { useState } from 'react'
+import { useState, memo } from 'react'
 import api, { ModelInfo } from '../api/client'
 
+const ModelRow = memo(function ModelRow({ m, onTest, onDelete }: {
+  m: ModelInfo
+  onTest: (id: string) => void
+  onDelete: (id: string, name: string) => void
+}) {
+  return (
+    <tr style={{ borderBottom: '1px solid #eee' }}>
+      <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{m.id}</td>
+      <td>{m.name}</td>
+      <td>{m.model_type}</td>
+      <td style={{ color: m.is_downloaded ? '#4caf50' : '#e94560' }}>
+        {m.is_downloaded ? '已缓存' : '未下载'}
+      </td>
+      <td>{m.supported_peft_methods.join(', ') || '-'}</td>
+      <td>
+        {m.is_downloaded && (
+          <button onClick={() => onTest(m.id)} style={{ marginRight: 8, padding: '2px 8px', color: '#2196f3', border: '1px solid #2196f3', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>测试</button>
+        )}
+        <button onClick={() => onDelete(m.id, m.name)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>删除</button>
+      </td>
+    </tr>
+  )
+})
+
 export function Models() {
   const [modelId, setModelId] = useState('')
   const [useModelscope, setUseModelscope] = useState(false)
@@ -133,21 +157,7 @@ export function Models() {
             </thead>
             <tbody>
               {models.map(m => (
-                <tr key={m.id} style={{ borderBottom: '1px solid #eee' }}>
-                  <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{m.id}</td>
-                  <td>{m.name}</td>
-                  <td>{m.model_type}</td>
-                  <td style={{ color: m.is_downloaded ? '#4caf50' : '#e94560' }}>
-                    {m.is_downloaded ? '已缓存' : '未下载'}
-                  </td>
-                  <td>{m.supported_peft_methods.join(', ') || '-'}</td>
-                  <td>
-                    {m.is_downloaded && (
-                      <button onClick={() => handleTest(m.id)} style={{ marginRight: 8, padding: '2px 8px', color: '#2196f3', border: '1px solid #2196f3', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>测试</button>
-                    )}
-                    <button onClick={() => handleDelete(m.id, m.name)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>删除</button>
-                  </td>
-                </tr>
+                <ModelRow key={m.id} m={m} onTest={handleTest} onDelete={handleDelete} />
               ))}
             </tbody>
           </table>

+ 67 - 38
frontend/src/pages/Training.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback } from 'react'
+import { useState, useEffect, useRef, useCallback, memo } from 'react'
 import api, { TrainingJob, ModelInfo, DatasetInfo } from '../api/client'
 import { wsManager } from '../api/websocket'
 
@@ -72,20 +72,26 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
     o.label.toLowerCase().includes(filter.toLowerCase()) || o.value.toLowerCase().includes(filter.toLowerCase())
   )
 
-  const handleKeyDown = (e: React.KeyboardEvent) => {
+  const handleSelect = useCallback((val: string) => {
+    onChange(val)
+    setOpen(false)
+  }, [onChange])
+
+  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
     if (e.key === 'Enter' && filtered.length === 1) {
-      onChange(filtered[0].value)
-      setOpen(false)
+      handleSelect(filtered[0].value)
     } else if (e.key === 'Escape') {
       setOpen(false)
     }
-  }
+  }, [filtered, handleSelect])
+
+  const toggleOpen = useCallback(() => setOpen(prev => !prev), [])
 
   return (
     <div ref={wrapperRef} style={{ position: 'relative' }}>
       {/* 显示框 */}
       <div
-        onClick={() => setOpen(!open)}
+        onClick={toggleOpen}
         style={{
           padding: '6px 8px',
           borderRadius: 4,
@@ -148,7 +154,7 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
             {!loading && filtered.map(opt => (
               <div
                 key={opt.value}
-                onClick={() => { onChange(opt.value); setOpen(false) }}
+                onClick={() => handleSelect(opt.value)}
                 style={{
                   padding: '8px 12px',
                   cursor: 'pointer',
@@ -222,11 +228,26 @@ export function Training() {
     return () => wsManager.disconnect()
   }, [])
 
+  // 将 jobs 存入 ref 用于比较,避免相同数据触发重渲染
+  const jobsRef = useRef<TrainingJob[]>([])
+
   const fetchJobs = () => {
     setLoading(true)
     api.training.list()
-      .then(setJobs)
-      .catch(() => setJobs([]))
+      .then(newJobs => {
+        // 仅在数据真正变化时更新 state
+        const prev = jobsRef.current
+        if (JSON.stringify(prev) !== JSON.stringify(newJobs)) {
+          setJobs(newJobs)
+          jobsRef.current = newJobs
+        }
+      })
+      .catch(() => {
+        if (jobsRef.current.length > 0) {
+          setJobs([])
+          jobsRef.current = []
+        }
+      })
       .finally(() => setLoading(false))
   }
 
@@ -269,17 +290,43 @@ export function Training() {
       .catch(console.error)
   }
 
-  const statusColor = (status: string) => {
-    switch (status) {
-      case 'completed': return '#4caf50'
-      case 'failed': return '#e94560'
-      case 'training': return '#2196f3'
-      case 'pending': case 'queued': return '#ff9800'
-      case 'preprocessing': return '#9c27b0'
-      case 'cancelled': return '#999'
-      default: return '#666'
-    }
+// --- 任务状态颜色 ---
+const statusColor = (status: string) => {
+  switch (status) {
+    case 'completed': return '#4caf50'
+    case 'failed': return '#e94560'
+    case 'training': return '#2196f3'
+    case 'pending': case 'queued': return '#ff9800'
+    case 'preprocessing': return '#9c27b0'
+    case 'cancelled': return '#999'
+    default: return '#666'
   }
+}
+
+// --- 任务行(memo 避免父组件渲染时重渲染) ---
+const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel: (id: string) => void }) {
+  return (
+    <tr style={{ borderBottom: '1px solid #eee' }}>
+      <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{j.id.slice(0, 8)}...</td>
+      <td>{j.model_id}</td>
+      <td>{j.peft_method}</td>
+      <td style={{ fontSize: 12 }}>{j.status === 'preprocessing' ? '预处理' : j.status === 'training' ? '训练中' : j.status}</td>
+      <td style={{ color: statusColor(j.status), fontWeight: 600 }}>{j.status}</td>
+      <td>
+        <div style={{ width: 120, height: 6, background: '#eee', borderRadius: 3, overflow: 'hidden' }}>
+          <div style={{ width: `${j.progress}%`, height: '100%', background: j.status === 'failed' ? '#e94560' : '#4caf50', transition: 'width 0.3s' }} />
+        </div>
+        <span style={{ fontSize: 11, color: '#999' }}>{j.progress.toFixed(1)}%</span>
+      </td>
+      <td>{j.loss?.toFixed(4) ?? '-'}</td>
+      <td>
+        {(j.status === 'training' || j.status === 'pending' || j.status === 'queued' || j.status === 'preprocessing') && (
+          <button onClick={() => onCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>取消</button>
+        )}
+      </td>
+    </tr>
+  )
+})
 
   // 构建下拉选项
   const modelOptions = models.map(m => ({
@@ -407,25 +454,7 @@ export function Training() {
             </thead>
             <tbody>
               {jobs.map(j => (
-                <tr key={j.id} style={{ borderBottom: '1px solid #eee' }}>
-                  <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{j.id.slice(0, 8)}...</td>
-                  <td>{j.model_id}</td>
-                  <td>{j.peft_method}</td>
-                  <td style={{ fontSize: 12 }}>{j.status === 'preprocessing' ? '预处理' : j.status === 'training' ? '训练中' : j.status}</td>
-                  <td style={{ color: statusColor(j.status), fontWeight: 600 }}>{j.status}</td>
-                  <td>
-                    <div style={{ width: 120, height: 6, background: '#eee', borderRadius: 3, overflow: 'hidden' }}>
-                      <div style={{ width: `${j.progress}%`, height: '100%', background: j.status === 'failed' ? '#e94560' : '#4caf50', transition: 'width 0.3s' }} />
-                    </div>
-                    <span style={{ fontSize: 11, color: '#999' }}>{j.progress.toFixed(1)}%</span>
-                  </td>
-                  <td>{j.loss?.toFixed(4) ?? '-'}</td>
-                  <td>
-                    {(j.status === 'training' || j.status === 'pending' || j.status === 'queued' || j.status === 'preprocessing') && (
-                      <button onClick={() => handleCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>取消</button>
-                    )}
-                  </td>
-                </tr>
+                <JobRow key={j.id} j={j} onCancel={handleCancel} />
               ))}
             </tbody>
           </table>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác