From 544c889533064b18b049ecf9c769f46763e0901d Mon Sep 17 00:00:00 2001 From: yuyu5333 <1812107659@qq.com> Date: Fri, 14 Nov 2025 16:08:30 +0800 Subject: [PATCH] rewrite web --- trainer_web/start_web_ui.sh | 68 +- trainer_web/static/js/script.js | 1462 ------------------------------- 2 files changed, 8 insertions(+), 1522 deletions(-) delete mode 100644 trainer_web/static/js/script.js diff --git a/trainer_web/start_web_ui.sh b/trainer_web/start_web_ui.sh index e47ee81..1702227 100755 --- a/trainer_web/start_web_ui.sh +++ b/trainer_web/start_web_ui.sh @@ -31,76 +31,24 @@ echo "日志文件: $LOG_FILE" nohup python -u train_web_ui.py > "$LOG_FILE" 2>&1 & # 保存PID -pid=$! -echo $pid > "train_web_ui.pid" +echo $! > "train_web_ui.pid" -# 检查进程是否成功启动 -if ! ps -p "$pid" > /dev/null 2>&1; then - echo "错误: Web UI 服务启动失败,进程已不存在" - # 显示日志文件的最后几行以提供错误信息 - if [ -f "$LOG_FILE" ]; then - echo "错误日志信息:" - tail -n 20 "$LOG_FILE" - fi - rm -f "train_web_ui.pid" - exit 1 -fi - -# 轮询日志以获取实际端口号并检查是否有错误(最多等待10秒) +# 轮询日志以获取实际端口号(最多等待10秒) PORT="" -START_TIME=$(date +%s) -MAX_WAIT_TIME=10 -SUCCESS=false - -while [ $(( $(date +%s) - $START_TIME )) -lt $MAX_WAIT_TIME ]; do - # 检查是否有错误信息 - if grep -i "error\|exception\|failed\|traceback" "$LOG_FILE" > /dev/null 2>&1; then - echo "错误: Web UI 服务启动过程中出现错误" - echo "错误日志信息:" - grep -i "error\|exception\|failed\|traceback" "$LOG_FILE" - tail -n 20 "$LOG_FILE" - echo "请查看完整日志文件了解详细错误信息: $LOG_FILE" - rm -f "train_web_ui.pid" - kill $pid > /dev/null 2>&1 - exit 1 - fi - +for i in {1..20}; do # 提取形如 http://0.0.0.0:12345 的地址,再截取端口 PORT=$(grep -Eo 'http://0\.0\.0\.0:[0-9]+' "$LOG_FILE" | tail -n1 | awk -F: '{print $NF}') if [ -n "$PORT" ]; then - SUCCESS=true break fi - - # 再次检查进程是否还在运行 - if ! ps -p "$pid" > /dev/null 2>&1; then - echo "错误: Web UI 服务启动失败,进程已退出" - echo "错误日志信息:" - tail -n 20 "$LOG_FILE" - rm -f "train_web_ui.pid" - exit 1 - fi - sleep 0.5 done -# 如果超时且没有找到端口 -if [ "$SUCCESS" = false ]; then - # 再次检查进程是否还在运行 - if ps -p "$pid" > /dev/null 2>&1; then - echo "警告: 无法在指定时间内确认服务是否完全启动" - # 如果仍未获取到端口,回退为默认提示端口(与后端起始端口一致) - PORT=12581 - echo "服务可能已启动,但未能确认。请检查日志文件: $LOG_FILE" - else - echo "错误: Web UI 服务启动超时且进程已退出" - echo "错误日志信息:" - tail -n 20 "$LOG_FILE" - rm -f "train_web_ui.pid" - exit 1 - fi +# 如果仍未获取到端口,回退为默认提示端口(与后端起始端口一致) +if [ -z "$PORT" ]; then + PORT=12581 fi -echo "服务已成功启动! PID: $pid" +echo "服务已启动! PID: $(cat "train_web_ui.pid")" echo "访问地址: http://localhost:$PORT" -echo "停止命令: kill $pid or bash trainer_web/stop_web_ui.sh" +echo "停止命令: kill $(cat "train_web_ui.pid") or bash trainer_web/stop_web_ui.sh" diff --git a/trainer_web/static/js/script.js b/trainer_web/static/js/script.js deleted file mode 100644 index 749185a..0000000 --- a/trainer_web/static/js/script.js +++ /dev/null @@ -1,1462 +0,0 @@ -// 标签切换功能 -function openTab(evt, tabName) { - var i, tabContent, tabLinks; - tabContent = document.getElementsByClassName("tab-content"); - for (i = 0; i < tabContent.length; i++) { - tabContent[i].classList.add("hidden"); - } - tabLinks = document.getElementsByClassName("tab"); - for (i = 0; i < tabLinks.length; i++) { - tabLinks[i].classList.remove("active"); - } - document.getElementById(tabName).classList.remove("hidden"); - evt.currentTarget.classList.add("active"); - - // 当切换到其他标签页时,清除所有日志自动更新定时器 - if (tabName !== 'processes') { - for (let processId in logTimers) { - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - } - - // 如果切换到进程页面,刷新进程列表 - if (tabName === 'processes') { - loadProcesses(); - } else if (tabName === 'logfiles') { - loadLogFiles(); - } -} - -// 根据训练类型显示/隐藏特定参数 -document.getElementById('train_type').addEventListener('change', function() { - const trainType = this.value; - const pretrainSftFields = document.querySelectorAll('.pretrain-sft'); - const fromWeightFields = document.querySelectorAll('.from-weight'); - const loraFields = document.querySelectorAll('.lora'); - const dpoFields = document.querySelectorAll('.dpo'); - const dpoParamCard = document.querySelector('.parameter-card.dpo'); - const ppoFields = document.querySelectorAll('.ppo'); - const ppoParamCard = document.querySelector('.parameter-card.ppo'); - - pretrainSftFields.forEach(field => { - field.style.display = (trainType === 'pretrain' || trainType === 'sft' || trainType === 'dpo' || trainType === 'ppo') ? 'block' : 'none'; - }); - - fromWeightFields.forEach(field => { - field.style.display = (trainType !== 'ppo') ? 'block' : 'none'; - }); - - loraFields.forEach(field => { - field.style.display = trainType === 'lora' ? 'block' : 'none'; - }); - - dpoFields.forEach(field => { - field.style.display = trainType === 'dpo' ? 'block' : 'none'; - }); - - ppoFields.forEach(field => { - field.style.display = trainType === 'ppo' ? 'block' : 'none'; - }); - - if (dpoParamCard) { - dpoParamCard.style.display = trainType === 'dpo' ? 'block' : 'none'; - } - - if (ppoParamCard) { - ppoParamCard.style.display = trainType === 'ppo' ? 'block' : 'none'; - } - - // 设置默认值 - if (trainType === 'pretrain') { - document.getElementById('save_dir').value = '../out'; - document.getElementById('save_weight').value = 'pretrain'; - document.getElementById('epochs').value = '1'; - document.getElementById('batch_size').value = '32'; - document.getElementById('learning_rate').value = '5e-4'; - document.getElementById('data_path').value = '../dataset/pretrain_hq.jsonl'; - document.getElementById('from_weight').value = 'none'; - document.getElementById('log_interval').value = '100'; - document.getElementById('save_interval').value = '100'; - // 模型结构参数默认值 - document.getElementById('hidden_size').value = '512'; - document.getElementById('num_hidden_layers').value = '8'; - document.getElementById('max_seq_len').value = '512'; - document.getElementById('use_moe').value = '0'; - } else if (trainType === 'sft') { - document.getElementById('save_dir').value = '../out'; - document.getElementById('save_weight').value = 'full_sft'; - document.getElementById('epochs').value = '2'; - document.getElementById('batch_size').value = '16'; - document.getElementById('learning_rate').value = '5e-7'; - document.getElementById('data_path').value = '../dataset/sft_mini_512.jsonl'; - document.getElementById('from_weight').value = 'pretrain'; - document.getElementById('log_interval').value = '100'; - document.getElementById('save_interval').value = '100'; - // 模型结构参数默认值 - document.getElementById('hidden_size').value = '512'; - document.getElementById('num_hidden_layers').value = '8'; - document.getElementById('max_seq_len').value = '512'; - document.getElementById('use_moe').value = '0'; - } else if (trainType === 'lora') { - document.getElementById('save_dir').value = '../out/lora'; - document.getElementById('lora_name').value = 'lora_identity'; - document.getElementById('epochs').value = '50'; - document.getElementById('batch_size').value = '32'; - document.getElementById('learning_rate').value = '1e-4'; - document.getElementById('data_path').value = '../dataset/lora_identity.jsonl'; - document.getElementById('from_weight').value = 'full_sft'; - document.getElementById('log_interval').value = '10'; - document.getElementById('save_interval').value = '1'; - // 模型结构参数默认值 - document.getElementById('hidden_size').value = '512'; - document.getElementById('num_hidden_layers').value = '8'; - document.getElementById('max_seq_len').value = '512'; - document.getElementById('use_moe').value = '0'; - } else if (trainType === 'dpo') { - document.getElementById('save_dir').value = '../out'; - document.getElementById('save_weight').value = 'dpo'; - document.getElementById('epochs').value = '1'; - document.getElementById('batch_size').value = '4'; - document.getElementById('learning_rate').value = '4e-8'; - document.getElementById('data_path').value = '../dataset/dpo.jsonl'; - document.getElementById('from_weight').value = 'full_sft'; - document.getElementById('log_interval').value = '100'; - document.getElementById('save_interval').value = '100'; - document.getElementById('beta').value = '0.1'; - // 模型结构参数默认值 - document.getElementById('hidden_size').value = '512'; - document.getElementById('num_hidden_layers').value = '8'; - document.getElementById('max_seq_len').value = '1024'; - document.getElementById('use_moe').value = '0'; - } else if (trainType === 'ppo') { - document.getElementById('save_dir').value = '../out'; - document.getElementById('save_weight').value = 'ppo_actor'; - document.getElementById('epochs').value = '1'; - document.getElementById('batch_size').value = '2'; - document.getElementById('learning_rate').value = '8e-8'; - document.getElementById('data_path').value = '../dataset/rlaif-mini.jsonl'; - document.getElementById('log_interval').value = '1'; - document.getElementById('save_interval').value = '10'; - // PPO特有参数默认值 - document.getElementById('clip_epsilon').value = '0.1'; - document.getElementById('vf_coef').value = '0.5'; - document.getElementById('kl_coef').value = '0.02'; - document.getElementById('reasoning').value = '1'; - document.getElementById('update_old_actor_freq').value = '4'; - document.getElementById('reward_model_path').value = '../../internlm2-1_8b-reward'; - // 模型结构参数默认值 - document.getElementById('hidden_size').value = '512'; - document.getElementById('num_hidden_layers').value = '8'; - document.getElementById('max_seq_len').value = '66'; - document.getElementById('use_moe').value = '0'; - } -}); - -// 初始触发一次change事件以设置默认值 -document.getElementById('train_type').dispatchEvent(new Event('change')); - -// 页面加载时启动进程状态轮询 -window.addEventListener('load', () => { - startProcessPolling(); - - // 加载初始进程列表 - loadProcesses(); -}); - -// 在进程标签页切换时也触发一次检查 -const tabLinks = document.querySelectorAll('.tab'); -tabLinks.forEach(tab => { - tab.addEventListener('click', () => { - // 给标签页切换一些时间完成 - setTimeout(() => { - const processesTab = document.querySelector('.tab.active'); - if (processesTab && processesTab.textContent.includes('进程')) { - checkProcessStatusChanges(); - } - }, 100); - }); -}); - -// 添加定时轮询机制,每5秒检查一次进程状态变化 -let processPollingTimer; - -// 开始进程状态轮询 -function startProcessPolling() { - // 清除可能存在的旧定时器 - if (processPollingTimer) { - clearInterval(processPollingTimer); - } - // 每5秒检查一次进程状态 - processPollingTimer = setInterval(() => { - // 只有在进程标签页打开时才检查 - const processesTab = document.querySelector('.tab.active'); - if (processesTab && processesTab.textContent.includes('进程')) { - checkProcessStatusChanges(); - } - }, 5000); -} - -// 带超时和重试的fetch请求函数 -function fetchWithTimeoutAndRetry(url, options = {}, timeout = 10000, retries = 3) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - // 添加缓存控制头 - const fetchOptions = { - ...options, - headers: { - ...options.headers, - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - }, - signal: controller.signal - }; - - return fetch(url, fetchOptions) - .then(response => { - clearTimeout(timeoutId); - - // 检查响应是否成功 - if (!response.ok) { - throw new Error(`HTTP错误! 状态码: ${response.status}`); - } - return response; - }) - .catch(error => { - clearTimeout(timeoutId); - - // 如果是AbortError,说明请求超时 - if (error.name === 'AbortError') { - throw new Error('请求超时'); - } - - // 如果还有重试次数,重新发起请求 - if (retries > 0) { - console.warn(`请求失败,${retries}次重试剩余,${timeout/2}ms后重试...`); - return new Promise(resolve => { - setTimeout(() => { - resolve(fetchWithTimeoutAndRetry(url, options, timeout, retries - 1)); - }, timeout / 2); - }); - } - - // 重试次数用完,抛出错误 - throw error; - }); -} - -// 检查进程状态变化(特别是错误状态) -function checkProcessStatusChanges() { - fetchWithTimeoutAndRetry('/processes') - .then(response => response.json()) - .then(data => { - // 遍历每个进程,检查状态变化 - data.forEach(process => { - const processItem = document.querySelector(`[data-process-id="${process.id}"]`); - if (processItem) { - const currentStatus = processItem.dataset.processStatus; - const newStatus = process.status; - - // 如果状态发生变化,更新进程项 - if (currentStatus !== newStatus) { - updateProcessItem(processItem, process); - - // 如果状态变为出错,显示通知 - if (newStatus === '出错') { - showNotification(`进程 ${process.train_type} 已出错`, 'error'); - } - } - } - }); - }) - .catch(error => { - console.error('检查进程状态时出错:', error); - // 显示连接错误通知 - showNotification('连接服务器失败,请刷新页面重试', 'error'); - }); -} - -// 检查并打开SwanLab链接 -function checkAndOpenSwanlab(processId) { - // 首先检查训练监控设置 - const processItem = document.querySelector(`[data-process-id="${processId}"]`); - const trainMonitor = processItem ? processItem.dataset.trainMonitor : 'none'; - - // 如果训练监控设置为'none',显示提示信息 - if (trainMonitor === 'none') { - showNotification('此训练未启用监控功能', 'info'); - return; - } - - // 首先从DOM中获取最新的URL - let currentUrl = processItem ? processItem.dataset.swanlabUrl : ''; - - // 如果没有URL或URL不完整,尝试从后端获取最新的进程信息 - if (!currentUrl || currentUrl.trim() === '') { - fetchWithTimeoutAndRetry('/processes') - .then(response => response.json()) - .then(data => { - const process = data.find(p => p.id === processId); - if (process && process.swanlab_url) { - currentUrl = process.swanlab_url; - // 更新DOM中的URL数据属性 - if (processItem) { - processItem.dataset.swanlabUrl = currentUrl; - } - openSwanlab(processId, currentUrl); - } else { - // 没有找到有效的链接 - showNotification('SwanLab链接尚未生成,请稍后再试', 'info'); - } - }) - .catch(error => { - console.error('获取进程信息失败:', error); - showNotification('获取SwanLab链接失败,请稍后再试', 'error'); - }); - } else { - // 有URL,直接打开 - openSwanlab(processId, currentUrl); - } -} - -// 打开SwanLab链接 -function openSwanlab(processId, url) { - // 检查URL是否有效 - if (!url || typeof url !== 'string' || url.trim() === '' || !isValidUrl(url)) { - showNotification('SwanLab链接无效或尚未生成', 'info'); - return; - } - - // 在新窗口打开链接 - const newWindow = window.open(url, '_blank'); - - // 检查窗口是否成功打开 - if (newWindow) { - // 显示成功通知 - showNotification('正在打开SwanLab页面', 'info'); - } else { - // 弹出窗口被阻止 - showNotification('无法打开新窗口,请检查浏览器设置', 'error'); - } -} - -// 检查URL是否有效 -function isValidUrl(url) { - try { - // 尝试创建URL对象,如果失败说明URL无效 - new URL(url); - return true; - } catch (error) { - // 简单检查是否以http或https开头 - return url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://'); - } -} - -// 更新单个进程项 -function updateProcessItem(processItem, process) { - // 更新数据属性 - processItem.dataset.processStatus = process.status; - processItem.dataset.trainMonitor = process.train_monitor || 'none'; - - // 更新SwanLab URL数据属性 - if (process.swanlab_url) { - processItem.dataset.swanlabUrl = process.swanlab_url; - } - - // 更新状态类和文本 - const statusElement = processItem.querySelector('.process-status'); - if (statusElement) { - // 移除所有状态类 - statusElement.classList.remove('status-running', 'status-manual-stop', 'status-error', 'status-completed'); - - // 添加新的状态类 - let statusClass = ''; - if (process.status === '运行中') { - statusClass = 'status-running'; - } else if (process.status === '手动停止') { - statusClass = 'status-manual-stop'; - } else if (process.status === '出错') { - statusClass = 'status-error'; - } else { - statusClass = 'status-completed'; - } - - statusElement.classList.add(statusClass); - statusElement.textContent = process.status; - } - - // 更新SwanLab按钮 - const existingSwanlabButton = processItem.querySelector('.btn-swanlab'); - const buttonContainer = processItem.querySelector('div:nth-child(2)'); // 按钮容器是第二个div - - // 只有当train_monitor不是'none'时才显示SwanLab按钮 - const shouldShowSwanlab = process.train_monitor !== 'none'; - - // 如果应该显示按钮但不存在,则创建并添加 - if (shouldShowSwanlab && !existingSwanlabButton && buttonContainer) { - const swanlabButton = document.createElement('button'); - swanlabButton.className = 'btn-swanlab'; - swanlabButton.textContent = 'SwanLab'; - swanlabButton.onclick = function() { - checkAndOpenSwanlab(process.id); - }; - - // 插入到停止按钮之前 - const stopButton = buttonContainer.querySelector('.btn-stop'); - if (stopButton) { - buttonContainer.insertBefore(swanlabButton, stopButton); - } else { - // 如果没有停止按钮,插入到刷新按钮之后 - const refreshButton = buttonContainer.querySelector('.btn-logs:nth-child(2)'); - if (refreshButton) { - buttonContainer.insertBefore(swanlabButton, refreshButton.nextSibling); - } - } - } else if (!shouldShowSwanlab && existingSwanlabButton) { - // 如果不应该显示按钮但存在,则移除 - existingSwanlabButton.remove(); - } else if (existingSwanlabButton) { - // 更新现有按钮的点击事件 - existingSwanlabButton.onclick = function() { - checkAndOpenSwanlab(process.id); - }; - } - - // 更新停止按钮 - const stopButton = processItem.querySelector('.btn-stop'); - if (stopButton) { - if (!process.running) { - stopButton.remove(); - } - } else if (process.running && buttonContainer) { - const newStopButton = document.createElement('button'); - newStopButton.className = 'btn-stop'; - newStopButton.onclick = () => stopProcess(process.id); - newStopButton.textContent = '停止训练'; - buttonContainer.appendChild(newStopButton); - } - - // 处理删除按钮 - const deleteButton = processItem.querySelector('.btn-delete'); - if (!process.running) { - // 非运行中状态应该有删除按钮 - if (!deleteButton) { - const buttonContainer = processItem.querySelector('div:last-child'); - if (buttonContainer) { - const newDeleteButton = document.createElement('button'); - newDeleteButton.className = 'btn-delete'; - newDeleteButton.onclick = () => deleteProcess(process.id); - newDeleteButton.textContent = '删除'; - buttonContainer.appendChild(newDeleteButton); - } - } - } else if (deleteButton) { - // 运行中状态不应该有删除按钮 - deleteButton.remove(); - } - - // 如果进程不再运行中,清除日志定时器 - if (!process.running && logTimers[process.id]) { - clearInterval(logTimers[process.id]); - delete logTimers[process.id]; - } -} - -// 加载进程列表 -function loadProcesses() { - fetchWithTimeoutAndRetry('/processes') - .then(response => response.json()) - .then(data => { - const processList = document.getElementById('process-list'); - processList.innerHTML = ''; - - if (data.length === 0) { - processList.innerHTML = '

暂无训练进程

'; - return; - } - - // 按启动时间倒序排序,最新启动的进程显示在前面 - data.sort((a, b) => new Date(b.start_time) - new Date(a.start_time)); - - // 按训练类型分组 - const groupedProcesses = {}; - data.forEach(process => { - if (!groupedProcesses[process.train_type]) { - groupedProcesses[process.train_type] = []; - } - groupedProcesses[process.train_type].push(process); - }); - - // 定义训练类型的显示顺序(可以根据需要调整) - const trainTypeOrder = ['pretrain', 'sft', 'lora', 'dpo']; - - // 创建类型分组并添加收起/展开功能的辅助函数 - function createTypeGroupWithToggle(trainType, processes) { - // 创建类型分组容器 - const typeGroup = document.createElement('div'); - typeGroup.className = 'process-type-group'; - - // 创建标题容器(包含标题文本和切换按钮) - const titleContainer = document.createElement('div'); - titleContainer.className = 'process-type-header'; - titleContainer.dataset.expanded = 'true'; // 默认展开状态 - - // 添加类型标题 - const typeTitle = document.createElement('h3'); - typeTitle.className = 'process-type-title'; - typeTitle.textContent = getTrainTypeDisplayName(trainType); - - // 添加切换按钮 - const toggleButton = document.createElement('button'); - toggleButton.className = 'toggle-btn'; - toggleButton.innerHTML = '▼'; // 向下箭头表示展开 - toggleButton.onclick = function(e) { - e.stopPropagation(); // 防止触发标题容器的点击事件 - toggleGroup(titleContainer); - }; - - // 将标题和按钮添加到容器 - titleContainer.appendChild(typeTitle); - titleContainer.appendChild(toggleButton); - - // 添加点击标题也可以切换展开/收起 - titleContainer.onclick = function() { - toggleGroup(titleContainer); - }; - - // 创建内容容器,用于容纳进程项 - const contentContainer = document.createElement('div'); - contentContainer.className = 'process-type-content'; - - // 添加该类型的所有进程 - processes.forEach(process => { - addProcessItemToGroup(contentContainer, process); - }); - - // 将标题容器和内容容器添加到类型分组 - typeGroup.appendChild(titleContainer); - typeGroup.appendChild(contentContainer); - - return typeGroup; - } - - // 切换分组展开/收起状态的函数 - function toggleGroup(headerElement) { - const isExpanded = headerElement.dataset.expanded === 'true'; - const contentContainer = headerElement.nextElementSibling; - const toggleButton = headerElement.querySelector('.toggle-btn'); - - if (isExpanded) { - // 收起分组 - headerElement.dataset.expanded = 'false'; - contentContainer.style.maxHeight = '0'; - contentContainer.style.overflow = 'hidden'; - toggleButton.innerHTML = '▶'; // 向右箭头表示收起 - } else { - // 展开分组前先重置样式 - contentContainer.style.overflow = 'hidden'; // 确保计算高度准确 - contentContainer.style.maxHeight = 'none'; // 临时设置为none以获取真实高度 - - // 获取真实高度 - const actualHeight = contentContainer.scrollHeight; - - // 然后先设置为0,准备动画 - contentContainer.style.maxHeight = '0'; - - // 强制重排 - contentContainer.offsetHeight; - - // 展开分组 - headerElement.dataset.expanded = 'true'; - contentContainer.style.maxHeight = actualHeight + 'px'; - - setTimeout(() => { - // 动画完成后,设置为真实高度并允许溢出 - contentContainer.style.maxHeight = 'none'; - contentContainer.style.overflow = 'visible'; - }, 300); // 动画完成后显示溢出内容 - - toggleButton.innerHTML = '▼'; // 向下箭头表示展开 - } - } - - // 首先显示有明确顺序的训练类型 - trainTypeOrder.forEach(trainType => { - if (groupedProcesses[trainType]) { - const typeGroup = createTypeGroupWithToggle(trainType, groupedProcesses[trainType]); - processList.appendChild(typeGroup); - - // 从分组中移除已处理的类型 - delete groupedProcesses[trainType]; - } - }); - - // 显示剩余的训练类型(不在预定义顺序中的) - Object.keys(groupedProcesses).forEach(trainType => { - const typeGroup = createTypeGroupWithToggle(trainType, groupedProcesses[trainType]); - processList.appendChild(typeGroup); - }); - }); -} - -// 获取训练类型的显示名称 -function getTrainTypeDisplayName(trainType) { - const typeNames = { - 'pretrain': '预训练 (Pretrain)', - 'sft': '全参数监督微调 (SFT - Full)', - 'lora': 'LoRA监督微调 (SFT - Lora)', - 'dpo': '直接偏好优化 (RL - DPO)' - }; - return typeNames[trainType] || trainType; -} - -// 添加进程项到分组 -function addProcessItemToGroup(parentElement, process) { - const processItem = document.createElement('div'); - processItem.className = 'process-item'; - - let statusClass = ''; - // 根据后端返回的status字段设置状态类 - if (process.status === '运行中') { - statusClass = 'status-running'; - } else if (process.status === '手动停止') { - statusClass = 'status-manual-stop'; - } else if (process.status === '出错') { - statusClass = 'status-error'; - } else { - statusClass = 'status-completed'; - } - - // 设置进程数据属性,用于后续检查状态 - processItem.dataset.processId = process.id; - processItem.dataset.processStatus = process.status; - processItem.dataset.trainMonitor = process.train_monitor || 'none'; - processItem.dataset.swanlabUrl = process.swanlab_url || ''; - - // 检查是否显示删除按钮(对于非运行中的进程) - const showDeleteButton = !process.running; - - // 只有当train_monitor不是'none'时才显示SwanLab按钮 - const showSwanlabButton = process.train_monitor !== 'none'; - const swanlabButton = showSwanlabButton ? `` : ''; - - processItem.innerHTML = ` -
-
- ${process.start_time} -
-
- ${process.status} -
-
-
- - - ${swanlabButton} - ${process.running ? `` : ''} - ${showDeleteButton ? `` : ''} -
- - `; - - parentElement.appendChild(processItem); -} - -// 存储训练日志定时器的ID -let logTimers = {}; - -// 显示日志 -function showLogs(processId) { - const logsContainer = document.getElementById(`logs-${processId}`); - logsContainer.classList.toggle('hidden'); - - if (!logsContainer.classList.contains('hidden')) { - // 加载日志内容 - loadLogContent(processId, logsContainer); - - // 清除可能存在的旧定时器 - if (logTimers[processId]) { - clearInterval(logTimers[processId]); - } - - // 查找进程元素,获取其状态 - const processItem = document.querySelector(`[data-process-id="${processId}"]`); - const isRunning = processItem && processItem.dataset.processStatus === '运行中'; - - // 只有运行中的进程才设置自动刷新定时器 - if (isRunning) { - logTimers[processId] = setInterval(() => { - // 检查日志容器是否可见 - if (!logsContainer.classList.contains('hidden')) { - // 再次检查进程状态,确保仍然是运行中 - const currentProcessItem = document.querySelector(`[data-process-id="${processId}"]`); - const stillRunning = currentProcessItem && currentProcessItem.dataset.processStatus === '运行中'; - - if (stillRunning) { - loadLogContent(processId, logsContainer); - } else { - // 如果进程不再运行中,清除定时器 - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - } - }, 1000); - } - } else { - // 隐藏日志时清除定时器 - if (logTimers[processId]) { - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - } -} - -// 刷新日志 -function refreshLog(processId) { - const logsContainer = document.getElementById(`logs-${processId}`); - if (!logsContainer.classList.contains('hidden')) { - loadLogContent(processId, logsContainer); - - // 查找进程元素,获取其状态 - const processItem = document.querySelector(`[data-process-id="${processId}"]`); - const isRunning = processItem && processItem.dataset.processStatus === '运行中'; - - // 重置定时器,确保从现在开始每1秒更新一次 - if (logTimers[processId]) { - clearInterval(logTimers[processId]); - } - - // 只有运行中的进程才设置定时器 - if (isRunning) { - logTimers[processId] = setInterval(() => { - if (!logsContainer.classList.contains('hidden')) { - // 再次检查进程状态 - const currentProcessItem = document.querySelector(`[data-process-id="${processId}"]`); - const stillRunning = currentProcessItem && currentProcessItem.dataset.processStatus === '运行中'; - - if (stillRunning) { - loadLogContent(processId, logsContainer); - } else { - // 如果进程不再运行中,清除定时器 - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - } else { - // 如果日志容器被隐藏,清除定时器 - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - }, 1000); - } - } -} - -// 加载日志内容 -function loadLogContent(processId, logsContainer) { - // 显示加载状态 - const oldContent = logsContainer.textContent; - const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10; - - fetchWithTimeoutAndRetry(`/logs/${processId}`) - .then(response => { - if (!response.ok) { - throw new Error('获取日志失败'); - } - return response.text(); - }) - .then(logs => { - // 更新日志内容 - logsContainer.textContent = logs; - - // 如果用户之前滚动到底部,则继续保持在底部 - // 否则保持用户的滚动位置 - if (isScrolledToBottom || oldContent === logsContainer.textContent) { - logsContainer.scrollTop = logsContainer.scrollHeight; - } - }) - .catch(error => { - console.error('加载日志失败:', error); - // 只在内容为空或之前不是错误信息时才显示错误 - if (!logsContainer.textContent.includes('加载失败')) { - logsContainer.textContent = `加载日志失败: ${error.message}`; - } - }); -} - -// 显示右上角消息弹窗 -function showNotification(message, type = 'success') { - // 创建通知元素 - const notification = document.createElement('div'); - notification.className = `notification notification-${type}`; - notification.textContent = message; - - // 添加到页面 - document.body.appendChild(notification); - - // 显示动画 - setTimeout(() => { - notification.classList.add('show'); - }, 10); - - // 自动关闭 - setTimeout(() => { - notification.classList.remove('show'); - setTimeout(() => { - document.body.removeChild(notification); - }, 300); - }, 3000); -} - -// 自定义确认对话框 -function showConfirmDialog(message, onConfirm, onCancel = null) { - // 检查是否已存在对话框,如果有则移除 - const existingDialog = document.querySelector('.custom-dialog'); - if (existingDialog) { - document.body.removeChild(existingDialog); - } - - // 创建对话框遮罩 - const dialogOverlay = document.createElement('div'); - dialogOverlay.className = 'dialog-overlay'; - - // 创建对话框容器 - const dialogContainer = document.createElement('div'); - dialogContainer.className = 'custom-dialog'; - - // 设置对话框内容 - dialogContainer.innerHTML = ` -
-
${message}
-
- - -
-
- `; - - // 添加到页面 - dialogOverlay.appendChild(dialogContainer); - document.body.appendChild(dialogOverlay); - - // 添加动画类 - setTimeout(() => { - dialogOverlay.classList.add('show'); - dialogContainer.classList.add('show'); - }, 10); - - // 绑定确认按钮事件 - const confirmBtn = dialogContainer.querySelector('.dialog-confirm'); - confirmBtn.addEventListener('click', () => { - if (onConfirm) onConfirm(); - closeDialog(dialogOverlay); - }); - - // 绑定取消按钮事件 - const cancelBtn = dialogContainer.querySelector('.dialog-cancel'); - cancelBtn.addEventListener('click', () => { - if (onCancel) onCancel(); - closeDialog(dialogOverlay); - }); - - // 点击遮罩层也可以取消 - dialogOverlay.addEventListener('click', (e) => { - if (e.target === dialogOverlay) { - if (onCancel) onCancel(); - closeDialog(dialogOverlay); - } - }); -} - -// 关闭对话框函数 -function closeDialog(dialogOverlay) { - dialogOverlay.classList.remove('show'); - const dialogContainer = dialogOverlay.querySelector('.custom-dialog'); - dialogContainer.classList.remove('show'); - - setTimeout(() => { - if (dialogOverlay.parentNode) { - document.body.removeChild(dialogOverlay); - } - }, 300); -} - -// 删除进程 -function deleteProcess(processId) { - showConfirmDialog( - '确定要删除这个训练进程吗?此操作不可恢复。', - () => { - // 确认删除 - fetchWithTimeoutAndRetry(`/delete/${processId}`, { - method: 'POST' - }) - .then(response => { - // 检查响应状态 - if (!response.ok) { - throw new Error('删除请求失败'); - } - return response.json().catch(() => ({})); // 即使没有JSON响应也继续 - }) - .then(() => { - // 从UI中移除进程项 - const processItem = document.querySelector(`[data-process-id="${processId}"]`); - if (processItem) { - // 添加移除动画 - processItem.style.transition = 'opacity 0.3s, transform 0.3s'; - processItem.style.opacity = '0'; - processItem.style.transform = 'translateX(-20px)'; - - // 动画完成后移除 - setTimeout(() => { - // 获取内容容器和类型分组 - const contentContainer = processItem.closest('.process-type-content'); - const typeGroup = contentContainer ? contentContainer.closest('.process-type-group') : null; - - if (processItem.parentNode) { - processItem.parentNode.removeChild(processItem); - } - - // 检查内容容器中是否还有进程项 - if (contentContainer) { - const remainingProcessesInGroup = contentContainer.querySelectorAll('.process-item'); - - // 如果内容容器中没有进程项了,移除整个分组 - if (remainingProcessesInGroup.length === 0 && typeGroup) { - setTimeout(() => { - // 添加分组移除动画 - typeGroup.style.transition = 'opacity 0.3s, transform 0.3s'; - typeGroup.style.opacity = '0'; - typeGroup.style.transform = 'translateY(-10px)'; - - setTimeout(() => { - if (typeGroup.parentNode) { - typeGroup.parentNode.removeChild(typeGroup); - } - - // 检查是否还有进程项 - const remainingProcesses = document.querySelectorAll('.process-item'); - if (remainingProcesses.length === 0) { - const processList = document.getElementById('process-list'); - processList.innerHTML = '

暂无训练进程

'; - } - }, 300); - }, 100); - } else { - // 重新计算内容容器的高度(如果分组是展开的) - const headerElement = contentContainer.previousElementSibling; - if (headerElement && headerElement.dataset.expanded === 'true') { - contentContainer.style.maxHeight = contentContainer.scrollHeight + 'px'; - } - - // 检查是否还有进程项 - const remainingProcesses = document.querySelectorAll('.process-item'); - if (remainingProcesses.length === 0) { - const processList = document.getElementById('process-list'); - processList.innerHTML = '

暂无训练进程

'; - } - } - } else { - // 检查是否还有进程项 - const remainingProcesses = document.querySelectorAll('.process-item'); - if (remainingProcesses.length === 0) { - const processList = document.getElementById('process-list'); - processList.innerHTML = '

暂无训练进程

'; - } - } - }, 300); - } - - // 清除日志定时器 - if (logTimers[processId]) { - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - - showNotification('训练进程已删除', 'success'); - }) - .catch(error => { - console.error('删除进程时出错:', error); - showNotification('删除进程失败,请刷新页面重试', 'error'); - }); - } - ); -} - -// 停止进程 -function stopProcess(processId) { - showConfirmDialog( - '确定要停止这个训练进程吗?', - () => { - // 确认停止 - fetchWithTimeoutAndRetry(`/stop/${processId}`, { - method: 'POST' - }) - .then(() => { - // 立即给用户反馈,设置为手动停止状态 - const processItem = document.querySelector(`[data-process-id="${processId}"]`); - if (processItem) { - // 先设置为手动停止状态(即时反馈) - processItem.dataset.processStatus = '手动停止'; - const statusElement = processItem.querySelector('.process-status'); - if (statusElement) { - statusElement.classList.remove('status-running', 'status-error', 'status-completed'); - statusElement.classList.add('status-manual-stop'); - statusElement.textContent = '手动停止'; - } - - // 移除停止按钮 - const stopButton = processItem.querySelector('.btn-stop'); - if (stopButton) { - stopButton.remove(); - } - - // 清除日志定时器 - if (logTimers[processId]) { - clearInterval(logTimers[processId]); - delete logTimers[processId]; - } - } - - // 显示通知 - showNotification('训练进程已停止', 'info'); - - // 为了确保状态准确,仍然获取完整列表并更新单个进程 - fetchWithTimeoutAndRetry('/processes') - .then(response => response.json()) - .then(data => { - // 从完整列表中找到特定进程 - const updatedProcess = data.find(p => p.id === processId); - if (updatedProcess && processItem) { - // 使用updateProcessItem函数更新进程(如果需要的话) - updateProcessItem(processItem, updatedProcess); - } - }) - .catch(error => { - console.error('获取进程列表更新单个进程失败:', error); - }); - }) - .catch(() => { - showNotification('停止进程失败', 'error'); - }); - }, - () => { - // 取消停止,可选的回调 - showNotification('已取消停止操作', 'info'); - } - ); -} - -// 表单提交处理 -document.getElementById('train-form').addEventListener('submit', function(e) { - e.preventDefault(); - const formData = new FormData(this); - const data = {}; - const trainingMode = formData.get('training_mode'); - - // 处理表单数据,特别关注训练方式相关参数 - formData.forEach((value, key) => { - // 对于GPU数量,确保只有当选择了多卡训练时才添加到数据中 - if (key === 'gpu_num') { - const multiGpuSelection = document.getElementById('multi-gpu-selection'); - if (multiGpuSelection && multiGpuSelection.style.display !== 'none') { - data[key] = value; - } - } else if (key === 'device') { - // 处理device参数,根据训练方式设置正确格式 - if (trainingMode === 'single_gpu') { - // 单卡训练时,格式为cuda:序号 - data[key] = `cuda:${value}`; - } else if (trainingMode === 'cpu') { - // CPU训练时,固定为cpu - data[key] = 'cpu'; - } - // 多卡训练时不需要device参数,会通过torchrun自动设置 - } else if (key !== 'training_mode') { - // 排除training_mode参数,保留其他参数 - data[key] = value; - } - }); - - // 立即显示加载通知 - showNotification('正在启动训练...', 'info'); - - // 延迟1秒后发送请求,确保两个通知之间有间隔 - setTimeout(() => { - fetchWithTimeoutAndRetry('/train', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache' - }, - body: JSON.stringify(data) - }) - .then(response => { - if (!response.ok) { - throw new Error('网络响应异常'); - } - return response.json(); - }) - .then(result => { - if (result.success) { - showNotification('训练已开始!', 'success'); - - // 延迟1秒后切换到进程标签页,确保进程信息已更新 - setTimeout(() => { - // 获取进程标签按钮并触发点击 - const processTab = document.querySelector('.tab[onclick*="processes"]'); - if (processTab) { - processTab.click(); - } - }, 1000); - } else { - // 显示错误通知 - showNotification('训练启动失败:' + result.error, 'error'); - } - }) - .catch(error => { - // 显示等待通知 - showNotification('启动训练中,请耐心等待...', 'info'); - }); - }, 1000); // 1秒延迟确保两个通知之间有间隔 -}); - -// 加载日志文件列表 -function loadLogFiles() { - fetchWithTimeoutAndRetry('/logfiles') - .then(response => response.json()) - .then(data => { - const logfilesList = document.getElementById('logfiles-list'); - logfilesList.innerHTML = ''; - - if (data.length === 0) { - logfilesList.innerHTML = '

暂无日志文件

'; - return; - } - - // 按日期倒序排序 - data.sort((a, b) => new Date(b.modified_time) - new Date(a.modified_time)); - - // 按训练类型分组 - const groupedLogFiles = {}; - data.forEach(logfile => { - // 从文件名提取训练类型 - let trainType = '自定义训练'; - if (logfile.filename.includes('train_pretrain_')) { - trainType = 'pretrain'; - } else if (logfile.filename.includes('train_sft_')) { - trainType = 'sft'; - } else if (logfile.filename.includes('train_lora_')) { - trainType = 'lora'; - } else if (logfile.filename.includes('train_dpo_')) { - trainType = 'dpo'; - } else if (logfile.filename.includes('train_ppo_')) { - trainType = 'ppo'; - } else if (logfile.filename.includes('train_grpo_')) { - trainType = 'grpo'; - } else if (logfile.filename.includes('train_spo_')) { - trainType = 'spo'; - } - - // 为日志文件添加训练类型信息 - logfile.train_type = trainType; - - if (!groupedLogFiles[trainType]) { - groupedLogFiles[trainType] = []; - } - groupedLogFiles[trainType].push(logfile); - }); - - // 定义训练类型的显示顺序(与进程显示顺序保持一致) - const trainTypeOrder = ['pretrain', 'sft', 'lora', 'dpo', 'ppo', 'grpo', 'spo', '未知']; - - // 创建类型分组并添加收起/展开功能的辅助函数 - function createTypeGroupWithToggle(trainType, logfiles) { - // 创建类型分组容器 - const typeGroup = document.createElement('div'); - typeGroup.className = 'process-type-group'; - - // 创建标题容器(包含标题文本和切换按钮) - const titleContainer = document.createElement('div'); - titleContainer.className = 'process-type-header'; - titleContainer.dataset.expanded = 'true'; // 默认展开状态 - - // 添加类型标题 - const typeTitle = document.createElement('h3'); - typeTitle.className = 'process-type-title'; - typeTitle.textContent = getTrainTypeDisplayName(trainType); - - // 添加切换按钮 - const toggleButton = document.createElement('button'); - toggleButton.className = 'toggle-btn'; - toggleButton.innerHTML = '▼'; // 向下箭头表示展开 - toggleButton.onclick = function(e) { - e.stopPropagation(); // 防止触发标题容器的点击事件 - toggleGroup(titleContainer); - }; - - // 将标题和按钮添加到容器 - titleContainer.appendChild(typeTitle); - titleContainer.appendChild(toggleButton); - - // 添加点击标题也可以切换展开/收起 - titleContainer.onclick = function() { - toggleGroup(titleContainer); - }; - - // 创建内容容器,用于容纳日志文件项 - const contentContainer = document.createElement('div'); - contentContainer.className = 'process-type-content'; - - // 添加该类型的所有日志文件 - logfiles.forEach(logfile => { - addLogFileItemToGroup(contentContainer, logfile); - }); - - // 将标题容器和内容容器添加到类型分组 - typeGroup.appendChild(titleContainer); - typeGroup.appendChild(contentContainer); - - return typeGroup; - } - - // 切换分组展开/收起状态的函数 - function toggleGroup(headerElement) { - const isExpanded = headerElement.dataset.expanded === 'true'; - const contentContainer = headerElement.nextElementSibling; - const toggleButton = headerElement.querySelector('.toggle-btn'); - - if (isExpanded) { - // 收起分组 - headerElement.dataset.expanded = 'false'; - contentContainer.style.maxHeight = '0'; - contentContainer.style.overflow = 'hidden'; - toggleButton.innerHTML = '▶'; // 向右箭头表示收起 - } else { - // 展开分组前先重置样式 - contentContainer.style.overflow = 'hidden'; // 确保计算高度准确 - contentContainer.style.maxHeight = 'none'; // 临时设置为none以获取真实高度 - - // 获取真实高度 - const actualHeight = contentContainer.scrollHeight; - - // 然后先设置为0,准备动画 - contentContainer.style.maxHeight = '0'; - - // 强制重排 - contentContainer.offsetHeight; - - // 展开分组 - headerElement.dataset.expanded = 'true'; - contentContainer.style.maxHeight = actualHeight + 'px'; - - setTimeout(() => { - // 动画完成后,设置为真实高度并允许溢出 - contentContainer.style.maxHeight = 'none'; - contentContainer.style.overflow = 'visible'; - }, 300); // 动画完成后显示溢出内容 - - toggleButton.innerHTML = '▼'; // 向下箭头表示展开 - } - } - - // 首先显示有明确顺序的训练类型 - trainTypeOrder.forEach(trainType => { - if (groupedLogFiles[trainType]) { - const typeGroup = createTypeGroupWithToggle(trainType, groupedLogFiles[trainType]); - logfilesList.appendChild(typeGroup); - - // 从分组中移除已处理的类型 - delete groupedLogFiles[trainType]; - } - }); - - // 显示剩余的训练类型(不在预定义顺序中的) - Object.keys(groupedLogFiles).forEach(trainType => { - const typeGroup = createTypeGroupWithToggle(trainType, groupedLogFiles[trainType]); - logfilesList.appendChild(typeGroup); - }); - }); -} - -// 添加日志文件项到分组 -function addLogFileItemToGroup(parentElement, logfile) { - const fileItem = document.createElement('div'); - fileItem.className = 'process-item'; - - fileItem.innerHTML = ` -
-
- ${logfile.filename} -
-
- 已保存 - ${logfile.modified_time} -
-
-
- - -
- - `; - - parentElement.appendChild(fileItem); -} - -// 删除日志文件 -function deleteLogFile(filename, button) { - // 显示确认对话框 - showConfirmDialog( - `确定要删除日志文件 "${filename}" 吗?此操作无法恢复。`, - function() { - // 用户确认删除 - const processItem = button.closest('.process-item'); - const contentContainer = processItem.closest('.process-type-content'); - const typeGroup = contentContainer.closest('.process-type-group'); - - // 显示加载状态 - const originalText = button.textContent; - button.textContent = '删除中...'; - button.disabled = true; - - // 发送删除请求到服务器 - fetchWithTimeoutAndRetry(`/delete-logfile/${encodeURIComponent(filename)}`, { - method: 'DELETE', - headers: { - 'Cache-Control': 'no-cache' - } - }) - .then(response => { - if (!response.ok) { - throw new Error('删除失败'); - } - return response.json(); - }) - .then(data => { - if (data.success) { - // 从UI中移除日志项 - processItem.remove(); - - // 检查是否需要移除整个分组 - if (contentContainer.children.length === 0) { - typeGroup.remove(); - } else { - // 更新内容容器的高度 - const headerElement = contentContainer.previousElementSibling; - if (headerElement && headerElement.dataset.expanded === 'true') { - // 临时设置为none以获取真实高度 - contentContainer.style.maxHeight = 'none'; - const actualHeight = contentContainer.scrollHeight; - contentContainer.style.maxHeight = actualHeight + 'px'; - } - } - - // 显示成功通知 - showNotification(`日志文件 "${filename}" 已成功删除`); - } else { - throw new Error(data.message || '删除失败'); - } - }) - .catch(error => { - console.error('删除日志失败:', error); - showNotification(`删除失败: ${error.message}`, 'error'); - // 恢复按钮状态 - button.textContent = originalText; - button.disabled = false; - }); - }, - function() { - // 用户取消删除,不执行任何操作 - } - ); -} - -// 查看日志文件内容 -function viewLogFile(filename, button) { - // 生成安全的文件名,用于元素ID - const safeFilename = filename.replace(/[^a-zA-Z0-9_.-]/g, '_'); - const idSafeFilename = safeFilename.replace(/\./g, '-'); - - // 查找对应的日志容器 - const processItem = button.closest('.process-item'); - const logContainer = processItem.querySelector(`#log-content-${idSafeFilename}`); - - // 获取内容容器和头部元素 - const contentContainer = processItem.closest('.process-type-content'); - const headerElement = contentContainer ? contentContainer.previousElementSibling : null; - - // 确保日志所在的分组是展开状态 - if (contentContainer && headerElement && headerElement.dataset.expanded !== 'true') { - // 如果分组是折叠的,先展开分组 - toggleGroup(headerElement); - } - - // 切换日志容器的显示状态 - if (logContainer.classList.contains('hidden')) { - logContainer.classList.remove('hidden'); - - // 读取完整日志内容 - logContainer.textContent = '加载中...'; - - fetchWithTimeoutAndRetry(`/logfile-content/${encodeURIComponent(filename)}`) - .then(response => { - if (!response.ok) { - throw new Error('获取日志失败'); - } - return response.text(); - }) - .then(logs => { - // 保留空白格式,使用textContent而不是innerHTML - logContainer.textContent = logs; - // 默认滚动到顶部,显示完整历史 - logContainer.scrollTop = 0; - - // 更新内容容器的高度 - updateContentContainerHeight(); - }) - .catch(error => { - console.error('获取日志失败:', error); - logContainer.textContent = `获取日志失败: ${error.message}`; - - // 更新内容容器的高度 - updateContentContainerHeight(); - }); - } else { - logContainer.classList.add('hidden'); - - // 更新内容容器的高度 - updateContentContainerHeight(); - } - - // 辅助函数:更新内容容器的高度 - function updateContentContainerHeight() { - if (contentContainer && headerElement && headerElement.dataset.expanded === 'true') { - // 保存当前的maxHeight设置 - const currentMaxHeight = contentContainer.style.maxHeight; - - // 暂时设置为none以获取真实高度 - contentContainer.style.maxHeight = 'none'; - - // 获取真实高度 - const actualHeight = contentContainer.scrollHeight; - - // 检查是否需要重新设置高度 - if (currentMaxHeight === 'none' || parseInt(currentMaxHeight) !== actualHeight) { - contentContainer.style.maxHeight = actualHeight + 'px'; - - // 短暂延迟后再设置为none,确保高度变化正确应用 - setTimeout(() => { - if (headerElement.dataset.expanded === 'true') { - contentContainer.style.maxHeight = 'none'; - } - }, 300); - } else { - // 如果高度没有变化,恢复之前的设置 - contentContainer.style.maxHeight = currentMaxHeight; - } - } - } -} \ No newline at end of file