diff --git a/CLAUDE.md b/CLAUDE.md index 5d4b486..015f1d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,21 @@ # GTD 时间项目管理 (跨平台版) -Tauri 2.0 (桌面端) + Capacitor 8.0 (移动端) + Vite 7 + React 19 + TailwindCSS v4 + shadcn/ui + Framer Motion + react-i18next +Tauri 2.0 (桌面端) + Capacitor 8.0 (移动端) + Vite 7 + React 19 + TailwindCSS v4 + shadcn/ui + GSAP + react-i18next src/ ├── components/ │ ├── ui/ - shadcn 组件库 -│ └── gtd/ - GTD 业务组件 (22文件: QuickCapture, Sidebar, Settings, SyncSettings, ConflictDialog, FolderPicker, TaskItem, TaskList, CalendarView, CalendarGrid, CalendarCell, CalendarTaskChip, UnscheduledPanel, NotesPanel, Drawer, ActionSheet, JournalNowView, JournalPastView, JournalItem, JournalChip, AIPromptCard, AISettings) -├── stores/ - 状态管理 (4文件: gtd.js, calendar.js, journal.js, ai.js) +│ └── gtd/ - GTD 业务组件 (37文件: QuickCapture, Sidebar, Settings, SyncSettings, ConflictDialog, FolderPicker, TaskItem, TaskList, CalendarView, CalendarGrid, CalendarCell, CalendarTaskChip, UnscheduledPanel, NotesPanel, Drawer, ActionSheet, JournalNowView, JournalPastView, JournalItem, JournalChip, AIPromptCard, AISettings, FocusView, FocusGreeting, FocusRecommendCard, FocusTaskItem, FocusOverdueCard, FocusEmptyState, ProjectList, ProjectSettings, ProjectBoard, ProjectColumn, ProjectTaskCard, SortableTask) +├── stores/ - 状态管理 (6文件: gtd.js, project.js, calendar.js, journal.js, ai.js, editor.js) ├── hooks/ - React Hooks (2文件: useFileSystem.js, useSync.js) -├── lib/ - 工具函数 (5文件: utils.js, motion.js, platform.js, tauri.js(废弃), i18n.js) +├── lib/ - 工具函数 (6文件: utils.js, motion.js, platform.js, haptics.js, tauri.js(废弃), i18n.js) │ ├── ai/ - AI 功能模块 (3文件: crypto.js, prompts.js, openai.js) │ ├── fs/ - 文件系统抽象层 (5文件: adapter.js, tauri.js, capacitor.js, web.js, index.js) -│ ├── format/ - 数据格式处理 (3文件: task.js, journal.js, index.js) -│ └── sync/ - 云同步功能 (3文件: conflict.js, webdav.js, index.js) +│ ├── format/ - 数据格式处理 (4文件: task.js, journal.js, project.js, index.js) +│ ├── sync/ - 云同步功能 (3文件: conflict.js, webdav.js, index.js) +│ └── planet/ - 共享星球素材系统 (3文件: index.js, svgs.js, colors.js) ├── locales/ - 国际化翻译文件 (2文件: zh-CN.json, en-US.json) -├── App.jsx - 应用入口,支持列表/日历/日记视图切换,集成跨平台功能 +├── App.jsx - 应用入口,支持专注/列表/看板/日历/日记视图切换,集成跨平台功能 ├── main.jsx - React 挂载点,初始化 i18n └── index.css - 全局样式 + CSS 变量 @@ -45,13 +46,49 @@ package.json - 包含 tauri:dev/tauri:build (桌面端) 和 cap:android/c - 将来/也许 (Someday): 暂时搁置 - 已完成 (Done): 归档 +## 专注视图 (Focus View) + +- **设计哲学**:专注是一种"主动选择",而非"被动提醒";减法设计,屏蔽干扰 +- **视觉风格**:柔性宇宙插画,SVG filter 手绘行星 + 椭圆轨道带 + GSAP 动画 +- **轨道带**:多条同心椭圆(像土星环),深蓝紫色,-15度倾斜 +- **手绘行星**:SVG feTurbulence + feDisplacementMap 实现不规则边缘,玻璃球高光 +- **独立视图**:与列表/日历/日记并列,作为侧边栏第一个入口 +- **时间感知问候**:根据早中晚显示不同问候语 + 今日任务数量 +- **星标功能**:任务可标记星标,星标任务优先显示在主角位置(中间大行星) +- **星球筛选**:只显示有截止日期且为今天或过期的任务,最多 6 个 +- **排序规则**:星标优先 → 过期优先 → 创建时间 +- **溢出任务**:超过 6 个任务时,底部折叠卡片显示剩余任务 +- **过期任务处理**:折叠卡片显示过期任务,支持快速操作(今天/明天/删除) +- **空状态引导**:无任务时引导用户去收集箱选择 + +## 专注模式 (Focus Mode) + +- **极简设计**:深邃黑色 + 一个发光的 SVG 手绘星球 = 唯一视觉焦点 +- **共享素材**:使用 lib/planet/ 共享素材系统,与主视图星球风格一致 +- **有机运动**:GSAP 呼吸动画(scale 1→1.02,5秒循环)+ 轻微漂移(x/y ±8px,12秒循环) +- **进度表达**:星球随进度增大(140px → 280px),无进度环 +- **暂停状态**:呼吸幅度加大(1.04),表示"等待" +- **极简背景**:纯色背景 + 20个极微星点,无星云、无辉光、无噪点 +- **信息层级**:星球(视觉焦点)→ 时间 + 任务标题(上下文)→ 控制按钮(最小化) + +## 看板视图 (Board View) + +- **设计理念**:长期目标跟进系统,项目驱动而非 GTD 列表驱动 +- **双重归属**:任务同时属于项目和 GTD 列表,互不干扰 +- **自定义列**:每个项目可定义自己的状态流(默认:待办/进行中/完成) +- **拖拽流转**:任务在列之间拖拽改变 columnId,不影响 GTD list 归属 +- **视觉区分**:项目颜色标识,GTD 归属标签显示 +- **职责定位**:长期目标管理和进度追踪,与 GTD 系统并行不悖 + ## 日历视图 +- **经典设计**:固定 5-6 行一屏显示,自适应行高,不可滚动 - 月视图/周视图切换 - 拖拽任务设置日期 - 无日期任务面板 - 点击日期快速添加任务 - 日记显示:日历格子内显示当天日记(虚线边框区分) +- **职责定位**:纯粹的时间导航器,用于查看整月安排、拖拽任务设置日期、查看历史日记 ## 日记功能 @@ -66,12 +103,13 @@ package.json - 包含 tauri:dev/tauri:build (桌面端) 和 cap:android/c ## AI 功能 - **智能问题生成**:根据用户指导方向、任务完成情况和历史日记,动态生成个性化引导问题 +- **任务推荐**:分析任务紧急性、重要性、可行性,推荐最应该优先处理的任务 - **用户指导方向**:用户输入指导性提示词(如"我想探讨人生的哲理和意义"),AI 据此生成问题 - **上下文感知**:结合今日任务、最近日记、时间上下文(周几、早中晚)生成问题 - **优雅交互**:问题卡片轻量非侵入,点击插入、悬停删除、支持刷新 - **隐私优先**:用户自己配置 OpenAI API Key,加密存储在本地 - **完全可选**:默认关闭,用户完全控制 -- **优雅降级**:API 失败时使用通用开放式问题 +- **优雅降级**:API 失败时使用通用开放式问题或本地排序 ## 跨平台特性 @@ -105,6 +143,9 @@ package.json - 包含 tauri:dev/tauri:build (桌面端) 和 cap:android/c │ ├── inbox.json │ ├── today.json │ └── done/ # 按月归档 + ├── projects/ # 项目数据 (JSON) + │ ├── proj-xxx.json + │ └── proj-yyy.json └── journals/ # 日记数据 (Markdown) └── 2026/01/ ``` diff --git a/package-lock.json b/package-lock.json index 4c399e2..4aabf66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,12 @@ "@codemirror/lang-markdown": "^6.3.4", "@codemirror/state": "^6.5.3", "@codemirror/view": "^6.38.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -36,11 +41,13 @@ "date-fns": "^4.1.0", "framer-motion": "^12.27.5", "gray-matter": "^4.0.3", + "gsap": "^3.14.2", "i18next": "^25.8.0", "idb": "^8.0.3", "jszip": "^3.10.1", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", + "paper": "^0.12.18", "png-to-ico": "^3.0.1", "react": "^19.2.0", "react-day-picker": "^9.13.0", @@ -49,6 +56,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.12.0", "replicate": "^1.4.0", + "roughjs": "^4.6.6", "sharp": "^0.34.5", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -508,6 +516,59 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -1776,6 +1837,52 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1903,6 +2010,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -4816,6 +4951,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/gsap": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5679,6 +5826,18 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/paper": { + "version": "0.12.18", + "resolved": "https://registry.npmjs.org/paper/-/paper-0.12.18.tgz", + "integrity": "sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5692,6 +5851,12 @@ "node": ">=6" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5777,6 +5942,22 @@ "node": ">=14.19.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6134,6 +6315,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/package.json b/package.json index 196bd3b..bb2c2b6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,12 @@ "@codemirror/lang-markdown": "^6.3.4", "@codemirror/state": "^6.5.3", "@codemirror/view": "^6.38.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -41,11 +46,13 @@ "date-fns": "^4.1.0", "framer-motion": "^12.27.5", "gray-matter": "^4.0.3", + "gsap": "^3.14.2", "i18next": "^25.8.0", "idb": "^8.0.3", "jszip": "^3.10.1", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", + "paper": "^0.12.18", "png-to-ico": "^3.0.1", "react": "^19.2.0", "react-day-picker": "^9.13.0", @@ -54,6 +61,7 @@ "react-icons": "^5.5.0", "react-router-dom": "^7.12.0", "replicate": "^1.4.0", + "roughjs": "^4.6.6", "sharp": "^0.34.5", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/public/planets/jupiter.png b/public/planets/jupiter.png new file mode 100644 index 0000000..4bea673 Binary files /dev/null and b/public/planets/jupiter.png differ diff --git a/public/planets/mars.jpg b/public/planets/mars.jpg new file mode 100644 index 0000000..fc5debf Binary files /dev/null and b/public/planets/mars.jpg differ diff --git a/public/planets/mercury.jpg b/public/planets/mercury.jpg new file mode 100644 index 0000000..fdf3d65 Binary files /dev/null and b/public/planets/mercury.jpg differ diff --git a/public/planets/moon.jpg b/public/planets/moon.jpg new file mode 100644 index 0000000..9726f71 Binary files /dev/null and b/public/planets/moon.jpg differ diff --git a/public/planets/neptune.png b/public/planets/neptune.png new file mode 100644 index 0000000..06cb383 Binary files /dev/null and b/public/planets/neptune.png differ diff --git a/public/planets/pluto.jpg b/public/planets/pluto.jpg new file mode 100644 index 0000000..50f5995 Binary files /dev/null and b/public/planets/pluto.jpg differ diff --git a/public/planets/venus.jpg b/public/planets/venus.jpg new file mode 100644 index 0000000..177298d Binary files /dev/null and b/public/planets/venus.jpg differ diff --git a/src/App.jsx b/src/App.jsx index 7aa9c46..b36879d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,13 +1,14 @@ /** - * [INPUT]: 依赖 @/stores/gtd, @/stores/journal, @/hooks/useFileSystem, @/hooks/useSync, @/components/gtd/*, @/components/ui/sonner, @/lib/platform, react-i18next + * [INPUT]: 依赖 @/stores/gtd, @/stores/project, @/stores/journal, @/hooks/useFileSystem, @/hooks/useSync, @/components/gtd/*, @/components/ui/sonner, @/lib/platform, react-i18next * [OUTPUT]: 导出 App 根组件 - * [POS]: 应用入口,组装 GTD 布局,支持列表/日历/日记视图切换,集成跨平台功能(桌面端+移动端),管理抽屉和快速捕获状态,集成文件系统和云同步 + * [POS]: 应用入口,组装 GTD 布局,支持专注/列表/看板/日历/日记视图切换,集成跨平台功能(桌面端+移动端),管理抽屉和快速捕获状态,集成文件系统和云同步 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react' import { useTranslation } from 'react-i18next' import { useGTD, GTD_LIST_META, GTD_LISTS } from '@/stores/gtd' +import { useProject } from '@/stores/project' import { JournalProvider, useJournal } from '@/stores/journal' import { useFileSystem } from '@/hooks/useFileSystem' import { useSync } from '@/hooks/useSync' @@ -17,9 +18,14 @@ import { Drawer } from '@/components/gtd/Drawer' import { QuickCapture } from '@/components/gtd/QuickCapture' import { TaskList } from '@/components/gtd/TaskList' import { CalendarView } from '@/components/gtd/CalendarView' +import { ProjectBoard } from '@/components/gtd/ProjectBoard' +import { ProjectSettings } from '@/components/gtd/ProjectSettings' +import { ProjectList } from '@/components/gtd/ProjectList' +import { ProjectGallery } from '@/components/gtd/ProjectGallery' import { NotesPanel } from '@/components/gtd/NotesPanel' import { JournalNowView } from '@/components/gtd/JournalNowView' import { JournalPastView } from '@/components/gtd/JournalPastView' +import { FocusView } from '@/components/gtd/FocusView' import { ConflictDialog } from '@/components/gtd/ConflictDialog' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' @@ -66,8 +72,15 @@ function AppContent({ fileSystem, sync }) { updateTask, toggleComplete, moveTask, + toggleStar, deleteTask, - loadTasks + loadTasks, + // 预处理数据 - 供 FocusView 使用 + todayTasks, + completedToday, + overdueTasks, + planetTasks, + overflowTasks } = useGTD({ fileSystem: fileSystem.isReady ? fileSystem.fs : null }) const { @@ -78,8 +91,26 @@ function AppContent({ fileSystem, sync }) { getJournalById } = useJournal() - const [viewMode, setViewMode] = useState('list') // 'list' | 'calendar' + // 项目状态管理 + const { + projects, + activeProject, + activeProjectId, + setActiveProjectId, + createProject, + updateProject, + deleteProject, + archiveProject, + addColumn, + updateColumn, + deleteColumn, + reorderColumns + } = useProject({ fileSystem: fileSystem.isReady ? fileSystem.fs : null }) + + const [viewMode, setViewMode] = useState('focus') // 'focus' | 'list' | 'board' | 'calendar' const [journalView, setJournalView] = useState(null) // 'now' | 'past' | null + const [projectSettingsOpen, setProjectSettingsOpen] = useState(false) + const [settingsProjectId, setSettingsProjectId] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false) // 移动端抽屉状态 const [quickCaptureOpen, setQuickCaptureOpen] = useState(false) // 移动端快速捕获模态框状态 @@ -92,7 +123,7 @@ function AppContent({ fileSystem, sync }) { const dockPanelRef = useRef(null) const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight }) const selectedTask = tasks.find(t => t.id === selectedTaskId) - const showDockPanel = viewMode === 'list' && selectedTaskId && selectedTask + const showDockPanel = (viewMode === 'list' || viewMode === 'board') && selectedTaskId && selectedTask const showImmersivePanel = selectedTaskId && selectedTask && immersivePhase !== 'dock' const isImmersive = immersivePhase !== 'dock' const immersiveActive = immersivePhase === 'immersive' @@ -327,6 +358,7 @@ function AppContent({ fileSystem, sync }) { activeList={activeList} onSelect={setActiveList} counts={counts} + tasks={tasks} viewMode={viewMode} onViewModeChange={setViewMode} journalView={journalView} @@ -337,6 +369,20 @@ function AppContent({ fileSystem, sync }) { onSettingsOpenChange={setSettingsOpen} sync={sync} fileSystem={fileSystem} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onToggleComplete={handleToggleComplete} + // 项目相关 + projects={projects} + activeProjectId={activeProjectId} + onSelectProject={setActiveProjectId} + onCreateProject={createProject} + onDeleteProject={deleteProject} + onArchiveProject={archiveProject} + onOpenProjectSettings={(id) => { + setSettingsProjectId(id) + setProjectSettingsOpen(true) + }} /> )} {/* 视图渲染优先级:journalView > viewMode */} @@ -344,6 +390,43 @@ function AppContent({ fileSystem, sync }) { setJournalView(null)} /> ) : journalView === 'past' ? ( + ) : viewMode === 'focus' ? ( + moveTask(id, GTD_LISTS.TODAY)} + onMoveToTomorrow={(id) => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + tomorrow.setHours(0, 0, 0, 0) + updateTask(id, { dueDate: tomorrow.getTime() }) + }} + onDelete={handleDelete} + onGoToInbox={() => { + setActiveList(GTD_LISTS.INBOX) + setViewMode('list') + }} + onGoToToday={() => { + setActiveList(GTD_LISTS.TODAY) + setViewMode('list') + }} + onEditTask={(task) => { + setSelectedTaskId(task.id) + setViewMode('list') + }} + onUpdatePomodoro={(taskId, count) => { + updateTask(taskId, { + pomodoros: count, + lastPomodoroAt: new Date().toISOString() + }) + }} + onToggleStar={toggleStar} + /> ) : viewMode === 'calendar' ? ( + ) : viewMode === 'board' ? ( +
+ {activeProject ? ( + t.projectId === activeProject.id)} + onUpdateTask={updateTask} + onAddTask={(title, projectId, columnId) => { + const project = projects.find(p => p.id === projectId) + const firstColumnId = project?.columns[0]?.id + addTask(title, null, { projectId, columnId: columnId || firstColumnId }) + }} + onDeleteTask={deleteTask} + onDeleteProject={deleteProject} + onReorderColumns={reorderColumns} + onBack={() => setActiveProjectId(null)} + onOpenSettings={(id) => { + setSettingsProjectId(id) + setProjectSettingsOpen(true) + }} + onTaskClick={setSelectedTaskId} + /> + ) : ( + { + setSettingsProjectId(id) + setProjectSettingsOpen(true) + }} + /> + )} +
) : (
updateTask(id, { dueDate })} onTaskClick={setSelectedTaskId} + onToggleStar={toggleStar} />
@@ -534,6 +656,7 @@ function AppContent({ fileSystem, sync }) { activeList={activeList} onSelect={setActiveList} counts={counts} + tasks={tasks} viewMode={viewMode} onViewModeChange={setViewMode} journalView={journalView} @@ -546,6 +669,20 @@ function AppContent({ fileSystem, sync }) { onQuickCaptureOpen={() => setQuickCaptureOpen(true)} sync={sync} fileSystem={fileSystem} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onToggleComplete={handleToggleComplete} + // 项目相关 + projects={projects} + activeProjectId={activeProjectId} + onSelectProject={setActiveProjectId} + onCreateProject={createProject} + onDeleteProject={deleteProject} + onArchiveProject={archiveProject} + onOpenProjectSettings={(id) => { + setSettingsProjectId(id) + setProjectSettingsOpen(true) + }} /> {/* 移动端抽屉 */} @@ -555,9 +692,16 @@ function AppContent({ fileSystem, sync }) { activeList={activeList} onSelect={setActiveList} counts={counts} + viewMode={viewMode} + onViewModeChange={setViewMode} journalView={journalView} onJournalViewChange={handleJournalViewChange} onSettingsOpen={() => setSettingsOpen(true)} + projects={projects} + tasks={tasks} + activeProjectId={activeProjectId} + onSelectProject={onSelectProject} + onCreateProject={onCreateProject} /> {/* 移动端快速捕获模态框 */} @@ -600,6 +744,17 @@ function AppContent({ fileSystem, sync }) { onResolve={(strategy) => sync.resolveConflicts(strategy)} /> + {/* 项目设置对话框 */} + p.id === settingsProjectId)} + onUpdateProject={updateProject} + onAddColumn={addColumn} + onUpdateColumn={updateColumn} + onDeleteColumn={deleteColumn} + /> + ) diff --git a/src/assets/plant.svg b/src/assets/plant.svg new file mode 100644 index 0000000..73bae1d --- /dev/null +++ b/src/assets/plant.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/plant/plant1.svg b/src/assets/plant/plant1.svg new file mode 100644 index 0000000..788ca43 --- /dev/null +++ b/src/assets/plant/plant1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/plant/plant2.svg b/src/assets/plant/plant2.svg new file mode 100644 index 0000000..788ca43 --- /dev/null +++ b/src/assets/plant/plant2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/plant/plant3.svg b/src/assets/plant/plant3.svg new file mode 100644 index 0000000..334e21c --- /dev/null +++ b/src/assets/plant/plant3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/plant/star.svg b/src/assets/plant/star.svg new file mode 100644 index 0000000..4939838 --- /dev/null +++ b/src/assets/plant/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/plant2.svg b/src/assets/plant2.svg new file mode 100644 index 0000000..4939838 --- /dev/null +++ b/src/assets/plant2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/yellow_plant.svg b/src/assets/yellow_plant.svg new file mode 100644 index 0000000..9ab40ba --- /dev/null +++ b/src/assets/yellow_plant.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/gtd/BlueDust.jsx b/src/components/gtd/BlueDust.jsx new file mode 100644 index 0000000..6af46cf --- /dev/null +++ b/src/components/gtd/BlueDust.jsx @@ -0,0 +1,72 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: BlueDust 组件 + * [POS]: 蓝色粒子层,GSAP 动画,中间区域密集分布 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useMemo } from 'react' +import gsap from 'gsap' + +// ═══════════════════════════════════════════════════════════════════════════ +// 蓝色粒子 - GSAP 动画,中间区域密集 +// ═══════════════════════════════════════════════════════════════════════════ +export function BlueDust({ count = 25 }) { + const containerRef = useRef(null) + + const particles = useMemo(() => + Array.from({ length: count }, () => ({ + // 集中在中间区域 (30%-70%) + x: 30 + Math.random() * 40, + y: 30 + Math.random() * 40, + size: 3 + Math.random() * 5, + })), + [count] + ) + + useEffect(() => { + if (!containerRef.current) return + + const dots = containerRef.current.children + const tweens = [] + + Array.from(dots).forEach(dot => { + const duration = 5 + Math.random() * 4 + const driftX = (Math.random() - 0.5) * 15 + const driftY = (Math.random() - 0.5) * 15 + + const tween = gsap.to(dot, { + x: `+=${driftX}`, + y: `+=${driftY}`, + opacity: 0.7, + duration, + delay: Math.random() * 3, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + }) + tweens.push(tween) + }) + + return () => tweens.forEach(t => t.kill()) + }, [particles]) + + return ( +
+ {particles.map((p, i) => ( +
+ ))} +
+ ) +} diff --git a/src/components/gtd/CLAUDE.md b/src/components/gtd/CLAUDE.md index 4410cfe..c568009 100644 --- a/src/components/gtd/CLAUDE.md +++ b/src/components/gtd/CLAUDE.md @@ -4,14 +4,15 @@ ## 成员清单 QuickCapture.jsx: 快速收集输入框,顶部任务添加入口 -Sidebar.jsx: 侧边栏导航,GTD 五大列表切换 + 列表/日历视图切换 + 日记分组(此刻/过往)+ 设置入口,移动端简化为 3 按钮底部导航(Menu、FAB、日历) -Drawer.jsx: 移动端左侧滑抽屉,显示 GTD 五大列表 + 日记分组 + 设置入口,替代底部导航的列表切换功能 +SidebarGroup.jsx: 侧边栏可折叠分组组件,统一处理展开/折叠逻辑、箭头旋转动画、分组标题样式(小号、大写、灰色) +Sidebar.jsx: 侧边栏导航,分组式设计(固定导航区:专注/日程 + 可折叠分组区:GTD/项目/日记),折叠时只显示固定导航区(隐藏分组区),折叠按钮使用 PanelLeftClose/PanelLeftOpen 图标 + 旋转动画,移动端简化为 3 按钮底部导航(Menu、FAB、日历),分组状态持久化到 localStorage +Drawer.jsx: 移动端左侧滑抽屉,分组式设计(固定导航区 + 可折叠分组区),与桌面端 Sidebar 保持一致,共享分组折叠状态 ActionSheet.jsx: 移动端底部操作表,显示任务操作选项(设置日期、移动到列表、删除),替代桌面端的下拉菜单 ConflictDialog.jsx: 同步冲突解决对话框,展示冲突详情 + 策略选择(合并/本地/远程/保留两者) -TaskItem.jsx: 单任务项,支持完成/移动/删除/日期设置,移动端支持滑动手势(右滑完成、左滑删除)和长按菜单,行高 64px,Checkbox 24px +TaskItem.jsx: 单任务项,支持完成/移动/删除/日期设置/星标切换,移动端支持滑动手势(右滑完成、左滑删除)和长按菜单,行高 64px,Checkbox 24px TaskList.jsx: 任务列表容器,处理空状态和序列动画 CalendarView.jsx: 日历视图容器,组装 Header + Grid + UnscheduledPanel,移动端简化 Header,隐藏 UnscheduledPanel,支持日记显示 -CalendarGrid.jsx: 日历网格渲染,星期标题 + 日期矩阵,传递日记数据到 CalendarCell +CalendarGrid.jsx: 日历网格渲染,星期标题 + 日期矩阵,固定 5-6 行一屏显示,自适应行高 CalendarCell.jsx: 单日格子,显示日期、日记和任务,支持拖放,移动端减小尺寸(min-h-20,日期 text-xs) CalendarTaskChip.jsx: 日历内任务小卡片,可拖拽,实线边框 UnscheduledPanel.jsx: 无日期任务面板,可折叠,支持拖拽到日历 @@ -21,6 +22,29 @@ JournalPastView.jsx: "过往"视图,历史日记支持列表/弧线画布( JournalItem.jsx: 过往日记列表项,显示日期 + 标题 + 预览 + 字数 JournalChip.jsx: 日历内日记小卡片,虚线边框,不可拖拽,BookText 图标 AIPromptCard.jsx: AI 问题卡片,展示生成的引导问题(无 emoji),支持点击插入、悬停删除、刷新,淡入淡出动画,显示加载状态 +FocusView.jsx: 专注视图主组件,柔性宇宙插画风格,整合 FocusMode 专注模式 + Constellation 星座系统 + OverdueCard 过期任务卡片 + OverflowCard 溢出任务卡片 + 两层空状态引导 +FocusCircle.jsx: 专注视图核心 - 深邃宇宙插画,时间感知背景,深度分层(far/mid/near)+ 鼠标视差,集成 DarkNebula/DeepSpaceDust/SpaceGlow/OrbitPaths/StarDust/BlueDust/Constellation +Planet.jsx: 手绘风格行星,使用 @/lib/planet 共享素材系统,支持坍缩动画(GSAP 收缩 + 粒子迸发 + 闪白)+ 红巨星状态(过期任务暗红脉动)+ 番茄环渲染 + 长按进入专注模式 + 极简右键菜单 + 拖拽整理位置 + 星标金色光晕 +FocusMode.jsx: 全屏专注模式组件,极简设计(SVG 手绘星球 + 有机运动 + 尺寸进度),使用 @/lib/planet 共享素材系统,番茄钟计时器(15/25/45分钟可选),GSAP 呼吸漂移动画 +Constellation.jsx: 完成任务星座系统,已完成任务留下微弱恒星(闪烁动画),当天完成的恒星之间虚线连线,useConstellation hook 管理状态 + localStorage 持久化 +ZDepthLayer.jsx: 深度层管理器,定义 far/mid/near 三层配置(zIndex/blur/parallaxSpeed/opacity),提供 ParallaxProvider/使用视差组件/FarLayer/MidLayer/NearLayer +DarkNebula.jsx: 巨大暗星云层,占据画面大比例,极暗透明度(2-3.5%),大模糊(80px),mix-blend-mode: multiply 实现遮挡效果,分钟级呼吸漂移 +DeepSpaceDust.jsx: 极微星点层,200个 0.3-1px 星尘,低透明度(5-15%),冷色系(80%灰白20%冷蓝),创造尺度差 +SpaceGlow.jsx: 空间辉光层,非中心式不规则光斑,极低对比度(2-5%),大模糊(60px),分钟级脉动 +StarDust.jsx: 背景星点层,GSAP 动画,35个微小粒子极慢漂浮,冷色系(80%灰白20%冷蓝) +OrbitPaths.jsx: 断续轨道带 - 不完整椭圆弧线,随机亮度,CSS mask 局部遮挡,GSAP 描边动画 +BlueDust.jsx: 蓝色粒子层,GSAP 动画,25个蓝色小点集中在中间区域 +MiniInfo.jsx: 右上角极简信息标签,GSAP 入场动画,问候语 + 数字,支持时间感知 +NoiseOverlay.jsx: 全局噪点纹理层,SVG feTurbulence 实现颗粒感 +FloatingTaskBubble.jsx: 漂浮气泡任务卡片,圆角胶囊形状,渐变圆点前缀,与行星系统融为一体 +TaskBubbleZone.jsx: 底部任务气泡区域,水平排列漂浮气泡,最多显示5个 +ProjectList.jsx: 项目列表组件,侧边栏导航,仅支持点击切换视图和创建项目,操作功能(设置/归档/删除)移至主区域 ProjectGallery 右键菜单 +ProjectGallery.jsx: 项目画廊组件,呼吸感卡片网格布局(h-40 垂直居中),系统主题色进度环 + 标题 + 纯文字统计,响应式 1/2/3 列,hover 仅背景色变化,右键菜单操作(设置/归档/删除) +ProjectSettings.jsx: 项目设置对话框,支持编辑标题/描述,管理自定义列(添加/删除/重命名/排序),移除颜色选择,统一使用系统主题色 +ProjectBoard.jsx: 项目看板主容器,整合列组件和拖拽功能,管理任务在列之间的流转,支持 dnd-kit +ProjectColumn.jsx: 看板列组件,显示列内任务,支持任务拖拽,支持添加新任务(添加按钮固定在列头下方,始终可见) +ProjectTaskCard.jsx: 看板任务卡片,显示任务信息,支持拖拽,显示 GTD 归属标签 +SortableTask.jsx: 可拖拽排序的任务卡片包装器,集成 dnd-kit ## 子目录 diff --git a/src/components/gtd/CalendarGrid.jsx b/src/components/gtd/CalendarGrid.jsx index c3eb223..60dc084 100644 --- a/src/components/gtd/CalendarGrid.jsx +++ b/src/components/gtd/CalendarGrid.jsx @@ -1,7 +1,7 @@ /** - * [INPUT]: 依赖 framer-motion,依赖 CalendarCell, react-i18next + * [INPUT]: 依赖 framer-motion,依赖 CalendarCell, react-i18next, @/lib/platform * [OUTPUT]: 导出 CalendarGrid 组件 - * [POS]: 日历网格渲染,包含星期标题和日期矩阵 + * [POS]: 日历网格渲染,包含星期标题和日期矩阵,固定 5-6 行一屏显示 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -9,13 +9,13 @@ import { motion, AnimatePresence } from 'framer-motion' import { useTranslation } from 'react-i18next' import { CalendarCell } from './CalendarCell' import { gentle } from '@/lib/motion' +import { isMobile } from '@/lib/platform' export function CalendarGrid({ grid, tasksByDate, journalsByDate, isToday, - toDateKey, onDrop, onAddEntry, onToggle, @@ -24,11 +24,12 @@ export function CalendarGrid({ }) { const { t } = useTranslation() const weekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] + const mobile = isMobile() return ( -
+
{/* 星期标题 */} -
+
{weekdays.map(day => (
- {/* 日期网格 */} - - - {grid.map((week, i) => ( -
- {week.map(cell => ( - - ))} -
- ))} -
-
+ {/* 日期网格 - 自适应行高,不可滚动 */} +
+ + + {grid.map((week, i) => ( +
+ {week.map(cell => ( + + ))} +
+ ))} +
+
+
) } diff --git a/src/components/gtd/CalendarView.jsx b/src/components/gtd/CalendarView.jsx index 8a17107..deecec9 100644 --- a/src/components/gtd/CalendarView.jsx +++ b/src/components/gtd/CalendarView.jsx @@ -5,7 +5,7 @@ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useState, useRef } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useCalendar } from '@/stores/calendar' import { CalendarGrid } from './CalendarGrid' diff --git a/src/components/gtd/Constellation.jsx b/src/components/gtd/Constellation.jsx new file mode 100644 index 0000000..96c069b --- /dev/null +++ b/src/components/gtd/Constellation.jsx @@ -0,0 +1,283 @@ +/** + * [INPUT]: react, gsap, @/lib/utils + * [OUTPUT]: Constellation 组件, useConstellation hook + * [POS]: 完成任务的星座系统,显示已完成任务的恒星和连线 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useMemo, useState, useCallback } from 'react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 单个已完成任务的恒星 +// ═══════════════════════════════════════════════════════════════════════════ +function CompletedStar({ star, index }) { + const starRef = useRef(null) + const glowRef = useRef(null) + + useEffect(() => { + if (!starRef.current) return + + // 入场动画 - 从小点浮现 + gsap.fromTo(starRef.current, + { scale: 0, opacity: 0 }, + { scale: 1, opacity: 0.6, duration: 0.8, delay: index * 0.1, ease: 'back.out(1.7)' } + ) + + // 闪烁动画 - 随机周期 + const twinkleDuration = 2 + Math.random() * 3 + const twinkleDelay = Math.random() * 2 + + if (glowRef.current) { + gsap.to(glowRef.current, { + opacity: [0.3, 0.8, 0.3], + duration: twinkleDuration, + repeat: -1, + delay: twinkleDelay, + ease: 'sine.inOut' + }) + } + }, [index]) + + return ( +
+ {/* 光晕 */} +
+ + {/* 核心亮点 */} +
+ + {/* 十字星芒 */} +
+
+
+
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 星座连线 +// ═══════════════════════════════════════════════════════════════════════════ +function ConstellationLines({ stars }) { + const linesRef = useRef(null) + const svgRef = useRef(null) + + // 生成的连线数据 + const lines = useMemo(() => { + const result = [] + // 只连接今天完成的恒星 + const today = new Date() + today.setHours(0, 0, 0, 0) + + const todayStars = stars.filter(s => { + const completedDate = new Date(s.completedAt) + completedDate.setHours(0, 0, 0, 0) + return completedDate.getTime() === today.getTime() + }) + + // 按位置排序,连接相邻的恒星 + for (let i = 0; i < todayStars.length - 1; i++) { + const from = todayStars[i] + const to = todayStars[i + 1] + + // 解析百分比位置 - 处理可能是字符串 "50%" 或数字 0.5 的情况 + const parsePercent = (val) => { + if (!val) return 0.5 + const str = String(val) + if (str.includes('%')) { + return parseFloat(str.replace('%', '')) / 100 + } + const num = parseFloat(val) + return isNaN(num) ? 0.5 : Math.max(0, Math.min(1, num)) + } + + const fromX = parsePercent(from.x) + const fromY = parsePercent(from.y) + const toX = parsePercent(to.x) + const toY = parsePercent(to.y) + + // 检查是否有效 + if ([fromX, fromY, toX, toY].some(v => isNaN(v))) continue + + result.push({ + x1: fromX, + y1: fromY, + x2: toX, + y2: toY + }) + } + + return result + }, [stars]) + + useEffect(() => { + if (!svgRef.current || lines.length === 0) return + + // 连线动画 + gsap.fromTo(svgRef.current, + { opacity: 0 }, + { opacity: 1, duration: 1, delay: 0.5, ease: 'power2.out' } + ) + }, [lines.length]) + + if (lines.length === 0) return null + + return ( + + + + + + + + + {lines.map((line, i) => ( + + ))} + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 - 完成任务的星座系统 +// ═══════════════════════════════════════════════════════════════════════════ +export function Constellation({ + stars = [], // { id, x, y, size, completedAt, title } + className +}) { + const containerRef = useRef(null) + const tooltipRef = useRef(null) + + if (stars.length === 0) return null + + // 今天完成的数量 + const todayCount = stars.filter(s => { + const today = new Date() + today.setHours(0, 0, 0, 0) + const completedDate = new Date(s.completedAt) + completedDate.setHours(0, 0, 0, 0) + return completedDate.getTime() === today.getTime() + }).length + + return ( +
+ {/* 连线 */} + + + {/* 恒星 */} + {stars.map((star, index) => ( + + ))} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Hook: 管理已完成任务的位置和状态 +// ═══════════════════════════════════════════════════════════════════════════ +export function useConstellation() { + const [stars, setStars] = useState([]) + + // 添加一颗新的恒星 + const addStar = useCallback((task, position, planetSize) => { + const newStar = { + id: task.id, + x: position.x, + y: position.y, + size: planetSize * 0.25, // 恒星大小是星球的 1/4 + completedAt: task.completedAt || new Date().toISOString(), + title: task.title + } + + setStars(prev => { + // 检查是否已存在 + const exists = prev.find(s => s.id === task.id) + if (exists) return prev + return [...prev, newStar] + }) + + // 持久化到 localStorage + try { + const key = 'gtd-constellation-stars' + const existing = JSON.parse(localStorage.getItem(key) || '[]') + const updated = existing.filter(s => s.id !== task.id) + updated.push(newStar) + localStorage.setItem(key, JSON.stringify(updated)) + } catch (e) { + console.error('Failed to save constellation:', e) + } + }, []) + + // 加载已保存的恒星 + useEffect(() => { + try { + const key = 'gtd-constellation-stars' + const saved = JSON.parse(localStorage.getItem(key) || '[]') + + // 只保留最近 7 天的恒星 + const weekAgo = new Date() + weekAgo.setDate(weekAgo.getDate() - 7) + + const filtered = saved.filter(s => { + const completedDate = new Date(s.completedAt) + return completedDate > weekAgo + }) + + setStars(filtered) + + // 清理过期数据 + if (filtered.length < saved.length) { + localStorage.setItem(key, JSON.stringify(filtered)) + } + } catch (e) { + console.error('Failed to load constellation:', e) + } + }, []) + + return { stars, addStar } +} diff --git a/src/components/gtd/DarkNebula.jsx b/src/components/gtd/DarkNebula.jsx new file mode 100644 index 0000000..2fa3a6a --- /dev/null +++ b/src/components/gtd/DarkNebula.jsx @@ -0,0 +1,83 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: DarkNebula 组件 + * [POS]: 星云层 - 细腻的云雾感,大量小光点叠加 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useMemo } from 'react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 星云粒子 - 小而多 +// ═══════════════════════════════════════════════════════════════════════════ + +function NebulaParticle({ particle }) { + return ( +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 生成大量细腻星云粒子 +// ═══════════════════════════════════════════════════════════════════════════ + +function generateNebulaParticles() { + const particles = [] + + // 20-30 个小光点,形成云雾感 + for (let i = 0; i < 8; i++) { + particles.push({ + x: 10 + Math.random() * 80, + y: 10 + Math.random() * 80, + size: 8 + Math.random() * 20, // 8-28% 小尺寸 + opacity: 0.15 + Math.random() * 0.25, // 0.15-0.4 + blur: 10 + Math.random() * 25, // 10-35px + color: `rgba(${150 + Math.random() * 40}, ${165 + Math.random() * 40}, ${180 + Math.random() * 35}, ${0.15 + Math.random() * 0.15})` + }) + } + + return particles +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 +// ═══════════════════════════════════════════════════════════════════════════ + +export function DarkNebula({ className }) { + const containerRef = useRef(null) + const particles = useMemo(() => generateNebulaParticles(), []) + + useEffect(() => { + if (!containerRef.current) return + gsap.fromTo(containerRef.current, + { opacity: 0 }, + { opacity: 1, duration: 4 } + ) + }, []) + + return ( +
+ {particles.map((p, i) => ( + + ))} +
+ ) +} diff --git a/src/components/gtd/DeepSpaceDust.jsx b/src/components/gtd/DeepSpaceDust.jsx new file mode 100644 index 0000000..4719149 --- /dev/null +++ b/src/components/gtd/DeepSpaceDust.jsx @@ -0,0 +1,136 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: DeepSpaceDust 组件 + * [POS]: 极微小星点层,创造尺度差,漂移 + 闪烁动画 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useMemo } from 'react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 极微小星点 - 0.3-1px,创造尺度差 +// ═══════════════════════════════════════════════════════════════════════════ + +const MICRO_DUST_COUNT = 25 // 从 80 减到 25,提升性能 + +// 冷色系星点颜色 - 80% 灰白,20% 冷蓝 +function pickDustColor() { + const r = Math.random() + if (r < 0.5) return '#ffffff' // 50% 白 + if (r < 0.8) return '#f0f0f0' // 30% 浅灰 + if (r < 0.95) return '#d0d5dc' // 15% 冷灰 + return '#b0c4de' // 5% 钢蓝 +} + +// 生成极微星点数据 +function generateMicroDust() { + return Array.from({ length: MICRO_DUST_COUNT }, () => ({ + id: Math.random().toString(36).substr(2, 9), + x: Math.random() * 100, + y: Math.random() * 100, + size: 0.3 + Math.random() * 0.7, // 0.3-1px + opacity: 0.05 + Math.random() * 0.1, // 5-15% 极低 + color: pickDustColor() + })) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 单个极微星点 +// ═══════════════════════════════════════════════════════════════════════════ +function MicroDustParticle({ dust, index }) { + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + + // 极慢漂移 - 5-10 分钟 + const driftDuration = 300 + Math.random() * 300 // 5-10 分钟 + const driftX = (Math.random() - 0.5) * 3 + const driftY = (Math.random() - 0.5) * 3 + + gsap.to(ref.current, { + x: driftX, + y: driftY, + duration: driftDuration, + delay: Math.random() * 120, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + + // 只有前15个星点闪烁,减少性能开销 + if (index < 15) { + const twinkleDuration = 2 + Math.random() * 4 + const targetOpacity = dust.opacity * (0.4 + Math.random() * 0.5) + + gsap.to(ref.current, { + opacity: targetOpacity, + duration: twinkleDuration, + delay: Math.random() * 3, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + } + }, [dust.opacity, index]) + + return ( +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 - 极微星点层 +// ═══════════════════════════════════════════════════════════════════════════ +export function DeepSpaceDust({ + count = MICRO_DUST_COUNT, + className +}) { + const containerRef = useRef(null) + + // 生成星点数据(只生成一次) + const dustParticles = useMemo(() => generateMicroDust(), []) + + useEffect(() => { + if (!containerRef.current) return + + // 整体入场动画 - 极慢浮现 + gsap.fromTo(containerRef.current, + { opacity: 0 }, + { + opacity: 1, + duration: 3, + ease: 'power1.out' + } + ) + }, []) + + return ( +
+ {dustParticles.slice(0, count).map((dust, index) => ( + + ))} +
+ ) +} diff --git a/src/components/gtd/Drawer.jsx b/src/components/gtd/Drawer.jsx index ef45aa3..4c5a90d 100644 --- a/src/components/gtd/Drawer.jsx +++ b/src/components/gtd/Drawer.jsx @@ -1,21 +1,89 @@ /** - * [INPUT]: 依赖 @/stores/gtd 的 GTD_LIST_META,依赖 lucide-react 图标,依赖 framer-motion,依赖 @/lib/haptics,依赖 react-i18next + * [INPUT]: 依赖 @/stores/gtd 的 GTD_LIST_META,依赖 lucide-react 图标,依赖 framer-motion,依赖 @/lib/haptics,依赖 react-i18next,依赖 @/components/gtd/SidebarGroup * [OUTPUT]: 导出 Drawer 组件 - * [POS]: 移动端左侧滑抽屉,显示 GTD 列表和设置入口,替代底部导航的列表切换功能 + * [POS]: 移动端左侧滑抽屉,分组式设计(GTD 列表 + 项目 + 日记),与桌面端 Sidebar 保持一致 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ +import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' -import { GTD_LIST_META, GTD_LISTS } from '@/stores/gtd' -import { Inbox, Sun, ArrowRight, Calendar, CheckCircle, Settings, X, PenLine, BookOpen } from 'lucide-react' +import { GTD_LIST_META } from '@/stores/gtd' +import { Inbox, Sun, ArrowRight, Calendar, CheckCircle, Settings, X, PenLine, BookOpen, Focus, CalendarDays, Plus, FolderKanban } from 'lucide-react' import { hapticsLight } from '@/lib/haptics' +import { SidebarGroup } from './SidebarGroup' const ICONS = { Inbox, Sun, ArrowRight, Calendar, CheckCircle } -export function Drawer({ open, onOpenChange, activeList, onSelect, counts, journalView, onJournalViewChange, onSettingsOpen }) { +// ───────────────────────────────────────────────────────────────────────────── +// localStorage 持久化分组折叠状态(与 Sidebar 共享) +// ───────────────────────────────────────────────────────────────────────────── +const COLLAPSED_GROUPS_KEY = 'sidebar-collapsed-groups' + +function loadCollapsedGroups() { + try { + const saved = localStorage.getItem(COLLAPSED_GROUPS_KEY) + return saved ? new Set(JSON.parse(saved)) : new Set() + } catch { + return new Set() + } +} + +function saveCollapsedGroups(groups) { + try { + localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify([...groups])) + } catch { + // ignore + } +} + +export function Drawer({ + open, + onOpenChange, + activeList, + onSelect, + counts, + viewMode, + onViewModeChange, + journalView, + onJournalViewChange, + onSettingsOpen, + // 项目相关 + projects = [], + tasks = [], + activeProjectId, + onSelectProject, + onCreateProject +}) { const { t } = useTranslation() + const [collapsedGroups, setCollapsedGroups] = useState(() => loadCollapsedGroups()) + + // 持久化分组折叠状态 + useEffect(() => { + saveCollapsedGroups(collapsedGroups) + }, [collapsedGroups]) + + // 切换分组展开/折叠 + const toggleGroup = (groupId) => { + setCollapsedGroups(prev => { + const next = new Set(prev) + if (next.has(groupId)) { + next.delete(groupId) + } else { + next.add(groupId) + } + return next + }) + } + + // 计算项目进度 + const getProjectProgress = (projectId) => { + const projectTasks = tasks.filter(t => t.projectId === projectId) + if (projectTasks.length === 0) return 0 + const completed = projectTasks.filter(t => t.list === 'done').length + return Math.round((completed / projectTasks.length) * 100) + } return ( @@ -57,12 +125,70 @@ export function Drawer({ open, onOpenChange, activeList, onSelect, counts, journ
- {/* GTD 列表 */} -
-
+ {/* 内容区 */} +
+ {/* ───────────────────────────────────────────────────────────── + * 固定导航区:专注、日程 + * ───────────────────────────────────────────────────────────── */} +
+ {/* 专注视图 */} + { + hapticsLight() + if (journalView) { + onJournalViewChange(null) + } + onViewModeChange('focus') + onOpenChange(false) + }} + className={cn( + "w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors", + "hover:bg-sidebar-accent", + viewMode === 'focus' && !journalView && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" + )} + > + + {t('focus.title')} + + + {/* 日程视图 */} + { + hapticsLight() + if (journalView) { + onJournalViewChange(null) + } + onViewModeChange('calendar') + onOpenChange(false) + }} + className={cn( + "w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors", + "hover:bg-sidebar-accent", + viewMode === 'calendar' && !journalView && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" + )} + > + + {t('views.calendar')} + +
+ + {/* 分隔线 */} +
+ + {/* ───────────────────────────────────────────────────────────── + * 可折叠分组区:GTD 列表 + * ───────────────────────────────────────────────────────────── */} + toggleGroup('gtd')} + > {Object.entries(GTD_LIST_META).map(([key, meta]) => { const Icon = ICONS[meta.icon] - const isActive = activeList === key + const isActive = viewMode === 'list' && activeList === key && !journalView const count = counts[key] || 0 return ( @@ -71,11 +197,15 @@ export function Drawer({ open, onOpenChange, activeList, onSelect, counts, journ whileTap={{ scale: 0.96 }} onClick={() => { hapticsLight() + if (journalView) { + onJournalViewChange(null) + } + onViewModeChange('list') onSelect(key) onOpenChange(false) }} className={cn( - "w-full flex items-center gap-3 px-4 py-4 rounded-lg text-sm transition-colors", + "w-full flex items-center gap-3 pl-6 pr-4 py-3 rounded-lg text-sm transition-colors", "hover:bg-sidebar-accent", isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" )} @@ -90,51 +220,115 @@ export function Drawer({ open, onOpenChange, activeList, onSelect, counts, journ ) })} -
+ - {/* 日记分组 */} -
-
- {t('journal.title')} -
-
- {/* 此刻 */} - { - hapticsLight() - onJournalViewChange('now') - onOpenChange(false) - }} - className={cn( - "w-full flex items-center gap-3 px-4 py-4 rounded-lg text-sm transition-colors", - "hover:bg-sidebar-accent", - journalView === 'now' && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" - )} - > - - {t('journal.now')} - - - {/* 过往 */} - { - hapticsLight() - onJournalViewChange('past') - onOpenChange(false) - }} - className={cn( - "w-full flex items-center gap-3 px-4 py-4 rounded-lg text-sm transition-colors", - "hover:bg-sidebar-accent", - journalView === 'past' && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" - )} - > - - {t('journal.past')} - -
-
+ {/* ───────────────────────────────────────────────────────────── + * 可折叠分组区:项目 + * ───────────────────────────────────────────────────────────── */} + toggleGroup('projects')} + > + {projects.map(project => { + const isActive = viewMode === 'board' && activeProjectId === project.id && !journalView + const progress = getProjectProgress(project.id) + + return ( + { + hapticsLight() + if (journalView) { + onJournalViewChange(null) + } + onViewModeChange('board') + onSelectProject(project.id) + onOpenChange(false) + }} + className={cn( + "w-full flex items-center gap-3 pl-6 pr-4 py-3 rounded-lg text-sm transition-colors", + "hover:bg-sidebar-accent", + isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" + )} + > + + {project.title} + {progress > 0 && ( + + {progress}% + + )} + + ) + })} + + {/* 新建项目 */} + { + hapticsLight() + if (journalView) { + onJournalViewChange(null) + } + onViewModeChange('board') + onCreateProject() + onOpenChange(false) + }} + className="w-full flex items-center gap-3 pl-6 pr-4 py-3 rounded-lg text-sm transition-colors hover:bg-sidebar-accent text-muted-foreground" + > + + {t('project.create')} + + + + {/* ───────────────────────────────────────────────────────────── + * 可折叠分组区:日记 + * ───────────────────────────────────────────────────────────── */} + toggleGroup('journal')} + > + {/* 此刻 */} + { + hapticsLight() + onJournalViewChange('now') + onOpenChange(false) + }} + className={cn( + "w-full flex items-center gap-3 pl-6 pr-4 py-3 rounded-lg text-sm transition-colors", + "hover:bg-sidebar-accent", + journalView === 'now' && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" + )} + > + + {t('journal.now')} + + + {/* 过往 */} + { + hapticsLight() + onJournalViewChange('past') + onOpenChange(false) + }} + className={cn( + "w-full flex items-center gap-3 pl-6 pr-4 py-3 rounded-lg text-sm transition-colors", + "hover:bg-sidebar-accent", + journalView === 'past' && "bg-sidebar-accent text-sidebar-accent-foreground font-medium" + )} + > + + {t('journal.past')} + +
{/* 底部设置 */} @@ -146,7 +340,7 @@ export function Drawer({ open, onOpenChange, activeList, onSelect, counts, journ onSettingsOpen() onOpenChange(false) }} - className="w-full flex items-center gap-3 px-4 py-4 rounded-lg text-sm transition-colors hover:bg-sidebar-accent text-muted-foreground" + className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors hover:bg-sidebar-accent text-muted-foreground" > {t('common.settings')} diff --git a/src/components/gtd/FloatingTaskBubble.jsx b/src/components/gtd/FloatingTaskBubble.jsx new file mode 100644 index 0000000..92e4c6e --- /dev/null +++ b/src/components/gtd/FloatingTaskBubble.jsx @@ -0,0 +1,95 @@ +/** + * [INPUT]: react, framer-motion, lucide-react, @/lib/utils, @/lib/planet + * [OUTPUT]: FloatingTaskBubble 组件 + * [POS]: 漂浮气泡任务卡片,圆角胶囊,与行星系统融为一体 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useMemo } from 'react' +import { motion } from 'framer-motion' +import { Zap } from 'lucide-react' +import { cn } from '@/lib/utils' +import { PLANET_COLORS } from '@/lib/planet' + +// ═══════════════════════════════════════════════════════════════════════════ +// 漂浮气泡任务卡片 +// ═══════════════════════════════════════════════════════════════════════════ + +const COLOR_KEYS = ['green', 'blue', 'purple', 'orange', 'pink', 'cream'] + +export function FloatingTaskBubble({ + task, + index = 0, + isAIRecommended = false, + isSelected = false, + onSelect, + onComplete, + className +}) { + if (!task) return null + + // 获取对应的行星颜色 + const colorKey = COLOR_KEYS[index % COLOR_KEYS.length] + const color = PLANET_COLORS[colorKey] || PLANET_COLORS.cream || { filter: 'none' } + + // 随机动画参数 + const animDuration = useMemo(() => 4 + Math.random() * 2, []) + const animDelay = useMemo(() => Math.random() * 2, []) + + return ( + { + if (isSelected) { + onComplete?.(task.id) + } else { + onSelect?.(task.id) + } + }} + > + {/* 小圆点 - 与行星呼应,用相同 filter */} +
+ + {/* 任务标题 */} + + {task.title} + + + {/* AI 推荐标记 */} + {isAIRecommended && ( + + )} + + ) +} diff --git a/src/components/gtd/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx new file mode 100644 index 0000000..61f0a04 --- /dev/null +++ b/src/components/gtd/FocusCircle.jsx @@ -0,0 +1,261 @@ +/** + * [INPUT]: react, @/lib/utils, ./StarDust, ./OrbitPaths, ./Planet, ./BlueDust, ./MiniInfo, ./NoiseOverlay, ./Constellation, ./ZDepthLayer, ./DeepSpaceDust, ./DarkNebula, ./SpaceGlow + * [OUTPUT]: FocusCircle 组件 + * [POS]: 专注视图核心 - 柔性宇宙插画,时间感知背景,深度分层 + 视差,已完成任务星座 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useMemo, useState, useCallback, useEffect } from 'react' +import { cn } from '@/lib/utils' +import { isPast } from '@/stores/gtd' +import { StarDust } from './StarDust' +import { OrbitPaths } from './OrbitPaths' +import { Planet } from './Planet' +import { BlueDust } from './BlueDust' +import { NoiseOverlay } from './NoiseOverlay' +import { Constellation } from './Constellation' +import { ParallaxProvider, FarLayer, MidLayer, NearLayer } from './ZDepthLayer' +import { DeepSpaceDust } from './DeepSpaceDust' +import { DarkNebula } from './DarkNebula' +import { SpaceGlow } from './SpaceGlow' + +// ═══════════════════════════════════════════════════════════════════════════ +// 时间感知背景色配置 - 使用 CSS 变量 +// ═══════════════════════════════════════════════════════════════════════════ +const TIME_BASED_BACKGROUNDS = { + // 早 5-8点:晨曦 + dawn: { + hours: [5, 8], + cssVar: '--focus-bg-dawn', + textClass: 'text-[var(--focus-text-primary)]' + }, + // 上午 8-12:清醒 + morning: { + hours: [8, 12], + cssVar: '--focus-bg-morning', + textClass: 'text-[var(--focus-text-primary)]' + }, + // 下午 12-18:午后 + afternoon: { + hours: [12, 18], + cssVar: '--focus-bg-afternoon', + textClass: 'text-[var(--focus-text-primary)]' + }, + // 晚上 18-22:暮色 + evening: { + hours: [18, 22], + cssVar: '--focus-bg-evening', + textClass: 'text-[var(--focus-text-bright)]' + }, + // 深夜 22-5:深空 + night: { + hours: [22, 5], + cssVar: '--focus-bg-night', + textClass: 'text-[var(--focus-text-bright)]' + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 获取当前时间段配置 +// ═══════════════════════════════════════════════════════════════════════════ +function getTimeBasedConfig() { + const hour = new Date().getHours() + + for (const [key, config] of Object.entries(TIME_BASED_BACKGROUNDS)) { + const [start, end] = config.hours + if (start < end) { + if (hour >= start && hour < end) return { key, ...config } + } else { + // 跨午夜的情况(如 22-5) + if (hour >= start || hour < end) return { key, ...config } + } + } + + return TIME_BASED_BACKGROUNDS.morning +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 行星配置 - 沿椭圆轨道分布,位置 0 是主角(最重要的任务) +// ═══════════════════════════════════════════════════════════════════════════ +const PLANET_CONFIG = [ + // 位置 0:主角(最重要的任务)- 中间大行星 + { x: '50%', y: '40%', size: 150, colorKey: 'coral', layer: 'front' }, + + // 位置 1-5:配角 + { x: '12%', y: '35%', size: 75, colorKey: 'purple', layer: 'back' }, + { x: '72%', y: '20%', size: 82, colorKey: 'cyan', layer: 'mid' }, + { x: '28%', y: '50%', size: 68, colorKey: 'purple', layer: 'back' }, + { x: '85%', y: '42%', size: 98, colorKey: 'cream', hasRing: true, layer: 'front' }, + { x: '62%', y: '48%', size: 52, colorKey: 'cyan', layer: 'mid' }, +] + +// ═══════════════════════════════════════════════════════════════════════════ +// 从 localStorage 加载保存的行星位置 +// ═══════════════════════════════════════════════════════════════════════════ +function loadSavedPositions() { + try { + const saved = localStorage.getItem('gtd-planet-positions') + return saved ? JSON.parse(saved) : {} + } catch (e) { + console.error('Failed to load planet positions:', e) + return {} + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 保存行星位置到 localStorage +// ═══════════════════════════════════════════════════════════════════════════ +function savePlanetPosition(taskId, position) { + try { + const saved = loadSavedPositions() + saved[taskId] = position + localStorage.setItem('gtd-planet-positions', JSON.stringify(saved)) + } catch (e) { + console.error('Failed to save planet position:', e) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 - 柔性宇宙插画 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusCircle({ + totalCount = 0, + completedCount = 0, + planetTasks = [], + allTasks = [], + onParticleClick, + onLongPress, + onPlanetCollapsed, + onPositionChange, + className +}) { + // 时间感知背景 + const [timeConfig, setTimeConfig] = useState(getTimeBasedConfig()) + + // 更新时间配置(每分钟检查一次) + useEffect(() => { + const interval = setInterval(() => { + setTimeConfig(getTimeBasedConfig()) + }, 60000) + + return () => clearInterval(interval) + }, []) + + // 加载保存的位置 + const savedPositions = loadSavedPositions() + + // 处理位置变化 + const handlePositionChange = useCallback((taskId, position) => { + savePlanetPosition(taskId, position) + onPositionChange?.(taskId, position) + }, [onPositionChange]) + + // 处理行星坍缩完成 + const handleCollapsed = useCallback((task, position, size) => { + onPlanetCollapsed?.(task, position, size) + }, [onPlanetCollapsed]) + + // 判断任务是否过期 + const isTaskOverdue = useCallback((task) => { + return task.dueDate && isPast(task.dueDate) + }, []) + + // 获取番茄钟数量(从任务数据) + const getTaskPomodoros = useCallback((task) => { + return task.pomodoros || 0 + }, []) + + const pendingCount = totalCount - completedCount + + // 获取已保存的位置或使用默认位置 + const getPlanetPosition = useCallback((task, defaultPos) => { + const saved = savedPositions[task.id] + return saved ? { x: saved.x, y: saved.y } : { x: defaultPos.x, y: defaultPos.y } + }, [savedPositions]) + + return ( + + {/* 时间感知背景底色 */} +
+ + {/* ═════════════════════════════════════════════════════════════════════════ + Far Layer - 最远景 + zIndex: 3-5, blur: 1px, 视差速度: 0.08 + ═════════════════════════════════════════════════════════════════════════ */} + + {/* 背景星点 - 1-3px */} + + + + {/* 星云 - 移出层测试 */} + + + {/* 极微星点 - 移出层测试 */} + + + {/* ═════════════════════════════════════════════════════════════════════════ + Mid Layer - 中景 + zIndex: 10, blur: 0.3px, 视差速度: 0.2 + ═════════════════════════════════════════════════════════════════════════ */} + + {/* 空间辉光 */} + + + {/* 蓝色粒子 */} + + + {/* 已完成任务星座 */} + t.completed)} /> + + + {/* 轨道 - 移出层测试 */} + + + {/* ═════════════════════════════════════════════════════════════════════════ + Near Layer - 近景 + zIndex: 20, blur: 0, 视差速度: 0.4 + ═════════════════════════════════════════════════════════════════════════ */} + + {/* 行星 */} + {PLANET_CONFIG.map((config, i) => { + const task = planetTasks[i] + if (!task) return null + + const position = getPlanetPosition(task, config) + + return ( + + ) + })} + + + {/* ═════════════════════════════════════════════════════════════════════════ + UI Layer - 不参与视差 + ═════════════════════════════════════════════════════════════════════════ */} +
+ {/* 噪点纹理 */} + +
+ + ) +} diff --git a/src/components/gtd/FocusMode.jsx b/src/components/gtd/FocusMode.jsx new file mode 100644 index 0000000..cd8ae94 --- /dev/null +++ b/src/components/gtd/FocusMode.jsx @@ -0,0 +1,607 @@ +/** + * [INPUT]: react, gsap, react-i18next, framer-motion, @/lib/utils, @/lib/planet + * [OUTPUT]: FocusMode 组件, FocusModeBackdrop 组件 + * [POS]: 全屏专注模式 - 极简设计,SVG 手绘星球 + 有机运动 + 尺寸进度 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Play, Pause, Check } from 'lucide-react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' +import { selectSVG, selectColor } from '@/lib/planet' + +// ═══════════════════════════════════════════════════════════════════════════ +// 番茄钟时长选项(分钟) +// ═══════════════════════════════════════════════════════════════════════════ +const POMODORO_DURATIONS = [15, 25, 45] + +// ═══════════════════════════════════════════════════════════════════════════ +// 格式化时间显示 +// ═══════════════════════════════════════════════════════════════════════════ +function formatTime(seconds) { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 极简星点背景 - 20 个微弱星点 +// ═══════════════════════════════════════════════════════════════════════════ +function MinimalStars() { + const stars = useMemo(() => { + return Array.from({ length: 20 }, (_, i) => ({ + id: i, + x: Math.random() * 100, + y: Math.random() * 100, + size: 0.5 + Math.random() * 0.8, + opacity: 0.08 + Math.random() * 0.12 + })) + }, []) + + return ( +
+ {stars.map(star => ( +
+ ))} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 粒子爆发效果 - 完成时触发 +// ═══════════════════════════════════════════════════════════════════════════ +function ParticleBurst({ active }) { + const particles = useMemo(() => { + return Array.from({ length: 16 }, (_, i) => ({ + id: i, + angle: (i / 16) * Math.PI * 2, + distance: 150 + Math.random() * 100, + size: 2 + Math.random() * 3, + delay: Math.random() * 0.3 + })) + }, []) + + if (!active) return null + + return ( +
+ {particles.map(p => ( + + ))} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 航程选择按钮 - 小星点设计 +// ═══════════════════════════════════════════════════════════════════════════ +function VoyageButton({ minutes, selected, onSelect, label }) { + return ( + onSelect(minutes)} + className="flex flex-col items-center gap-2 px-4 py-3" + > + {/* 星点 */} + +
+ + {/* 时长文字 */} + + {minutes} + + {/* 标签 */} + + {label} + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 专注模式主组件 - 极简设计 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusMode({ + task = null, + initialPomodoros = 0, + onPomodoroComplete, + onTaskComplete, + onAbandon +}) { + const { t } = useTranslation() + const containerRef = useRef(null) + const planetRef = useRef(null) + const breatheTweenRef = useRef(null) + const driftTweenRef = useRef(null) + + // 根据任务 ID 选择 SVG 和颜色 + const svgContent = useMemo(() => task ? selectSVG(task.id) : '', [task?.id]) + const colorConfig = useMemo(() => task ? selectColor(task.id).config : { filter: '' }, [task?.id]) + + // 状态 + const [step, setStep] = useState('select') + const [selectedDuration, setSelectedDuration] = useState(25) + const [remainingSeconds, setRemainingSeconds] = useState(25 * 60) + const [totalDuration, setTotalDuration] = useState(25 * 60) + const [completedPomodoros, setCompletedPomodoros] = useState(initialPomodoros) + const [showBurst, setShowBurst] = useState(false) + + // 计时器引用 + const timerRef = useRef(null) + const startTimeRef = useRef(null) + const totalDurationRef = useRef(25 * 60) + + // 计算进度 + const progress = (totalDuration - remainingSeconds) / totalDuration + // 星球尺寸:140px → 280px(随进度增大) + const planetSize = step === 'select' ? 160 : 140 + progress * 140 + const isPaused = step === 'paused' + + // 入场动画 + useEffect(() => { + if (!containerRef.current) return + gsap.fromTo(containerRef.current, + { opacity: 0 }, + { opacity: 1, duration: 0.8, ease: 'power2.out' } + ) + }, []) + + // GSAP 有机运动 - 呼吸 + 漂移 + useEffect(() => { + if (!planetRef.current || step === 'select') return + + // 呼吸动画:暂停时幅度加大 + breatheTweenRef.current = gsap.to(planetRef.current, { + scale: isPaused ? 1.04 : 1.02, + duration: isPaused ? 2 : 5, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + + // 漂移动画 + driftTweenRef.current = gsap.to(planetRef.current, { + x: 8, + y: 6, + duration: 12, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + + return () => { + breatheTweenRef.current?.kill() + driftTweenRef.current?.kill() + } + }, [step, isPaused]) + + // 启动计时器 + const startTimer = useCallback(() => { + startTimeRef.current = Date.now() + timerRef.current = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000) + const remaining = totalDurationRef.current - elapsed + if (remaining <= 0) { + clearInterval(timerRef.current) + setRemainingSeconds(0) + setShowBurst(true) + setTimeout(() => setStep('complete'), 500) + } else { + setRemainingSeconds(remaining) + } + }, 100) + }, []) + + // 开始专注 + const handleStart = useCallback(() => { + const duration = selectedDuration * 60 + totalDurationRef.current = duration + setTotalDuration(duration) + setRemainingSeconds(duration) + setStep('running') + startTimer() + }, [selectedDuration, startTimer]) + + // 暂停/继续 + const handlePauseToggle = useCallback(() => { + if (step === 'running') { + clearInterval(timerRef.current) + setStep('paused') + } else if (step === 'paused') { + const elapsedBeforePause = totalDurationRef.current - remainingSeconds + startTimeRef.current = Date.now() - elapsedBeforePause * 1000 + setStep('running') + startTimer() + } + }, [step, remainingSeconds, startTimer]) + + // 完成番茄钟 + const handlePomodoroComplete = useCallback(() => { + const newCount = completedPomodoros + 1 + setCompletedPomodoros(newCount) + onPomodoroComplete?.(task?.id, newCount) + setStep('select') + setRemainingSeconds(selectedDuration * 60) + setShowBurst(false) + }, [completedPomodoros, onPomodoroComplete, selectedDuration, task]) + + // 直接完成任务 + const handleTaskComplete = useCallback(() => { + clearInterval(timerRef.current) + onTaskComplete?.(task?.id) + }, [onTaskComplete, task]) + + // 放弃 + const handleAbandon = useCallback(() => { + clearInterval(timerRef.current) + onAbandon?.() + }, [onAbandon]) + + // 清理计时器 + useEffect(() => { + return () => { clearInterval(timerRef.current) } + }, []) + + if (!task) return null + + return ( + + {/* 极简星点背景 */} + + + {/* 粒子爆发 */} + + + {/* 返回按钮 */} + + + + + {/* 已完成番茄钟数量 */} + {completedPomodoros > 0 && ( + + {Array.from({ length: completedPomodoros }).map((_, i) => ( +
+ ))} + + )} + + {/* 主内容区 */} +
+ + {/* ═══════════════════════════════════════════════════════════ + 选择航程 + ═══════════════════════════════════════════════════════════ */} + {step === 'select' && ( + + {/* 星球预览 */} +
+ + {/* 任务标题 */} +

+ {task.title} +

+ + {/* 提示文字 */} +

+ {t('focus.pomodoro.selectVoyage', '选择航程')} +

+ + {/* 航程选择 */} +
+ + + +
+ + {/* 启航按钮 */} + + {t('focus.pomodoro.launch', '启航')} + + + )} + + {/* ═══════════════════════════════════════════════════════════ + 航行中 / 暂停 - 极简布局 + ═══════════════════════════════════════════════════════════ */} + {(step === 'running' || step === 'paused') && ( + + {/* SVG 手绘星球 - 唯一视觉焦点 */} +
+ + {/* 时间显示 */} +

+ {formatTime(remainingSeconds)} +

+ + {/* 任务标题 */} +

+ {task.title} +

+ + {/* 最小化控制 */} +
+ + {step === 'running' ? ( + + ) : ( + + )} + + + + + +
+ + )} + + {/* ═══════════════════════════════════════════════════════════ + 完成 - 征服仪式 + ═══════════════════════════════════════════════════════════ */} + {step === 'complete' && ( + + {/* 最大尺寸星球 */} + +
+ + + {/* 征服文字 */} + + {t('focus.pomodoro.conquered', '星球已征服')} + + + + {task.title} + + + {/* 操作按钮 */} +
+ + {t('focus.pomodoro.continueVoyage', '继续航行')} + + + +
+ + )} + +
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 用于显示打开时的过渡动画遮罩 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusModeBackdrop({ isOpening }) { + return ( + + ) +} + diff --git a/src/components/gtd/FocusView.jsx b/src/components/gtd/FocusView.jsx new file mode 100644 index 0000000..3b5a932 --- /dev/null +++ b/src/components/gtd/FocusView.jsx @@ -0,0 +1,548 @@ +/** + * [INPUT]: react, react-i18next, framer-motion, @/stores/gtd, @/lib/utils, @/components/gtd/Focus*, @/components/gtd/FocusMode + * [OUTPUT]: FocusView 组件 + * [POS]: 专注视图主组件,柔性宇宙插画风格,整合专注模式、坍缩动画、星座系统、溢出任务折叠卡片 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'framer-motion' +import { cn } from '@/lib/utils' +import { ChevronRight, ChevronDown, Plus, Calendar, BookOpen, Star } from 'lucide-react' +import { FocusCircle } from './FocusCircle' +import { FocusMode } from './FocusMode' +import { useConstellation } from './Constellation' + +// ═══════════════════════════════════════════════════════════════════════════ +// 空状态 - 宇宙创世风格 +// 设计理念:空状态是"宇宙诞生前的虚空"——宁静、神秘、充满可能性 +// ═══════════════════════════════════════════════════════════════════════════ +function EmptyState({ onGoToInbox, level = 'empty' }) { + const { t } = useTranslation() + + // 层级1:完全空白(无任务) + if (level === 'empty') { + return ( + + {/* 星点装饰 - 带光晕 */} +
+ {[...Array(5)].map((_, i) => ( + + + + ))} +
+ + + 宇宙诞生于你的第一个念头 + + +
+ + + 从收集箱选择 + +
+
+ ) + } + + // 层级2:今日任务全部完成 + if (level === 'complete') { + return ( + + {/* 恒星装饰 - 带光晕 */} +
+ {[...Array(6)].map((_, i) => ( + + + + ))} +
+ + + 今天的宇宙很完整 + + +
+ + + 写篇日记 + + + + + 查看明日计划 + +
+
+ ) + } + + return null +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 过期任务折叠卡片 - 红巨星星云风格 +// 设计理念:过期任务是"即将消亡的红巨星"——温暖、柔和、带有紧迫感但不刺眼 +// ═══════════════════════════════════════════════════════════════════════════ +function OverdueCard({ tasks, onMoveToToday, onMoveToTomorrow, onDelete }) { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + if (tasks.length === 0) return null + + return ( + +
+ {/* 头部 */} + + + {/* 展开 */} + + {isExpanded && ( + +
+ {tasks.map(task => ( +
+ + {task.title} + +
+ + + +
+
+ ))} +
+ + {/* 批量操作 */} +
+ + +
+
+ )} +
+
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 溢出任务折叠卡片 - 深空门户风格 +// 设计理念:溢出任务是"更深处的宇宙"——点击展开就像"放大望远镜" +// ═══════════════════════════════════════════════════════════════════════════ +function OverflowCard({ tasks, onTaskClick, onToggleStar }) { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + if (tasks.length === 0) return null + + return ( + +
+ {/* 头部 */} + + + {/* 展开 */} + + {isExpanded && ( + +
+ {tasks.map(task => ( +
onTaskClick?.(task)} + > +
+ + + {task.title} + +
+
+ ))} +
+ + {/* 收起按钮 */} +
+ +
+
+ )} +
+
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusView({ + todayTasks = [], + completedCount = 0, + overdueTasks = [], + planetTasks = [], + overflowTasks = [], + allTasks = [], + onComplete, + onMoveToToday, + onMoveToTomorrow, + onDelete, + onGoToInbox, + onGoToToday, + onEditTask, + onUpdatePomodoro, + onToggleStar, + className +}) { + const { t } = useTranslation() + + // 专注模式状态 + const [focusModeTask, setFocusModeTask] = useState(null) + + // 星座系统 + const { stars, addStar } = useConstellation() + + // 处理任务完成 + const handleComplete = useCallback((taskId) => { + onComplete?.(taskId) + }, [onComplete]) + + // 处理长按进入专注模式 + const handleLongPress = useCallback((task) => { + setFocusModeTask(task) + }, []) + + // 处理行星坍缩完成 + const handlePlanetCollapsed = useCallback((task, position, size) => { + // 添加到星座系统 + addStar(task, position, size) + }, [addStar]) + + // 处理番茄钟完成 + const handlePomodoroComplete = useCallback((taskId, count) => { + onUpdatePomodoro?.(taskId, count) + }, [onUpdatePomodoro]) + + // 处理专注模式放弃 + const handleFocusModeAbandon = useCallback(() => { + setFocusModeTask(null) + }, []) + + // 判断状态 + const isEmpty = todayTasks.length === 0 && completedCount === 0 + const isAllDone = todayTasks.length === 0 && completedCount > 0 + + return ( +
+ {/* 柔性宇宙插画 */} + + + {/* 过期任务卡片 */} + + + {/* 溢出任务卡片 */} + + + {/* 空状态 */} + {isEmpty && } + + {/* 完成状态 */} + {isAllDone && } + + {/* 专注模式 */} + + {focusModeTask && ( + + )} + +
+ ) +} diff --git a/src/components/gtd/MiniInfo.jsx b/src/components/gtd/MiniInfo.jsx new file mode 100644 index 0000000..090b303 --- /dev/null +++ b/src/components/gtd/MiniInfo.jsx @@ -0,0 +1,56 @@ +/** + * [INPUT]: react, gsap, react-i18next, @/lib/utils + * [OUTPUT]: MiniInfo 组件 + * [POS]: 右上角极简信息标签,问候语 + 数字,GSAP 入场动画 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useMemo, useEffect, useRef } from 'react' +import gsap from 'gsap' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 右上角极简信息 +// ═══════════════════════════════════════════════════════════════════════════ +export function MiniInfo({ count = 0, className }) { + const { t } = useTranslation() + const ref = useRef(null) + + // 根据时间获取问候语 + const greeting = useMemo(() => { + const hour = new Date().getHours() + if (hour >= 5 && hour < 12) return t('focus.circle.morning') + if (hour >= 12 && hour < 18) return t('focus.circle.afternoon') + if (hour >= 18 && hour < 22) return t('focus.circle.evening') + return t('focus.circle.night') + }, [t]) + + // GSAP 入场动画 + useEffect(() => { + if (!ref.current) return + + gsap.fromTo(ref.current, + { opacity: 0, y: -20 }, + { opacity: 1, y: 0, duration: 0.6, delay: 0.5, ease: 'power2.out' } + ) + }, []) + + return ( +
+ {greeting} + {count} +
+ ) +} diff --git a/src/components/gtd/NoiseOverlay.jsx b/src/components/gtd/NoiseOverlay.jsx new file mode 100644 index 0000000..704f465 --- /dev/null +++ b/src/components/gtd/NoiseOverlay.jsx @@ -0,0 +1,28 @@ +/** + * [INPUT]: react + * [OUTPUT]: NoiseOverlay 组件 + * [POS]: 全局噪点纹理层,SVG feTurbulence 实现颗粒感 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// 噪点纹理层 - SVG Filter 实现颗粒感 +// ═══════════════════════════════════════════════════════════════════════════ +export function NoiseOverlay() { + return ( + + + + + + + + + + ) +} diff --git a/src/components/gtd/OrbitPaths.jsx b/src/components/gtd/OrbitPaths.jsx new file mode 100644 index 0000000..371a5ed --- /dev/null +++ b/src/components/gtd/OrbitPaths.jsx @@ -0,0 +1,156 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: OrbitPaths 组件 + * [POS]: 轨道带 - 独立短弧线片段,随机分布,自然感 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import React, { useEffect, useRef, useMemo } from 'react' +import gsap from 'gsap' + +// ═══════════════════════════════════════════════════════════════════════════ +// 椭圆上的点计算 +// ═══════════════════════════════════════════════════════════════════════════ + +function getEllipsePoint(cx, cy, rx, ry, angle, rotation) { + const rad = angle * (Math.PI / 180) + const cos = Math.cos(rad) + const sin = Math.sin(rad) + + // 旋转 + const rotRad = rotation * (Math.PI / 180) + const rotCos = Math.cos(rotRad) + const rotSin = Math.sin(rotRad) + + const x = cx + rx * cos + const y = cy + ry * sin + + return { + x: cx + (x - cx) * rotCos - (y - cy) * rotSin, + y: cy + (x - cx) * rotSin + (y - cy) * rotCos + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 单个弧线片段 +// ═══════════════════════════════════════════════════════════════════════════ + +const OrbitSegment = React.memo(function OrbitSegment({ segment, index }) { + // 计算弧线路径 + const start = getEllipsePoint(segment.cx, segment.cy, segment.rx, segment.ry, segment.startAngle, segment.rotation) + const end = getEllipsePoint(segment.cx, segment.cy, segment.rx, segment.ry, segment.endAngle, segment.rotation) + + const largeArcFlag = segment.endAngle - segment.startAngle <= 180 ? '0' : '1' + + const pathData = `M ${start.x} ${start.y} A ${segment.rx} ${segment.ry} ${segment.rotation} ${largeArcFlag} 1 ${end.x} ${end.y}` + + // 根据弧线在椭圆上的位置决定亮度 - 透视效果 + const avgY = (start.y + end.y) / 2 + const positionFactor = (avgY - 150) / 200 + 0.5 + const clampedFactor = Math.max(0.4, Math.min(1.6, positionFactor)) + + const gradientId = `orbit-seg-${index}` + + return ( + <> + + + + + + + + + + ) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 生成轨道片段 - 随机分布的短弧线 +// ═══════════════════════════════════════════════════════════════════════════ + +function generateOrbitSegments() { + const segments = [] + const rotation = -15 // 统一倾斜 + + // 每条轨道分成 8-12 个随机片段 + const orbits = [ + { rx: 350, ry: 100, count: 12, width: 1.5, blur: 0.3, baseOpacity: 0.85 }, + { rx: 400, ry: 130, count: 10, width: 1.2, blur: 0.5, baseOpacity: 0.75 }, + { rx: 450, ry: 160, count: 8, width: 1, blur: 0.8, baseOpacity: 0.65 }, + { rx: 500, ry: 190, count: 7, width: 0.8, blur: 1, baseOpacity: 0.55 }, + { rx: 550, ry: 220, count: 5, width: 0.5, blur: 1.2, baseOpacity: 0.45 }, + ] + + orbits.forEach((orbit) => { + const totalAngle = 340 // 总共 340 度,留 20 度缺口 + const usedAngles = [] + + for (let i = 0; i < orbit.count; i++) { + // 随机位置和长度 + let startAngle, length + let attempts = 0 + + do { + startAngle = Math.random() * totalAngle + length = 15 + Math.random() * 35 // 15-50 度的弧长 + attempts++ + } while (attempts < 50 && usedAngles.some(a => Math.abs(a - startAngle) < 20)) + + if (attempts >= 50) continue + + usedAngles.push(startAngle) + + segments.push({ + cx: 400, cy: 300, + rx: orbit.rx, + ry: orbit.ry, + rotation, + startAngle, + endAngle: startAngle + length, + width: orbit.width, + blur: orbit.blur, + opacity: orbit.baseOpacity + }) + } + }) + + return segments +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 +// ═══════════════════════════════════════════════════════════════════════════ + +export function OrbitPaths() { + const containerRef = useRef(null) + const segments = useMemo(() => generateOrbitSegments(), []) + + useEffect(() => { + if (!containerRef.current) return + gsap.fromTo(containerRef.current, + { opacity: 0 }, + { opacity: 1, duration: 3 } + ) + }, []) + + return ( + + {segments.map((seg, index) => ( + + ))} + + ) +} diff --git a/src/components/gtd/Planet.jsx b/src/components/gtd/Planet.jsx new file mode 100644 index 0000000..12c7d12 --- /dev/null +++ b/src/components/gtd/Planet.jsx @@ -0,0 +1,666 @@ +/** + * [INPUT]: react, gsap, framer-motion, @/lib/utils, @/lib/planet + * [OUTPUT]: Planet 组件 + * [POS]: 手绘风格行星,随机素材渲染,支持坍缩动画、红巨星状态、番茄环渲染、长按专注、右键菜单、星标功能 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useState, useMemo, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' +import gsap from 'gsap' +import { cn } from '@/lib/utils' +import { selectSVG, selectColor, PLANET_COLORS } from '@/lib/planet' + +// ═══════════════════════════════════════════════════════════════════════════ +// 粒子效果组件 +// ═══════════════════════════════════════════════════════════════════════════ +function Particles({ origin, count = 12, onComplete }) { + const particlesRef = useRef(null) + + useEffect(() => { + if (!particlesRef.current) return + + const particles = particlesRef.current.children + const tl = gsap.timeline({ + onComplete + }) + + // 每个粒子向不同方向飞散 + Array.from(particles).forEach((particle, i) => { + const angle = (i / count) * Math.PI * 2 + const distance = 80 + Math.random() * 60 + const x = Math.cos(angle) * distance + const y = Math.sin(angle) * distance + + tl.to(particle, { + x, + y, + opacity: 0, + scale: 0, + duration: 0.8 + Math.random() * 0.4, + ease: 'power2.out' + }, 0) + }) + + return () => tl.kill() + }, [count, onComplete, origin]) + + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 番茄环组件 - 显示已完成的番茄钟数量 +// ═══════════════════════════════════════════════════════════════════════════ +function PomodoroRings({ count, size }) { + const ringsRef = useRef(null) + + useEffect(() => { + if (!ringsRef.current || count === 0) return + + // 入场动画 + gsap.fromTo(ringsRef.current.children, + { scale: 0, opacity: 0 }, + { + scale: 1, + opacity: 1, + duration: 0.4, + stagger: 0.1, + ease: 'back.out(1.7)' + } + ) + }, [count]) + + if (count === 0) return null + + // 显示最多 2 个环,更多显示数字 + const ringCount = Math.min(count, 2) + const ringSize = size * 0.7 + const ringGap = 8 + + return ( +
+ {Array.from({ length: ringCount }).map((_, i) => ( +
+ ))} + {count >= 3 && ( +
+ {count} +
+ )} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 右键菜单组件 - 星云风格,使用 Portal 渲染到 body 避免 transform 影响 fixed 定位 +// ═══════════════════════════════════════════════════════════════════════════ +function ContextMenu({ position, onClose, onComplete, onFocus }) { + if (!position) return null + + // 边界检测 - 菜单尺寸约 110x80,确保不超出视口 + const menuWidth = 110 + const menuHeight = 80 + const pad = 12 + const x = Math.min(position.x + 8, window.innerWidth - menuWidth - pad) + const y = Math.min(position.y + 8, window.innerHeight - menuHeight - pad) + + return createPortal( + <> + {/* 透明遮罩层 */} +
+ {/* 菜单本体 - 星云风格 */} + e.stopPropagation()} + > + {/* 进入专注 */} + {onFocus && ( + + 进入专注 + + )} + + 完成任务 + + + , + document.body + ) +} + +function ContextMenuButton({ children, onClick, danger = false }) { + const [isHovered, setIsHovered] = useState(false) + + return ( + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 坍缩动画完成后的恒星残留 +// ═══════════════════════════════════════════════════════════════════════════ +function CollapsedStar({ size, onComplete }) { + const starRef = useRef(null) + + useEffect(() => { + if (!starRef.current) return + + // 恒星浮现动画 + gsap.fromTo(starRef.current, + { scale: 0, opacity: 0 }, + { + scale: 1, + opacity: 1, + duration: 0.5, + delay: 0.3, + ease: 'back.out(2)', + onComplete + } + ) + }, [onComplete]) + + return ( +
+ {/* 闪烁的光晕 */} +
+ {/* 核心亮点 */} +
+ {/* 星芒 */} +
+
+
+
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 - 手绘风格行星 +// ═══════════════════════════════════════════════════════════════════════════ +export function Planet({ + task, + size = 60, + position = { x: '50%', y: '50%' }, + colorKey = 'coral', + hasRing = false, + layer = 'mid', + isOverdue = false, + pomodoroCount = 0, + onClick, + onLongPress, + onPositionChange, + onCollapsed, + className +}) { + const ref = useRef(null) + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [showContextMenu, setShowContextMenu] = useState(null) + const [contextMenuPos, setContextMenuPos] = useState(null) + const [isCollapsing, setIsCollapsing] = useState(false) + const [collapsed, setCollapsed] = useState(false) + + // 长按相关 + const longPressTimerRef = useRef(null) + const [isLongPressing, setIsLongPressing] = useState(false) + + // 本地位置状态(用于拖拽) + const [localPos, setLocalPos] = useState({ x: position.x, y: position.y }) + + // 根据任务 ID 选择随机颜色(过期任务除外) + const { key: randomColorKey, config: randomColorConfig } = useMemo(() => selectColor(task.id), [task.id]) + const effectiveColorKey = isOverdue ? 'urgent' : randomColorKey + const colorConfig = isOverdue ? PLANET_COLORS.urgent : randomColorConfig + + // 层级配置 + const layerConfig = useMemo(() => { + switch (layer) { + case 'front': return { zIndex: 30, speed: 1 } + case 'back': return { zIndex: 10, speed: 0.5 } + default: return { zIndex: 20, speed: 0.75 } + } + }, [layer]) + + // 动画引用 + const breatheTweenRef = useRef(null) + const driftTweenRef = useRef(null) + + // 坍缩动画 + const triggerCollapse = useCallback(() => { + if (!ref.current || isCollapsing || collapsed) return + + setIsCollapsing(true) + + const tl = gsap.timeline({ + onComplete: () => { + setCollapsed(true) + setIsCollapsing(false) + onCollapsed?.(task, { + x: localPos.x, + y: localPos.y + }, size) + } + }) + + // 1. 收缩 + tl.to(ref.current, { + scale: 0, + opacity: 0.5, + duration: 0.5, + ease: 'power4.in' + }) + + // 2. 同时触发粒子效果和闪白 + tl.add(() => { + // 创建闪白效果 + const flash = document.createElement('div') + flash.className = 'fixed inset-0 bg-white pointer-events-none z-50' + flash.style.opacity = '0.3' + document.body.appendChild(flash) + + gsap.to(flash, { + opacity: 0, + duration: 0.2, + onComplete: () => flash.remove() + }) + }, 0.3) + + }, [isCollapsing, collapsed, onCollapsed, task, localPos, size]) + + // 长按处理 + const handleMouseDown = (e) => { + if (e.button === 2) return // 右键不处理 + + e.stopPropagation() + setIsDragging(true) + + const startX = e.clientX + const startY = e.clientY + const container = ref.current.parentElement + const containerRect = container.getBoundingClientRect() + const hasMoved = useRef(false) + + // 当前位置转像素 + const currentX = (parseFloat(localPos.x) / 100) * containerRect.width + const currentY = (parseFloat(localPos.y) / 100) * containerRect.height + + // 启动长按计时 + longPressTimerRef.current = setTimeout(() => { + if (!hasMoved.current) { + setIsLongPressing(true) + onLongPress?.(task) + } + }, 800) + + const handleMouseMove = (moveEvent) => { + hasMoved.current = true + + // 取消长按 + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + + const deltaX = moveEvent.clientX - startX + const deltaY = moveEvent.clientY - startY + + let newX = ((currentX + deltaX) / containerRect.width) * 100 + let newY = ((currentY + deltaY) / containerRect.height) * 100 + + newX = Math.max(5, Math.min(95, newX)) + newY = Math.max(5, Math.min(95, newY)) + + setLocalPos({ x: `${newX.toFixed(1)}%`, y: `${newY.toFixed(1)}%` }) + } + + const handleMouseUp = (upEvent) => { + setIsDragging(false) + + // 清理长按计时 + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + + setIsLongPressing(false) + + // 保存位置 + if (hasMoved.current) { + onPositionChange?.(task.id, localPos) + } + + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + // 右键菜单处理 + const handleContextMenu = (e) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPos({ x: e.clientX, y: e.clientY }) + setShowContextMenu(true) + } + + // 点击处理(阻止冒泡,不做其他操作) + const handleClick = useCallback((e) => { + e.stopPropagation() + }, []) + + // GSAP 呼吸感动画 + useEffect(() => { + if (!ref.current || collapsed) return + + const duration = 4 + Math.random() * 2 + const driftX = (Math.random() - 0.5) * 15 * layerConfig.speed + const driftY = (Math.random() - 0.5) * 12 * layerConfig.speed + + // 入场动画 + gsap.fromTo(ref.current, + { opacity: 0, scale: 0.6, rotation: -10 }, + { opacity: 1, scale: 1, rotation: 0, duration: 1, delay: Math.random() * 0.5, ease: 'back.out(1.7)' } + ) + + // 过期任务的快速脉动 + if (isOverdue) { + breatheTweenRef.current = gsap.to(ref.current, { + scale: 1.12, + filter: 'hue-rotate(10deg)', + duration: 0.6, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + } else { + // 正常呼吸 + breatheTweenRef.current = gsap.to(ref.current, { + scale: 1.05, + rotation: 3, + duration, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + }) + } + + // 漂移动画 + driftTweenRef.current = gsap.to(ref.current, { + x: driftX, + y: driftY, + duration: duration * 2, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + }) + + return () => { + if (breatheTweenRef.current) breatheTweenRef.current.kill() + if (driftTweenRef.current) driftTweenRef.current.kill() + } + }, [layerConfig.speed, isOverdue, collapsed]) + + // 拖拽时暂停呼吸动画 + useEffect(() => { + if (breatheTweenRef.current) { + if (isDragging || isLongPressing) { + breatheTweenRef.current.pause() + } else { + breatheTweenRef.current.resume() + } + } + if (driftTweenRef.current) { + if (isDragging || isLongPressing) { + driftTweenRef.current.pause() + } else { + driftTweenRef.current.resume() + } + } + }, [isDragging, isLongPressing]) + + // 长按进度指示器 + const longPressProgress = useLongPressProgress(isLongPressing) + + return ( + <> +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onContextMenu={handleContextMenu} + onClick={handleClick} + > + {/* 星标光晕 */} + {task.starred && !collapsed && ( +
+ )} + + {/* 番茄环 */} + + + {/* 长按进度环 */} + {isLongPressing && ( +
+ + + +
+ )} + + {/* SVG 素材 - 根据任务 ID 随机选择,使用素材自带的 viewBox */} +
+ + {/* Tooltip - 重新设计 */} + {isHovered && task && !isDragging && !collapsed && ( +
+ {/* 连接线 */} +
+ {/* 内容卡片 */} +
+ {task.title} + {pomodoroCount > 0 && ( + + {pomodoroCount} + + )} +
+
+ )} + +
+ + {/* 坍缩后的恒星残留 */} + {collapsed && ( +
+ +
+ )} + + {/* 右键菜单 */} + + {showContextMenu && ( + setShowContextMenu(false)} + onComplete={() => { triggerCollapse(); onClick?.(task.id); setShowContextMenu(false) }} + onFocus={() => { onLongPress?.(task); setShowContextMenu(false) }} + /> + )} + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Hook: 长按进度动画 +// ═══════════════════════════════════════════════════════════════════════════ +function useLongPressProgress(isActive) { + const [progress, setProgress] = useState(0) + + useEffect(() => { + if (!isActive) { + setProgress(0) + return + } + + const startTime = Date.now() + const duration = 800 // 长按触发时间 + + const raf = requestAnimationFrame(function update() { + const elapsed = Date.now() - startTime + setProgress(Math.min(elapsed / duration, 1)) + + if (elapsed < duration) { + requestAnimationFrame(update) + } + }) + + return () => cancelAnimationFrame(raf) + }, [isActive]) + + return progress +} diff --git a/src/components/gtd/ProjectBoard.jsx b/src/components/gtd/ProjectBoard.jsx new file mode 100644 index 0000000..1963c72 --- /dev/null +++ b/src/components/gtd/ProjectBoard.jsx @@ -0,0 +1,326 @@ +/** + * [INPUT]: 依赖 @dnd-kit/core,依赖 @dnd-kit/sortable,依赖 ./ProjectColumn,依赖 @/stores/project,依赖 @/stores/gtd,依赖 lucide-react + * [OUTPUT]: 导出 ProjectBoard 组件 + * [POS]: 项目看板主容器,整合列组件和拖拽功能,管理任务在列之间的流转,支持列拖拽排序 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useMemo } from 'react' +import { + DndContext, + DragOverlay, + closestCorners, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors +} from '@dnd-kit/core' +import { + SortableContext, + horizontalListSortingStrategy, + arrayMove, + sortableKeyboardCoordinates, + useSortable +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { ChevronLeft, Settings, MoreHorizontal } from 'lucide-react' +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { ProjectColumn } from './ProjectColumn' +import { ProjectTaskCard } from './ProjectTaskCard' +import { snappy } from '@/lib/motion' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' + +// ============================================ +// 可排序列包装器 +// ============================================ + +function SortableColumn({ column, children }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ + id: column.id, + data: { type: 'column', column } + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1 + } + + return ( +
+ {children} +
+ ) +} + +export function ProjectBoard({ + project, + tasks, + onUpdateTask, + onAddTask, + onDeleteTask, + onDeleteProject, + onReorderColumns, + onBack, + onOpenSettings, + onTaskClick +}) { + const [activeId, setActiveId] = useState(null) + const [activeType, setActiveType] = useState(null) // 'task' | 'column' + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + // 按列分组任务 + const tasksByColumn = useMemo(() => { + const grouped = {} + project.columns.forEach(col => { + grouped[col.id] = tasks.filter(t => t.columnId === col.id) + }) + return grouped + }, [project.columns, tasks]) + + // 拖拽传感器 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8 + } + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ) + + // 查找活动任务或列 + const activeTask = activeType === 'task' && activeId ? tasks.find(t => t.id === activeId) : null + const activeColumn = activeType === 'column' && activeId ? project.columns.find(c => c.id === activeId) : null + + // 处理拖拽开始 + const handleDragStart = (event) => { + const { active } = event + setActiveId(active.id) + + // 判断拖拽的是列还是任务 + if (active.data.current?.type === 'column') { + setActiveType('column') + } else { + setActiveType('task') + } + } + + // 处理拖拽结束 + const handleDragEnd = (event) => { + const { active, over } = event + setActiveId(null) + setActiveType(null) + + if (!over) return + + // 列排序 + if (active.data.current?.type === 'column') { + if (active.id !== over.id) { + const oldIndex = project.columns.findIndex(c => c.id === active.id) + const newIndex = project.columns.findIndex(c => c.id === over.id) + if (oldIndex !== -1 && newIndex !== -1 && onReorderColumns) { + const newColumns = arrayMove(project.columns, oldIndex, newIndex) + onReorderColumns(project.id, newColumns) + } + } + return + } + + // 任务拖拽 + const activeTask = tasks.find(t => t.id === active.id) + if (!activeTask) return + + const overId = over.id + const overColumn = project.columns.find(c => c.id === overId) + const overTask = tasks.find(t => t.id === overId) + + // 拖拽到列:改变列 ID + if (overColumn) { + if (activeTask.columnId !== overColumn.id) { + onUpdateTask(activeTask.id, { columnId: overColumn.id }) + } + return + } + + // 拖拽到任务:在同一列内排序或跨列移动 + if (overTask) { + const activeColumnId = activeTask.columnId + const overColumnId = overTask.columnId + + if (activeColumnId === overColumnId) { + // 同列内排序 + const columnTasks = tasksByColumn[activeColumnId] + const oldIndex = columnTasks.findIndex(t => t.id === active.id) + const newIndex = columnTasks.findIndex(t => t.id === over.id) + + if (oldIndex !== newIndex) { + // 重新排序所有任务的 order 字段 + const newTasks = arrayMove(columnTasks, oldIndex, newIndex) + newTasks.forEach((task, index) => { + if (task.order !== index) { + onUpdateTask(task.id, { order: index }) + } + }) + } + } else { + // 跨列移动 + onUpdateTask(activeTask.id, { columnId: overColumnId }) + } + } + } + + // 添加任务到列 + const handleAddTask = (columnId, title) => { + onAddTask(title, project.id, columnId) + } + + // 获取列的拖拽状态 + const isColumnOver = (columnId) => { + return activeType === 'task' && activeId && tasks.find(t => t.id === activeId)?.columnId !== columnId + } + + const columnIds = project.columns.map(c => c.id) + + return ( +
+ {/* 头部 */} +
+
+ +

{project.title}

+
+
+ + + + + + + setDeleteDialogOpen(true)} + className="text-destructive" + > + 删除项目 + + + +
+
+ + {/* 看板区域 */} +
+ + +
+ {project.columns.map((column) => ( + + onUpdateTask(id, { completed: !tasks.find(t => t.id === id)?.completed })} + onToggleStar={(id) => onUpdateTask(id, { starred: !tasks.find(t => t.id === id)?.starred })} + onUpdateDate={(id, dueDate) => onUpdateTask(id, { dueDate })} + onUpdateTitle={(id, title) => onUpdateTask(id, { title })} + onDeleteTask={onDeleteTask} + onTaskClick={onTaskClick} + onAddTask={handleAddTask} + isOver={isColumnOver(column.id)} + /> + + ))} +
+
+ + {/* 拖拽预览 */} + + {activeTask ? ( + {}} + onToggleStar={() => {}} + onClick={() => {}} + isDragging + className="rotate-3 shadow-xl" + /> + ) : activeColumn ? ( +
+ {activeColumn.title} +
+ ) : null} +
+
+
+ + {/* 删除确认对话框 */} + + + + 删除项目 + + 确定要删除项目「{project.title}」吗?此操作不可撤销,项目中的任务将保留但不再关联此项目。 + + + + 取消 + { + onDeleteProject(project.id) + setDeleteDialogOpen(false) + }} + className="bg-destructive text-destructive-foreground" + > + 删除 + + + + +
+ ) +} diff --git a/src/components/gtd/ProjectColumn.jsx b/src/components/gtd/ProjectColumn.jsx new file mode 100644 index 0000000..93e574b --- /dev/null +++ b/src/components/gtd/ProjectColumn.jsx @@ -0,0 +1,175 @@ +/** + * [INPUT]: 依赖 @/components/ui/progress,依赖 @/components/gtd/ProjectTaskCard,依赖 @dnd-kit/core,依赖 @dnd-kit/sortable,依赖 framer-motion + * [OUTPUT]: 导出 ProjectColumn 组件 + * [POS]: 看板列组件,显示列内任务,支持任务拖拽,支持添加新任务,显示进度百分比,已完成任务沉底 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useMemo } from 'react' +import { useDroppable } from '@dnd-kit/core' +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { motion, AnimatePresence } from 'framer-motion' +import { cn } from '@/lib/utils' +import { Plus, GripVertical } from 'lucide-react' +import { Progress } from '@/components/ui/progress' +import { ProjectTaskCard } from './ProjectTaskCard' +import { snappy } from '@/lib/motion' +import { SortableTask } from './SortableTask' + +export function ProjectColumn({ + column, + tasks = [], + onToggleComplete, + onToggleStar, + onUpdateDate, + onUpdateTitle, + onDeleteTask, + onTaskClick, + onAddTask, + isOver = false +}) { + const { setNodeRef } = useDroppable({ + id: column.id + }) + + // ===================================================== + // 进度计算 (完成数 / 总数) + // ===================================================== + const { completedCount, totalCount, progress } = useMemo(() => { + const completed = tasks.filter(t => t.completed).length + const total = tasks.length + return { + completedCount: completed, + totalCount: total, + progress: total > 0 ? Math.round((completed / total) * 100) : 0 + } + }, [tasks]) + + // ===================================================== + // 任务排序 (未完成在前,已完成沉底) + // ===================================================== + const sortedTasks = useMemo(() => { + return [...tasks].sort((a, b) => { + // 已完成沉底 + if (a.completed !== b.completed) { + return a.completed ? 1 : -1 + } + // 同组按 order 排序 + return (a.order || 0) - (b.order || 0) + }) + }, [tasks]) + + const [isAdding, setIsAdding] = useState(false) + const [newTaskTitle, setNewTaskTitle] = useState('') + + const handleAddTask = () => { + if (newTaskTitle.trim()) { + onAddTask(column.id, newTaskTitle.trim()) + setNewTaskTitle('') + setIsAdding(false) + } + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + handleAddTask() + } else if (e.key === 'Escape') { + setNewTaskTitle('') + setIsAdding(false) + } + } + + const taskIds = sortedTasks.map(t => t.id) + + return ( +
+ {/* 列头 */} +
+ +

{column.title}

+ {/* 进度条 + 百分比 */} +
+ + 0 ? "text-primary font-medium" : "text-muted-foreground" + )}> + {totalCount > 0 ? `${progress}%` : '—'} + +
+
+ + {/* 添加任务区域 - 固定在列头下方 */} +
+ {isAdding ? ( + + setNewTaskTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newTaskTitle.trim()) { + setIsAdding(false) + } + }} + placeholder="任务标题..." + autoFocus + className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-primary transition-colors" + /> + + ) : ( + setIsAdding(true)} + className="w-full flex items-center justify-center gap-1.5 py-1.5 rounded-md border border-dashed border-border/50 text-muted-foreground text-xs hover:border-primary/50 hover:text-primary hover:bg-primary/5 transition-colors" + > + + 添加任务 + + )} +
+ + {/* 任务列表 - 可滚动 */} +
+ + + {sortedTasks.map((task) => ( + + ))} + + + + {/* 空状态提示 */} + {sortedTasks.length === 0 && ( +
+ 拖拽任务到此处 +
+ )} +
+
+ ) +} diff --git a/src/components/gtd/ProjectGallery.jsx b/src/components/gtd/ProjectGallery.jsx new file mode 100644 index 0000000..d2dfdc8 --- /dev/null +++ b/src/components/gtd/ProjectGallery.jsx @@ -0,0 +1,324 @@ +/** + * [INPUT]: 依赖 @/components/ui/circular-progress,依赖 lucide-react 图标,依赖 framer-motion,依赖 react-i18next + * [OUTPUT]: 导出 ProjectGallery 组件 + * [POS]: 项目画廊组件,呼吸感卡片网格布局(h-40 垂直居中),系统主题色进度环 + 标题 + 纯文字统计,响应式 1/2/3 列,支持右键菜单操作 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'framer-motion' +import { cn } from '@/lib/utils' +import { Plus, FolderOpen, MoreHorizontal, Settings, Archive, Trash2 } from 'lucide-react' +import { snappy, staggerContainer, staggerItem } from '@/lib/motion' +import { CircularProgress } from '@/components/ui/circular-progress' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' + +// ============================================ +// 项目卡片 - 呼吸感设计 + 右键菜单 +// ============================================ + +function ProjectCard({ project, stats, onClick, onSettings, onArchive, onDelete }) { + const { t } = useTranslation() + const { total, completed, overdue, progress } = stats + const isComplete = progress === 100 && total > 0 + + return ( + + + + {/* 进度圆环 - 32px,使用系统主题色 */} + + + {/* 项目标题 */} +

+ {project.title} +

+ + {/* 统计信息 - 纯文字,淡色 */} +
+ {isComplete ? ( + `${completed}/${total} completed` + ) : total === 0 ? ( + 'No tasks' + ) : ( + <> + {total} task{total !== 1 ? 's' : ''} + {overdue > 0 && ( + · {overdue} overdue + )} + + )} +
+ + {/* Hover 时显示的操作提示 */} +
+ +
+
+
+ + onSettings(project.id)}> + + {t('project.settings')} + + onArchive(project.id)}> + + {t('project.archive')} + + onDelete(project.id)} + className="text-destructive" + > + + {t('common.delete')} + + +
+ ) +} + +// ============================================ +// 新建项目卡片 - 呼吸感设计 +// ============================================ + +function CreateProjectCard({ onClick }) { + const { t } = useTranslation() + return ( + + + {t('project.gallery.newProject')} + + ) +} + +// ============================================ +// 空状态 - 无项目时 +// ============================================ + +function EmptyState({ onCreate }) { + const { t } = useTranslation() + return ( + +
+ +
+

{t('project.gallery.emptyTitle')}

+

+ {t('project.gallery.emptyDesc')} +

+ + + {t('project.gallery.createButton')} + +
+ ) +} + +// ============================================ +// 创建项目输入框 - 呼吸感设计 +// ============================================ + +function CreateProjectInput({ onCancel, onConfirm }) { + const { t } = useTranslation() + const [title, setTitle] = useState('') + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && title.trim()) { + onConfirm(title.trim()) + } else if (e.key === 'Escape') { + onCancel() + } + } + + return ( + + setTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!title.trim()) onCancel() + }} + placeholder={t('project.newPlaceholder')} + autoFocus + className={cn( + 'w-full bg-transparent text-center font-medium', + 'placeholder:text-muted-foreground outline-none', + 'border-b-2 border-primary/50 focus:border-primary', + 'pb-2 mb-2' + )} + /> +

+ 按 Enter 确认,Esc 取消 +

+
+ ) +} + +// ============================================ +// 项目画廊主组件 +// ============================================ + +export function ProjectGallery({ + projects, + tasks = [], + onSelect, + onCreateProject, + onDeleteProject, + onArchiveProject, + onOpenSettings, + className +}) { + const { t } = useTranslation() + const [isCreating, setIsCreating] = useState(false) + + // 计算项目统计数据 + const getProjectStats = useCallback((projectId) => { + const projectTasks = tasks.filter(t => t.projectId === projectId) + const total = projectTasks.length + const completed = projectTasks.filter(t => t.completed).length + + // 逾期任务:有截止日期、未完成、已过期 + const today = new Date() + today.setHours(0, 0, 0, 0) + const overdue = projectTasks.filter(t => + t.dueDate && !t.completed && new Date(t.dueDate) < today + ).length + + const progress = total > 0 ? Math.round((completed / total) * 100) : 0 + + return { total, completed, overdue, progress } + }, [tasks]) + + // 处理创建项目 + const handleCreate = (title) => { + onCreateProject(title) + setIsCreating(false) + } + + // 空状态 + if (projects.length === 0 && !isCreating) { + return ( +
+ setIsCreating(true)} /> +
+ ) + } + + return ( +
+ {/* 标题栏 */} +
+

{t('project.title')}

+ {!isCreating && ( + setIsCreating(true)} + className={cn( + 'flex items-center gap-2 px-4 py-2 rounded-xl', + 'bg-primary text-primary-foreground', + 'text-sm font-medium' + )} + > + + {t('project.gallery.newProject')} + + )} +
+ + {/* 网格布局 - 响应式 */} + + + {projects.map((project) => ( + onSelect(project.id)} + onSettings={onOpenSettings} + onArchive={onArchiveProject} + onDelete={onDeleteProject} + /> + ))} + {isCreating ? ( + setIsCreating(false)} + onConfirm={handleCreate} + /> + ) : ( + setIsCreating(true)} /> + )} + + +
+ ) +} diff --git a/src/components/gtd/ProjectList.jsx b/src/components/gtd/ProjectList.jsx new file mode 100644 index 0000000..5143439 --- /dev/null +++ b/src/components/gtd/ProjectList.jsx @@ -0,0 +1,132 @@ +/** + * [INPUT]: 依赖 @/stores/project,依赖 lucide-react 图标,依赖 framer-motion,依赖 react-i18next,依赖 @/components/ui/circular-progress + * [OUTPUT]: 导出 ProjectList 组件 + * [POS]: 项目列表组件,侧边栏导航,仅支持点击切换视图,操作功能移至主区域卡片 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'framer-motion' +import { cn } from '@/lib/utils' +import { Plus } from 'lucide-react' +import { snappy } from '@/lib/motion' +import { CircularProgress } from '@/components/ui/circular-progress' + +export function ProjectList({ + projects, + tasks = [], + activeProjectId, + onSelect, + onCreateProject, + collapsed = false, + className +}) { + const { t } = useTranslation() + const [isCreating, setIsCreating] = useState(false) + const [newTitle, setNewTitle] = useState('') + + // 计算项目进度 + const getProjectProgress = useCallback((projectId) => { + const projectTasks = tasks.filter(t => t.projectId === projectId) + const total = projectTasks.length + const completed = projectTasks.filter(t => t.completed).length + return total > 0 ? Math.round((completed / total) * 100) : 0 + }, [tasks]) + + const handleCreate = () => { + if (newTitle.trim()) { + onCreateProject(newTitle.trim()) + setNewTitle('') + setIsCreating(false) + } + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + handleCreate() + } else if (e.key === 'Escape') { + setNewTitle('') + setIsCreating(false) + } + } + + return ( +
+ {/* 项目列表 - 仅导航 */} + + {projects.map((project) => { + const isActive = activeProjectId === project.id + return ( + onSelect(project.id)} + title={collapsed ? project.title : undefined} + className={cn( + 'flex items-center gap-3 py-2 rounded-lg text-sm transition-colors w-full', + 'px-3', + 'hover:bg-sidebar-accent', + isActive && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium', + collapsed && 'justify-center' + )} + > + + {!collapsed && ( + {project.title} + )} + + ) + })} + + + {/* 创建新项目 */} + {isCreating ? ( +
+ setNewTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newTitle.trim()) { + setIsCreating(false) + } + }} + placeholder={t('project.newPlaceholder')} + autoFocus + className={cn( + 'w-full bg-transparent border-b border-primary/50 outline-none', + 'text-sm py-1 placeholder:text-muted-foreground' + )} + /> +
+ ) : ( + setIsCreating(true)} + title={collapsed ? t('project.new') : undefined} + className={cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors', + 'hover:bg-sidebar-accent/50 text-muted-foreground', + collapsed && 'justify-center' + )} + > + + {!collapsed && {t('project.new')}} + + )} +
+ ) +} diff --git a/src/components/gtd/ProjectSettings.jsx b/src/components/gtd/ProjectSettings.jsx new file mode 100644 index 0000000..ae09f07 --- /dev/null +++ b/src/components/gtd/ProjectSettings.jsx @@ -0,0 +1,185 @@ +/** + * [INPUT]: 依赖 @/stores/project,依赖 lucide-react 图标,依赖 @/components/ui/*,依赖 react-i18next + * [OUTPUT]: 导出 ProjectSettings 组件 + * [POS]: 项目设置对话框,支持编辑标题/描述,管理自定义列(移除颜色选择,统一使用系统主题色) + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { GripVertical, Plus, Trash2, X } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' + +export function ProjectSettings({ + open, + onOpenChange, + project, + onUpdateProject, + onAddColumn, + onUpdateColumn, + onDeleteColumn, + onReorderColumns +}) { + const { t } = useTranslation() + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [newColumnTitle, setNewColumnTitle] = useState('') + const [editingColumnId, setEditingColumnId] = useState(null) + const [editingColumnTitle, setEditingColumnTitle] = useState('') + + // 同步项目数据 + useEffect(() => { + if (project) { + setTitle(project.title) + setDescription(project.description || '') + } + }, [project]) + + if (!project) return null + + const handleSave = () => { + onUpdateProject(project.id, { title, description }) + onOpenChange(false) + } + + const handleAddColumn = () => { + if (newColumnTitle.trim()) { + onAddColumn(project.id, newColumnTitle.trim()) + setNewColumnTitle('') + } + } + + const handleColumnTitleSave = (columnId) => { + if (editingColumnTitle.trim()) { + onUpdateColumn(project.id, columnId, { title: editingColumnTitle.trim() }) + } + setEditingColumnId(null) + setEditingColumnTitle('') + } + + return ( + + + + {t('project.settings')} + + 编辑项目设置 + + + +
+ {/* 基本信息 */} +
+
+ + setTitle(e.target.value)} + placeholder={t('project.titlePlaceholder')} + /> +
+ +
+ +