diff --git a/trainer_web/static/css/style.css b/trainer_web/static/css/style.css index c16ec39..dc9fd50 100644 --- a/trainer_web/static/css/style.css +++ b/trainer_web/static/css/style.css @@ -253,6 +253,119 @@ input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:foc background: rgba(45, 55, 72, 0.9); transform: translateY(-1px); } + +/* 文件夹选择器样式 */ +.input-with-picker { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.input-with-picker input { + flex: 1; +} + +.btn-picker { + background: linear-gradient(135deg, var(--info-grad-start) 0%, var(--info-grad-end) 100%); + color: white; + border: none; + padding: 0.75rem; + border-radius: var(--radius-md); + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: var(--shadow-sm); + min-width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-picker:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + filter: brightness(1.1); +} + +/* 进度条样式 */ +.progress-container { + margin: 0.5rem 0; + background: rgba(45, 55, 72, 0.3); + border-radius: var(--radius-lg); + padding: 0.5rem; + border: 1px solid var(--border); +} + +.progress-bar { + width: 100%; + height: 8px; + background: rgba(45, 55, 72, 0.5); + border-radius: var(--radius-sm); + overflow: hidden; + margin: 0.5rem 0; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-grad-start) 0%, var(--accent-grad-end) 100%); + border-radius: var(--radius-sm); + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: progress-shine 2s infinite; +} + +@keyframes progress-shine { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.progress-metrics { + display: flex; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.metric-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: rgba(139, 92, 246, 0.1); + border-radius: var(--radius-sm); + border: 1px solid rgba(139, 92, 246, 0.2); +} + +.metric-label { + font-weight: 500; + color: var(--text-secondary); +} + +.metric-value { + font-weight: 600; + color: var(--accent); +} .checkbox-group { display: flex; align-items: center; diff --git a/trainer_web/static/js/app.js b/trainer_web/static/js/app.js index 580127b..6606144 100644 --- a/trainer_web/static/js/app.js +++ b/trainer_web/static/js/app.js @@ -17,6 +17,38 @@ const hooks = { window.openTab = (evt, tabName) => _openTab(evt, tabName, hooks); +// 文件夹选择器功能 +window.selectFolder = async (inputId) => { + try { + // 检查是否支持 File System Access API + if ('showDirectoryPicker' in window) { + const dirHandle = await window.showDirectoryPicker(); + const path = dirHandle.name; // 使用目录名称作为路径 + document.getElementById(inputId).value = `./${path}`; + } else { + // 降级方案:使用文件输入模拟 + const input = document.createElement('input'); + input.type = 'file'; + input.webkitdirectory = true; + input.onchange = (e) => { + const files = e.target.files; + if (files.length > 0) { + // 提取相对路径 + const path = files[0].webkitRelativePath.split('/')[0]; + document.getElementById(inputId).value = `./${path}`; + } + }; + input.click(); + } + } catch (error) { + console.warn('文件夹选择失败:', error); + // 如果用户取消选择,不显示错误 + if (error.name !== 'AbortError') { + alert('文件夹选择失败,请手动输入路径'); + } + } +}; + window.addEventListener('load', () => { initTrainForm(); startProcessPolling(); diff --git a/trainer_web/static/js/processes/list.js b/trainer_web/static/js/processes/list.js index 9e9b911..1329b34 100644 --- a/trainer_web/static/js/processes/list.js +++ b/trainer_web/static/js/processes/list.js @@ -4,6 +4,64 @@ import { showConfirmDialog } from '../ui/dialog.js'; import { el, clearChildren } from '../utils/dom.js'; import { showLogs, refreshLog, clearLogTimerFor } from './logs.js'; +// 计算训练进度信息 +function calculateProgress(process) { + const defaultProgress = { + percentage: 0, + current: 0, + total: 0, + remaining: '计算中...', + loss: null, + epoch: null, + lr: null + }; + + // 如果进程不在运行,返回默认进度 + if (!process.running) return defaultProgress; + + // 从进程数据中提取进度信息 + if (process.progress) { + return { + percentage: process.progress.percentage || 0, + current: process.progress.current_epoch || 0, + total: process.progress.total_epochs || 0, + remaining: process.progress.remaining_time || '计算中...', + loss: process.progress.current_loss || null, + epoch: process.progress.current_epoch ? `${process.progress.current_epoch}/${process.progress.total_epochs}` : null, + lr: process.progress.current_lr || null + }; + } + + // 尝试从日志中提取进度信息(简化版本) + if (process.logs) { + const logText = process.logs.slice(-1000); // 取最近1000字符 + + // 提取epoch信息 + const epochMatch = logText.match(/epoch\s+(\d+)\/(\d+)/i); + if (epochMatch) { + const current = parseInt(epochMatch[1]); + const total = parseInt(epochMatch[2]); + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; + + // 提取loss信息 + const lossMatch = logText.match(/loss[\s:=]\s*([\d.]+)/i); + const currentLoss = lossMatch ? parseFloat(lossMatch[1]).toFixed(4) : null; + + return { + percentage, + current, + total, + remaining: '计算中...', + loss: currentLoss, + epoch: `${current}/${total}`, + lr: null + }; + } + } + + return defaultProgress; +} + let processPollingTimer = null; export function startProcessPolling() { @@ -135,11 +193,32 @@ export function addProcessItemToGroup(parent, process) { const showDelete = !process.running; const showSwanlab = process.train_monitor !== 'none'; const swanBtn = showSwanlab ? `` : ''; + + // 计算进度信息 + const progressInfo = calculateProgress(process); + const progressBar = process.running ? ` +