From bf5f3678bab961e4ad225752454b11a3f1ddfe30 Mon Sep 17 00:00:00 2001 From: siage Date: Tue, 29 Apr 2025 19:40:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B6=B2=E9=A0=81=E5=9C=96?= =?UTF-8?q?=E5=BD=A2=E4=BB=8B=E9=9D=A2=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=80=8F?= =?UTF-8?q?=E9=81=8E=E7=92=B0=E5=A2=83=E8=AE=8A=E6=95=B8=20`ENABLE=5FGUI`?= =?UTF-8?q?=20=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + README.md | 25 +- docs/zh/README.md | 25 +- package-lock.json | 197 +++++- package.json | 5 +- src/index.ts | 136 ++++ src/public/index.html | 139 ++++ src/public/locales/en.json | 55 ++ src/public/locales/zh-TW.json | 55 ++ src/public/script.js | 1186 +++++++++++++++++++++++++++++++++ src/public/style.css | 461 +++++++++++++ 11 files changed, 2277 insertions(+), 13 deletions(-) create mode 100644 src/public/index.html create mode 100644 src/public/locales/en.json create mode 100644 src/public/locales/zh-TW.json create mode 100644 src/public/script.js create mode 100644 src/public/style.css diff --git a/.env.example b/.env.example index bf9f06d..b65c1f7 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,12 @@ ENABLE_THOUGHT_CHAIN=true # (e.g., to 'my_templates'), and set TEMPLATES_USE to the new directory name (e.g., 'my_templates'). TEMPLATES_USE=en +# Enable GUI interface +# If set to true, a file named 'WebGUI.md' will be created in the DATA_DIR. +# This file will contain a local web address you can open in your browser to access the GUI. +# Default is false. +ENABLE_GUI=false + # ============================ # Prompt Customization # ============================ diff --git a/README.md b/README.md index b8686a6..9679b97 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [🧠 Task Memory Function](#task-memory-function) - [🤔 Thought Chain Process](#thought-chain) - [📋 Project Rules Initialization](#project-rules) +- [🌐 Web GUI](#web-gui) - [📚 Documentation Resources](#documentation) - [🔧 Installation and Usage](#installation) - [🔌 Using with MCP-Compatible Clients](#clients) @@ -39,6 +40,7 @@ Shrimp Task Manager guides Agents through structured workflows for systematic pr - **Task Memory Function**: Automatically backup task history, providing long-term memory and reference capabilities - **Thought Chain Process**: Step-by-step reasoning to analyze complex problems systematically - **Project Rules Initialization**: Define project standards and rules to maintain consistency across large projects +- **Web GUI**: Provides an optional web-based graphical user interface for task management. Enable by setting `ENABLE_GUI=true` in your `.env` file. When enabled, a `WebGUI.md` file containing the access address will be created in your `DATA_DIR`. ## 🧭 Usage Guide @@ -178,7 +180,10 @@ Shrimp Task Manager offers two configuration methods: global configuration and p "command": "node", "args": ["/mcp-shrimp-task-manager/dist/index.js"], "env": { - "DATA_DIR": "/path/to/project/data" // 必須使用絕對路徑 + "DATA_DIR": "/path/to/project/data", // 必須使用絕對路徑 + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -193,7 +198,10 @@ or "command": "npx", "args": ["-y", "mcp-shrimp-task-manager"], "env": { - "DATA_DIR": "/mcp-shrimp-task-manager/data" + "DATA_DIR": "/mcp-shrimp-task-manager/data", + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -216,7 +224,10 @@ You can also set up dedicated configurations for each project to use independent "command": "node", "args": ["/path/to/mcp-shrimp-task-manager/dist/index.js"], "env": { - "DATA_DIR": "/path/to/project/data" // Must use absolute path + "DATA_DIR": "/path/to/project/data", // Must use absolute path + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -231,7 +242,10 @@ or "command": "npx", "args": ["-y", "mcp-shrimp-task-manager"], "env": { - "DATA_DIR": "/path/to/project/data" // 必須使用絕對路徑 + "DATA_DIR": "/path/to/project/data", // 必須使用絕對路徑 + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -264,7 +278,8 @@ Shrimp Task Manager supports customizing prompt behavior through environment var "MCP_PROMPT_PLAN_TASK": "Custom planning guidance...", "MCP_PROMPT_EXECUTE_TASK_APPEND": "Additional execution instructions...", "ENABLE_THOUGHT_CHAIN": "true", - "TEMPLATES_USE": "en" + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } diff --git a/docs/zh/README.md b/docs/zh/README.md index bb60d09..3e89aa5 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -7,6 +7,7 @@ - [🧠 任務記憶功能](#任務記憶功能) - [🤔 思維鏈過程](#思維鏈過程) - [📋 專案規範初始化](#專案規範初始化) +- [🌐 網頁圖形介面](#網頁圖形介面) - [📚 文件資源](#文件資源) - [🔧 安裝與使用](#安裝與使用) - [🔌 在支援 MCP 的客戶端中使用](#客戶端中使用) @@ -35,6 +36,7 @@ - **任務記憶功能**:自動備份任務歷史記錄,提供長期記憶和參考能力 - **思維鏈過程**:通過步驟化的推理系統性地分析複雜問題 - **專案規範初始化**:定義專案標準和規則,維持大型專案的一致性 +- **網頁圖形介面**:提供選用的網頁圖形化使用者介面來管理任務。透過在您的 `.env` 檔案中設定 `ENABLE_GUI=true` 來啟用。啟用後,將會在您的 `DATA_DIR` 中建立一個包含存取網址的 `WebGUI.md` 檔案。 ## 🧭 使用指南 @@ -174,7 +176,10 @@ npm run build "command": "node", "args": ["/mcp-shrimp-task-manager/dist/index.js"], "env": { - "DATA_DIR": "/mcp-shrimp-task-manager/data" + "DATA_DIR": "/mcp-shrimp-task-manager/data", + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -188,7 +193,10 @@ or "command": "npx", "args": ["-y", "mcp-shrimp-task-manager"], "env": { - "DATA_DIR": "/mcp-shrimp-task-manager/data" + "DATA_DIR": "/mcp-shrimp-task-manager/data", + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -211,7 +219,10 @@ or "command": "node", "args": ["/path/to/mcp-shrimp-task-manager/dist/index.js"], "env": { - "DATA_DIR": "/path/to/project/data" // 必須使用絕對路徑 + "DATA_DIR": "/path/to/project/data", // 必須使用絕對路徑 + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -225,7 +236,10 @@ or "command": "npx", "args": ["-y", "mcp-shrimp-task-manager"], "env": { - "DATA_DIR": "/path/to/project/data" // 必須使用絕對路徑 + "DATA_DIR": "/path/to/project/data", // 必須使用絕對路徑 + "ENABLE_THOUGHT_CHAIN": "true", + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } @@ -258,7 +272,8 @@ or "MCP_PROMPT_PLAN_TASK": "自定義規劃指導...", "MCP_PROMPT_EXECUTE_TASK_APPEND": "附加執行說明...", "ENABLE_THOUGHT_CHAIN": "true", - "TEMPLATES_USE": "en" + "TEMPLATES_USE": "en", + "ENABLE_GUI": "false" } } } diff --git a/package-lock.json b/package-lock.json index 8b532d4..f0b9908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "mcp-shrimp-task-manager", - "version": "1.0.10", + "version": "1.0.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mcp-shrimp-task-manager", - "version": "1.0.10", + "version": "1.0.11", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "dotenv": "^16.5.0", + "express": "^5.1.0", + "get-port": "^7.1.0", "uuid": "^9.0.1", "zod": "^3.22.4", "zod-to-json-schema": "^3.24.5" @@ -19,6 +21,7 @@ "mcp-shrimp-task-manager": "dist/index.js" }, "devDependencies": { + "@types/express": "^5.0.1", "@types/node": "^20.8.2", "@types/uuid": "^9.0.6", "copyfiles": "^2.4.1", @@ -107,6 +110,60 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", + "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, "node_modules/@types/node": { "version": "20.17.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", @@ -116,6 +173,39 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -677,6 +767,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -1664,6 +1765,60 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", + "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, "@types/node": { "version": "20.17.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", @@ -1673,6 +1828,39 @@ "undici-types": "~6.19.2" } }, + "@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -2074,6 +2262,11 @@ "math-intrinsics": "^1.1.0" } }, + "get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==" + }, "get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 3feff06..83b77b1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "mcp-shrimp-task-manager": "./dist/index.js" }, "scripts": { - "build": "tsc && copyfiles -u 1 './src/**/*.md' dist && node scripts/add-shebang.js", + "build": "tsc && copyfiles -u 1 './src/**/*.md' dist && copyfiles -u 1 './src/public/**/*' dist && node scripts/add-shebang.js", "dev": "ts-node src/index.ts", "start": "node dist/index.js", "test": "echo \"Error: no test specified\" && exit 1" @@ -32,11 +32,14 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "dotenv": "^16.5.0", + "express": "^5.1.0", + "get-port": "^7.1.0", "uuid": "^9.0.1", "zod": "^3.22.4", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@types/express": "^5.0.1", "@types/node": "^20.8.2", "@types/uuid": "^9.0.6", "copyfiles": "^2.4.1", diff --git a/src/index.ts b/src/index.ts index 0d90f11..c6e2dda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,12 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; +import express, { Request, Response, NextFunction } from "express"; +import getPort from "get-port"; +import path from "path"; +import fs from "fs"; +import fsPromises from "fs/promises"; +import { fileURLToPath } from "url"; // 導入工具函數 import { @@ -54,6 +60,136 @@ import { async function main() { try { console.log("Starting Shrimp Task Manager service..."); + const ENABLE_GUI = process.env.ENABLE_GUI === "true"; + + if (ENABLE_GUI) { + // 創建 Express 應用 + const app = express(); + + // 儲存 SSE 客戶端的列表 + let sseClients: Response[] = []; + + // 發送 SSE 事件的輔助函數 + function sendSseUpdate() { + console.log("Tasks changed, sending update to clients..."); + sseClients.forEach((client) => { + // 檢查客戶端是否仍然連接 + if (!client.writableEnded) { + client.write( + `event: update\ndata: ${JSON.stringify({ + timestamp: Date.now(), + })}\n\n` + ); + } + }); + // 清理已斷開的客戶端 (可選,但建議) + sseClients = sseClients.filter((client) => !client.writableEnded); + } + + // 設置靜態文件目錄 + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const publicPath = path.join(__dirname, "public"); + const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data"); + const TASKS_FILE_PATH = path.join(DATA_DIR, "tasks.json"); // 提取檔案路徑 + + app.use(express.static(publicPath)); + + // 設置 API 路由 + app.get("/api/tasks", async (req: Request, res: Response) => { + try { + // 使用 fsPromises 保持異步讀取 + const tasksData = await fsPromises.readFile(TASKS_FILE_PATH, "utf-8"); + res.json(JSON.parse(tasksData)); + } catch (error) { + console.error("Error reading tasks.json:", error); + // 確保檔案不存在時返回空任務列表 + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + res.json({ tasks: [] }); + } else { + res.status(500).json({ error: "Failed to read tasks data" }); + } + } + }); + + // 新增:SSE 端點 + app.get("/api/tasks/stream", (req: Request, res: Response) => { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + // 可選: CORS 頭,如果前端和後端不在同一個 origin + // "Access-Control-Allow-Origin": "*", + }); + + // 發送一個初始事件或保持連接 + res.write("data: connected\n\n"); + + // 將客戶端添加到列表 + sseClients.push(res); + + // 當客戶端斷開連接時,將其從列表中移除 + req.on("close", () => { + sseClients = sseClients.filter((client) => client !== res); + }); + }); + + // 獲取可用埠 + const port = await getPort(); + + // 啟動 HTTP 伺服器 + const httpServer = app.listen(port, () => { + // 在伺服器啟動後開始監聽檔案變化 + try { + // 檢查檔案是否存在,如果不存在則不監聽 (避免 watch 報錯) + if (fs.existsSync(TASKS_FILE_PATH)) { + fs.watch(TASKS_FILE_PATH, (eventType, filename) => { + if ( + filename && + (eventType === "change" || eventType === "rename") + ) { + // 稍微延遲發送,以防短時間內多次觸發 (例如編輯器保存) + // debounce sendSseUpdate if needed + sendSseUpdate(); + } + }); + } else { + console.warn( + `${TASKS_FILE_PATH} does not exist. File watching not started. It will start if the file is created later by the application.` + ); + // 可以考慮在這裡也設置一個 watcher 監聽目錄創建或檔案創建 + } + } catch (watchError) { + console.error( + `Error setting up file watch on ${TASKS_FILE_PATH}:`, + watchError + ); + } + }); + + // 將 URL 寫入 ebGUI.md + try { + const websiteUrl = `[Task Manager UI](http://localhost:${port})`; + const websiteFilePath = path.join(DATA_DIR, "WebGUI.md"); + await fsPromises.writeFile(websiteFilePath, websiteUrl, "utf-8"); + } catch (error) { + console.error("Error writing website URL to file:", error); + } + + // 設置進程終止事件處理 (確保移除 watcher) + const shutdownHandler = async () => { + // 關閉所有 SSE 連接 + sseClients.forEach((client) => client.end()); + sseClients = []; + + // 關閉 HTTP 伺服器 + await new Promise((resolve) => httpServer.close(() => resolve())); + process.exit(0); + }; + + process.on("SIGINT", shutdownHandler); + process.on("SIGTERM", shutdownHandler); + } // 創建MCP服務器 const server = new Server( diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 0000000..72836d1 --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,139 @@ + + + + + + Shrimp Task Manager + + + +
+
+

Shrimp Task Manager

+
+
+
+ ONLINE +
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+
+

Dependency View

+
+
+

+
+
+ +
+
+
+

Task List

+
+ + + +
+
+ +
+
+
+
+ +
+
+

Task Details

+
+
+

+
+
+
+
+ +
+

+ © 2023 Shrimp Task Manager - Current time: + +

+
+
+ + + + + diff --git a/src/public/locales/en.json b/src/public/locales/en.json new file mode 100644 index 0000000..7d75188 --- /dev/null +++ b/src/public/locales/en.json @@ -0,0 +1,55 @@ +{ + "app_title": "Shrimp Task Manager", + "status_indicator_online": "ONLINE", + "dependency_view_title": "Dependency View", + "dependency_graph_placeholder": "Dependency relationship for all tasks", + "task_list_title": "Task List", + "search_placeholder": "Search tasks...", + "sort_option_date_desc": "Creation Time (New-Old)", + "sort_option_date_asc": "Creation Time (Old-New)", + "sort_option_name_asc": "Name (A-Z)", + "sort_option_name_desc": "Name (Z-A)", + "sort_option_status": "Status", + "status_filter_all": "All Statuses", + "status_filter_pending": "Pending", + "status_filter_in_progress": "In Progress", + "status_filter_completed": "Completed", + "task_list_loading": "Loading...", + "task_list_empty": "No matching tasks", + "task_details_title": "Task Details", + "task_details_placeholder": "Select a task to view details", + "footer_copyright": "© 2023 Shrimp Task Manager - Current time: ", + + "status_pending": "Pending", + "status_in_progress": "In Progress", + "status_completed": "Completed", + + "task_detail_status_label": "Status:", + "task_detail_summary_title": "Completion Summary", + "task_detail_description_title": "Task Description", + "task_detail_implementation_guide_title": "Implementation Guide", + "task_detail_verification_criteria_title": "Verification Criteria", + "task_detail_dependencies_title": "Dependencies (Prerequisites)", + "task_detail_related_files_title": "Related Files", + "task_detail_notes_title": "Notes", + "task_detail_no_summary": "No summary provided.", + "task_detail_no_description": "No description", + "task_detail_no_implementation_guide": "No implementation guide", + "task_detail_no_verification_criteria": "No verification criteria", + "task_detail_no_dependencies": "No dependencies", + "task_detail_no_related_files": "No related files", + "task_detail_no_notes": "No notes", + "task_detail_unknown_dependency": "Unknown Task", + + "progress_completed": "Completed", + "progress_in_progress": "In Progress", + "progress_pending": "Pending", + "progress_total": "Total", + + "global_analysis_title": "Goal", + + "error_loading_tasks": "Failed to load tasks: {message}", + "error_updating_tasks": "Failed to update tasks: {message}", + "error_loading_graph": "Failed to load dependency graph", + "error_task_not_found": "Task not found" +} diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json new file mode 100644 index 0000000..d9b99bd --- /dev/null +++ b/src/public/locales/zh-TW.json @@ -0,0 +1,55 @@ +{ + "app_title": "Shrimp Task Manager", + "status_indicator_online": "在線", + "dependency_view_title": "依賴關係", + "dependency_graph_placeholder": "所有任務的依賴關係", + "task_list_title": "任務列表", + "search_placeholder": "搜索任務...", + "sort_option_date_desc": "創建時間 (新-舊)", + "sort_option_date_asc": "創建時間 (舊-新)", + "sort_option_name_asc": "名稱 (A-Z)", + "sort_option_name_desc": "名稱 (Z-A)", + "sort_option_status": "狀態", + "status_filter_all": "所有狀態", + "status_filter_pending": "等待中", + "status_filter_in_progress": "進行中", + "status_filter_completed": "已完成", + "task_list_loading": "載入中...", + "task_list_empty": "沒有符合條件的任務", + "task_details_title": "任務詳情", + "task_details_placeholder": "選擇一個任務查看詳情", + "footer_copyright": "© 2023 Shrimp Task Manager - 當前時間: ", + + "status_pending": "等待中", + "status_in_progress": "進行中", + "status_completed": "已完成", + + "task_detail_status_label": "狀態:", + "task_detail_summary_title": "完成摘要", + "task_detail_description_title": "任務描述", + "task_detail_implementation_guide_title": "實現指南", + "task_detail_verification_criteria_title": "驗證標準", + "task_detail_dependencies_title": "依賴項 (前置任務)", + "task_detail_related_files_title": "相關文件", + "task_detail_notes_title": "備註", + "task_detail_no_summary": "沒有提供摘要。", + "task_detail_no_description": "無描述", + "task_detail_no_implementation_guide": "無實現指南", + "task_detail_no_verification_criteria": "無驗證標準", + "task_detail_no_dependencies": "無依賴項", + "task_detail_no_related_files": "無相關文件", + "task_detail_no_notes": "無備註", + "task_detail_unknown_dependency": "未知任務", + + "progress_completed": "已完成", + "progress_in_progress": "進行中", + "progress_pending": "待處理", + "progress_total": "總計", + + "global_analysis_title": "目標", + + "error_loading_tasks": "載入任務失敗: {message}", + "error_updating_tasks": "更新任務失敗: {message}", + "error_loading_graph": "載入依賴圖失敗", + "error_task_not_found": "任務未找到" +} diff --git a/src/public/script.js b/src/public/script.js new file mode 100644 index 0000000..80b0666 --- /dev/null +++ b/src/public/script.js @@ -0,0 +1,1186 @@ +// 全局變量 +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_list_loading" + )}
`; + } + + 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 = `
${translate( + "error_loading_tasks", + { message: error.message } + )}
`; + if (progressIndicator) progressIndicator.style.display = "none"; + if (dependencyGraphElement) + dependencyGraphElement.innerHTML = `
${translate( + "error_loading_graph" + )}
`; + } 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 = `
${translate( + "task_list_empty" + )}
`; + } else { + taskListElement.innerHTML = filteredTasks + .map( + (task) => ` +
+

${task.name}

+
+ ${getStatusText(task.status)} +
+
+ ` + ) + .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 = `

${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_task_not_found" + )}
`; + return; + } + + // --- 安全地填充任務詳情 --- + // 1. 創建基本骨架 (使用 innerHTML,但將動態內容替換為帶 ID 的空元素) + taskDetailsContent.innerHTML = ` +
+

+
+ 狀態: +
+
+ + + + +
+

任務描述

+

+
+ +
+

實現指南

+

+    
+ +
+

驗證標準

+

+
+ +
+

依賴項 (前置任務)

+
+ +
+
+ +
+

相關文件

+ +
+ +
+

備註

+

+
+ `; + + // 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("") + : `${translate( + "task_detail_no_dependencies" + )}`; // 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("") + : `${translate( + "task_detail_no_related_files" + )}`; // 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 = `

${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 = ` +

${translate( + "global_analysis_title" + )}

+
${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 ... diff --git a/src/public/style.css b/src/public/style.css new file mode 100644 index 0000000..f019803 --- /dev/null +++ b/src/public/style.css @@ -0,0 +1,461 @@ +:root { + --primary-color: #3498db; + --secondary-color: #2ecc71; + --background-color: #1a1a2e; + --panel-color: #16213e; + --text-color: #f0f0f0; + --accent-color: #4cd137; + --danger-color: #e74c3c; + --border-radius: 8px; + --box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + line-height: 1.6; +} + +.container { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 20px; +} + +h1 { + font-size: 1.8rem; + font-weight: 600; + color: var(--primary-color); +} + +.status-bar { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.9rem; + font-weight: 500; +} + +.status-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--accent-color); + box-shadow: 0 0 8px var(--accent-color); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} + +main { + display: grid; + grid-template-rows: auto 1fr; + grid-template-areas: + "dependency" + "bottom"; + gap: 20px; + flex-grow: 1; +} + +.task-panel, +.task-details { + background-color: var(--panel-color); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.dependency-view { + grid-area: dependency; + background-color: var(--panel-color); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.bottom-panels { + grid-area: bottom; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + overflow: hidden; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background-color: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +h2 { + font-size: 1.2rem; + font-weight: 500; +} + +select { + background-color: rgba(0, 0, 0, 0.3); + color: var(--text-color); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 5px 10px; + border-radius: 4px; + outline: none; +} + +.task-list, +.dependency-graph, +#task-details-content { + padding: 15px; + flex-grow: 1; + overflow-y: auto; +} + +.dependency-graph { + min-height: 300px; +} + +.task-item { + padding: 15px; + margin-bottom: 15px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: var(--border-radius); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + position: relative; + overflow: hidden; +} + +.task-item::before { + content: ""; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 4px; + background-color: transparent; + transition: background-color 0.3s; +} + +.task-item.status-pending::before { + background-color: #f1c40f; +} + +.task-item.status-in-progress::before { + background-color: var(--primary-color); +} + +.task-item.status-completed::before { + background-color: var(--secondary-color); +} + +.task-item:hover { + transform: translateY(-3px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); +} + +.task-item.selected { + background-color: rgba(52, 152, 219, 0.15); + box-shadow: 0 0 0 2px var(--primary-color); +} + +.task-item h3 { + font-size: 1.1rem; + margin-bottom: 5px; +} + +.task-meta { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); +} + +.task-status { + padding: 2px 8px; + border-radius: 20px; + font-size: 0.7rem; + font-weight: 500; +} + +.status-pending { + background-color: rgba(241, 196, 15, 0.2); + color: #f1c40f; +} + +.status-in-progress { + background-color: rgba(52, 152, 219, 0.2); + color: #3498db; +} + +.status-completed { + background-color: rgba(46, 204, 113, 0.2); + color: #2ecc71; +} + +.placeholder { + text-align: center; + color: rgba(255, 255, 255, 0.5); + padding: 50px 0; +} + +.loading { + text-align: center; + padding: 20px; + color: rgba(255, 255, 255, 0.7); + animation: fadeInOut 1.5s infinite; +} + +@keyframes fadeInOut { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +.task-details-header { + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.task-details-header h3 { + font-size: 1.3rem; + margin-bottom: 5px; +} + +.task-details-section { + margin-bottom: 20px; +} + +.task-details-section h4 { + font-size: 1rem; + color: var(--primary-color); + margin-bottom: 10px; +} + +.dependencies, +.related-files { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.dependency-tag, +.file-tag { + background-color: rgba(0, 0, 0, 0.3); + border-radius: 4px; + padding: 5px 10px; + font-size: 0.8rem; +} + +pre { + background-color: rgba(0, 0, 0, 0.3); + padding: 15px; + border-radius: var(--border-radius); + overflow-x: auto; + margin: 10px 0; + font-family: "Consolas", "Monaco", monospace; + font-size: 0.9rem; +} + +footer { + margin-top: 20px; + text-align: center; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + padding: 10px 0; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* 進度指示器樣式 */ +.progress-indicator { + margin-bottom: 20px; + padding: 15px; + background-color: var(--panel-color); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); +} + +.progress-bar-container { + display: flex; + height: 10px; + border-radius: 5px; + overflow: hidden; + background-color: rgba(0, 0, 0, 0.3); +} + +.progress-segment { + height: 100%; + transition: width 0.5s ease-in-out; +} + +.progress-completed { + background-color: var(--secondary-color); +} + +.progress-in-progress { + background-color: var(--primary-color); +} + +.progress-pending { + background-color: #f1c40f; /* 與status-pending一致 */ +} + +.progress-labels { + display: flex; + justify-content: space-between; + margin-top: 10px; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.7); +} + +.progress-labels span { + padding: 2px 5px; +} + +.label-completed { + color: var(--secondary-color); +} + +.label-in-progress { + color: var(--primary-color); +} + +.label-pending { + color: #f1c40f; +} + +/* 依賴關係圖樣式 */ +.dependency-graph svg { + display: block; /* 避免底部多餘空間 */ + width: 100%; + height: 100%; +} + +.nodes g.node-item circle { + transition: r 0.2s, stroke 0.2s; + /* 根據類型設置基礎顏色 */ +} +.nodes g.type-current circle { + fill: var(--primary-color); /* 當前選中任務 */ +} +.nodes g.type-dependency circle { + fill: var(--danger-color); /* 前置依賴任務 */ +} +.nodes g.type-dependent circle { + fill: var(--secondary-color); /* 後置依賴任務 */ +} +.nodes g.type-unknown circle { + fill: #7f8c8d; /* 未知任務 */ +} + +/* 根據狀態調整節點透明度或邊框 */ +.nodes g.status-completed circle { + opacity: 0.6; +} +.nodes g.status-in-progress circle { + /* 可以添加特殊效果,如描邊動畫 */ + stroke: var(--accent-color); + stroke-width: 2px; +} + +.nodes g.node-item:hover circle { + r: 14; /* 懸停時放大 */ + stroke: #fff; + stroke-width: 2.5px; +} + +.nodes g.node-item text { + fill: var(--text-color); + font-size: 10px; + pointer-events: none; /* 避免文本干擾點擊 */ +} + +/* 新增:高亮節點樣式 */ +g.node-item.highlighted circle { + stroke: var(--accent-color) !important; /* 使用重要標誌確保覆蓋 */ + stroke-width: 3px !important; +} + +@media (max-width: 768px) { + main { + grid-template-rows: auto auto; /* Stack dependency and bottom panels */ + grid-template-areas: + "dependency" + "bottom"; + } + + .bottom-panels { + grid-template-columns: 1fr; /* Stack task list and details */ + grid-template-rows: auto auto; /* Or let them take natural height */ + } + + .task-panel, + .dependency-view, + .task-details { + /* grid-column: 1 / -1; No longer needed */ + min-height: 300px; /* Ensure panels have some height */ + } +} + +/* 過濾器區域樣式 */ +.filters { + display: flex; + gap: 10px; +} + +.filters input[type="text"], +.filters select { + background-color: rgba(0, 0, 0, 0.3); + color: var(--text-color); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 5px 10px; + border-radius: 4px; + outline: none; + font-size: 0.9rem; +} + +.filters input[type="text"] { + flex-grow: 1; /* 讓搜索框佔據更多空間 */ + min-width: 150px; +}