|
|
@@ -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>
|