Explorar o código

优化界面显示

lxylxy123321 hai 1 día
pai
achega
d1f235cf62

+ 1 - 0
backend/app/schemas/training.py

@@ -46,6 +46,7 @@ class TrainingJobResponse(BaseModel):
     model_id: str
     model_id: str
     model_type: str
     model_type: str
     peft_method: str
     peft_method: str
+    dataset_id: str = ""
     status: JobStatus
     status: JobStatus
     progress: float = Field(default=0.0, ge=0.0, le=100.0)
     progress: float = Field(default=0.0, ge=0.0, le=100.0)
     current_epoch: int = 0
     current_epoch: int = 0

+ 1 - 0
backend/app/services/training_service.py

@@ -159,6 +159,7 @@ def _job_to_dict(r) -> dict[str, Any]:
         "model_id": r.model_id,
         "model_id": r.model_id,
         "model_type": r.model_type,
         "model_type": r.model_type,
         "peft_method": r.peft_method,
         "peft_method": r.peft_method,
+        "dataset_id": r.dataset_id,
         "status": r.status,
         "status": r.status,
         "progress": r.progress or 0.0,
         "progress": r.progress or 0.0,
         "current_epoch": r.current_epoch or 0,
         "current_epoch": r.current_epoch or 0,

+ 1 - 0
frontend/src/api/client.ts

@@ -313,6 +313,7 @@ interface TrainingJob {
   model_id: string
   model_id: string
   model_type: string
   model_type: string
   peft_method: string
   peft_method: string
+  dataset_id?: string
   status: string
   status: string
   progress: number
   progress: number
   current_epoch: number
   current_epoch: number

+ 110 - 58
frontend/src/pages/Datasets.tsx

@@ -1,52 +1,120 @@
-import { useState, useEffect, useRef, memo, useCallback } from 'react'
+import { useState, useEffect, useRef, useCallback } from 'react'
 import api, { DatasetInfo, KnowledgeBaseItem, DatasetDownloadTaskResponse } from '../api/client'
 import api, { DatasetInfo, KnowledgeBaseItem, DatasetDownloadTaskResponse } from '../api/client'
-import { Database, Upload, Loader2, FolderOpen, CheckCircle, XCircle } from 'lucide-react'
+import { Database, Upload, Loader2, FolderOpen, CheckCircle, XCircle, Eye, Trash2, FileText } from 'lucide-react'
+
+function formatBadge(format: string) {
+  const map: Record<string, { bg: string; color: string; border: string }> = {
+    jsonl: { bg: '#eff6ff', color: '#2563eb', border: '#bfdbfe' },
+    csv: { bg: '#f0fdf4', color: '#16a34a', border: '#bbf7d0' },
+    parquet: { bg: '#faf5ff', color: '#7c3aed', border: '#ddd6fe' },
+    json: { bg: '#fff7ed', color: '#d97706', border: '#fed7aa' },
+  }
+  return map[format] || { bg: '#f1f5f9', color: '#64748b', border: '#e2e8f0' }
+}
+
+function formatDate(iso: string) {
+  if (!iso) return '-'
+  const d = new Date(iso)
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+}
 
 
-const DatasetRow = memo(function DatasetRow({ d, onPreview, onDelete }: {
+function DatasetCard({ d, onPreview, onDelete }: {
   d: DatasetInfo
   d: DatasetInfo
   onPreview: (id: string) => void
   onPreview: (id: string) => void
   onDelete: (id: string) => void
   onDelete: (id: string) => void
 }) {
 }) {
+  const [hovered, setHovered] = useState(false)
+  const fb = formatBadge(d.format)
+
   return (
   return (
-    <tr style={{
-      borderBottom: '1px solid #f1f5f9',
-      transition: 'background 0.15s ease',
-    }}
-    onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
-    onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
+    <div
+      onMouseEnter={() => setHovered(true)}
+      onMouseLeave={() => setHovered(false)}
+      style={{
+        background: '#fff',
+        borderRadius: 12,
+        padding: 20,
+        border: `1px solid ${hovered ? 'rgba(20,184,166,0.3)' : 'rgba(0,0,0,0.06)'}`,
+        boxShadow: hovered
+          ? '0 4px 12px rgba(20,184,166,0.12)'
+          : '0 1px 3px rgba(0,0,0,0.04)',
+        transform: hovered ? 'translateY(-2px)' : 'none',
+        transition: 'all 0.2s ease',
+        display: 'flex',
+        flexDirection: 'column' as const,
+        gap: 12,
+      }}
     >
     >
-      <td style={{ padding: '12px 12px', fontWeight: 500, fontSize: 13 }}>{d.name}</td>
-      <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b' }}>
+      {/* Header: icon + format */}
+      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <div style={{
+          width: 36, height: 36, borderRadius: 10,
+          background: '#f0fdfa', color: '#0d9488',
+          display: 'flex', alignItems: 'center', justifyContent: 'center',
+        }}>
+          <Database size={20} strokeWidth={1.8} />
+        </div>
         <span style={{
         <span style={{
-          display: 'inline-block', padding: '2px 8px', borderRadius: 4, fontSize: 12,
-          background: '#f1f5f9', color: '#64748b',
+          padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 500,
+          background: fb.bg, color: fb.color, border: `1px solid ${fb.border}`,
+          textTransform: 'uppercase' as const,
         }}>
         }}>
           {d.format}
           {d.format}
         </span>
         </span>
-      </td>
-      <td style={{ padding: '12px 12px', fontSize: 13 }}>{d.record_count}</td>
-      <td style={{ padding: '12px 12px', fontSize: 13, color: '#94a3b8' }}>{d.created_at}</td>
-      <td style={{ padding: '12px 12px' }}>
-        <button onClick={() => onPreview(d.id)} style={{
-          marginRight: 8, padding: '4px 12px', color: '#0ea5e9',
-          border: '1px solid #0ea5e9', borderRadius: 6, background: 'transparent',
-          cursor: 'pointer', fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
-        }}
-        onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
-        onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
-        >预览</button>
-        <button onClick={() => onDelete(d.id)} style={{
-          padding: '4px 12px', color: '#f43f5e', border: '1px solid #f43f5e',
-          borderRadius: 6, background: 'transparent', cursor: 'pointer',
-          fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
-        }}
-        onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
-        onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
-        >删除</button>
-      </td>
-    </tr>
+      </div>
+
+      {/* Name */}
+      <div>
+        <div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.3, wordBreak: 'break-all' }}>
+          {d.name}
+        </div>
+      </div>
+
+      {/* Stats */}
+      <div style={{ display: 'flex', gap: 16, fontSize: 13, color: '#64748b' }}>
+        <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+          <FileText size={14} color="#94a3b8" />
+          <span style={{ fontWeight: 600, color: '#1e293b' }}>{d.record_count.toLocaleString()}</span> 条记录
+        </div>
+      </div>
+
+      {/* Time */}
+      <div style={{ fontSize: 12, color: '#94a3b8' }}>
+        上传于 {formatDate(d.created_at)}
+      </div>
+
+      {/* Actions */}
+      <div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 4 }}>
+        <button
+          onClick={() => onPreview(d.id)}
+          style={{
+            flex: 1, padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 500,
+            color: '#0ea5e9', border: '1px solid #0ea5e9', background: 'transparent',
+            cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+            transition: 'all 0.15s ease',
+          }}
+          onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
+          onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
+        >
+          <Eye size={14} /> 预览
+        </button>
+        <button
+          onClick={() => onDelete(d.id)}
+          style={{
+            flex: 1, padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 500,
+            color: '#f43f5e', border: '1px solid #f43f5e', background: 'transparent',
+            cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+            transition: 'all 0.15s ease',
+          }}
+          onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
+          onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
+        >
+          <Trash2 size={14} /> 删除
+        </button>
+      </div>
+    </div>
   )
   )
-})
+}
 
 
 export function Datasets() {
 export function Datasets() {
   const [datasets, setDatasets] = useState<DatasetInfo[]>([])
   const [datasets, setDatasets] = useState<DatasetInfo[]>([])
@@ -199,7 +267,6 @@ export function Datasets() {
     try {
     try {
       await api.sampleCenter.importFromKnowledgeBase(kb.id, kb.name)
       await api.sampleCenter.importFromKnowledgeBase(kb.id, kb.name)
       setKbStatus(`"${kb.name}" 导入请求已提交,可在样本中心查看入库进度`)
       setKbStatus(`"${kb.name}" 导入请求已提交,可在样本中心查看入库进度`)
-      // 刷新本地数据集列表
       fetchDatasets()
       fetchDatasets()
     } catch (err: unknown) {
     } catch (err: unknown) {
       const msg = err instanceof Error ? err.message : '导入失败'
       const msg = err instanceof Error ? err.message : '导入失败'
@@ -383,7 +450,6 @@ export function Datasets() {
               选择要导入的知识库,数据将转为训练格式
               选择要导入的知识库,数据将转为训练格式
             </p>
             </p>
 
 
-            {/* KB list */}
             {kbLoading && (
             {kbLoading && (
               <div style={{ textAlign: 'center', padding: 20, color: '#94a3b8' }}>
               <div style={{ textAlign: 'center', padding: 20, color: '#94a3b8' }}>
                 <Loader2 size={24} style={{ animation: 'lucide-spin 1s linear infinite' }} />
                 <Loader2 size={24} style={{ animation: 'lucide-spin 1s linear infinite' }} />
@@ -450,7 +516,6 @@ export function Datasets() {
               </div>
               </div>
             )}
             )}
 
 
-            {/* Pagination */}
             {!kbLoading && kbTotal > 20 && (
             {!kbLoading && kbTotal > 20 && (
               <div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
               <div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
                 <button
                 <button
@@ -477,7 +542,6 @@ export function Datasets() {
               </div>
               </div>
             )}
             )}
 
 
-            {/* Status */}
             {kbStatus && (
             {kbStatus && (
               <p style={{
               <p style={{
                 marginTop: 12, padding: '8px 12px', borderRadius: 6, fontSize: 13,
                 marginTop: 12, padding: '8px 12px', borderRadius: 6, fontSize: 13,
@@ -519,25 +583,13 @@ export function Datasets() {
 
 
         {!loading && datasets.length > 0 && (
         {!loading && datasets.length > 0 && (
           <div style={{
           <div style={{
-            background: '#fff', borderRadius: 10, overflow: 'hidden',
-            boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
+            display: 'grid',
+            gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
+            gap: 16,
           }}>
           }}>
-            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
-              <thead>
-                <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>名称</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>格式</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>记录数</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>上传时间</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>操作</th>
-                </tr>
-              </thead>
-              <tbody>
-                {datasets.map(d => (
-                  <DatasetRow key={d.id} d={d} onPreview={handlePreview} onDelete={handleDelete} />
-                ))}
-              </tbody>
-            </table>
+            {datasets.map(d => (
+              <DatasetCard key={d.id} d={d} onPreview={handlePreview} onDelete={handleDelete} />
+            ))}
           </div>
           </div>
         )}
         )}
       </div>
       </div>

+ 9 - 4
frontend/src/pages/Deployment.tsx

@@ -1,11 +1,13 @@
 import { useState, useEffect, useRef, useCallback } from 'react'
 import { useState, useEffect, useRef, useCallback } from 'react'
-import api, { DeployResponse, DeployedServiceInfo, TrainingJob, ApiKeyInfo, ApiKeyCreateResponse } from '../api/client'
+import api, { DeployResponse, DeployedServiceInfo, TrainingJob, DatasetInfo, ApiKeyInfo, ApiKeyCreateResponse } from '../api/client'
+import { jobLabel } from '../utils/jobLabel'
 
 
 type Tab = 'serve' | 'export'
 type Tab = 'serve' | 'export'
 
 
 export function Deployment() {
 export function Deployment() {
   const [tab, setTab] = useState<Tab>('serve')
   const [tab, setTab] = useState<Tab>('serve')
   const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [jobs, setJobs] = useState<TrainingJob[]>([])
+  const [datasets, setDatasets] = useState<DatasetInfo[]>([])
   const [services, setServices] = useState<DeployedServiceInfo[]>([])
   const [services, setServices] = useState<DeployedServiceInfo[]>([])
   const [loadingServices, setLoadingServices] = useState(false)
   const [loadingServices, setLoadingServices] = useState(false)
 
 
@@ -39,11 +41,14 @@ export function Deployment() {
     api.apiKeys.list().then(setApiKeys).catch(() => setApiKeys([]))
     api.apiKeys.list().then(setApiKeys).catch(() => setApiKeys([]))
   }, [])
   }, [])
 
 
-  // 加载已完成训练任务
+  // 加载已完成训练任务和数据集
   useEffect(() => {
   useEffect(() => {
     api.training.list()
     api.training.list()
       .then(data => setJobs(data.filter(j => j.status === 'completed')))
       .then(data => setJobs(data.filter(j => j.status === 'completed')))
       .catch(() => setJobs([]))
       .catch(() => setJobs([]))
+    api.datasets.list()
+      .then(setDatasets)
+      .catch(() => setDatasets([]))
   }, [])
   }, [])
 
 
   // 加载已部署服务列表
   // 加载已部署服务列表
@@ -205,7 +210,7 @@ export function Deployment() {
               >
               >
                 <option value="" disabled>选择已完成的训练任务</option>
                 <option value="" disabled>选择已完成的训练任务</option>
                 {jobs.map(j => (
                 {jobs.map(j => (
-                  <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+                  <option key={j.id} value={j.id}>{jobLabel(j, datasets)}</option>
                 ))}
                 ))}
               </select>
               </select>
             </div>
             </div>
@@ -258,7 +263,7 @@ export function Deployment() {
               >
               >
                 <option value="" disabled>选择已完成的训练任务</option>
                 <option value="" disabled>选择已完成的训练任务</option>
                 {jobs.map(j => (
                 {jobs.map(j => (
-                  <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+                  <option key={j.id} value={j.id}>{jobLabel(j, datasets)}</option>
                 ))}
                 ))}
               </select>
               </select>
             </div>
             </div>

+ 7 - 2
frontend/src/pages/Evaluation.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useRef } from 'react'
 import { useState, useEffect, useRef } from 'react'
-import api, { EvalResult, TrainingJob } from '../api/client'
+import api, { EvalResult, TrainingJob, DatasetInfo } from '../api/client'
+import { jobLabel } from '../utils/jobLabel'
 
 
 const METRICS_PRESETS = [
 const METRICS_PRESETS = [
   { value: 'perplexity,loss', label: 'Perplexity + Loss' },
   { value: 'perplexity,loss', label: 'Perplexity + Loss' },
@@ -10,6 +11,7 @@ const METRICS_PRESETS = [
 
 
 export function Evaluation() {
 export function Evaluation() {
   const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [jobs, setJobs] = useState<TrainingJob[]>([])
+  const [datasets, setDatasets] = useState<DatasetInfo[]>([])
   const [jobId, setJobId] = useState('')
   const [jobId, setJobId] = useState('')
   const [metrics, setMetrics] = useState('perplexity,loss')
   const [metrics, setMetrics] = useState('perplexity,loss')
   const [running, setRunning] = useState(false)
   const [running, setRunning] = useState(false)
@@ -20,6 +22,9 @@ export function Evaluation() {
     api.training.list()
     api.training.list()
       .then(data => setJobs(data.filter(j => j.status === 'completed')))
       .then(data => setJobs(data.filter(j => j.status === 'completed')))
       .catch(() => setJobs([]))
       .catch(() => setJobs([]))
+    api.datasets.list()
+      .then(setDatasets)
+      .catch(() => setDatasets([]))
   }, [])
   }, [])
 
 
   const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
   const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -95,7 +100,7 @@ export function Evaluation() {
             >
             >
               <option value="" disabled>选择已完成的训练任务</option>
               <option value="" disabled>选择已完成的训练任务</option>
               {jobs.map(j => (
               {jobs.map(j => (
-                <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+                <option key={j.id} value={j.id}>{jobLabel(j, datasets)}</option>
               ))}
               ))}
             </select>
             </select>
           </div>
           </div>

+ 10 - 5
frontend/src/pages/Inference.tsx

@@ -144,11 +144,16 @@ export function Inference() {
             onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
             onFocus={e => { e.currentTarget.style.borderColor = '#14b8a6' }}
             onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
             onBlur={e => { e.currentTarget.style.borderColor = '#cbd5e1' }}
           >
           >
-            {adapters.map(a => (
-              <option key={a.id} value={a.id}>
-                {a.model_id || a.base_model} | {(a.peft_method || a.peft_type).toUpperCase()} | {a.id.slice(0, 8)}...
-              </option>
-            ))}
+            {adapters.map(a => {
+              const modelShort = (a.model_id || a.base_model).split('/').pop() || a.model_id || a.base_model
+              const method = (a.peft_method || a.peft_type).toUpperCase()
+              const time = a.created_at ? new Date(a.created_at).toLocaleDateString() : ''
+              return (
+                <option key={a.id} value={a.id}>
+                  {modelShort} | {method} | {a.id.slice(0, 8)}...{time ? ` (${time})` : ''}
+                </option>
+              )
+            })}
           </select>
           </select>
         )}
         )}
       </div>
       </div>

+ 133 - 69
frontend/src/pages/Models.tsx

@@ -1,57 +1,144 @@
-import { useState, useEffect, memo, useRef } from 'react'
+import { useState, useEffect, useRef } from 'react'
 import api, { ModelInfo, ModelDownloadTaskResponse } from '../api/client'
 import api, { ModelInfo, ModelDownloadTaskResponse } from '../api/client'
-import { Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react'
+import { Cpu, Eye, Layers, CheckCircle, XCircle, Loader2, Play, Trash2 } from 'lucide-react'
 
 
-const ModelRow = memo(function ModelRow({ m, onTest, onDelete }: {
+function modelIcon(type: string) {
+  switch (type) {
+    case 'vision': return <Eye size={20} strokeWidth={1.8} />
+    case 'multimodal': return <Layers size={20} strokeWidth={1.8} />
+    default: return <Cpu size={20} strokeWidth={1.8} />
+  }
+}
+
+function modelTypeLabel(type: string) {
+  switch (type) {
+    case 'vision': return '视觉'
+    case 'multimodal': return '多模态'
+    default: return '文本'
+  }
+}
+
+function modelTypeColor(type: string) {
+  switch (type) {
+    case 'vision': return { bg: '#eff6ff', color: '#2563eb', border: '#bfdbfe' }
+    case 'multimodal': return { bg: '#faf5ff', color: '#7c3aed', border: '#ddd6fe' }
+    default: return { bg: '#f0fdfa', color: '#0d9488', border: '#99f6e4' }
+  }
+}
+
+function ModelCard({ m, onTest, onDelete }: {
   m: ModelInfo
   m: ModelInfo
   onTest: (id: string) => void
   onTest: (id: string) => void
   onDelete: (id: string, name: string) => void
   onDelete: (id: string, name: string) => void
 }) {
 }) {
+  const [hovered, setHovered] = useState(false)
+  const shortName = m.id.split('/').pop() || m.id
+  const tc = modelTypeColor(m.model_type)
+
   return (
   return (
-    <tr style={{
-      borderBottom: '1px solid #f1f5f9',
-      transition: 'background 0.15s ease',
-    }}
-    onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
-    onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
+    <div
+      onMouseEnter={() => setHovered(true)}
+      onMouseLeave={() => setHovered(false)}
+      style={{
+        background: '#fff',
+        borderRadius: 12,
+        padding: 20,
+        border: `1px solid ${hovered ? 'rgba(20,184,166,0.3)' : 'rgba(0,0,0,0.06)'}`,
+        boxShadow: hovered
+          ? '0 4px 12px rgba(20,184,166,0.12)'
+          : '0 1px 3px rgba(0,0,0,0.04)',
+        transform: hovered ? 'translateY(-2px)' : 'none',
+        transition: 'all 0.2s ease',
+        display: 'flex',
+        flexDirection: 'column' as const,
+        gap: 12,
+      }}
     >
     >
-      <td style={{ padding: '12px 12px', fontFamily: 'monospace', fontSize: 12, color: '#64748b' }}>{m.id}</td>
-      <td style={{ padding: '12px 12px', fontWeight: 500, fontSize: 13 }}>{m.name}</td>
-      <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b' }}>{m.model_type}</td>
-      <td style={{ padding: '12px 12px' }}>
+      {/* Header: icon + status */}
+      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+        <div style={{
+          width: 36, height: 36, borderRadius: 10,
+          background: tc.bg, color: tc.color,
+          display: 'flex', alignItems: 'center', justifyContent: 'center',
+        }}>
+          {modelIcon(m.model_type)}
+        </div>
         <span style={{
         <span style={{
-          display: 'inline-block', padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 500,
-          background: m.is_downloaded ? '#ecfdf5' : '#fff1f2',
-          color: m.is_downloaded ? '#059669' : '#f43f5e',
-          border: `1px solid ${m.is_downloaded ? '#d1fae5' : '#fecdd3'}`,
+          display: 'inline-flex', alignItems: 'center', gap: 4,
+          padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 500,
+          background: m.is_downloaded ? '#ecfdf5' : '#fff7ed',
+          color: m.is_downloaded ? '#059669' : '#d97706',
+          border: `1px solid ${m.is_downloaded ? '#d1fae5' : '#fed7aa'}`,
         }}>
         }}>
+          {m.is_downloaded && <CheckCircle size={12} />}
           {m.is_downloaded ? '已缓存' : '未下载'}
           {m.is_downloaded ? '已缓存' : '未下载'}
         </span>
         </span>
-      </td>
-      <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b' }}>{m.supported_peft_methods.join(', ') || '-'}</td>
-      <td style={{ padding: '12px 12px' }}>
+      </div>
+
+      {/* Name */}
+      <div>
+        <div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.3, wordBreak: 'break-all' }}>
+          {shortName}
+        </div>
+        {shortName !== m.id && (
+          <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 2, fontFamily: 'monospace', wordBreak: 'break-all' }}>
+            {m.id}
+          </div>
+        )}
+      </div>
+
+      {/* Tags */}
+      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' as const }}>
+        <span style={{
+          padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
+          background: tc.bg, color: tc.color, border: `1px solid ${tc.border}`,
+        }}>
+          {modelTypeLabel(m.model_type)}
+        </span>
+        {m.supported_peft_methods.filter(Boolean).slice(0, 3).map(method => (
+          <span key={method} style={{
+            padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
+            background: '#f1f5f9', color: '#64748b', border: '1px solid #e2e8f0',
+          }}>
+            {method.toUpperCase()}
+          </span>
+        ))}
+      </div>
+
+      {/* Actions */}
+      <div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 4 }}>
         {m.is_downloaded && (
         {m.is_downloaded && (
-          <button onClick={() => onTest(m.id)} style={{
-            marginRight: 8, padding: '4px 12px', color: '#0ea5e9',
-            border: '1px solid #0ea5e9', borderRadius: 6, background: 'transparent',
-            cursor: 'pointer', fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
-          }}
-          onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
-          onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
-          >测试</button>
+          <button
+            onClick={() => onTest(m.id)}
+            style={{
+              flex: 1, padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 500,
+              color: '#0ea5e9', border: '1px solid #0ea5e9', background: 'transparent',
+              cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+              transition: 'all 0.15s ease',
+            }}
+            onMouseEnter={e => { e.currentTarget.style.background = '#0ea5e9'; e.currentTarget.style.color = '#fff' }}
+            onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#0ea5e9' }}
+          >
+            <Play size={14} /> 测试
+          </button>
         )}
         )}
-        <button onClick={() => onDelete(m.id, m.name)} style={{
-          padding: '4px 12px', color: '#f43f5e', border: '1px solid #f43f5e',
-          borderRadius: 6, background: 'transparent', cursor: 'pointer',
-          fontSize: 12, fontWeight: 500, transition: 'all 0.15s ease',
-        }}
-        onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
-        onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
-        >删除</button>
-      </td>
-    </tr>
+        <button
+          onClick={() => onDelete(m.id, m.name)}
+          style={{
+            flex: 1, padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 500,
+            color: '#f43f5e', border: '1px solid #f43f5e', background: 'transparent',
+            cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+            transition: 'all 0.15s ease',
+          }}
+          onMouseEnter={e => { e.currentTarget.style.background = '#f43f5e'; e.currentTarget.style.color = '#fff' }}
+          onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#f43f5e' }}
+        >
+          <Trash2 size={14} /> 删除
+        </button>
+      </div>
+    </div>
   )
   )
-})
+}
 
 
 export function Models() {
 export function Models() {
   const [modelId, setModelId] = useState('')
   const [modelId, setModelId] = useState('')
@@ -62,11 +149,9 @@ export function Models() {
   const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
   const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
   const [statusContent, setStatusContent] = useState('')
   const [statusContent, setStatusContent] = useState('')
 
 
-  // Active downloads tracking
   const [activeDownloads, setActiveDownloads] = useState<Map<string, ModelDownloadTaskResponse>>(new Map())
   const [activeDownloads, setActiveDownloads] = useState<Map<string, ModelDownloadTaskResponse>>(new Map())
   const pollIntervals = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
   const pollIntervals = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
 
 
-  // Test state
   const [testModelId, setTestModelId] = useState('')
   const [testModelId, setTestModelId] = useState('')
   const [testPrompt, setTestPrompt] = useState('')
   const [testPrompt, setTestPrompt] = useState('')
   const [testResult, setTestResult] = useState('')
   const [testResult, setTestResult] = useState('')
@@ -94,9 +179,7 @@ export function Models() {
       const res = await api.models.download(modelId, useModelscope)
       const res = await api.models.download(modelId, useModelscope)
       setStatusType('success')
       setStatusType('success')
       setStatusContent(`下载任务已提交: ${res.model_id}`)
       setStatusContent(`下载任务已提交: ${res.model_id}`)
-      // Add to active downloads
       setActiveDownloads(prev => new Map(prev).set(res.task_id, res))
       setActiveDownloads(prev => new Map(prev).set(res.task_id, res))
-      // Start polling
       startModelDownloadPolling(res.task_id)
       startModelDownloadPolling(res.task_id)
     } catch (err) {
     } catch (err) {
       setStatusType('error')
       setStatusType('error')
@@ -128,9 +211,7 @@ export function Models() {
             setStatusContent(`${res.model_id} 下载失败: ${res.error}`)
             setStatusContent(`${res.model_id} 下载失败: ${res.error}`)
           }
           }
         })
         })
-        .catch(() => {
-          // ignore polling errors
-        })
+        .catch(() => {})
     }, 3000)
     }, 3000)
     pollIntervals.current.set(taskId, interval)
     pollIntervals.current.set(taskId, interval)
   }
   }
@@ -149,7 +230,6 @@ export function Models() {
     }
     }
   }
   }
 
 
-  // Cleanup polling on unmount
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {
       pollIntervals.current.forEach(interval => clearInterval(interval))
       pollIntervals.current.forEach(interval => clearInterval(interval))
@@ -323,26 +403,13 @@ export function Models() {
 
 
         {!loading && models.length > 0 && (
         {!loading && models.length > 0 && (
           <div style={{
           <div style={{
-            background: '#fff', borderRadius: 10, overflow: 'hidden',
-            boxShadow: '0 1px 3px rgba(0,0,0,0.06)', border: '1px solid rgba(0,0,0,0.04)',
+            display: 'grid',
+            gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
+            gap: 16,
           }}>
           }}>
-            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
-              <thead>
-                <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>ID</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>名称</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>类型</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>状态</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>PEFT 支持</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#64748b', fontWeight: 600 }}>操作</th>
-                </tr>
-              </thead>
-              <tbody>
-                {models.map(m => (
-                  <ModelRow key={m.id} m={m} onTest={handleTest} onDelete={handleDelete} />
-                ))}
-              </tbody>
-            </table>
+            {models.map(m => (
+              <ModelCard key={m.id} m={m} onTest={handleTest} onDelete={handleDelete} />
+            ))}
           </div>
           </div>
         )}
         )}
       </div>
       </div>
@@ -361,7 +428,6 @@ export function Models() {
             }}>关闭</button>
             }}>关闭</button>
           </div>
           </div>
 
 
-          {/* Chat-like input */}
           <div style={{ display: 'flex', gap: 8 }}>
           <div style={{ display: 'flex', gap: 8 }}>
             <input
             <input
               value={testPrompt}
               value={testPrompt}
@@ -390,14 +456,12 @@ export function Models() {
             </button>
             </button>
           </div>
           </div>
 
 
-          {/* Error */}
           {testError && (
           {testError && (
             <div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, color: '#e11d48', fontSize: 13, border: '1px solid #fecdd3' }}>
             <div style={{ marginTop: 16, padding: 12, background: '#fff1f2', borderRadius: 8, color: '#e11d48', fontSize: 13, border: '1px solid #fecdd3' }}>
               {testError}
               {testError}
             </div>
             </div>
           )}
           )}
 
 
-          {/* Result */}
           {testResult && (
           {testResult && (
             <div style={{ marginTop: 16 }}>
             <div style={{ marginTop: 16 }}>
               <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
               <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>

+ 13 - 6
frontend/src/pages/Training.tsx

@@ -259,7 +259,11 @@ const statusLabel = (status: string) => {
 }
 }
 
 
 // --- 任务行(memo) ---
 // --- 任务行(memo) ---
-const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel: (id: string) => void }) {
+const JobRow = memo(function JobRow({ j, onCancel, datasets }: { j: TrainingJob; onCancel: (id: string) => void; datasets: DatasetInfo[] }) {
+  const modelShort = j.model_id.split('/').pop() || j.model_id
+  const dsName = datasets?.find(d => d.id === j.dataset_id)?.name
+    || j.dataset_id?.split('/').pop()
+    || '-'
   return (
   return (
     <tr style={{
     <tr style={{
       borderBottom: '1px solid #f0f0f0',
       borderBottom: '1px solid #f0f0f0',
@@ -268,9 +272,12 @@ const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel:
     onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
     onMouseEnter={e => { e.currentTarget.style.background = '#f0fdfa' }}
     onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
     onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
     >
     >
-      <td style={{ padding: '12px 12px', fontFamily: 'monospace', fontSize: 12, color: '#666' }}>{j.id.slice(0, 8)}...</td>
-      <td style={{ padding: '12px 12px', fontSize: 13, fontWeight: 500 }}>{j.model_id}</td>
+      <td style={{ padding: '12px 12px' }}>
+        <div style={{ fontSize: 13, fontWeight: 500, color: '#1e293b' }}>{modelShort}</div>
+        <div style={{ fontFamily: 'monospace', fontSize: 11, color: '#94a3b8', marginTop: 2 }}>{j.id.slice(0, 8)}...</div>
+      </td>
       <td style={{ padding: '12px 12px', fontSize: 13, textTransform: 'uppercase', color: '#666' }}>{j.peft_method}</td>
       <td style={{ padding: '12px 12px', fontSize: 13, textTransform: 'uppercase', color: '#666' }}>{j.peft_method}</td>
+      <td style={{ padding: '12px 12px', fontSize: 13, color: '#64748b', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={j.dataset_id}>{dsName}</td>
       <td style={{ padding: '12px 12px' }}>
       <td style={{ padding: '12px 12px' }}>
         <span style={{
         <span style={{
           display: 'inline-block', padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 600,
           display: 'inline-block', padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 600,
@@ -636,9 +643,9 @@ export function Training() {
             <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
             <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
               <thead>
               <thead>
                 <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
                 <tr style={{ background: '#f0fdfa', borderBottom: '2px solid #f1f5f9', textAlign: 'left' }}>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>任务 ID</th>
-                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>模型</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>任务</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>PEFT</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>PEFT</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>数据集</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>状态</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>状态</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>进度</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>进度</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>Loss</th>
                   <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>Loss</th>
@@ -648,7 +655,7 @@ export function Training() {
               </thead>
               </thead>
               <tbody>
               <tbody>
                 {jobs.map(j => (
                 {jobs.map(j => (
-                  <JobRow key={j.id} j={j} onCancel={handleCancel} />
+                  <JobRow key={j.id} j={j} onCancel={handleCancel} datasets={datasets} />
                 ))}
                 ))}
               </tbody>
               </tbody>
             </table>
             </table>

+ 22 - 0
frontend/src/utils/jobLabel.ts

@@ -0,0 +1,22 @@
+import type { TrainingJob, DatasetInfo } from '../api/client'
+
+function formatTime(iso: string): string {
+  const d = new Date(iso)
+  return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+}
+
+/**
+ * 生成训练任务的可读标签
+ * 格式: "模型短名 / LoRA / 数据集名 (05-26 14:30)"
+ */
+export function jobLabel(job: TrainingJob, datasets?: DatasetInfo[]): string {
+  const modelShort = job.model_id.split('/').pop() || job.model_id
+  const method = job.peft_method.toUpperCase()
+  const dsName = datasets?.find(d => d.id === job.dataset_id)?.name
+    || job.dataset_id?.split('/').pop()
+    || ''
+  const time = job.created_at ? formatTime(job.created_at) : ''
+  const parts = [modelShort, method]
+  if (dsName) parts.push(dsName)
+  return time ? `${parts.join(' / ')} (${time})` : parts.join(' / ')
+}