// 全局變量 let tasks = []; let selectedTaskId = null; let searchTerm = ""; let sortOption = "date-asc"; let globalAnalysisResult = null; // 新增:儲存全局分析結果 let svg, g, simulation; // << 修改:定義 D3 相關變量 // 新增:i18n 全局變量 let currentLang = "en"; // 預設語言 let translations = {}; // 儲存加載的翻譯 // DOM元素 const taskListElement = document.getElementById("task-list"); const taskDetailsContent = document.getElementById("task-details-content"); const statusFilter = document.getElementById("status-filter"); const currentTimeElement = document.getElementById("current-time"); const progressIndicator = document.getElementById("progress-indicator"); const progressCompleted = document.getElementById("progress-completed"); const progressInProgress = document.getElementById("progress-in-progress"); const progressPending = document.getElementById("progress-pending"); const progressLabels = document.getElementById("progress-labels"); const dependencyGraphElement = document.getElementById("dependency-graph"); const globalAnalysisResultElement = document.getElementById( "global-analysis-result" ); // 假設 HTML 中有這個元素 const langSwitcher = document.getElementById("lang-switcher"); // << 新增:獲取切換器元素 // 初始化 document.addEventListener("DOMContentLoaded", () => { // fetchTasks(); // 將由 initI18n() 觸發 initI18n(); // << 新增:初始化 i18n updateCurrentTime(); setInterval(updateCurrentTime, 1000); // 事件監聽器 // statusFilter.addEventListener("change", renderTasks); // 將由 changeLanguage 觸發或在 applyTranslations 後觸發 if (statusFilter) { statusFilter.addEventListener("change", renderTasks); } // 新增:搜索和排序事件監聽 const searchInput = document.getElementById("search-input"); const sortOptions = document.getElementById("sort-options"); if (searchInput) { searchInput.addEventListener("input", (e) => { searchTerm = e.target.value.toLowerCase(); renderTasks(); }); } if (sortOptions) { sortOptions.addEventListener("change", (e) => { sortOption = e.target.value; renderTasks(); }); } // 新增:設置 SSE 連接 setupSSE(); // 新增:語言切換器事件監聽 if (langSwitcher) { langSwitcher.addEventListener("change", (e) => changeLanguage(e.target.value) ); } }); // 新增:i18n 核心函數 // 1. 語言檢測 (localStorage > navigator.language > 'en') function detectLanguage() { const savedLang = localStorage.getItem("lang"); if (savedLang && ["en", "zh-TW"].includes(savedLang)) { // 確保保存的是有效語言 return savedLang; } // 檢查瀏覽器語言 const browserLang = navigator.language || navigator.userLanguage; if (browserLang) { if (browserLang.toLowerCase().startsWith("zh-tw")) return "zh-TW"; if (browserLang.toLowerCase().startsWith("zh")) return "zh-TW"; // 簡體也先 fallback 到繁體 if (browserLang.toLowerCase().startsWith("en")) return "en"; } return "en"; // 預設 } // 2. 異步加載翻譯文件 async function loadTranslations(lang) { try { const response = await fetch(`/locales/${lang}.json`); if (!response.ok) { throw new Error( `Failed to load ${lang}.json, status: ${response.status}` ); } translations = await response.json(); console.log(`Translations loaded for ${lang}`); } catch (error) { console.error("Error loading translations:", error); if (lang !== "en") { console.warn(`Falling back to English translations.`); await loadTranslations("en"); // Fallback to English } else { translations = {}; // Clear translations if even English fails // Maybe display a more persistent error message? alert("Critical error: Could not load language files."); } } } // 3. 翻譯函數 function translate(key, replacements = {}) { let translated = translations[key] || key; // Fallback to key itself // 簡單的佔位符替換(例如 {message}) for (const placeholder in replacements) { translated = translated.replace( `{${placeholder}}`, replacements[placeholder] ); } return translated; } // 4. 應用翻譯到 DOM (處理 textContent, placeholder, title) function applyTranslations() { console.log("Applying translations for:", currentLang); document.querySelectorAll("[data-i18n-key]").forEach((el) => { const key = el.dataset.i18nKey; const translatedText = translate(key); // 優先處理特定屬性 if (el.hasAttribute("placeholder")) { el.placeholder = translatedText; } else if (el.hasAttribute("title")) { el.title = translatedText; } else if (el.tagName === "OPTION") { el.textContent = translatedText; // 如果需要,也可以翻譯 value,但通常不需要 } else { // 對於大多數元素,設置 textContent el.textContent = translatedText; } }); // 手動更新沒有 data-key 的元素(如果有的話) // 例如,如果 footer 時間格式需要本地化,可以在這裡處理 // updateCurrentTime(); // 確保時間格式也可能更新(如果需要) } // 5. 初始化 i18n async function initI18n() { currentLang = detectLanguage(); console.log(`Initializing i18n with language: ${currentLang}`); localStorage.setItem("lang", currentLang); // 確保 lang 被保存 // << 新增:設置切換器的初始值 >> if (langSwitcher) { langSwitcher.value = currentLang; } await loadTranslations(currentLang); applyTranslations(); await fetchTasks(); } // 新增:語言切換函數 function changeLanguage(lang) { if (!lang || !["en", "zh-TW"].includes(lang)) { console.warn(`Invalid language selected: ${lang}. Defaulting to English.`); lang = "en"; } currentLang = lang; localStorage.setItem("lang", lang); console.log(`Changing language to: ${currentLang}`); loadTranslations(currentLang) .then(() => { console.log("Translations reloaded, applying..."); applyTranslations(); console.log("Re-rendering components..."); // 重新渲染需要翻譯的組件 renderTasks(); if (selectedTaskId) { const task = tasks.find((t) => t.id === selectedTaskId); if (task) { selectTask(selectedTaskId); // 確保傳遞 ID,讓 selectTask 重新查找並渲染 } else { // 如果選中的任務已不存在,清除詳情 taskDetailsContent.innerHTML = `
${translate( "task_details_placeholder" )}
`; selectedTaskId = null; highlightNode(null); } } else { // 如果沒有任務被選中,確保詳情面板顯示 placeholder taskDetailsContent.innerHTML = `${translate( "task_details_placeholder" )}
`; } renderDependencyGraph(); // 重新渲染圖表(可能包含 placeholder) updateProgressIndicator(); // 重新渲染進度條(包含標籤) renderGlobalAnalysisResult(); // 重新渲染全局分析(標題) // 確保下拉菜單的值與當前語言一致 if (langSwitcher) langSwitcher.value = currentLang; console.log("Language change complete."); }) .catch((error) => { console.error("Error changing language:", error); // 可以添加用戶反饋,例如顯示錯誤消息 showTemporaryError("Failed to change language. Please try again."); // Need translation key }); } // --- i18n 核心函數結束 --- // 獲取任務數據 async function fetchTasks() { try { // 初始載入時顯示 loading (現在使用翻譯) if (tasks.length === 0) { taskListElement.innerHTML = `${translate( "task_details_placeholder" )}
`; highlightNode(null); // 清除圖表高亮 } } } // 選擇任務 function selectTask(taskId) { // 清除舊的選中狀態和高亮 if (selectedTaskId) { const previousElement = document.querySelector( `.task-item[data-id="${selectedTaskId}"]` ); if (previousElement) { previousElement.classList.remove("selected"); } } // 如果再次點擊同一個任務,則取消選中 if (selectedTaskId === taskId) { selectedTaskId = null; taskDetailsContent.innerHTML = `${translate( "task_details_placeholder" )}
`; highlightNode(null); // 取消高亮 return; } selectedTaskId = taskId; // 添加新的選中狀態 const selectedElement = document.querySelector( `.task-item[data-id="${taskId}"]` ); if (selectedElement) { selectedElement.classList.add("selected"); } // 獲取並顯示任務詳情 const task = tasks.find((t) => t.id === taskId); if (!task) { taskDetailsContent.innerHTML = `${translate( "error_loading_graph_d3" // Use a specific key )}
`; } } return; } // 如果沒有任務,清空圖表並顯示提示 if (tasks.length === 0) { dependencyGraphElement.innerHTML = `${translate( "dependency_graph_placeholder_empty" )}
`; // 重置 SVG 和 simulation 變數,以便下次正確初始化 svg = null; g = null; simulation = null; return; } // 1. 準備節點 (Nodes) 和連結 (Links) const nodes = tasks.map((task) => ({ id: task.id, name: task.name, status: task.status, // 保留現有位置以便平滑過渡 x: simulation?.nodes().find((n) => n.id === task.id)?.x, y: simulation?.nodes().find((n) => n.id === task.id)?.y, fx: simulation?.nodes().find((n) => n.id === task.id)?.fx, // 保留固定位置 fy: simulation?.nodes().find((n) => n.id === task.id)?.fy, })); const links = []; tasks.forEach((task) => { if (task.dependencies && task.dependencies.length > 0) { task.dependencies.forEach((dep) => { const sourceId = typeof dep === "object" ? dep.taskId : dep; const targetId = task.id; if ( nodes.some((n) => n.id === sourceId) && nodes.some((n) => n.id === targetId) ) { // 確保 link 的 source/target 是 ID,以便力導向識別 links.push({ source: sourceId, target: targetId }); } else { console.warn( `Dependency link ignored: Task ${sourceId} or ${targetId} not found in task list.` ); } }); } }); // 2. D3 繪圖設置與更新 const width = dependencyGraphElement.clientWidth; const height = dependencyGraphElement.clientHeight || 400; if (!svg) { // --- 首次渲染 --- console.log("First render of dependency graph"); dependencyGraphElement.innerHTML = ""; // 清空 placeholder svg = d3 .select(dependencyGraphElement) .append("svg") .attr("viewBox", [0, 0, width, height]) .attr("preserveAspectRatio", "xMidYMid meet"); g = svg.append("g"); // 主要組,用於縮放和平移 // 添加縮放和平移 svg.call( d3.zoom().on("zoom", (event) => { g.attr("transform", event.transform); }) ); // 添加箭頭定義 g.append("defs") .append("marker") .attr("id", "arrowhead") .attr("viewBox", "-0 -5 10 10") .attr("refX", 25) .attr("refY", 0) .attr("orient", "auto") .attr("markerWidth", 8) .attr("markerHeight", 8) .append("path") .attr("d", "M0,-5L10,0L0,5") .attr("fill", "#999"); // 初始化力導向模擬 simulation = d3 .forceSimulation() // 初始化時不傳入 nodes .force( "link", d3 .forceLink() .id((d) => d.id) .distance(100) // 指定 id 訪問器 ) .force("charge", d3.forceManyBody().strength(-300)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collide", d3.forceCollide().radius(30)) .on("tick", ticked); // 綁定 tick 事件處理函數 // 添加用於存放連結和節點的組 g.append("g").attr("class", "links"); g.append("g").attr("class", "nodes"); } else { // --- 更新渲染 --- console.log("Updating dependency graph"); // 更新 SVG 尺寸和中心力 (如果窗口大小改變) svg.attr("viewBox", [0, 0, width, height]); simulation.force("center", d3.forceCenter(width / 2, height / 2)); } // 3. 更新連結 const linkSelection = g .select(".links") // 選擇放置連結的 g 元素 .selectAll("line.link") .data( links, (d) => `${d.source.id || d.source}-${d.target.id || d.target}` ); // Key function 基於 source/target ID // Exit - 移除舊連結 linkSelection .exit() .transition("exit") .duration(300) .attr("stroke-opacity", 0) .remove(); // Enter - 添加新連結 const linkEnter = linkSelection .enter() .append("line") .attr("class", "link") .attr("stroke", "#999") .attr("marker-end", "url(#arrowhead)") .attr("stroke-opacity", 0); // 初始透明 // Update + Enter - 更新所有連結的屬性 (合併 enter 和 update 選擇集) const linkUpdate = linkSelection.merge(linkEnter); linkUpdate .transition("update") .duration(500) .attr("stroke-opacity", 0.6) .attr("stroke-width", 1.5); // 4. 更新節點 const nodeSelection = g .select(".nodes") // 選擇放置節點的 g 元素 .selectAll("g.node-item") .data(nodes, (d) => d.id); // 使用 ID 作為 key // Exit - 移除舊節點 nodeSelection .exit() .transition("exit") .duration(300) .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0}) scale(0)`) // 從當前位置縮放消失 .attr("opacity", 0) .remove(); // Enter - 添加新節點組 const nodeEnter = nodeSelection .enter() .append("g") .attr("class", (d) => `node-item status-${getStatusClass(d.status)}`) // 使用輔助函數設置 class .attr("data-id", (d) => d.id) // 初始位置:從模擬計算的位置(如果存在)或隨機位置出現,初始縮放為0 .attr( "transform", (d) => `translate(${d.x || Math.random() * width}, ${ d.y || Math.random() * height }) scale(0)` ) .attr("opacity", 0) .call(drag(simulation)); // 添加拖拽 // 添加圓形到 Enter 選擇集 nodeEnter .append("circle") .attr("r", 10) .attr("stroke", "#fff") .attr("stroke-width", 1.5); // 顏色將在 merge 後通過 update 過渡設置 // 添加文字到 Enter 選擇集 nodeEnter .append("text") .attr("x", 15) .attr("y", 3) .text((d) => d.name) .attr("font-size", "10px") .attr("fill", "#ccc"); // 添加標題 (tooltip) 到 Enter 選擇集 nodeEnter .append("title") .text((d) => `${d.name} (${getStatusText(d.status)})`); // 添加點擊事件到 Enter 選擇集 nodeEnter.on("click", (event, d) => { selectTask(d.id); event.stopPropagation(); }); // Update + Enter - 合併並更新所有節點 const nodeUpdate = nodeSelection.merge(nodeEnter); // 過渡到最終位置和狀態 nodeUpdate .transition("update") .duration(500) .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0}) scale(1)`) // 移動到模擬位置並恢復大小 .attr("opacity", 1); // 更新節點顏色 (單獨過渡) nodeUpdate .select("circle") .transition("color") .duration(500) .attr("fill", getNodeColor); // 使用已有的 getNodeColor 函數 // 更新節點狀態 Class (即時更新,無需過渡) nodeUpdate.attr( "class", (d) => `node-item status-${getStatusClass(d.status)}` ); // << 新增:重新定義 drag 函數 >> function drag(simulation) { function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); // 取消固定位置,讓節點可以繼續被力導引影響 (如果需要) // d.fx = null; // d.fy = null; // 或者保留固定位置直到再次拖動 } return d3 .drag() .on("start", dragstarted) .on("drag", dragged) .on("end", dragended); } // << drag 函數定義結束 >> // 5. 更新力導向模擬 simulation.nodes(nodes); // 在處理完 enter/exit 後更新模擬節點 simulation.force("link").links(links); // 更新模擬連結 simulation.alpha(0.3).restart(); // 重新激活模擬 } // Tick 函數: 更新節點和連結位置 function ticked() { if (!g) return; // 更新連結位置 g.select(".links") .selectAll("line.link") .attr("x1", (d) => d.source.x) .attr("y1", (d) => d.source.y) .attr("x2", (d) => d.target.x) .attr("y2", (d) => d.target.y); // 更新節點組位置 g.select(".nodes") .selectAll("g.node-item") // << 修改:添加座標後備值 >> .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`); } // 函數:根據節點數據返回顏色 (示例) function getNodeColor(nodeData) { switch (nodeData.status) { case "已完成": return "var(--secondary-color)"; case "進行中": return "var(--primary-color)"; case "待處理": return "#f1c40f"; // 與進度條和狀態標籤一致 default: return "#7f8c8d"; // 未知狀態 } } // 輔助函數 function getStatusText(status) { switch (status) { case "pending": return translate("status_pending"); case "in_progress": return translate("status_in_progress"); case "completed": return translate("status_completed"); default: return status; } } function updateCurrentTime() { const now = new Date(); // 保留原始格式,如果需要本地化時間,可以在此處使用 translate 或其他庫 const timeString = now.toLocaleString(); // 考慮是否需要基於 currentLang 格式化 if (currentTimeElement) { // 將靜態文本和動態時間分開 const footerTextElement = currentTimeElement.parentNode.childNodes[0]; if (footerTextElement && footerTextElement.nodeType === Node.TEXT_NODE) { footerTextElement.nodeValue = translate("footer_copyright"); } currentTimeElement.textContent = timeString; } } // 更新項目進度指示器 function updateProgressIndicator() { const totalTasks = tasks.length; if (totalTasks === 0) { progressIndicator.style.display = "none"; // 沒有任務時隱藏 return; } progressIndicator.style.display = "block"; // 確保顯示 const completedTasks = tasks.filter( (task) => task.status === "已完成" ).length; const inProgressTasks = tasks.filter( (task) => task.status === "進行中" ).length; const pendingTasks = tasks.filter((task) => task.status === "待處理").length; const completedPercent = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; const inProgressPercent = totalTasks > 0 ? (inProgressTasks / totalTasks) * 100 : 0; const pendingPercent = totalTasks > 0 ? (pendingTasks / totalTasks) * 100 : 0; progressCompleted.style.width = `${completedPercent}%`; progressInProgress.style.width = `${inProgressPercent}%`; progressPending.style.width = `${pendingPercent}%`; // 更新標籤 (使用 translate) progressLabels.innerHTML = ` ${translate( "progress_completed" )}: ${completedTasks} (${completedPercent.toFixed(1)}%) ${translate( "progress_in_progress" )}: ${inProgressTasks} (${inProgressPercent.toFixed(1)}%) ${translate( "progress_pending" )}: ${pendingTasks} (${pendingPercent.toFixed(1)}%) ${translate( "progress_total" )}: ${totalTasks} `; } // 新增:渲染全局分析結果 function renderGlobalAnalysisResult() { let targetElement = document.getElementById("global-analysis-result"); // 如果元素不存在,嘗試創建並添加到合適的位置 (例如 header 或 main content 前) if (!targetElement) { targetElement = document.createElement("div"); targetElement.id = "global-analysis-result"; targetElement.className = "global-analysis-section"; // 添加樣式 class // 嘗試插入到 header 之後或 main 之前 const header = document.querySelector("header"); const mainContent = document.querySelector("main"); if (header && header.parentNode) { header.parentNode.insertBefore(targetElement, header.nextSibling); } else if (mainContent && mainContent.parentNode) { mainContent.parentNode.insertBefore(targetElement, mainContent); } else { // 作為最後手段,添加到 body 開頭 document.body.insertBefore(targetElement, document.body.firstChild); } } if (globalAnalysisResult) { targetElement.innerHTML = `${globalAnalysisResult}`; targetElement.style.display = "block"; } else { targetElement.style.display = "none"; // 如果沒有結果則隱藏 targetElement.innerHTML = ""; // 清空內容 } } // 新增:高亮依賴圖中的節點 function highlightNode(taskId, status = null) { if (!g || !window.d3) return; // 清除所有節點的高亮 g.select(".nodes") // 從 g 開始選擇 .selectAll("g.node-item") .classed("highlighted", false); if (!taskId) return; // 高亮選中的節點 const selectedNode = g .select(".nodes") // 從 g 開始選擇 .select(`g.node-item[data-id="${taskId}"]`); if (!selectedNode.empty()) { selectedNode.classed("highlighted", true); // 可以選擇性地將選中節點帶到最前面 // selectedNode.raise(); } } // 新增:輔助函數獲取狀態 class (應放在 ticked 函數之後,getNodeColor 之前或之後均可) function getStatusClass(status) { return status ? status.replace(/_/g, "-") : "unknown"; // 替換所有下劃線 } // 函數:啟用節點拖拽 (保持不變) // ... drag ...