mirror of
https://github.com/cjo4m06/mcp-shrimp-task-manager.git
synced 2025-07-26 16:02:26 +08:00
1243 lines
40 KiB
JavaScript
1243 lines
40 KiB
JavaScript
// 全局變量
|
||
let tasks = [];
|
||
let selectedTaskId = null;
|
||
let searchTerm = "";
|
||
let sortOption = "date-asc";
|
||
let globalAnalysisResult = null; // 新增:儲存全局分析結果
|
||
let svg, g, simulation;
|
||
let width, height; // << 新增:將寬高定義為全局變量
|
||
let isGraphInitialized = false; // << 新增:追蹤圖表是否已初始化
|
||
let zoom; // << 新增:保存縮放行為對象
|
||
|
||
// 新增: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"); // << 新增:獲取切換器元素
|
||
const resetViewBtn = document.getElementById("reset-view-btn"); // << 新增:獲取重置按鈕元素
|
||
|
||
// 初始化
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
// fetchTasks(); // 將由 initI18n() 觸發
|
||
initI18n(); // << 新增:初始化 i18n
|
||
updateCurrentTime();
|
||
setInterval(updateCurrentTime, 1000);
|
||
updateDimensions(); // << 新增:初始化時更新尺寸
|
||
|
||
// 事件監聽器
|
||
// statusFilter.addEventListener("change", renderTasks); // 將由 changeLanguage 觸發或在 applyTranslations 後觸發
|
||
if (statusFilter) {
|
||
statusFilter.addEventListener("change", renderTasks);
|
||
}
|
||
|
||
// 新增:重置視圖按鈕事件監聽
|
||
if (resetViewBtn) {
|
||
resetViewBtn.addEventListener("click", resetView);
|
||
}
|
||
|
||
// 新增:搜索和排序事件監聽
|
||
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)
|
||
);
|
||
}
|
||
|
||
// 新增:視窗大小改變時更新尺寸
|
||
window.addEventListener("resize", () => {
|
||
updateDimensions();
|
||
if (svg && simulation) {
|
||
svg.attr("viewBox", [0, 0, width, height]);
|
||
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
||
simulation.alpha(0.3).restart();
|
||
}
|
||
});
|
||
});
|
||
|
||
// 新增:i18n 核心函數
|
||
// 1. 語言檢測 (URL 參數 > navigator.language > 'en')
|
||
function detectLanguage() {
|
||
// 1. 優先從 URL 參數讀取
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const urlLang = urlParams.get("lang");
|
||
if (urlLang && ["en", "zh-TW"].includes(urlLang)) {
|
||
return urlLang;
|
||
}
|
||
|
||
// 2. 檢查瀏覽器語言(移除 localStorage 檢查)
|
||
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";
|
||
}
|
||
|
||
// 3. 預設值
|
||
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}`);
|
||
// << 新增:設置切換器的初始值 >>
|
||
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;
|
||
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 = `<p class="placeholder">${translate(
|
||
"task_details_placeholder"
|
||
)}</p>`;
|
||
selectedTaskId = null;
|
||
highlightNode(null);
|
||
}
|
||
} else {
|
||
// 如果沒有任務被選中,確保詳情面板顯示 placeholder
|
||
taskDetailsContent.innerHTML = `<p class="placeholder">${translate(
|
||
"task_details_placeholder"
|
||
)}</p>`;
|
||
}
|
||
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 = `<div class="loading">${translate(
|
||
"task_list_loading"
|
||
)}</div>`;
|
||
}
|
||
|
||
const response = await fetch("/api/tasks");
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const newTasks = data.tasks || [];
|
||
|
||
// 提取全局分析結果 (找第一個非空的)
|
||
let foundAnalysisResult = null;
|
||
for (const task of newTasks) {
|
||
if (task.analysisResult) {
|
||
foundAnalysisResult = task.analysisResult;
|
||
break; // 找到一個就夠了
|
||
}
|
||
}
|
||
// 只有當找到的結果與當前儲存的不同時才更新
|
||
if (foundAnalysisResult !== globalAnalysisResult) {
|
||
globalAnalysisResult = foundAnalysisResult;
|
||
renderGlobalAnalysisResult(); // 更新顯示
|
||
}
|
||
|
||
// --- 智慧更新邏輯 (初步 - 仍需改進以避免閃爍) ---
|
||
// 簡單地比較任務數量或標識符來決定是否重新渲染
|
||
// 理想情況下應比較每個任務的內容並進行 DOM 更新
|
||
const tasksChanged = didTasksChange(tasks, newTasks);
|
||
|
||
if (tasksChanged) {
|
||
tasks = newTasks; // 更新全局任務列表
|
||
console.log("Tasks updated via fetch, re-rendering...");
|
||
renderTasks();
|
||
updateProgressIndicator();
|
||
renderDependencyGraph(); // 更新圖表
|
||
} else {
|
||
console.log(
|
||
"No significant task changes detected, skipping full re-render."
|
||
);
|
||
// 如果不需要重新渲染列表,可能只需要更新進度條
|
||
updateProgressIndicator();
|
||
// 考慮是否需要更新圖表(如果狀態可能改變)
|
||
// renderDependencyGraph(); // 暫時註釋掉,除非狀態變化很關鍵
|
||
}
|
||
|
||
// *** 移除 setTimeout 輪詢 ***
|
||
// setTimeout(fetchTasks, 30000);
|
||
} catch (error) {
|
||
console.error("Error fetching tasks:", error);
|
||
// 避免覆蓋現有列表,除非是初始載入失敗
|
||
if (tasks.length === 0) {
|
||
taskListElement.innerHTML = `<div class="error">${translate(
|
||
"error_loading_tasks",
|
||
{ message: error.message }
|
||
)}</div>`;
|
||
if (progressIndicator) progressIndicator.style.display = "none";
|
||
if (dependencyGraphElement)
|
||
dependencyGraphElement.innerHTML = `<div class="error">${translate(
|
||
"error_loading_graph"
|
||
)}</div>`;
|
||
} else {
|
||
showTemporaryError(
|
||
translate("error_updating_tasks", { message: error.message })
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 新增:設置 Server-Sent Events 連接
|
||
function setupSSE() {
|
||
console.log("Setting up SSE connection to /api/tasks/stream");
|
||
const evtSource = new EventSource("/api/tasks/stream");
|
||
|
||
evtSource.onmessage = function (event) {
|
||
console.log("SSE message received:", event.data);
|
||
// 可以根據 event.data 內容做更複雜的判斷,目前只要收到消息就更新
|
||
};
|
||
|
||
evtSource.addEventListener("update", function (event) {
|
||
console.log("SSE 'update' event received:", event.data);
|
||
// 收到更新事件,重新獲取任務列表
|
||
fetchTasks();
|
||
});
|
||
|
||
evtSource.onerror = function (err) {
|
||
console.error("EventSource failed:", err);
|
||
// 可以實現重連邏輯
|
||
evtSource.close(); // 關閉錯誤的連接
|
||
// 延遲一段時間後嘗試重新連接
|
||
setTimeout(setupSSE, 5000); // 5秒後重試
|
||
};
|
||
|
||
evtSource.onopen = function () {
|
||
console.log("SSE connection opened.");
|
||
};
|
||
}
|
||
|
||
// 新增:比較任務列表是否有變化的輔助函數 (最全面版)
|
||
function didTasksChange(oldTasks, newTasks) {
|
||
if (!oldTasks || !newTasks) return true; // Handle initial load or error states
|
||
|
||
if (oldTasks.length !== newTasks.length) {
|
||
console.log("Task length changed.");
|
||
return true; // Length change definitely needs update
|
||
}
|
||
|
||
const oldTaskMap = new Map(oldTasks.map((task) => [task.id, task]));
|
||
const newTaskIds = new Set(newTasks.map((task) => task.id)); // For checking removed tasks
|
||
|
||
// Check for removed tasks first
|
||
for (const oldTask of oldTasks) {
|
||
if (!newTaskIds.has(oldTask.id)) {
|
||
console.log(`Task removed: ${oldTask.id}`);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Check for new or modified tasks
|
||
for (const newTask of newTasks) {
|
||
const oldTask = oldTaskMap.get(newTask.id);
|
||
if (!oldTask) {
|
||
console.log(`New task found: ${newTask.id}`);
|
||
return true; // New task ID found
|
||
}
|
||
|
||
// Compare relevant fields
|
||
const fieldsToCompare = [
|
||
"name",
|
||
"description",
|
||
"status",
|
||
"notes",
|
||
"implementationGuide",
|
||
"verificationCriteria",
|
||
"summary",
|
||
];
|
||
|
||
for (const field of fieldsToCompare) {
|
||
if (oldTask[field] !== newTask[field]) {
|
||
// Handle null/undefined comparisons carefully if needed
|
||
// e.g., !(oldTask[field] == null && newTask[field] == null) checks if one is null/undefined and the other isn't
|
||
if (
|
||
!(oldTask[field] === null && newTask[field] === null) &&
|
||
!(oldTask[field] === undefined && newTask[field] === undefined)
|
||
) {
|
||
console.log(`Task ${newTask.id} changed field: ${field}`);
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compare dependencies (array of strings or objects)
|
||
if (!compareDependencies(oldTask.dependencies, newTask.dependencies)) {
|
||
console.log(`Task ${newTask.id} changed field: dependencies`);
|
||
return true;
|
||
}
|
||
|
||
// Compare relatedFiles (array of objects) - simple length check first
|
||
if (!compareRelatedFiles(oldTask.relatedFiles, newTask.relatedFiles)) {
|
||
console.log(`Task ${newTask.id} changed field: relatedFiles`);
|
||
return true;
|
||
}
|
||
|
||
// Optional: Compare updatedAt as a final check if other fields seem identical
|
||
if (oldTask.updatedAt?.toString() !== newTask.updatedAt?.toString()) {
|
||
console.log(`Task ${newTask.id} changed field: updatedAt (fallback)`);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false; // No significant changes detected
|
||
}
|
||
|
||
// Helper function to compare dependency arrays
|
||
function compareDependencies(deps1, deps2) {
|
||
const arr1 = deps1 || [];
|
||
const arr2 = deps2 || [];
|
||
|
||
if (arr1.length !== arr2.length) return false;
|
||
|
||
// Extract IDs whether they are strings or objects {taskId: string}
|
||
const ids1 = new Set(
|
||
arr1.map((dep) =>
|
||
typeof dep === "object" && dep !== null ? dep.taskId : dep
|
||
)
|
||
);
|
||
const ids2 = new Set(
|
||
arr2.map((dep) =>
|
||
typeof dep === "object" && dep !== null ? dep.taskId : dep
|
||
)
|
||
);
|
||
|
||
if (ids1.size !== ids2.size) return false; // Different number of unique deps
|
||
for (const id of ids1) {
|
||
if (!ids2.has(id)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Helper function to compare relatedFiles arrays (can be simple or complex)
|
||
function compareRelatedFiles(files1, files2) {
|
||
const arr1 = files1 || [];
|
||
const arr2 = files2 || [];
|
||
|
||
if (arr1.length !== arr2.length) return false;
|
||
|
||
// Simple comparison: check if paths and types are the same in the same order
|
||
// For a more robust check, convert to Sets of strings like `path|type` or do deep object comparison
|
||
for (let i = 0; i < arr1.length; i++) {
|
||
if (arr1[i].path !== arr2[i].path || arr1[i].type !== arr2[i].type) {
|
||
return false;
|
||
}
|
||
// Add more field comparisons if needed (description, lines, etc.)
|
||
// if (arr1[i].description !== arr2[i].description) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 新增:顯示臨時錯誤訊息的函數
|
||
function showTemporaryError(message) {
|
||
const errorElement = document.createElement("div");
|
||
errorElement.className = "temporary-error";
|
||
errorElement.textContent = message; // 保持消息本身
|
||
document.body.appendChild(errorElement);
|
||
setTimeout(() => {
|
||
errorElement.remove();
|
||
}, 3000); // 顯示 3 秒
|
||
}
|
||
|
||
// 渲染任務列表 - *** 需要進一步優化以實現智慧更新 ***
|
||
function renderTasks() {
|
||
console.log("Rendering tasks..."); // 添加日誌
|
||
const filterValue = statusFilter.value;
|
||
|
||
let filteredTasks = tasks;
|
||
if (filterValue !== "all") {
|
||
filteredTasks = filteredTasks.filter((task) => task.status === filterValue);
|
||
}
|
||
|
||
if (searchTerm) {
|
||
const lowerCaseSearchTerm = searchTerm.toLowerCase();
|
||
filteredTasks = filteredTasks.filter(
|
||
(task) =>
|
||
(task.name && task.name.toLowerCase().includes(lowerCaseSearchTerm)) ||
|
||
(task.description &&
|
||
task.description.toLowerCase().includes(lowerCaseSearchTerm))
|
||
);
|
||
}
|
||
|
||
filteredTasks.sort((a, b) => {
|
||
switch (sortOption) {
|
||
case "name-asc":
|
||
return (a.name || "").localeCompare(b.name || "");
|
||
case "name-desc":
|
||
return (b.name || "").localeCompare(a.name || "");
|
||
case "status":
|
||
const statusOrder = { pending: 1, in_progress: 2, completed: 3 };
|
||
return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0);
|
||
case "date-asc":
|
||
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
||
case "date-desc":
|
||
default:
|
||
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0);
|
||
}
|
||
});
|
||
|
||
// --- 簡單粗暴的替換 (會導致閃爍) ---
|
||
// TODO: 實現 DOM Diffing 或更智慧的更新策略
|
||
if (filteredTasks.length === 0) {
|
||
taskListElement.innerHTML = `<div class="placeholder">${translate(
|
||
"task_list_empty"
|
||
)}</div>`;
|
||
} else {
|
||
taskListElement.innerHTML = filteredTasks
|
||
.map(
|
||
(task) => `
|
||
<div class="task-item status-${task.status.replace(
|
||
"_",
|
||
"-"
|
||
)}" data-id="${task.id}" onclick="selectTask('${task.id}')">
|
||
<h3>${task.name}</h3>
|
||
<div class="task-meta">
|
||
<span class="task-status status-${task.status.replace(
|
||
"_",
|
||
"-"
|
||
)}">${getStatusText(task.status)}</span>
|
||
</div>
|
||
</div>
|
||
`
|
||
)
|
||
.join("");
|
||
}
|
||
// --- 結束簡單粗暴的替換 ---
|
||
|
||
// 重新應用選中狀態
|
||
if (selectedTaskId) {
|
||
const taskExists = tasks.some((t) => t.id === selectedTaskId);
|
||
if (taskExists) {
|
||
const selectedElement = document.querySelector(
|
||
`.task-item[data-id="${selectedTaskId}"]`
|
||
);
|
||
if (selectedElement) {
|
||
selectedElement.classList.add("selected");
|
||
}
|
||
} else {
|
||
// 如果選中的任務在新的列表中不存在了,清除選擇
|
||
console.log(
|
||
`Selected task ${selectedTaskId} no longer exists, clearing selection.`
|
||
);
|
||
selectedTaskId = null;
|
||
taskDetailsContent.innerHTML = `<p class="placeholder">${translate(
|
||
"task_details_placeholder"
|
||
)}</p>`;
|
||
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 = `<p class="placeholder">${translate(
|
||
"task_details_placeholder"
|
||
)}</p>`;
|
||
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 = `<div class="placeholder">${translate(
|
||
"error_task_not_found"
|
||
)}</div>`;
|
||
return;
|
||
}
|
||
|
||
// --- 安全地填充任務詳情 ---
|
||
// 1. 創建基本骨架 (使用 innerHTML,但將動態內容替換為帶 ID 的空元素)
|
||
taskDetailsContent.innerHTML = `
|
||
<div class="task-details-header">
|
||
<h3 id="detail-name"></h3>
|
||
<div class="task-meta">
|
||
<span>${translate(
|
||
"task_detail_status_label"
|
||
)} <span id="detail-status" class="task-status"></span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增:條件顯示 Summary -->
|
||
<div class="task-details-section" id="detail-summary-section" style="display: none;">
|
||
<h4>${translate("task_detail_summary_title")}</h4>
|
||
<p id="detail-summary"></p>
|
||
</div>
|
||
|
||
<div class="task-details-section">
|
||
<h4>${translate("task_detail_description_title")}</h4>
|
||
<p id="detail-description"></p>
|
||
</div>
|
||
|
||
<div class="task-details-section">
|
||
<h4>${translate("task_detail_implementation_guide_title")}</h4>
|
||
<pre id="detail-implementation-guide"></pre>
|
||
</div>
|
||
|
||
<div class="task-details-section">
|
||
<h4>${translate("task_detail_verification_criteria_title")}</h4>
|
||
<p id="detail-verification-criteria"></p>
|
||
</div>
|
||
|
||
<div class="task-details-section">
|
||
<h4>${translate("task_detail_dependencies_title")}</h4>
|
||
<div class="dependencies" id="detail-dependencies">
|
||
<!-- Dependencies will be populated by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-details-section">
|
||
<h4>${translate("task_detail_related_files_title")}</h4>
|
||
<div class="related-files" id="detail-related-files">
|
||
<!-- Related files will be populated by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-details-section">
|
||
<h4>${translate("task_detail_notes_title")}</h4>
|
||
<p id="detail-notes"></p>
|
||
</div>
|
||
`;
|
||
|
||
// 2. 獲取對應元素並使用 textContent 安全地填充內容
|
||
const detailName = document.getElementById("detail-name");
|
||
const detailStatus = document.getElementById("detail-status");
|
||
const detailDescription = document.getElementById("detail-description");
|
||
const detailImplementationGuide = document.getElementById(
|
||
"detail-implementation-guide"
|
||
);
|
||
const detailVerificationCriteria = document.getElementById(
|
||
"detail-verification-criteria"
|
||
);
|
||
// 新增:獲取 Summary 相關元素
|
||
const detailSummarySection = document.getElementById(
|
||
"detail-summary-section"
|
||
);
|
||
const detailSummary = document.getElementById("detail-summary");
|
||
const detailNotes = document.getElementById("detail-notes");
|
||
const detailDependencies = document.getElementById("detail-dependencies");
|
||
const detailRelatedFiles = document.getElementById("detail-related-files");
|
||
|
||
if (detailName) detailName.textContent = task.name;
|
||
if (detailStatus) {
|
||
detailStatus.textContent = getStatusText(task.status);
|
||
detailStatus.className = `task-status status-${task.status.replace(
|
||
"_",
|
||
"-"
|
||
)}`;
|
||
}
|
||
if (detailDescription)
|
||
detailDescription.textContent =
|
||
task.description || translate("task_detail_no_description");
|
||
if (detailImplementationGuide)
|
||
detailImplementationGuide.textContent =
|
||
task.implementationGuide ||
|
||
translate("task_detail_no_implementation_guide");
|
||
if (detailVerificationCriteria)
|
||
detailVerificationCriteria.textContent =
|
||
task.verificationCriteria ||
|
||
translate("task_detail_no_verification_criteria");
|
||
|
||
// 新增:填充 Summary (如果存在且已完成)
|
||
if (task.summary && detailSummarySection && detailSummary) {
|
||
detailSummary.textContent = task.summary;
|
||
detailSummarySection.style.display = "block"; // 顯示區塊
|
||
} else if (detailSummarySection) {
|
||
detailSummarySection.style.display = "none"; // 隱藏區塊
|
||
}
|
||
|
||
if (detailNotes)
|
||
detailNotes.textContent = task.notes || translate("task_detail_no_notes");
|
||
|
||
// 3. 動態生成依賴項和相關文件 (這些可以包含安全的 HTML 結構如 span)
|
||
if (detailDependencies) {
|
||
const dependenciesHtml =
|
||
task.dependencies && task.dependencies.length
|
||
? task.dependencies
|
||
.map((dep) => {
|
||
const depId =
|
||
typeof dep === "object" && dep !== null && dep.taskId
|
||
? dep.taskId
|
||
: dep;
|
||
const depTask = tasks.find((t) => t.id === depId);
|
||
// Translate the fallback text for unknown dependency
|
||
const depName = depTask
|
||
? depTask.name
|
||
: `${translate("task_detail_unknown_dependency")}(${depId})`;
|
||
const span = document.createElement("span");
|
||
span.className = "dependency-tag";
|
||
span.dataset.id = depId;
|
||
span.textContent = depName;
|
||
span.onclick = () => highlightNode(depId);
|
||
return span.outerHTML;
|
||
})
|
||
.join("")
|
||
: `<span class="placeholder">${translate(
|
||
"task_detail_no_dependencies"
|
||
)}</span>`; // Translate placeholder
|
||
detailDependencies.innerHTML = dependenciesHtml;
|
||
}
|
||
|
||
if (detailRelatedFiles) {
|
||
const relatedFilesHtml =
|
||
task.relatedFiles && task.relatedFiles.length
|
||
? task.relatedFiles
|
||
.map((file) => {
|
||
const span = document.createElement("span");
|
||
span.className = "file-tag";
|
||
span.title = file.description || "";
|
||
const pathText = document.createTextNode(`${file.path} `);
|
||
const small = document.createElement("small");
|
||
small.textContent = `(${file.type})`; // Type is likely technical, maybe no translation needed?
|
||
span.appendChild(pathText);
|
||
span.appendChild(small);
|
||
return span.outerHTML;
|
||
})
|
||
.join("")
|
||
: `<span class="placeholder">${translate(
|
||
"task_detail_no_related_files"
|
||
)}</span>`; // Translate placeholder
|
||
detailRelatedFiles.innerHTML = relatedFilesHtml;
|
||
}
|
||
|
||
// --- 原來的 innerHTML 賦值已移除 ---
|
||
|
||
// 只調用高亮函數
|
||
highlightNode(taskId); // 只調用 highlightNode
|
||
}
|
||
|
||
// 新增:重置視圖功能
|
||
function resetView() {
|
||
if (!svg || !simulation) return;
|
||
|
||
// 添加重置動畫效果
|
||
resetViewBtn.classList.add("resetting");
|
||
|
||
// 計算視圖中心
|
||
const centerX = width / 2;
|
||
const centerY = height / 2;
|
||
|
||
// 重置縮放和平移(使用 transform 過渡)
|
||
svg.transition()
|
||
.duration(750)
|
||
.call(zoom.transform, d3.zoomIdentity);
|
||
|
||
// 重置所有節點位置到中心附近
|
||
simulation.nodes().forEach(node => {
|
||
node.x = centerX + (Math.random() - 0.5) * 50; // 在中心點附近隨機分佈
|
||
node.y = centerY + (Math.random() - 0.5) * 50;
|
||
node.fx = null; // 清除固定位置
|
||
node.fy = null;
|
||
});
|
||
|
||
// 重置力導向模擬
|
||
simulation
|
||
.force("center", d3.forceCenter(centerX, centerY))
|
||
.alpha(1) // 完全重啟模擬
|
||
.restart();
|
||
|
||
// 750ms 後移除動畫類
|
||
setTimeout(() => {
|
||
resetViewBtn.classList.remove("resetting");
|
||
}, 750);
|
||
}
|
||
|
||
// 新增:初始化縮放行為
|
||
function initZoom() {
|
||
zoom = d3.zoom()
|
||
.scaleExtent([0.1, 4]) // 設置縮放範圍
|
||
.on("zoom", (event) => {
|
||
g.attr("transform", event.transform);
|
||
});
|
||
|
||
if (svg) {
|
||
svg.call(zoom);
|
||
}
|
||
}
|
||
|
||
// 渲染依賴關係圖 - 修改為全局視圖和 enter/update/exit 模式
|
||
function renderDependencyGraph() {
|
||
if (!dependencyGraphElement || !window.d3) {
|
||
console.warn("D3 or dependency graph element not found.");
|
||
if (dependencyGraphElement) {
|
||
if (!dependencyGraphElement.querySelector("svg")) {
|
||
dependencyGraphElement.innerHTML = `<p class="placeholder">${translate("error_loading_graph_d3")}</p>`;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
updateDimensions();
|
||
|
||
// 如果沒有任務,清空圖表並顯示提示
|
||
if (tasks.length === 0) {
|
||
dependencyGraphElement.innerHTML = `<p class="placeholder">${translate("dependency_graph_placeholder_empty")}</p>`;
|
||
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)) {
|
||
links.push({ source: sourceId, target: targetId });
|
||
} else {
|
||
console.warn(`Dependency link ignored: Task ${sourceId} or ${targetId} not found in task list.`);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
if (!svg) {
|
||
// --- 首次渲染 ---
|
||
console.log("First render of dependency graph");
|
||
dependencyGraphElement.innerHTML = "";
|
||
|
||
svg = d3.select(dependencyGraphElement)
|
||
.append("svg")
|
||
.attr("viewBox", [0, 0, width, height])
|
||
.attr("preserveAspectRatio", "xMidYMid meet");
|
||
|
||
g = svg.append("g");
|
||
|
||
// 初始化並添加縮放行為
|
||
initZoom();
|
||
|
||
// 添加箭頭定義
|
||
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()
|
||
.force("link", d3.forceLink().id((d) => d.id).distance(100))
|
||
.force("charge", d3.forceManyBody().strength(-300))
|
||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||
.force("collide", d3.forceCollide().radius(30))
|
||
.on("tick", ticked);
|
||
|
||
// 添加用於存放連結和節點的組
|
||
g.append("g").attr("class", "links");
|
||
g.append("g").attr("class", "nodes");
|
||
} else {
|
||
// --- 更新渲染 ---
|
||
console.log("Updating dependency graph");
|
||
svg.attr("viewBox", [0, 0, width, height]);
|
||
simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
||
}
|
||
|
||
// --- 預先運算穩定的節點位置 ---
|
||
// 複製節點和連結以進行穩定化計算
|
||
const stableNodes = [...nodes];
|
||
const stableLinks = [...links];
|
||
|
||
// 暫時創建一個模擬器來計算穩定的位置
|
||
const stableSim = d3
|
||
.forceSimulation(stableNodes)
|
||
.force("link", d3.forceLink(stableLinks).id(d => d.id).distance(100))
|
||
.force("charge", d3.forceManyBody().strength(-300))
|
||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||
.force("collide", d3.forceCollide().radius(30));
|
||
|
||
// 預熱模擬獲得穩定位置
|
||
for (let i = 0; i < 10; i++) {
|
||
stableSim.tick();
|
||
}
|
||
|
||
// 將穩定位置複製回原始節點
|
||
stableNodes.forEach((stableNode) => {
|
||
const originalNode = nodes.find(n => n.id === stableNode.id);
|
||
if (originalNode) {
|
||
originalNode.x = stableNode.x;
|
||
originalNode.y = stableNode.y;
|
||
}
|
||
});
|
||
|
||
// 停止臨時模擬器
|
||
stableSim.stop();
|
||
// --- 預先運算結束 ---
|
||
|
||
// 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().remove();
|
||
|
||
// Enter - 添加新連結 (無動畫)
|
||
const linkEnter = linkSelection
|
||
.enter()
|
||
.append("line")
|
||
.attr("class", "link")
|
||
.attr("stroke", "#999")
|
||
.attr("marker-end", "url(#arrowhead)")
|
||
.attr("stroke-opacity", 0.6)
|
||
.attr("stroke-width", 1.5);
|
||
|
||
// 立即設置連結位置
|
||
linkEnter
|
||
.attr("x1", d => d.source.x || 0)
|
||
.attr("y1", d => d.source.y || 0)
|
||
.attr("x2", d => d.target.x || 0)
|
||
.attr("y2", d => d.target.y || 0);
|
||
|
||
// 4. 更新節點 (無動畫)
|
||
const nodeSelection = g
|
||
.select(".nodes") // 選擇放置節點的 g 元素
|
||
.selectAll("g.node-item")
|
||
.data(nodes, (d) => d.id); // 使用 ID 作為 key
|
||
|
||
// Exit - 直接移除舊節點
|
||
nodeSelection.exit().remove();
|
||
|
||
// Enter - 添加新節點組 (無動畫,直接在最終位置創建)
|
||
const nodeEnter = nodeSelection
|
||
.enter()
|
||
.append("g")
|
||
.attr("class", (d) => `node-item status-${getStatusClass(d.status)}`) // 使用輔助函數設置 class
|
||
.attr("data-id", (d) => d.id)
|
||
// 直接使用預計算的位置,無需縮放或透明度過渡
|
||
.attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`)
|
||
.call(drag(simulation)); // 添加拖拽
|
||
|
||
// 添加圓形到 Enter 選擇集
|
||
nodeEnter
|
||
.append("circle")
|
||
.attr("r", 10)
|
||
.attr("stroke", "#fff")
|
||
.attr("stroke-width", 1.5)
|
||
.attr("fill", getNodeColor); // 直接設置顏色
|
||
|
||
// 添加文字到 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 - 立即更新現有節點 (無動畫)
|
||
nodeSelection
|
||
.attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`)
|
||
.attr("class", (d) => `node-item status-${getStatusClass(d.status)}`);
|
||
|
||
nodeSelection
|
||
.select("circle")
|
||
.attr("fill", getNodeColor);
|
||
|
||
// << 新增:重新定義 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); // 更新模擬節點
|
||
simulation.force("link").links(links); // 更新模擬連結
|
||
// 注意:移除了 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 "已完成":
|
||
case "completed":
|
||
return "var(--secondary-color)";
|
||
case "進行中":
|
||
case "in_progress":
|
||
return "var(--primary-color)";
|
||
case "待處理":
|
||
case "pending":
|
||
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 === "completed" || task.status === "已完成"
|
||
).length;
|
||
const inProgressTasks = tasks.filter(
|
||
(task) => task.status === "in_progress" || task.status === "進行中"
|
||
).length;
|
||
const pendingTasks = tasks.filter(
|
||
(task) => task.status === "pending" || 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 = `
|
||
<span class="label-completed">${translate(
|
||
"progress_completed"
|
||
)}: ${completedTasks} (${completedPercent.toFixed(1)}%)</span>
|
||
<span class="label-in-progress">${translate(
|
||
"progress_in_progress"
|
||
)}: ${inProgressTasks} (${inProgressPercent.toFixed(1)}%)</span>
|
||
<span class="label-pending">${translate(
|
||
"progress_pending"
|
||
)}: ${pendingTasks} (${pendingPercent.toFixed(1)}%)</span>
|
||
<span class="label-total">${translate(
|
||
"progress_total"
|
||
)}: ${totalTasks}</span>
|
||
`;
|
||
}
|
||
|
||
// 新增:渲染全局分析結果
|
||
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 = `
|
||
<h4 data-i18n-key="global_analysis_title">${translate(
|
||
"global_analysis_title"
|
||
)}</h4>
|
||
<pre>${globalAnalysisResult}</pre>
|
||
`;
|
||
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"; // 替換所有下劃線
|
||
}
|
||
|
||
// 新增:更新寬高的函數
|
||
function updateDimensions() {
|
||
if (dependencyGraphElement) {
|
||
width = dependencyGraphElement.clientWidth;
|
||
height = dependencyGraphElement.clientHeight || 400;
|
||
}
|
||
}
|
||
|
||
// 函數:啟用節點拖拽 (保持不變)
|
||
// ... drag ...
|