2025-04-29 19:40:20 +08:00
|
|
|
|
// 全局變量
|
|
|
|
|
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 = `<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>狀態: <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>完成摘要</h4>
|
|
|
|
|
<p id="detail-summary"></p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="task-details-section">
|
|
|
|
|
<h4>任務描述</h4>
|
|
|
|
|
<p id="detail-description"></p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="task-details-section">
|
|
|
|
|
<h4>實現指南</h4>
|
|
|
|
|
<pre id="detail-implementation-guide"></pre>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="task-details-section">
|
|
|
|
|
<h4>驗證標準</h4>
|
|
|
|
|
<p id="detail-verification-criteria"></p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="task-details-section">
|
|
|
|
|
<h4>依賴項 (前置任務)</h4>
|
|
|
|
|
<div class="dependencies" id="detail-dependencies">
|
|
|
|
|
<!-- Dependencies will be populated by JS -->
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="task-details-section">
|
|
|
|
|
<h4>相關文件</h4>
|
|
|
|
|
<div class="related-files" id="detail-related-files">
|
|
|
|
|
<!-- Related files will be populated by JS -->
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="task-details-section">
|
|
|
|
|
<h4>備註</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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 渲染依賴關係圖 - 修改為全局視圖和 enter/update/exit 模式
|
|
|
|
|
function renderDependencyGraph() {
|
|
|
|
|
if (!dependencyGraphElement || !window.d3) {
|
|
|
|
|
console.warn("D3 or dependency graph element not found.");
|
|
|
|
|
if (dependencyGraphElement) {
|
|
|
|
|
// 首次或D3丟失時顯示提示,不清空已有的圖
|
|
|
|
|
if (!dependencyGraphElement.querySelector("svg")) {
|
|
|
|
|
dependencyGraphElement.innerHTML = `<p class="placeholder">${translate(
|
|
|
|
|
"error_loading_graph_d3" // Use a specific key
|
|
|
|
|
)}</p>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果沒有任務,清空圖表並顯示提示
|
|
|
|
|
if (tasks.length === 0) {
|
|
|
|
|
dependencyGraphElement.innerHTML = `<p class="placeholder">${translate(
|
|
|
|
|
"dependency_graph_placeholder_empty"
|
|
|
|
|
)}</p>`;
|
|
|
|
|
// 重置 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 "已完成":
|
2025-05-23 16:25:52 +08:00
|
|
|
|
case "completed":
|
2025-04-29 19:40:20 +08:00
|
|
|
|
return "var(--secondary-color)";
|
|
|
|
|
case "進行中":
|
2025-05-23 16:25:52 +08:00
|
|
|
|
case "in_progress":
|
2025-04-29 19:40:20 +08:00
|
|
|
|
return "var(--primary-color)";
|
|
|
|
|
case "待處理":
|
2025-05-23 16:25:52 +08:00
|
|
|
|
case "pending":
|
2025-04-29 19:40:20 +08:00
|
|
|
|
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(
|
2025-05-23 16:25:52 +08:00
|
|
|
|
(task) => task.status === "completed" || task.status === "已完成"
|
2025-04-29 19:40:20 +08:00
|
|
|
|
).length;
|
|
|
|
|
const inProgressTasks = tasks.filter(
|
2025-05-23 16:25:52 +08:00
|
|
|
|
(task) => task.status === "in_progress" || task.status === "進行中"
|
|
|
|
|
).length;
|
|
|
|
|
const pendingTasks = tasks.filter(
|
|
|
|
|
(task) => task.status === "pending" || task.status === "待處理"
|
2025-04-29 19:40:20 +08:00
|
|
|
|
).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"; // 替換所有下劃線
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函數:啟用節點拖拽 (保持不變)
|
|
|
|
|
// ... drag ...
|