From c6efca25c42a5fe131b07ea7b291d9780360e80e Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 14:50:27 +0800 Subject: [PATCH 01/32] Add Focus View with cosmic illustration and AI task recommendations --- CLAUDE.md | 26 ++- package-lock.json | 61 +++++++ package.json | 3 + src/App.jsx | 34 +++- src/components/gtd/BlueDust.jsx | 72 ++++++++ src/components/gtd/CLAUDE.md | 14 +- src/components/gtd/FloatingTaskBubble.jsx | 87 +++++++++ src/components/gtd/FocusCircle.jsx | 101 +++++++++++ src/components/gtd/FocusView.jsx | 206 ++++++++++++++++++++++ src/components/gtd/MiniInfo.jsx | 56 ++++++ src/components/gtd/NoiseOverlay.jsx | 28 +++ src/components/gtd/OrbitPaths.jsx | 76 ++++++++ src/components/gtd/Planet.jsx | 206 ++++++++++++++++++++++ src/components/gtd/Sidebar.jsx | 91 ++++++---- src/components/gtd/StarDust.jsx | 70 ++++++++ src/components/gtd/TaskBubbleZone.jsx | 80 +++++++++ src/index.css | 9 + src/lib/ai/openai.js | 82 ++++++++- src/lib/ai/prompts.js | 121 ++++++++++++- src/locales/en-US.json | 67 +++++++ src/locales/zh-CN.json | 67 +++++++ src/stores/CLAUDE.md | 4 +- src/stores/ai.js | 50 +++++- src/stores/gtd.js | 49 ++++- 24 files changed, 1600 insertions(+), 60 deletions(-) create mode 100644 src/components/gtd/BlueDust.jsx create mode 100644 src/components/gtd/FloatingTaskBubble.jsx create mode 100644 src/components/gtd/FocusCircle.jsx create mode 100644 src/components/gtd/FocusView.jsx create mode 100644 src/components/gtd/MiniInfo.jsx create mode 100644 src/components/gtd/NoiseOverlay.jsx create mode 100644 src/components/gtd/OrbitPaths.jsx create mode 100644 src/components/gtd/Planet.jsx create mode 100644 src/components/gtd/StarDust.jsx create mode 100644 src/components/gtd/TaskBubbleZone.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 5d4b486..a03997a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,20 @@ # 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 业务组件 (28文件: 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) +├── stores/ - 状态管理 (5文件: gtd.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) ├── locales/ - 国际化翻译文件 (2文件: zh-CN.json, en-US.json) -├── App.jsx - 应用入口,支持列表/日历/日记视图切换,集成跨平台功能 +├── App.jsx - 应用入口,支持专注/列表/日历/日记视图切换,集成跨平台功能 ├── main.jsx - React 挂载点,初始化 i18n └── index.css - 全局样式 + CSS 变量 @@ -45,6 +45,19 @@ package.json - 包含 tauri:dev/tauri:build (桌面端) 和 cap:android/c - 将来/也许 (Someday): 暂时搁置 - 已完成 (Done): 归档 +## 专注视图 (Focus View) + +- **设计哲学**:专注是一种"主动选择",而非"被动提醒" +- **视觉风格**:柔性宇宙插画,SVG filter 手绘行星 + 椭圆轨道带 + GSAP 动画 +- **轨道带**:多条同心椭圆(像土星环),深蓝紫色,-15度倾斜 +- **手绘行星**:SVG feTurbulence + feDisplacementMap 实现不规则边缘,玻璃球高光 +- **独立视图**:与列表/日历/日记并列,作为侧边栏第一个入口 +- **时间感知问候**:根据早中晚显示不同问候语 + 今日任务数量 +- **AI 推荐**:智能推荐最应该优先处理的 3 个任务 +- **本地降级**:AI 禁用或失败时使用本地排序(紧急性 + 创建时间) +- **过期任务处理**:折叠卡片显示过期任务,支持快速操作(今天/明天/删除) +- **空状态引导**:无任务时引导用户去收集箱选择 + ## 日历视图 - 月视图/周视图切换 @@ -66,12 +79,13 @@ package.json - 包含 tauri:dev/tauri:build (桌面端) 和 cap:android/c ## AI 功能 - **智能问题生成**:根据用户指导方向、任务完成情况和历史日记,动态生成个性化引导问题 +- **任务推荐**:分析任务紧急性、重要性、可行性,推荐最应该优先处理的任务 - **用户指导方向**:用户输入指导性提示词(如"我想探讨人生的哲理和意义"),AI 据此生成问题 - **上下文感知**:结合今日任务、最近日记、时间上下文(周几、早中晚)生成问题 - **优雅交互**:问题卡片轻量非侵入,点击插入、悬停删除、支持刷新 - **隐私优先**:用户自己配置 OpenAI API Key,加密存储在本地 - **完全可选**:默认关闭,用户完全控制 -- **优雅降级**:API 失败时使用通用开放式问题 +- **优雅降级**:API 失败时使用通用开放式问题或本地排序 ## 跨平台特性 diff --git a/package-lock.json b/package-lock.json index 4c399e2..ac18ed1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,11 +36,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 +51,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", @@ -4816,6 +4819,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 +5694,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 +5719,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 +5810,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 +6183,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..3943727 100644 --- a/package.json +++ b/package.json @@ -41,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", @@ -54,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", diff --git a/src/App.jsx b/src/App.jsx index 7aa9c46..12e5b85 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,7 @@ /** * [INPUT]: 依赖 @/stores/gtd, @/stores/journal, @/hooks/useFileSystem, @/hooks/useSync, @/components/gtd/*, @/components/ui/sonner, @/lib/platform, react-i18next * [OUTPUT]: 导出 App 根组件 - * [POS]: 应用入口,组装 GTD 布局,支持列表/日历/日记视图切换,集成跨平台功能(桌面端+移动端),管理抽屉和快速捕获状态,集成文件系统和云同步 + * [POS]: 应用入口,组装 GTD 布局,支持专注/列表/日历/日记视图切换,集成跨平台功能(桌面端+移动端),管理抽屉和快速捕获状态,集成文件系统和云同步 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -20,6 +20,7 @@ import { CalendarView } from '@/components/gtd/CalendarView' 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' @@ -78,7 +79,7 @@ function AppContent({ fileSystem, sync }) { getJournalById } = useJournal() - const [viewMode, setViewMode] = useState('list') // 'list' | 'calendar' + const [viewMode, setViewMode] = useState('focus') // 'focus' | 'list' | 'calendar' const [journalView, setJournalView] = useState(null) // 'now' | 'past' | null const [settingsOpen, setSettingsOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false) // 移动端抽屉状态 @@ -327,6 +328,7 @@ function AppContent({ fileSystem, sync }) { activeList={activeList} onSelect={setActiveList} counts={counts} + tasks={tasks} viewMode={viewMode} onViewModeChange={setViewMode} journalView={journalView} @@ -337,6 +339,9 @@ function AppContent({ fileSystem, sync }) { onSettingsOpenChange={setSettingsOpen} sync={sync} fileSystem={fileSystem} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onToggleComplete={handleToggleComplete} /> )} {/* 视图渲染优先级:journalView > viewMode */} @@ -344,6 +349,27 @@ 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') + }} + /> ) : viewMode === 'calendar' ? ( setQuickCaptureOpen(true)} sync={sync} fileSystem={fileSystem} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onToggleComplete={handleToggleComplete} /> {/* 移动端抽屉 */} 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..e9fd6af 100644 --- a/src/components/gtd/CLAUDE.md +++ b/src/components/gtd/CLAUDE.md @@ -4,7 +4,7 @@ ## 成员清单 QuickCapture.jsx: 快速收集输入框,顶部任务添加入口 -Sidebar.jsx: 侧边栏导航,GTD 五大列表切换 + 列表/日历视图切换 + 日记分组(此刻/过往)+ 设置入口,移动端简化为 3 按钮底部导航(Menu、FAB、日历) +Sidebar.jsx: 侧边栏导航,GTD 五大列表切换 + 专注/列表/日历视图切换 + 日记分组(此刻/过往)+ 设置入口,移动端简化为 3 按钮底部导航(Menu、FAB、日历) Drawer.jsx: 移动端左侧滑抽屉,显示 GTD 五大列表 + 日记分组 + 设置入口,替代底部导航的列表切换功能 ActionSheet.jsx: 移动端底部操作表,显示任务操作选项(设置日期、移动到列表、删除),替代桌面端的下拉菜单 ConflictDialog.jsx: 同步冲突解决对话框,展示冲突详情 + 策略选择(合并/本地/远程/保留两者) @@ -21,9 +21,19 @@ JournalPastView.jsx: "过往"视图,历史日记支持列表/弧线画布( JournalItem.jsx: 过往日记列表项,显示日期 + 标题 + 预览 + 字数 JournalChip.jsx: 日历内日记小卡片,虚线边框,不可拖拽,BookText 图标 AIPromptCard.jsx: AI 问题卡片,展示生成的引导问题(无 emoji),支持点击插入、悬停删除、刷新,淡入淡出动画,显示加载状态 +FocusView.jsx: 专注视图主组件,柔性宇宙插画风格,整合 FocusCircle + TaskBubbleZone + Empty/Complete 状态 +FocusCircle.jsx: 专注视图核心 - 柔性宇宙插画,SVG filter 手绘行星 + 椭圆轨道带 +StarDust.jsx: 背景星点层,GSAP 动画,35个微小白色粒子极慢漂浮 +OrbitPaths.jsx: 椭圆轨道带 - 多条同心椭圆(像土星环),深蓝紫色,GSAP 描边动画 +Planet.jsx: SVG filter 手绘风格行星,feTurbulence + feDisplacementMap 实现不规则边缘,玻璃球高光 +BlueDust.jsx: 蓝色粒子层,GSAP 动画,25个蓝色小点集中在中间区域 +MiniInfo.jsx: 右上角极简信息标签,GSAP 入场动画,问候语 + 数字 +NoiseOverlay.jsx: 全局噪点纹理层,SVG feTurbulence 实现颗粒感 +FloatingTaskBubble.jsx: 漂浮气泡任务卡片,圆角胶囊形状,渐变圆点前缀,与行星系统融为一体 +TaskBubbleZone.jsx: 底部任务气泡区域,水平排列漂浮气泡,最多显示5个 ## 子目录 -settings/: 设置模块,左右分栏布局(桌面端)+ Sheet 全屏(移动端) +settings/: 设置模块,左右分栏布局��桌面端)+ Sheet 全屏(移动端) [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md diff --git a/src/components/gtd/FloatingTaskBubble.jsx b/src/components/gtd/FloatingTaskBubble.jsx new file mode 100644 index 0000000..9f82a0d --- /dev/null +++ b/src/components/gtd/FloatingTaskBubble.jsx @@ -0,0 +1,87 @@ +/** + * [INPUT]: react, framer-motion, lucide-react, @/lib/utils + * [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 './Planet' + +// ═══════════════════════════════════════════════════════════════════════════ +// 漂浮气泡任务卡片 +// ═══════════════════════════════════════════════════════════════════════════ + +const COLOR_KEYS = ['coral', 'purple', 'cyan', 'cream'] + +export function FloatingTaskBubble({ + task, + index = 0, + isAIRecommended = false, + onComplete, + className +}) { + if (!task) return null + + // 获取对应的行星颜色 + const colorKey = COLOR_KEYS[index % COLOR_KEYS.length] + const color = PLANET_COLORS[colorKey] + + // 生成渐变 + const gradient = `radial-gradient(circle at 35% 35%, ${color.highlight} 0%, ${color.base} 50%, ${color.shadow} 100%)` + + // 随机动画参数 + const animDuration = useMemo(() => 4 + Math.random() * 2, []) + const animDelay = useMemo(() => Math.random() * 2, []) + + return ( + onComplete?.(task.id)} + > + {/* 小圆点 - 与行星呼应 */} +
+ + {/* 任务标题 */} + + {task.title} + + + {/* AI 推荐标记 */} + {isAIRecommended && ( + + )} + + ) +} diff --git a/src/components/gtd/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx new file mode 100644 index 0000000..436c331 --- /dev/null +++ b/src/components/gtd/FocusCircle.jsx @@ -0,0 +1,101 @@ +/** + * [INPUT]: react, @/lib/utils, ./StarDust, ./OrbitPaths, ./Planet, ./BlueDust, ./MiniInfo, ./NoiseOverlay + * [OUTPUT]: FocusCircle 组件 + * [POS]: 专注视图核心 - 柔性宇宙插画,SVG filter 手绘行星 + 椭圆轨道带 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useMemo } from 'react' +import { cn } from '@/lib/utils' +import { StarDust } from './StarDust' +import { OrbitPaths } from './OrbitPaths' +import { Planet } from './Planet' +import { BlueDust } from './BlueDust' +import { MiniInfo } from './MiniInfo' +import { NoiseOverlay } from './NoiseOverlay' + +// ═══════════════════════════════════════════════════════════════════════════ +// 行星配置 - 沿椭圆轨道分布 +// ═══════════════════════════════════════════════════════════════════════════ +const PLANET_CONFIG = [ + // 左侧小行星 + { x: '12%', y: '45%', size: 40, colorKey: 'purple', layer: 'back' }, + + // 中间巨大橙色行星(主角) + { x: '50%', y: '50%', size: 150, colorKey: 'coral', layer: 'front' }, + + // 右上小行星 + { x: '72%', y: '28%', size: 45, colorKey: 'cyan', layer: 'mid' }, + + // 左下小行星 + { x: '28%', y: '68%', size: 35, colorKey: 'purple', layer: 'back' }, + + // 右下土星 + { x: '85%', y: '55%', size: 80, colorKey: 'cream', hasRing: true, layer: 'front' }, + + // 额外小行星 + { x: '62%', y: '65%', size: 28, colorKey: 'cyan', layer: 'mid' }, +] + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 - 柔性宇宙插画 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusCircle({ + totalCount = 0, + completedCount = 0, + tasks = [], + onParticleClick, + className +}) { + // 准备行星任务数据 - 只取未完成的任务 + const planetTasks = useMemo(() => { + return tasks.filter(t => !t.completed).slice(0, PLANET_CONFIG.length) + }, [tasks]) + + const pendingCount = totalCount - completedCount + + return ( +
+ {/* Layer 1: 背景星点 */} + + + {/* Layer 2: 椭圆轨道带 */} + + + {/* Layer 3: 蓝色粒子 */} + + + {/* Layer 4: 行星 */} + {PLANET_CONFIG.map((config, i) => { + const task = planetTasks[i] + if (!task) return null + + return ( + + ) + })} + + {/* Layer 5: 噪点纹理 */} + + + {/* Layer 6: 右上角信息 */} + +
+ ) +} diff --git a/src/components/gtd/FocusView.jsx b/src/components/gtd/FocusView.jsx new file mode 100644 index 0000000..11cf190 --- /dev/null +++ b/src/components/gtd/FocusView.jsx @@ -0,0 +1,206 @@ +/** + * [INPUT]: react, react-i18next, framer-motion, @/stores/gtd, @/stores/ai, @/lib/utils, @/components/gtd/Focus*, @/components/gtd/TaskBubbleZone + * [OUTPUT]: FocusView 组件 + * [POS]: 专注视图主组件,柔性宇宙插画风格 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { motion } from 'framer-motion' +import { cn } from '@/lib/utils' +import { isToday, isPast } from '@/stores/gtd' +import { useAI } from '@/stores/ai' +import { ChevronRight, Sparkles } from 'lucide-react' +import { FocusCircle } from './FocusCircle' +import { TaskBubbleZone } from './TaskBubbleZone' + +// ═══════════════════════════════════════════════════════════════════════════ +// 空状态 - 宁静的虚无 +// ═══════════════════════════════════════════════════════════════════════════ +function EmptyState({ onGoToInbox }) { + const { t } = useTranslation() + + return ( + +

+ {t('focus.empty.hint')} +

+ + {t('focus.empty.action')} + + +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 完成状态 - 完美的圆 +// ═══════════════════════════════════════════════════════════════════════════ +function CompleteState() { + const { t } = useTranslation() + + return ( + + + + +

+ {t('focus.complete.hint')} +

+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusView({ + tasks = [], + onComplete, + onMoveToToday, + onMoveToTomorrow, + onDelete, + onGoToInbox, + onGoToToday, + className +}) { + const { t } = useTranslation() + const { recommendTasks } = useAI() + + // 推荐任务状态 + const [recommendedTasks, setRecommendedTasks] = useState([]) + const [isFallback, setIsFallback] = useState(false) + const [loading, setLoading] = useState(false) + + // 今日任务(包括过期) + const todayTasks = useMemo(() => { + return tasks.filter(t => + !t.completed && (isToday(t.dueDate) || isPast(t.dueDate)) + ) + }, [tasks]) + + // 已完成任务数 + const completedToday = useMemo(() => { + const today = new Date() + today.setHours(0, 0, 0, 0) + return tasks.filter(t => + t.completed && t.completedAt && new Date(t.completedAt) >= today + ).length + }, [tasks]) + + // 过期任务 + const overdueTasks = useMemo(() => { + return tasks.filter(t => !t.completed && isPast(t.dueDate)) + }, [tasks]) + + // 加载推荐任务 + const loadRecommendations = useCallback(async () => { + if (todayTasks.length === 0) { + setRecommendedTasks([]) + return + } + + setLoading(true) + try { + const result = await recommendTasks(todayTasks) + setRecommendedTasks(result.tasks.slice(0, 5)) + setIsFallback(result.fallback) + } catch (error) { + console.error('Failed to load recommendations:', error) + setRecommendedTasks(todayTasks.slice(0, 5)) + setIsFallback(true) + } finally { + setLoading(false) + } + }, [todayTasks, recommendTasks]) + + // 初始加载 + useEffect(() => { + loadRecommendations() + }, []) + + // 处理任务完成 + const handleComplete = useCallback((taskId) => { + onComplete?.(taskId) + setRecommendedTasks(prev => prev.filter(t => t.id !== taskId)) + }, [onComplete]) + + // 判断状态 + const isEmpty = todayTasks.length === 0 && completedToday === 0 + const isAllDone = todayTasks.length === 0 && completedToday > 0 + + return ( +
+ {/* 柔性宇宙插画 */} + + + {/* 空状态 */} + {isEmpty && } + + {/* 完成状态 */} + {isAllDone && } + + {/* 底部任务气泡区 */} + {!isEmpty && !isAllDone && recommendedTasks.length > 0 && ( + + )} + + {/* 过期任务提醒 - 左上角 */} + {overdueTasks.length > 0 && ( + +
+ + {t('focus.overdueTitle', { count: overdueTasks.length })} +
+
+ )} +
+ ) +} 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..ec7c313 --- /dev/null +++ b/src/components/gtd/OrbitPaths.jsx @@ -0,0 +1,76 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: OrbitPaths 组件 + * [POS]: 轨道带 - 椭圆形轨道线(像土星环),深蓝紫色 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef } from 'react' +import gsap from 'gsap' + +// ═══════════════════════════════════════════════════════════════════════════ +// 椭圆轨道配置 - 像土星环一样的椭圆 +// ═══════════════════════════════════════════════════════════════════════════ +const ORBIT_ELLIPSES = [ + { cx: 400, cy: 300, rx: 380, ry: 120, rotation: -15, opacity: 0.4, width: 2.5 }, + { cx: 400, cy: 300, rx: 420, ry: 140, rotation: -15, opacity: 0.35, width: 2 }, + { cx: 400, cy: 300, rx: 460, ry: 160, rotation: -15, opacity: 0.25, width: 1.5 }, + { cx: 400, cy: 300, rx: 500, ry: 180, rotation: -15, opacity: 0.2, width: 1 }, + { cx: 400, cy: 300, rx: 540, ry: 200, rotation: -15, opacity: 0.15, width: 1 }, + { cx: 400, cy: 300, rx: 580, ry: 220, rotation: -15, opacity: 0.1, width: 0.5 }, +] + +// 深蓝紫色 +const ORBIT_COLOR = '100, 120, 180' + +// ═══════════════════════════════════════════════════════════════════════════ +// 轨道带组件 - 椭圆形 +// ═══════════════════════════════════════════════════════════════════════════ +export function OrbitPaths() { + const pathsRef = useRef([]) + + // 入场动画 - 描边绘制 + useEffect(() => { + pathsRef.current.forEach((ellipse, i) => { + if (!ellipse) return + + // 计算椭圆周长近似值 + const rx = ORBIT_ELLIPSES[i].rx + const ry = ORBIT_ELLIPSES[i].ry + const length = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry))) + + ellipse.style.strokeDasharray = length + ellipse.style.strokeDashoffset = length + + gsap.to(ellipse, { + strokeDashoffset: 0, + duration: 2.5, + delay: i * 0.12, + ease: 'power2.out', + }) + }) + }, []) + + return ( + + {ORBIT_ELLIPSES.map((orbit, i) => ( + pathsRef.current[i] = el} + cx={orbit.cx} + cy={orbit.cy} + rx={orbit.rx} + ry={orbit.ry} + transform={`rotate(${orbit.rotation} ${orbit.cx} ${orbit.cy})`} + stroke={`rgba(${ORBIT_COLOR}, ${orbit.opacity})`} + strokeWidth={orbit.width} + fill="none" + /> + ))} + + ) +} diff --git a/src/components/gtd/Planet.jsx b/src/components/gtd/Planet.jsx new file mode 100644 index 0000000..d8b76ce --- /dev/null +++ b/src/components/gtd/Planet.jsx @@ -0,0 +1,206 @@ +/** + * [INPUT]: react, gsap, @/lib/utils + * [OUTPUT]: Planet 组件, PLANET_COLORS 常量 + * [POS]: SVG filter 手绘风格行星,不规则边缘 + 渐变填充 + 玻璃球高光 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useState, useMemo } from 'react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 行星颜色配置 +// ═══════════════════════════════════════════════════════════════════════════ +export const PLANET_COLORS = { + coral: { + base: '#ff7b5c', + highlight: '#ffb090', + shadow: '#cc5a40', + }, + purple: { + base: '#a855f7', + highlight: '#d4a5ff', + shadow: '#7c3aed', + }, + cyan: { + base: '#4dd4ac', + highlight: '#a0f0d0', + shadow: '#2a9d8f', + }, + cream: { + base: '#f0d090', + highlight: '#fff0c0', + shadow: '#c0a060', + }, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 手绘风格行星组件 - SVG filter 实现不规则边缘 +// ═══════════════════════════════════════════════════════════════════════════ +export function Planet({ + task, + size = 60, + position = { x: '50%', y: '50%' }, + colorKey = 'coral', + hasRing = false, + layer = 'mid', + onClick, + className +}) { + const ref = useRef(null) + const [isHovered, setIsHovered] = useState(false) + const color = PLANET_COLORS[colorKey] || PLANET_COLORS.coral + + // 唯一 ID 用于 SVG filter + const filterId = useMemo(() => `hand-drawn-${Math.random().toString(36).slice(2, 9)}`, []) + + // 层级配置 + 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]) + + // GSAP 漂移动画 + useEffect(() => { + if (!ref.current) return + + const duration = 12 + Math.random() * 8 + const driftX = (Math.random() - 0.5) * 20 * layerConfig.speed + const driftY = (Math.random() - 0.5) * 16 * layerConfig.speed + + // 入场动画 + gsap.fromTo(ref.current, + { opacity: 0, scale: 0.8 }, + { opacity: 1, scale: 1, duration: 0.8, delay: Math.random() * 0.5 } + ) + + // 漂移动画 + const tween = gsap.to(ref.current, { + x: driftX, + y: driftY, + duration, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + }) + + return () => tween.kill() + }, [layerConfig.speed]) + + // 渐变背景 + const gradient = `radial-gradient(circle at 30% 30%, ${color.highlight} 0%, ${color.base} 45%, ${color.shadow} 100%)` + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => onClick?.(task?.id)} + > + {/* SVG Filter 定义 */} + + + + {/* 手绘扭曲效果 */} + + + + + + + {/* 土星环(在行星后面) */} + {hasRing && ( +
+ )} + + {/* 行星主体 - 应用手绘 filter */} +
+ + {/* 玻璃球高光 */} +
+ + {/* 次级高光 */} +
+ + {/* Tooltip */} + {isHovered && task && ( +
+ {task.title} +
+ )} +
+ ) +} diff --git a/src/components/gtd/Sidebar.jsx b/src/components/gtd/Sidebar.jsx index 4dfb943..3b039f8 100644 --- a/src/components/gtd/Sidebar.jsx +++ b/src/components/gtd/Sidebar.jsx @@ -1,7 +1,7 @@ /** * [INPUT]: 依赖 @/stores/gtd 的 GTD_LISTS/GTD_LIST_META,依赖 lucide-react 图标,依赖 framer-motion,依赖 @/lib/platform 跨平台 API,依赖 react-i18next * [OUTPUT]: 导出 Sidebar 组件 - * [POS]: GTD 侧边栏导航,响应式设计(桌面端侧边栏,移动端底部导航),支持视图切换和列表导航,日记分组与标题同组展示 + * [POS]: GTD 侧边栏导航,响应式设计(桌面端侧边栏,移动端底部导航),支持视图切换和列表导航,日记分组与标题同组展示,专注视图入口 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' import { GTD_LISTS, GTD_LIST_META } from '@/stores/gtd' -import { Inbox, Sun, ArrowRight, Calendar, CheckCircle, CalendarDays, List, ChevronLeft, ChevronRight, ChevronDown, Settings, Plus, Menu, BookText, PenLine, BookOpen } from 'lucide-react' +import { Inbox, Sun, ArrowRight, Calendar, CheckCircle, CalendarDays, List, ChevronLeft, ChevronRight, Settings, Plus, Menu, BookText, PenLine, BookOpen, Focus } from 'lucide-react' import { snappy } from '@/lib/motion' import { isMobile } from '@/lib/platform' import { hapticsLight } from '@/lib/haptics' @@ -18,7 +18,7 @@ import { Settings as SettingsDialog } from './settings' const ICONS = { Inbox, Sun, ArrowRight, Calendar, CheckCircle } -export function Sidebar({ activeList, onSelect, counts, viewMode, onViewModeChange, journalView, onJournalViewChange, onExport, onImport, settingsOpen, onSettingsOpenChange, onDrawerOpen, onQuickCaptureOpen, sync, fileSystem, className }) { +export function Sidebar({ activeList, onSelect, counts, tasks = [], viewMode, onViewModeChange, journalView, onJournalViewChange, onExport, onImport, settingsOpen, onSettingsOpenChange, onDrawerOpen, onQuickCaptureOpen, sync, fileSystem, className }) { const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) const [journalExpanded, setJournalExpanded] = useState(true) @@ -57,7 +57,7 @@ export function Sidebar({ activeList, onSelect, counts, viewMode, onViewModeChan )} > - {viewMode === 'list' && ( + {viewMode === 'list' && !journalView && ( @@ -98,7 +98,7 @@ export function Sidebar({ activeList, onSelect, counts, viewMode, onViewModeChan )} > - {viewMode === 'calendar' && ( + {viewMode === 'calendar' && !journalView && ( + {/* 专注视图 - 放在最前面 */} + { + if (journalView) { + onJournalViewChange(null) + } + onViewModeChange('focus') + }} + title={collapsed ? t('focus.title') : undefined} + className={cn( + "flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors", + "hover:bg-sidebar-accent/50", + viewMode === 'focus' && !journalView ? "text-foreground font-medium" : "text-muted-foreground", + collapsed && "justify-center" + )} + > + + {!collapsed && {t('focus.title')}} + + {!collapsed && {t('views.list')}} + {/* 日记 - 一级标题 */} { - // 点击日记时,如果已经在日记视图,则切换展开状态;否则打开"此刻" if (journalView) { setJournalExpanded(!journalExpanded) } else { @@ -207,7 +230,7 @@ export function Sidebar({ activeList, onSelect, counts, viewMode, onViewModeChan className={cn( "flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors", "hover:bg-sidebar-accent/50", - viewMode === 'calendar' ? "text-foreground font-medium" : "text-muted-foreground", + viewMode === 'calendar' && !journalView ? "text-foreground font-medium" : "text-muted-foreground", collapsed && "justify-center" )} > @@ -315,31 +338,31 @@ export function Sidebar({ activeList, onSelect, counts, viewMode, onViewModeChan {/* 设置按钮 */} -
- onSettingsOpenChange(true)} - title={collapsed ? t('common.settings') : 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('common.settings')} - - -
+
+ onSettingsOpenChange(true)} + title={collapsed ? t('common.settings') : 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('common.settings')} + + +
) } diff --git a/src/components/gtd/StarDust.jsx b/src/components/gtd/StarDust.jsx new file mode 100644 index 0000000..48214b6 --- /dev/null +++ b/src/components/gtd/StarDust.jsx @@ -0,0 +1,70 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: StarDust 组件 + * [POS]: 背景星点层,GSAP 动画,微小粒子随机漂浮 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useMemo } from 'react' +import gsap from 'gsap' + +// ═══════════════════════════════════════════════════════════════════════════ +// 背景星点 - GSAP 极慢漂移 +// ═══════════════════════════════════════════════════════════════════════════ +export function StarDust({ count = 35 }) { + const containerRef = useRef(null) + + const particles = useMemo(() => + Array.from({ length: count }, () => ({ + x: Math.random() * 100, + y: Math.random() * 100, + size: 1 + Math.random() * 2, + })), + [count] + ) + + useEffect(() => { + if (!containerRef.current) return + + const dots = containerRef.current.children + const tweens = [] + + Array.from(dots).forEach(dot => { + const duration = 6 + Math.random() * 4 + const driftX = (Math.random() - 0.5) * 20 + const driftY = (Math.random() - 0.5) * 20 + + const tween = gsap.to(dot, { + x: `+=${driftX}`, + y: `+=${driftY}`, + opacity: 0.6, + duration, + delay: Math.random() * 4, + 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/TaskBubbleZone.jsx b/src/components/gtd/TaskBubbleZone.jsx new file mode 100644 index 0000000..acb4c4f --- /dev/null +++ b/src/components/gtd/TaskBubbleZone.jsx @@ -0,0 +1,80 @@ +/** + * [INPUT]: react, framer-motion, react-i18next, @/lib/utils, ./FloatingTaskBubble + * [OUTPUT]: TaskBubbleZone 组件 + * [POS]: 底部任务气泡区域,水平排列漂浮气泡 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { motion, AnimatePresence } from 'framer-motion' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { FloatingTaskBubble } from './FloatingTaskBubble' + +// ═══════════════════════════════════════════════════════════════════════════ +// 底部任务气泡区域 +// ═══════════════════════════════════════════════════════════════════════════ +export function TaskBubbleZone({ + tasks = [], + isFallback = false, + onComplete, + onViewAll, + className +}) { + const { t } = useTranslation() + const displayTasks = tasks.slice(0, 5) + const hasMore = tasks.length > 5 + + if (tasks.length === 0) return null + + return ( +
+ {/* 分隔线 - 与轨道线呼应 */} + + + {/* 标签 */} + + + {isFallback ? t('focus.recommend.fallback') : t('focus.recommend.short')} + + + + {/* 漂浮气泡 */} +
+ + {displayTasks.map((task, i) => ( + + ))} + +
+ + {/* 更多任务提示 */} + {hasMore && ( + + +{tasks.length - 5} {t('focus.viewAll')} + + )} +
+ ) +} diff --git a/src/index.css b/src/index.css index b0db313..671faa7 100644 --- a/src/index.css +++ b/src/index.css @@ -328,3 +328,12 @@ ); } } + +/* ═══════════════════════════════════════════════════════════════════════════ + * 工具类 - 星海之眼需要 + * ═══════════════════════════════════════════════════════════════════════════ */ +@layer utilities { + .bg-gradient-radial { + background: radial-gradient(var(--tw-gradient-stops)); + } +} diff --git a/src/lib/ai/openai.js b/src/lib/ai/openai.js index fc28650..0498d8e 100644 --- a/src/lib/ai/openai.js +++ b/src/lib/ai/openai.js @@ -1,6 +1,6 @@ /** - * [INPUT]: OpenAI API, crypto.js (decryptKey), prompts.js (buildSystemPrompt, buildUserPrompt, buildTitlePrompt, buildTitleUserPrompt, parseAIResponse, parseTitleResponse, getFallbackPrompts) - * [OUTPUT]: generatePrompts, generateTitle - 调用 OpenAI API 生成问题和标题(支持流式输出) + * [INPUT]: OpenAI API, crypto.js (decryptKey), prompts.js (buildSystemPrompt, buildUserPrompt, buildTitlePrompt, buildTitleUserPrompt, parseAIResponse, parseTitleResponse, getFallbackPrompts, buildTaskRecommendSystemPrompt, buildTaskRecommendUserPrompt, parseTaskRecommendResponse, getLocalRecommendedTasks) + * [OUTPUT]: generatePrompts, generateTitle, recommendTasks - 调用 OpenAI API 生成问题、标题和任务推荐(支持流式输出) * [POS]: AI 模块的 API 层,负责与 OpenAI 通信 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -13,7 +13,11 @@ import { buildTitleUserPrompt, parseAIResponse, parseTitleResponse, - getFallbackPrompts + getFallbackPrompts, + buildTaskRecommendSystemPrompt, + buildTaskRecommendUserPrompt, + parseTaskRecommendResponse, + getLocalRecommendedTasks } from './prompts' // ============================================================ @@ -147,3 +151,75 @@ export async function generateTitle(context, config) { return null } } + +// ============================================================ +// Task Recommendation (Non-streaming) +// ============================================================ + +export async function recommendTasks(tasks, timeContext, config) { + // 如果任务少于等于 3 个,直接返回本地排序结果 + if (tasks.length <= 3) { + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + + try { + // 解密 API Key + const apiKey = await decryptKey(config.apiKey) + if (!apiKey) { + console.error('No API key available') + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + + // 构建 prompt + const systemPrompt = buildTaskRecommendSystemPrompt() + const userPrompt = buildTaskRecommendUserPrompt(tasks, timeContext) + + // 使用配置的 baseURL + const baseURL = config.baseURL || 'https://api.openai.com/v1' + const endpoint = `${baseURL}/chat/completions` + + // 调用 OpenAI 兼容 API + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: config.model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.3, // 低随机性,保持推荐稳定 + max_tokens: 100 + }) + }) + + if (!response.ok) { + const error = await response.json() + console.error('OpenAI API error:', error) + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + + const data = await response.json() + const content = data.choices[0]?.message?.content + + if (!content) { + console.error('No content in response') + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + + // 解析推荐结果 + const recommended = parseTaskRecommendResponse(content, tasks) + + if (recommended.length === 0) { + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + + return { tasks: recommended, fallback: false } + } catch (error) { + console.error('Failed to recommend tasks:', error) + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } +} diff --git a/src/lib/ai/prompts.js b/src/lib/ai/prompts.js index c287d91..f8159fe 100644 --- a/src/lib/ai/prompts.js +++ b/src/lib/ai/prompts.js @@ -1,7 +1,7 @@ /** * [INPUT]: journal data, tasks, AI config, time context - * [OUTPUT]: buildContext, parseAIResponse - 构建 AI prompt 上下文和解析响应 - * [POS]: AI 模块的核心逻辑层,负责 prompt 工程(开放式问题引导 + 维度池灵感 + 反套路设计) + * [OUTPUT]: buildContext, parseAIResponse, buildTaskRecommendPrompt, parseTaskRecommendResponse - 构建 AI prompt 上下文和解析响应 + * [POS]: AI 模块的核心逻辑层,负责 prompt 工程(开放式问题引导 + 维度池灵感 + 反套路设计 + 任务推荐) * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -343,7 +343,7 @@ export function getFallbackPrompts() { '此刻你的身体在告诉你什么?' ], [ - '如果可以重来,今天你会做什么不同的选择?', + '如果可以重来,今天你���做什么不同的选择?', '最近有什么让你感到感恩的事情?', '你现在最想逃避的是什么?' ], @@ -366,3 +366,118 @@ export function getFallbackPrompts() { inserted: false })) } + +// ============================================================ +// Task Recommendation Prompt +// ============================================================ + +export function buildTaskRecommendSystemPrompt() { + return `你是一个任务优先级分析助手。你的任务是帮助用户从待办事项中选出最应该优先处理的 3 个任务。 + +分析原则: +1. 紧急性:过期任务 > 今天到期 > 即将到期 > 无截止日期 +2. 重要性:根据任务标题判断任务的重要程度 +3. 可行性:考虑当前时间,选择适合现在做的任务 +4. 心理负担:拖延较久的任务应该优先处理,减轻心理负担 + +输出格式(纯文本,每行一个任务 ID): +任务ID1 +任务ID2 +任务ID3 + +注意: +- 直接输出任务 ID,不要添加序号、标点或其他格式 +- 如果任务少于 3 个,输出所有任务 ID +- 按推荐优先级排序,最重要的在第一行` +} + +export function buildTaskRecommendUserPrompt(tasks, timeContext) { + const lines = [] + + // 时间上下文 + lines.push(`当前时间:${timeContext.dayOfWeek} ${timeContext.timeOfDay}`) + lines.push('') + + // 任务列表 + lines.push('待处理任务:') + tasks.forEach(task => { + const dueInfo = task.dueDate + ? `截止: ${new Date(task.dueDate).toLocaleDateString('zh-CN')}` + : '无截止日期' + const createdDays = Math.floor((Date.now() - task.createdAt) / (24 * 60 * 60 * 1000)) + const createdInfo = createdDays > 0 ? `创建于 ${createdDays} 天前` : '今天创建' + lines.push(`- ID: ${task.id}`) + lines.push(` 标题: ${task.title}`) + lines.push(` ${dueInfo} | ${createdInfo}`) + }) + lines.push('') + + lines.push('请从以上任务中选出最应该优先处理的 3 个任务(按优先级排序)。') + + return lines.join('\n') +} + +export function parseTaskRecommendResponse(response, tasks) { + try { + // 按行分割,提取任务 ID + const ids = response + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .slice(0, 3) + + // 根据 ID 找到对应的任务 + const recommended = [] + for (const id of ids) { + const task = tasks.find(t => t.id === id) + if (task) { + recommended.push(task) + } + } + + return recommended + } catch (error) { + console.error('Failed to parse task recommend response:', error) + return [] + } +} + +// 本地排序降级方案 +export function getLocalRecommendedTasks(tasks, count = 3) { + const now = Date.now() + const today = new Date() + today.setHours(0, 0, 0, 0) + const todayStart = today.getTime() + + // 计算任务优先级分数 + const scored = tasks.map(task => { + let score = 0 + + // 过期任务优先级最高 + if (task.dueDate && task.dueDate < todayStart) { + const daysOverdue = Math.floor((todayStart - task.dueDate) / (24 * 60 * 60 * 1000)) + score += 1000 + daysOverdue * 10 + } + // 今天到期 + else if (task.dueDate && task.dueDate < todayStart + 24 * 60 * 60 * 1000) { + score += 500 + } + // 有截止日期 + else if (task.dueDate) { + const daysUntilDue = Math.floor((task.dueDate - now) / (24 * 60 * 60 * 1000)) + score += Math.max(0, 100 - daysUntilDue * 5) + } + + // 创建时间越久,优先级越高(减轻心理负担) + const daysOld = Math.floor((now - task.createdAt) / (24 * 60 * 60 * 1000)) + score += Math.min(daysOld * 2, 50) + + return { task, score } + }) + + // 按分数排序,取前 N 个 + return scored + .sort((a, b) => b.score - a.score) + .slice(0, count) + .map(item => item.task) +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 894c8f1..ae0675b 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -256,5 +256,72 @@ "links": "Links", "madeWith": "Made with", "by": "by" + }, + "focus": { + "title": "Focus", + "circle": { + "morning": "Good morning", + "afternoon": "Good afternoon", + "evening": "Good evening", + "night": "Late night", + "empty": "Nothing planned", + "allDone": "All done", + "pending": "things to do" + }, + "greeting": { + "morning": "Good morning, {{count}} things today", + "afternoon": "Good afternoon, {{count}} things left", + "evening": "Good evening, you did well today", + "night": "It's late, get some rest" + }, + "state": { + "idle": "Nothing planned yet, pick some from inbox?", + "flow": "Nice rhythm, stay focused", + "optimal": "In the zone, keep it up", + "busy": "Getting busy, but you got this", + "overload": "Quite a lot, pick the top 3 first" + }, + "overdue": { + "title": "{{count}} overdue tasks need attention", + "one": "1 thing pending, handle it now?", + "few": "A few things piled up, time to sort?", + "many": "Some backlog, prioritize what matters" + }, + "recommend": { + "title": "AI recommends these {{count}} first", + "short": "AI Picks", + "loading": "AI is analyzing...", + "fallback": "Default sort", + "refresh": "Refresh", + "viewAll": "View all tasks" + }, + "empty": { + "title": "No tasks for today", + "hint": "Pick some from inbox?", + "action": "Go to Inbox" + }, + "complete": { + "title": "Awesome!", + "hint": "All tasks completed today" + }, + "task": { + "createdToday": "Created today", + "createdAgo": "Created {{days}} days ago", + "dueToday": "Due today", + "overdue": "{{days}} days overdue" + }, + "viewAll": "View all", + "viewToday": "View Today", + "fromInbox": "From Inbox", + "fromSomeday": "From Someday", + "simplify": "Simplify Tasks", + "aiRecommend": "AI Recommend", + "overdueTitle": "{{count}} overdue tasks", + "daysOverdue": "{{days}} days overdue", + "today": "Today", + "tomorrow": "Tomorrow", + "abandon": "Abandon", + "moveAllToToday": "Move all to today", + "abandonAll": "Abandon all" } } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 780f255..f320ebe 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -256,5 +256,72 @@ "links": "链接", "madeWith": "用", "by": "制作" + }, + "focus": { + "title": "专注", + "circle": { + "morning": "早安", + "afternoon": "下午好", + "evening": "晚上好", + "night": "夜深了", + "empty": "今天还没有安排", + "allDone": "全部完成", + "pending": "件事待完成" + }, + "greeting": { + "morning": "早安,今天有 {{count}} 件事", + "afternoon": "下午好,还有 {{count}} 件事", + "evening": "晚上好,今天完成得不错", + "night": "夜深了,好好休息" + }, + "state": { + "idle": "今天还没开始,从收集箱挑几件事?", + "flow": "节奏刚好,专注当下", + "optimal": "状态正佳,继续保持", + "busy": "有点忙,但你能搞定", + "overload": "事情有点多,挑最重要的 3 件先做" + }, + "overdue": { + "title": "{{count}} 个过期任务需要处理", + "one": "有 1 件事还没处理,要现在做吗?", + "few": "有几件事拖了一阵子,整理一下?", + "many": "积压了一些事情,挑重要的先处理" + }, + "recommend": { + "title": "AI 推荐你先做这 {{count}} 件", + "short": "AI 推荐", + "loading": "AI 正在分析...", + "fallback": "默认排序", + "refresh": "刷新推荐", + "viewAll": "查看全部任务" + }, + "empty": { + "title": "今天还没有任务", + "hint": "从收集箱选几件事?", + "action": "去收集箱" + }, + "complete": { + "title": "太棒了!", + "hint": "今天的任务都完成了" + }, + "task": { + "createdToday": "今天创建", + "createdAgo": "创建于 {{days}} 天前", + "dueToday": "今天到期", + "overdue": "过期 {{days}} 天" + }, + "viewAll": "查看全部", + "viewToday": "查看今日任务", + "fromInbox": "从收集箱选择", + "fromSomeday": "从搁置中选择", + "simplify": "精简任务", + "aiRecommend": "AI 推荐", + "overdueTitle": "{{count}} 个过期任务", + "daysOverdue": "过期 {{days}} 天", + "today": "今天", + "tomorrow": "明天", + "abandon": "放弃", + "moveAllToToday": "全部移到今天", + "abandonAll": "全部放弃" } } diff --git a/src/stores/CLAUDE.md b/src/stores/CLAUDE.md index 93e4685..f7a4438 100644 --- a/src/stores/CLAUDE.md +++ b/src/stores/CLAUDE.md @@ -3,10 +3,10 @@ ## 成员清单 -gtd.js: GTD 核心状态管理,任务 CRUD + 列表筛选 + 持久化 +gtd.js: GTD 核心状态管理,任务 CRUD + 列表筛选 + 持久化 + calculateFocusState 专注度计算 + isToday/isPast/isFuture 日期工具 calendar.js: 日历状态管理,日期分组 + 网格生成 + 导航 journal.js: 日记状态管理,一天一记约束 + 按日期分组 + 时间倒序 -ai.js: AI 配置和状态管理,OpenAI API 集成 + 问题生成逻辑(支持流式输出回调)+ API Key 加密存储 +ai.js: AI 配置和状态管理,OpenAI API 集成 + 问题生成逻辑(支持流式输出回调)+ 任务推荐逻辑 + API Key 加密存储 editor.js: 编辑器样式配置管理,预设主题(默认/极简/舒适)+ 自定义样式(bullet/标题大小/行高/字号/宽度/边框/字重)+ CSS 变量应用 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md diff --git a/src/stores/ai.js b/src/stores/ai.js index 18353e3..8cf6614 100644 --- a/src/stores/ai.js +++ b/src/stores/ai.js @@ -1,14 +1,14 @@ /** - * [INPUT]: React hooks, crypto.js (encryptKey, decryptKey), openai.js (generatePrompts, generateTitle), prompts.js (buildContext) + * [INPUT]: React hooks, crypto.js (encryptKey, decryptKey), openai.js (generatePrompts, generateTitle, recommendTasks), prompts.js (buildContext, getLocalRecommendedTasks) * [OUTPUT]: useAI hook - AI 配置和状态管理 - * [POS]: AI 模块的状态层,管理配置和生成逻辑(问题 + 标题) + * [POS]: AI 模块的状态层,管理配置和生成逻辑(问题 + 标题 + 任务推荐) * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useState, useEffect, useCallback } from 'react' import { encryptKey, decryptKey } from '@/lib/ai/crypto' -import { generatePrompts as generatePromptsAPI, generateTitle as generateTitleAPI } from '@/lib/ai/openai' -import { buildContext } from '@/lib/ai/prompts' +import { generatePrompts as generatePromptsAPI, generateTitle as generateTitleAPI, recommendTasks as recommendTasksAPI } from '@/lib/ai/openai' +import { buildContext, getLocalRecommendedTasks } from '@/lib/ai/prompts' // ============================================================ // Constants @@ -143,6 +143,47 @@ export function useAI() { setConfig(DEFAULT_CONFIG) }, []) + // 推荐任务 + const recommendTasks = useCallback(async (tasks) => { + // 如果没有任务,返回空 + if (!tasks || tasks.length === 0) { + return { tasks: [], fallback: true } + } + + // 如果 AI 未启用,使用本地排序 + if (!config.enabled || !config.apiKey) { + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + + setGenerating(true) + setError(null) + + try { + // 获取时间上下文 + const hour = new Date().getHours() + const dayOfWeek = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date().getDay()] + let timeOfDay = '清晨' + if (hour >= 8 && hour < 12) timeOfDay = '上午' + else if (hour >= 12 && hour < 14) timeOfDay = '中午' + else if (hour >= 14 && hour < 18) timeOfDay = '下午' + else if (hour >= 18 && hour < 22) timeOfDay = '晚上' + else if (hour >= 22 || hour < 5) timeOfDay = '深夜' + + const timeContext = { dayOfWeek, timeOfDay } + + // 调用 API + const result = await recommendTasksAPI(tasks, timeContext, config) + + setGenerating(false) + return result + } catch (err) { + console.error('Failed to recommend tasks:', err) + setGenerating(false) + setError(err.message) + return { tasks: getLocalRecommendedTasks(tasks, 3), fallback: true } + } + }, [config]) + return { config, generating, @@ -151,6 +192,7 @@ export function useAI() { getDecryptedApiKey, generatePrompts, generateTitle, + recommendTasks, resetConfig } } diff --git a/src/stores/gtd.js b/src/stores/gtd.js index 14d236b..afec120 100644 --- a/src/stores/gtd.js +++ b/src/stores/gtd.js @@ -1,6 +1,6 @@ /** * [INPUT]: React useState/useEffect/useCallback/useMemo/useRef, format/task.js - * [OUTPUT]: useGTD hook,提供任务 CRUD 和状态管理,支持文件系统持久化 + * [OUTPUT]: useGTD hook,提供任务 CRUD 和状态管理,支持文件系统持久化;calculateFocusState 专注度计算;isToday/isPast/isFuture 日期工具 * [POS]: stores 层核心状态模块,被所有 GTD 组件消费 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -103,19 +103,20 @@ const getTodayBounds = () => { return { start, end } } -const isToday = (timestamp) => { +// 导出日期工具函数供其他模块使用 +export const isToday = (timestamp) => { if (!timestamp) return false const { start, end } = getTodayBounds() return timestamp >= start && timestamp < end } -const isFuture = (timestamp) => { +export const isFuture = (timestamp) => { if (!timestamp) return false const { end } = getTodayBounds() return timestamp >= end } -const isPast = (timestamp) => { +export const isPast = (timestamp) => { if (!timestamp) return false const { start } = getTodayBounds() return timestamp < start @@ -123,6 +124,46 @@ const isPast = (timestamp) => { const getStartOfTomorrow = () => getTodayBounds().end +/* ======================================== + 专注度计算 + ======================================== */ + +/** + * 计算专注度状态 + * @param {Array} tasks - 所有任务 + * @returns {Object} 专注度状态 + */ +export function calculateFocusState(tasks) { + // 今日任务:dueDate 是今天且未完成 + const today = tasks.filter(t => isToday(t.dueDate) && !t.completed) + // 过期任务:dueDate 在今天之前且未完成 + const overdue = tasks.filter(t => isPast(t.dueDate) && !t.completed && t.dueDate) + + // 主状态:基于 Today 数量 + // idle(0) → flow(1-2) → optimal(3-5) → busy(6-7) → overload(8+) + let state = 'optimal' + + if (today.length === 0) { + state = 'idle' + } else if (today.length <= 2) { + state = 'flow' + } else if (today.length <= 5) { + state = 'optimal' + } else if (today.length <= 7) { + state = 'busy' + } else { + state = 'overload' + } + + return { + state, + todayCount: today.length, + overdueCount: overdue.length, + overdueTasks: overdue, + todayTasks: today + } +} + const isTaskInList = (task, list) => { switch (list) { case GTD_LISTS.INBOX: From 60a728de9f3b616e4ce2480a1cebd004ff35488b Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 14:53:35 +0800 Subject: [PATCH 02/32] Update planet design to flat color illustration style --- src/components/gtd/FloatingTaskBubble.jsx | 7 +- src/components/gtd/Planet.jsx | 114 +++++++++------------- 2 files changed, 46 insertions(+), 75 deletions(-) diff --git a/src/components/gtd/FloatingTaskBubble.jsx b/src/components/gtd/FloatingTaskBubble.jsx index 9f82a0d..6edf157 100644 --- a/src/components/gtd/FloatingTaskBubble.jsx +++ b/src/components/gtd/FloatingTaskBubble.jsx @@ -30,9 +30,6 @@ export function FloatingTaskBubble({ const colorKey = COLOR_KEYS[index % COLOR_KEYS.length] const color = PLANET_COLORS[colorKey] - // 生成渐变 - const gradient = `radial-gradient(circle at 35% 35%, ${color.highlight} 0%, ${color.base} 50%, ${color.shadow} 100%)` - // 随机动画参数 const animDuration = useMemo(() => 4 + Math.random() * 2, []) const animDelay = useMemo(() => Math.random() * 2, []) @@ -67,10 +64,10 @@ export function FloatingTaskBubble({ whileTap={{ scale: 0.95 }} onClick={() => onComplete?.(task.id)} > - {/* 小圆点 - 与行星呼应 */} + {/* 小圆点 - 与行星呼应,平涂风格 */}
{/* 任务标题 */} diff --git a/src/components/gtd/Planet.jsx b/src/components/gtd/Planet.jsx index d8b76ce..0eee5f0 100644 --- a/src/components/gtd/Planet.jsx +++ b/src/components/gtd/Planet.jsx @@ -1,7 +1,7 @@ /** * [INPUT]: react, gsap, @/lib/utils * [OUTPUT]: Planet 组件, PLANET_COLORS 常量 - * [POS]: SVG filter 手绘风格行星,不规则边缘 + 渐变填充 + 玻璃球高光 + * [POS]: 手绘插画风格行星,平涂色块 + SVG filter 不规则边缘 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -10,33 +10,29 @@ import gsap from 'gsap' import { cn } from '@/lib/utils' // ═══════════════════════════════════════════════════════════════════════════ -// 行星颜色配置 +// 行星颜色配置 - 平涂色块,手绘插画风格 // ═══════════════════════════════════════════════════════════════════════════ export const PLANET_COLORS = { coral: { - base: '#ff7b5c', - highlight: '#ffb090', - shadow: '#cc5a40', + fill: '#ff7b5c', + stroke: '#cc5a40', }, purple: { - base: '#a855f7', - highlight: '#d4a5ff', - shadow: '#7c3aed', + fill: '#a855f7', + stroke: '#7c3aed', }, cyan: { - base: '#4dd4ac', - highlight: '#a0f0d0', - shadow: '#2a9d8f', + fill: '#4dd4ac', + stroke: '#2a9d8f', }, cream: { - base: '#f0d090', - highlight: '#fff0c0', - shadow: '#c0a060', + fill: '#f0d090', + stroke: '#c0a060', }, } // ═══════════════════════════════════════════════════════════════════════════ -// 手绘风格行星组件 - SVG filter 实现不规则边缘 +// 手绘插画风格行星 - 平涂 + 不规则边缘 // ═══════════════════════════════════════════════════════════════════════════ export function Planet({ task, @@ -91,9 +87,6 @@ export function Planet({ return () => tween.kill() }, [layerConfig.speed]) - // 渐变背景 - const gradient = `radial-gradient(circle at 30% 30%, ${color.highlight} 0%, ${color.base} 45%, ${color.shadow} 100%)` - return (
setIsHovered(false)} onClick={() => onClick?.(task?.id)} > - {/* SVG Filter 定义 */} - + {/* SVG 手绘行星 */} + + {/* 手绘扭曲效果 */} - {/* 手绘扭曲效果 */} - - {/* 土星环(在行星后面) */} - {hasRing && ( -
+ )} + + {/* 行星主体 - 平涂色块 */} + - )} - - {/* 行星主体 - 应用手绘 filter */} -
- - {/* 玻璃球高光 */} -
- - {/* 次级高光 */} -
+ {/* Tooltip */} {isHovered && task && ( From 5ffcf6a7200a3902ae7599fcdccc8acff656fa0e Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 16:50:52 +0800 Subject: [PATCH 03/32] Add interactive planet selection in focus view - Add yellow planet SVG asset for visual consistency - Make planets selectable with visual feedback (glow, pulse animation) - Connect planet selection to task bubbles for two-way highlighting - Add drag support for planets with position persistence - Enhance orbit paths with breathing animation effect - Update task bubble interaction: select first, then complete on second click - Increase planet sizes for better visual hierarchy --- src/assets/yellow_plant.svg | 1 + src/components/gtd/FloatingTaskBubble.jsx | 27 ++- src/components/gtd/FocusCircle.jsx | 17 +- src/components/gtd/FocusView.jsx | 16 +- src/components/gtd/OrbitPaths.jsx | 35 ++-- src/components/gtd/Planet.jsx | 220 ++++++++++++++-------- src/components/gtd/TaskBubbleZone.jsx | 4 + src/index.css | 14 ++ 8 files changed, 225 insertions(+), 109 deletions(-) create mode 100644 src/assets/yellow_plant.svg 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/FloatingTaskBubble.jsx b/src/components/gtd/FloatingTaskBubble.jsx index 6edf157..27595e5 100644 --- a/src/components/gtd/FloatingTaskBubble.jsx +++ b/src/components/gtd/FloatingTaskBubble.jsx @@ -21,6 +21,8 @@ export function FloatingTaskBubble({ task, index = 0, isAIRecommended = false, + isSelected = false, + onSelect, onComplete, className }) { @@ -40,10 +42,13 @@ export function FloatingTaskBubble({ "inline-flex items-center gap-3 px-5 py-3", "bg-background/40 backdrop-blur-md", "rounded-full", - "border border-border/20", + "border", "cursor-pointer", - "hover:bg-background/60 hover:border-primary/30", - "transition-colors", + "hover:bg-background/60", + "transition-all duration-200", + isSelected + ? "border-primary/50 bg-primary/10 ring-2 ring-primary/20" + : "border-border/20 hover:border-primary/30", className )} initial={{ opacity: 0, y: 20 }} @@ -60,14 +65,20 @@ export function FloatingTaskBubble({ delay: animDelay, }, }} - whileHover={{ scale: 1.05 }} + whileHover={{ scale: isSelected ? 1.02 : 1.05 }} whileTap={{ scale: 0.95 }} - onClick={() => onComplete?.(task.id)} + onClick={() => { + if (isSelected) { + onComplete?.(task.id) + } else { + onSelect?.(task.id) + } + }} > - {/* 小圆点 - 与行星呼应,平涂风格 */} + {/* 小圆点 - 与行星呼应,用相同 filter */}
{/* 任务标题 */} diff --git a/src/components/gtd/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx index 436c331..deaaac5 100644 --- a/src/components/gtd/FocusCircle.jsx +++ b/src/components/gtd/FocusCircle.jsx @@ -19,22 +19,22 @@ import { NoiseOverlay } from './NoiseOverlay' // ═══════════════════════════════════════════════════════════════════════════ const PLANET_CONFIG = [ // 左侧小行星 - { x: '12%', y: '45%', size: 40, colorKey: 'purple', layer: 'back' }, + { x: '12%', y: '45%', size: 75, colorKey: 'purple', layer: 'back' }, - // 中间巨大橙色行星(主角) + // 中间大行星(主角) { x: '50%', y: '50%', size: 150, colorKey: 'coral', layer: 'front' }, // 右上小行星 - { x: '72%', y: '28%', size: 45, colorKey: 'cyan', layer: 'mid' }, + { x: '72%', y: '28%', size: 82, colorKey: 'cyan', layer: 'mid' }, // 左下小行星 - { x: '28%', y: '68%', size: 35, colorKey: 'purple', layer: 'back' }, + { x: '28%', y: '68%', size: 68, colorKey: 'purple', layer: 'back' }, // 右下土星 - { x: '85%', y: '55%', size: 80, colorKey: 'cream', hasRing: true, layer: 'front' }, + { x: '85%', y: '55%', size: 98, colorKey: 'cream', hasRing: true, layer: 'front' }, // 额外小行星 - { x: '62%', y: '65%', size: 28, colorKey: 'cyan', layer: 'mid' }, + { x: '62%', y: '65%', size: 52, colorKey: 'cyan', layer: 'mid' }, ] // ═══════════════════════════════════════════════════════════════════════════ @@ -44,7 +44,9 @@ export function FocusCircle({ totalCount = 0, completedCount = 0, tasks = [], + selectedTaskId = null, onParticleClick, + onTaskSelect, className }) { // 准备行星任务数据 - 只取未完成的任务 @@ -62,6 +64,7 @@ export function FocusCircle({ "bg-[#9aa8a0]", className )} + style={{ minHeight: '600px' }} > {/* Layer 1: 背景星点 */} @@ -86,7 +89,9 @@ export function FocusCircle({ colorKey={config.colorKey} hasRing={config.hasRing} layer={config.layer} + isSelected={task.id === selectedTaskId} onClick={onParticleClick} + onTaskSelect={onTaskSelect} /> ) })} diff --git a/src/components/gtd/FocusView.jsx b/src/components/gtd/FocusView.jsx index 11cf190..2433660 100644 --- a/src/components/gtd/FocusView.jsx +++ b/src/components/gtd/FocusView.jsx @@ -96,6 +96,9 @@ export function FocusView({ const [isFallback, setIsFallback] = useState(false) const [loading, setLoading] = useState(false) + // 选中任务状态 - 用于高亮对应星球 + const [selectedTaskId, setSelectedTaskId] = useState(null) + // 今日任务(包括过期) const todayTasks = useMemo(() => { return tasks.filter(t => @@ -147,7 +150,14 @@ export function FocusView({ const handleComplete = useCallback((taskId) => { onComplete?.(taskId) setRecommendedTasks(prev => prev.filter(t => t.id !== taskId)) - }, [onComplete]) + // 清除选中状态 + if (selectedTaskId === taskId) setSelectedTaskId(null) + }, [onComplete, selectedTaskId]) + + // 处理任务选择 - 高亮对应星球 + const handleTaskSelect = useCallback((taskId) => { + setSelectedTaskId(prev => prev === taskId ? null : taskId) + }, []) // 判断状态 const isEmpty = todayTasks.length === 0 && completedToday === 0 @@ -163,7 +173,9 @@ export function FocusView({ totalCount={todayTasks.length + completedToday} completedCount={completedToday} tasks={todayTasks} + selectedTaskId={selectedTaskId} onParticleClick={handleComplete} + onTaskSelect={handleTaskSelect} className="flex-1" /> @@ -178,6 +190,8 @@ export function FocusView({ diff --git a/src/components/gtd/OrbitPaths.jsx b/src/components/gtd/OrbitPaths.jsx index ec7c313..c9832e4 100644 --- a/src/components/gtd/OrbitPaths.jsx +++ b/src/components/gtd/OrbitPaths.jsx @@ -12,33 +12,32 @@ import gsap from 'gsap' // 椭圆轨道配置 - 像土星环一样的椭圆 // ═══════════════════════════════════════════════════════════════════════════ const ORBIT_ELLIPSES = [ - { cx: 400, cy: 300, rx: 380, ry: 120, rotation: -15, opacity: 0.4, width: 2.5 }, - { cx: 400, cy: 300, rx: 420, ry: 140, rotation: -15, opacity: 0.35, width: 2 }, - { cx: 400, cy: 300, rx: 460, ry: 160, rotation: -15, opacity: 0.25, width: 1.5 }, - { cx: 400, cy: 300, rx: 500, ry: 180, rotation: -15, opacity: 0.2, width: 1 }, - { cx: 400, cy: 300, rx: 540, ry: 200, rotation: -15, opacity: 0.15, width: 1 }, - { cx: 400, cy: 300, rx: 580, ry: 220, rotation: -15, opacity: 0.1, width: 0.5 }, + { cx: 400, cy: 300, rx: 380, ry: 120, rotation: -15, opacity: 0.4, width: 2.5, speed: 1 }, + { cx: 400, cy: 300, rx: 420, ry: 140, rotation: -15, opacity: 0.35, width: 2, speed: 0.8 }, + { cx: 400, cy: 300, rx: 460, ry: 160, rotation: -15, opacity: 0.25, width: 1.5, speed: 0.6 }, + { cx: 400, cy: 300, rx: 500, ry: 180, rotation: -15, opacity: 0.2, width: 1, speed: 0.5 }, + { cx: 400, cy: 300, rx: 540, ry: 200, rotation: -15, opacity: 0.15, width: 1, speed: 0.4 }, + { cx: 400, cy: 300, rx: 580, ry: 220, rotation: -15, opacity: 0.1, width: 0.5, speed: 0.3 }, ] // 深蓝紫色 const ORBIT_COLOR = '100, 120, 180' // ═══════════════════════════════════════════════════════════════════════════ -// 轨道带组件 - 椭圆形 +// 轨道带组件 - 椭圆形,呼吸感动效 // ═══════════════════════════════════════════════════════════════════════════ export function OrbitPaths() { - const pathsRef = useRef([]) + const ellipsesRef = useRef([]) - // 入场动画 - 描边绘制 + // 入场动画 + 呼吸效果 useEffect(() => { - pathsRef.current.forEach((ellipse, i) => { + ellipsesRef.current.forEach((ellipse, i) => { if (!ellipse) return - // 计算椭圆周长近似值 + // 入场描边动画 const rx = ORBIT_ELLIPSES[i].rx const ry = ORBIT_ELLIPSES[i].ry const length = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry))) - ellipse.style.strokeDasharray = length ellipse.style.strokeDashoffset = length @@ -48,6 +47,16 @@ export function OrbitPaths() { delay: i * 0.12, ease: 'power2.out', }) + + // 呼吸感 - 缓慢的透明度脉冲 + gsap.to(ellipse, { + strokeOpacity: ORBIT_ELLIPSES[i].opacity * 0.6, + duration: 3 + i * 0.3, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + delay: 2, + }) }) }, []) @@ -60,7 +69,7 @@ export function OrbitPaths() { {ORBIT_ELLIPSES.map((orbit, i) => ( pathsRef.current[i] = el} + ref={el => ellipsesRef.current[i] = el} cx={orbit.cx} cy={orbit.cy} rx={orbit.rx} diff --git a/src/components/gtd/Planet.jsx b/src/components/gtd/Planet.jsx index 0eee5f0..94027cf 100644 --- a/src/components/gtd/Planet.jsx +++ b/src/components/gtd/Planet.jsx @@ -1,38 +1,39 @@ /** * [INPUT]: react, gsap, @/lib/utils * [OUTPUT]: Planet 组件, PLANET_COLORS 常量 - * [POS]: 手绘插画风格行星,平涂色块 + SVG filter 不规则边缘 + * [POS]: 基于 SVG 素材的手绘风格行星,支持拖拽移动,呼吸动效 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useEffect, useRef, useState, useMemo } from 'react' import gsap from 'gsap' import { cn } from '@/lib/utils' +import yellowPlanetSVG from '@/assets/yellow_plant.svg?raw' // ═══════════════════════════════════════════════════════════════════════════ -// 行星颜色配置 - 平涂色块,手绘插画风格 +// 行星颜色配置 - 通过 hue-rotate 滤镜实现 // ═══════════════════════════════════════════════════════════════════════════ export const PLANET_COLORS = { coral: { - fill: '#ff7b5c', - stroke: '#cc5a40', + filter: 'hue-rotate(320deg) saturate(1.2)', // 黄→珊瑚橙 + glow: 'rgba(255, 150, 120, 0.6)', // 对应的光晕颜色 }, purple: { - fill: '#a855f7', - stroke: '#7c3aed', + filter: 'hue-rotate(200deg) saturate(0.9)', // 黄→紫色 + glow: 'rgba(180, 140, 255, 0.6)', // 紫色光晕 }, cyan: { - fill: '#4dd4ac', - stroke: '#2a9d8f', + filter: 'hue-rotate(140deg) saturate(1.1)', // 黄→青色 + glow: 'rgba(120, 200, 220, 0.6)', // 青色光晕 }, cream: { - fill: '#f0d090', - stroke: '#c0a060', + filter: 'none', // 保持原色(黄色/奶油色) + glow: 'rgba(255, 230, 150, 0.6)', // 奶油色光晕 }, } // ═══════════════════════════════════════════════════════════════════════════ -// 手绘插画风格行星 - 平涂 + 不规则边缘 +// 基于 SVG 素材的行星 - 支持拖拽 // ═══════════════════════════════════════════════════════════════════════════ export function Planet({ task, @@ -41,15 +42,19 @@ export function Planet({ colorKey = 'coral', hasRing = false, layer = 'mid', + isSelected = false, onClick, + onPositionChange, // 新增:位置变化回调 + onTaskSelect, // 新增:任务选择回调 className }) { const ref = useRef(null) const [isHovered, setIsHovered] = useState(false) - const color = PLANET_COLORS[colorKey] || PLANET_COLORS.coral + const [isDragging, setIsDragging] = useState(false) + const colorConfig = PLANET_COLORS[colorKey] || PLANET_COLORS.coral - // 唯一 ID 用于 SVG filter - const filterId = useMemo(() => `hand-drawn-${Math.random().toString(36).slice(2, 9)}`, []) + // 本地位置状态(用于拖拽) + const [localPos, setLocalPos] = useState({ x: position.x, y: position.y }) // 层级配置 const layerConfig = useMemo(() => { @@ -60,108 +65,161 @@ export function Planet({ } }, [layer]) - // GSAP 漂移动画 + // 拖拽处理 + const handleMouseDown = (e) => { + e.stopPropagation() + setIsDragging(true) + + const startX = e.clientX + const startY = e.clientY + const container = ref.current.parentElement + const containerRect = container.getBoundingClientRect() + + // 当前位置转像素 + const currentX = (parseFloat(localPos.x) / 100) * containerRect.width + const currentY = (parseFloat(localPos.y) / 100) * containerRect.height + + const handleMouseMove = (moveEvent) => { + 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 = () => { + setIsDragging(false) + onPositionChange?.(localPos) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + // GSAP 呼吸感动画 - 缩放 + 旋转(拖拽时暂停) useEffect(() => { if (!ref.current) return - const duration = 12 + Math.random() * 8 - const driftX = (Math.random() - 0.5) * 20 * layerConfig.speed - const driftY = (Math.random() - 0.5) * 16 * layerConfig.speed + 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.8 }, - { opacity: 1, scale: 1, duration: 0.8, delay: Math.random() * 0.5 } + { 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)' } ) + // 呼吸感动画 - 缩放 + 旋转 + breatheTweenRef.current = gsap.to(ref.current, { + scale: 1.05, + rotation: 3, + duration, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + }) + // 漂移动画 - const tween = gsap.to(ref.current, { + driftTweenRef.current = gsap.to(ref.current, { x: driftX, y: driftY, - duration, + duration: duration * 2, repeat: -1, yoyo: true, ease: 'sine.inOut', }) - return () => tween.kill() + return () => { + if (breatheTweenRef.current) breatheTweenRef.current.kill() + if (driftTweenRef.current) driftTweenRef.current.kill() + } }, [layerConfig.speed]) + // 存储动画 tween 引用,用于暂停/恢复 + const breatheTweenRef = useRef(null) + const driftTweenRef = useRef(null) + + // 拖拽时暂停呼吸动画 + useEffect(() => { + if (breatheTweenRef.current) { + if (isDragging) { + breatheTweenRef.current.pause() + } else { + breatheTweenRef.current.resume() + } + } + if (driftTweenRef.current) { + if (isDragging) { + driftTweenRef.current.pause() + } else { + driftTweenRef.current.resume() + } + } + }, [isDragging]) + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - onClick={() => onClick?.(task?.id)} + onClick={() => { + // 拖拽结束时才触发点击 + if (!isDragging) { + onTaskSelect?.(task?.id) + onClick?.(task?.id) + } + }} > - {/* SVG 手绘行星 */} - - - {/* 手绘扭曲效果 */} - - - - - - - {/* 土星环(在行星后面) */} - {hasRing && ( - - )} - - {/* 行星主体 - 平涂色块 */} - - + )} + {/* SVG 素材 - 用 filter 改变颜色,选中时叠加光晕 */} + {/* Tooltip */} - {isHovered && task && ( + {isHovered && task && !isDragging && (
))} diff --git a/src/index.css b/src/index.css index 671faa7..79dea2e 100644 --- a/src/index.css +++ b/src/index.css @@ -337,3 +337,17 @@ background: radial-gradient(var(--tw-gradient-stops)); } } + +/* ═══════════════════════════════════════════════════════════════════════════ + * 行星选中脉冲动画 + * ═══════════════════════════════════════════════════════════════════════════ */ +@keyframes pulse-glow { + 0%, 100% { + opacity: 0.6; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.1); + } +} From 7f4f5b549c142b95bff96dd5efe47666b8e449fd Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 18:16:32 +0800 Subject: [PATCH 04/32] Add focus mode with pomodoro timer and constellation system - Add FocusMode component with pomodoro timer (15/25/45 min options) - Implement constellation system showing completed tasks as stars with connections - Add time-aware backgrounds (dawn/morning/afternoon/evening/night) - Support planet collapse animation with particle effects - Add context menu for task operations (edit, move, delete, focus) - Show pomodoro rings around planets for completed sessions - Add two-level empty state guidance (empty/complete) - Add overdue task card with quick actions (today/tomorrow/delete) - Add new SVG assets for planet variations - Update CSS variables for focus view design system --- src/App.jsx | 10 + src/assets/plant.svg | 1 + src/assets/plant/plant1.svg | 0 src/assets/plant/plant2.svg | 1 + src/assets/plant/plant3.svg | 1 + src/assets/plant/star.svg | 1 + src/assets/plant2.svg | 1 + src/components/gtd/CLAUDE.md | 12 +- src/components/gtd/Constellation.jsx | 287 +++++++++++ src/components/gtd/FocusCircle.jsx | 169 ++++++- src/components/gtd/FocusMode.jsx | 433 ++++++++++++++++ src/components/gtd/FocusView.jsx | 409 +++++++++++++--- src/components/gtd/OrbitPaths.jsx | 14 +- src/components/gtd/Planet.jsx | 705 +++++++++++++++++++++++---- src/index.css | 67 +++ src/locales/en-US.json | 18 +- src/locales/zh-CN.json | 18 +- 17 files changed, 1945 insertions(+), 202 deletions(-) create mode 100644 src/assets/plant.svg create mode 100644 src/assets/plant/plant1.svg create mode 100644 src/assets/plant/plant2.svg create mode 100644 src/assets/plant/plant3.svg create mode 100644 src/assets/plant/star.svg create mode 100644 src/assets/plant2.svg create mode 100644 src/components/gtd/Constellation.jsx create mode 100644 src/components/gtd/FocusMode.jsx diff --git a/src/App.jsx b/src/App.jsx index 12e5b85..4083887 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -369,6 +369,16 @@ function AppContent({ fileSystem, sync }) { setActiveList(GTD_LISTS.TODAY) setViewMode('list') }} + onEditTask={(task) => { + setSelectedTaskId(task.id) + setViewMode('list') + }} + onUpdatePomodoro={(taskId, count) => { + updateTask(taskId, { + pomodoros: count, + lastPomodoroAt: new Date().toISOString() + }) + }} /> ) : viewMode === 'calendar' ? ( \ 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..e69de29 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/components/gtd/CLAUDE.md b/src/components/gtd/CLAUDE.md index e9fd6af..fbc68fb 100644 --- a/src/components/gtd/CLAUDE.md +++ b/src/components/gtd/CLAUDE.md @@ -21,19 +21,21 @@ JournalPastView.jsx: "过往"视图,历史日记支持列表/弧线画布( JournalItem.jsx: 过往日记列表项,显示日期 + 标题 + 预览 + 字数 JournalChip.jsx: 日历内日记小卡片,虚线边框,不可拖拽,BookText 图标 AIPromptCard.jsx: AI 问题卡片,展示生成的引导问题(无 emoji),支持点击插入、悬停删除、刷新,淡入淡出动画,显示加载状态 -FocusView.jsx: 专注视图主组件,柔性宇宙插画风格,整合 FocusCircle + TaskBubbleZone + Empty/Complete 状态 -FocusCircle.jsx: 专注视图核心 - 柔性宇宙插画,SVG filter 手绘行星 + 椭圆轨道带 +FocusView.jsx: 专注视图主组件,柔性宇宙插画风格,整合 FocusMode 专注模式 + Constellation 星座系统 + OverdueCard 过期任务卡片 + 两层空状态引导 +FocusCircle.jsx: 专注视图核心 - 柔性宇宙插画,时间感知背景(晨曦/清醒/午后/暮蓝/深空),行星位置 localStorage 持久化,集成 Constellation +Planet.jsx: 手绘风格行星,支持坍缩动画(GSAP 收缩 + 粒子迸发 + 闪白)+ 红巨星状态(过期任务暗红脉动)+ 番茄环渲染(显示已完成番茄钟数量)+ 长按进入专注模式 + 右键菜单(编辑/移到今天或明天/删除)+ 拖拽整理位置 +FocusMode.jsx: 全屏专注模式组件,番茄钟计时器(15/25/45分钟可选),倒计时进度,完成番茄钟/直接完成任务按钮,放弃专注,GSAP 入场动画 +Constellation.jsx: 完成任务星座系统,已完成任务留下微弱恒星(闪烁动画),当天完成的恒星之间虚线连线,useConstellation hook 管理状态 + localStorage 持久化 StarDust.jsx: 背景星点层,GSAP 动画,35个微小白色粒子极慢漂浮 OrbitPaths.jsx: 椭圆轨道带 - 多条同心椭圆(像土星环),深蓝紫色,GSAP 描边动画 -Planet.jsx: SVG filter 手绘风格行星,feTurbulence + feDisplacementMap 实现不规则边缘,玻璃球高光 BlueDust.jsx: 蓝色粒子层,GSAP 动画,25个蓝色小点集中在中间区域 -MiniInfo.jsx: 右上角极简信息标签,GSAP 入场动画,问候语 + 数字 +MiniInfo.jsx: 右上角极简信息标签,GSAP 入场动画,问候语 + 数字,支持时间感知 NoiseOverlay.jsx: 全局噪点纹理层,SVG feTurbulence 实现颗粒感 FloatingTaskBubble.jsx: 漂浮气泡任务卡片,圆角胶囊形状,渐变圆点前缀,与行星系统融为一体 TaskBubbleZone.jsx: 底部任务气泡区域,水平排列漂浮气泡,最多显示5个 ## 子目录 -settings/: 设置模块,左右分栏布局��桌面端)+ Sheet 全屏(移动端) +settings/: 设置模块,左右分栏布局(桌面端)+ Sheet 全屏(移动端) [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md diff --git a/src/components/gtd/Constellation.jsx b/src/components/gtd/Constellation.jsx new file mode 100644 index 0000000..3b109ff --- /dev/null +++ b/src/components/gtd/Constellation.jsx @@ -0,0 +1,287 @@ +/** + * [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] + + // 解析百分比位置 + const fromX = parseFloat(from.x) / 100 + const fromY = parseFloat(from.y) / 100 + const toX = parseFloat(to.x) / 100 + const toY = parseFloat(to.y) / 100 + + 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) => ( + + ))} + + {/* 悬停提示 */} + {todayCount > 0 && ( +
+

+ {todayCount === 1 + ? '今天完成了一颗恒星' + : `今天完成了 ${todayCount} 颗恒星`} +

+
+ )} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx index deaaac5..07f829e 100644 --- a/src/components/gtd/FocusCircle.jsx +++ b/src/components/gtd/FocusCircle.jsx @@ -1,18 +1,75 @@ /** - * [INPUT]: react, @/lib/utils, ./StarDust, ./OrbitPaths, ./Planet, ./BlueDust, ./MiniInfo, ./NoiseOverlay + * [INPUT]: react, @/lib/utils, ./StarDust, ./OrbitPaths, ./Planet, ./BlueDust, ./MiniInfo, ./NoiseOverlay, ./Constellation * [OUTPUT]: FocusCircle 组件 - * [POS]: 专注视图核心 - 柔性宇宙插画,SVG filter 手绘行星 + 椭圆轨道带 + * [POS]: 专注视图核心 - 柔性宇宙插画,时间感知背景,已完成任务星座 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useMemo } from 'react' +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 { MiniInfo } from './MiniInfo' import { NoiseOverlay } from './NoiseOverlay' +import { Constellation } from './Constellation' + +// ═══════════════════════════════════════════════════════════════════════════ +// 时间感知背景色配置 - 使用 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 +} // ═══════════════════════════════════════════════════════════════════════════ // 行星配置 - 沿椭圆轨道分布 @@ -37,6 +94,32 @@ const PLANET_CONFIG = [ { x: '62%', y: '65%', 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) + } +} + // ═══════════════════════════════════════════════════════════════════════════ // 主组件 - 柔性宇宙插画 // ═══════════════════════════════════════════════════════════════════════════ @@ -47,24 +130,74 @@ export function FocusCircle({ selectedTaskId = null, onParticleClick, onTaskSelect, + onLongPress, + onPlanetCollapsed, + onPositionChange, + onEditTask, + onMoveToToday, + onMoveToTomorrow, + onDeleteTask, className }) { + // 时间感知背景 + const [timeConfig, setTimeConfig] = useState(getTimeBasedConfig()) + + // 更新时间配置(每分钟检查一次) + useEffect(() => { + const interval = setInterval(() => { + setTimeConfig(getTimeBasedConfig()) + }, 60000) + + return () => clearInterval(interval) + }, []) + + // 加载保存的位置 + const savedPositions = loadSavedPositions() + // 准备行星任务数据 - 只取未完成的任务 const planetTasks = useMemo(() => { return tasks.filter(t => !t.completed).slice(0, PLANET_CONFIG.length) }, [tasks]) + // 处理位置变化 + 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 (
{/* Layer 1: 背景星点 */} @@ -75,32 +208,46 @@ export function FocusCircle({ {/* Layer 3: 蓝色粒子 */} - {/* Layer 4: 行星 */} + {/* Layer 4: 已完成任务星座 */} + t.completed)} /> + + {/* Layer 5: 行星 */} {PLANET_CONFIG.map((config, i) => { const task = planetTasks[i] if (!task) return null + const position = getPlanetPosition(task, config) + return ( ) })} - {/* Layer 5: 噪点纹理 */} + {/* Layer 6: 噪点纹理 */} - {/* Layer 6: 右上角信息 */} - + {/* Layer 7: 右上角信息 */} +
) } diff --git a/src/components/gtd/FocusMode.jsx b/src/components/gtd/FocusMode.jsx new file mode 100644 index 0000000..74cdbb6 --- /dev/null +++ b/src/components/gtd/FocusMode.jsx @@ -0,0 +1,433 @@ +/** + * [INPUT]: react, gsap, react-i18next, framer-motion, @/lib/utils + * [OUTPUT]: FocusMode 组件 + * [POS]: 全屏专注模式组件,番茄钟计时器,长按星球后进入 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useState, useEffect, useRef, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Play, Pause, Clock } from 'lucide-react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 番茄钟时长选项(分钟) +// ═══════════════════════════════════════════════════════════════════════════ +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')}` +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 时长选择按钮 +// ═══════════════════════════════════════════════════════════════════════════ +function DurationButton({ minutes, selected, onSelect }) { + return ( + onSelect(minutes)} + className={cn( + "w-16 h-16 rounded-2xl flex flex-col items-center justify-center transition-all" + )} + style={{ + background: selected + ? 'oklch(from var(--focus-text-bright) l c h / 30%)' + : 'oklch(from var(--focus-text-bright) l c h / 10%)', + color: selected + ? 'var(--focus-text-bright)' + : 'oklch(from var(--focus-text-bright) l c h / 60%)' + }} + > + {minutes} + 分钟 + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 专注模式主组件 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusMode({ + task = null, + initialPomodoros = 0, + onPomodoroComplete, + onTaskComplete, + onAbandon +}) { + const { t } = useTranslation() + const containerRef = useRef(null) + const circleRef = useRef(null) + const progressRef = useRef(null) + + // 状态 + const [step, setStep] = useState('select') // 'select' | 'running' | 'paused' | 'complete' + const [selectedDuration, setSelectedDuration] = useState(25) + const [remainingSeconds, setRemainingSeconds] = useState(25 * 60) + const [completedPomodoros, setCompletedPomodoros] = useState(initialPomodoros) + + // 计时器引用 + const timerRef = useRef(null) + const startTimeRef = useRef(null) + const totalDurationRef = useRef(25 * 60) + + // 入场动画 + useEffect(() => { + if (!containerRef.current) return + + const tl = gsap.timeline() + + // 背景渐入 + tl.fromTo(containerRef.current, + { opacity: 0 }, + { opacity: 1, duration: 0.5, ease: 'power2.out' } + ) + + // 中心圆环展开 + if (circleRef.current) { + tl.fromTo(circleRef.current, + { scale: 0, rotate: -90 }, + { scale: 1, rotate: 0, duration: 0.8, ease: 'back.out(1.7)' }, + '-=0.3' + ) + } + + return () => { tl.kill() } + }, []) + + // 启动计时器 + 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 (progressRef.current) { + const progress = 1 - (remaining / totalDurationRef.current) + progressRef.current.style.strokeDashoffset = 283 * (1 - progress) + } + + if (remaining <= 0) { + // 计时结束 + clearInterval(timerRef.current) + setRemainingSeconds(0) + setStep('complete') + } else { + setRemainingSeconds(remaining) + } + }, 100) + }, []) + + // 开始专注 + const handleStart = useCallback(() => { + totalDurationRef.current = selectedDuration * 60 + setRemainingSeconds(selectedDuration * 60) + 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) + + // 重置进度圆环 + if (progressRef.current) { + progressRef.current.style.strokeDashoffset = 283 + } + }, [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 ( + + {/* 放弃按钮 */} + + + {t('focus.pomodoro.abandon', '放弃')} + + + {/* 完成数量徽章 */} + {completedPomodoros > 0 && ( + + + + {t('focus.pomodoro.completedCount', '已完成 {{count}} 个', { count: completedPomodoros })} + + + )} + +
+ {/* 步骤1: 选择时长 */} + {step === 'select' && ( + +

+ {t('focus.pomodoro.selectDuration', '选择专注时长')} +

+

{task.title}

+ +
+ {POMODORO_DURATIONS.map(duration => ( + + ))} +
+ + + {t('focus.pomodoro.start', '开始专注')} + +
+ )} + + {/* 步骤2/3: 计时中 / 暂停 */} + {(step === 'running' || step === 'paused') && ( + + {/* 任务标题 */} +

+ {task.title} +

+ + {/* 计时器圆环 */} +
+ {/* 背景圆 */} + + + {/* 进度圆 */} + + + + {/* 时间显示 */} +
+ + {formatTime(remainingSeconds)} + +
+
+ + {/* 暂停/继续按钮 */} + + {step === 'running' ? ( + + ) : ( + + )} + + + {/* 直接完成任务 */} + + {t('focus.pomodoro.completeTask', '直接完成任务')} + +
+ )} + + {/* 步骤4: 完成 */} + {step === 'complete' && ( + +
+ +
+ +

+ {t('focus.pomodoro.timeUp', '时间到!')} +

+

+ {t('focus.pomodoro.takeBreak', '休息一下,喝杯水')} +

+ +
+ + {t('focus.pomodoro.continue', '继续下一个')} + + + + {t('focus.pomodoro.completeTask', '完成任务')} + +
+
+ )} +
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 用于显示打开时的过渡动画遮罩 +// ═══════════════════════════════════════════════════════════════════════════ +export function FocusModeBackdrop({ isOpening }) { + return ( + + ) +} diff --git a/src/components/gtd/FocusView.jsx b/src/components/gtd/FocusView.jsx index 2433660..0e62c00 100644 --- a/src/components/gtd/FocusView.jsx +++ b/src/components/gtd/FocusView.jsx @@ -1,76 +1,302 @@ /** - * [INPUT]: react, react-i18next, framer-motion, @/stores/gtd, @/stores/ai, @/lib/utils, @/components/gtd/Focus*, @/components/gtd/TaskBubbleZone + * [INPUT]: react, react-i18next, framer-motion, @/stores/gtd, @/stores/ai, @/lib/utils, @/components/gtd/Focus*, @/components/gtd/TaskBubbleZone, @/components/gtd/FocusMode * [OUTPUT]: FocusView 组件 - * [POS]: 专注视图主组件,柔性宇宙插画风格 + * [POS]: 专注视图主组件,柔性宇宙插画风格,整合专注模式、坍缩动画、星座系统 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { motion } from 'framer-motion' +import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' import { isToday, isPast } from '@/stores/gtd' import { useAI } from '@/stores/ai' -import { ChevronRight, Sparkles } from 'lucide-react' +import { ChevronRight, Sparkles, Plus, Calendar, BookOpen } from 'lucide-react' import { FocusCircle } from './FocusCircle' import { TaskBubbleZone } from './TaskBubbleZone' +import { FocusMode } from './FocusMode' +import { useConstellation } from './Constellation' // ═══════════════════════════════════════════════════════════════════════════ -// 空状态 - 宁静的虚无 +// 空状态 - 三层递进式引导 // ═══════════════════════════════════════════════════════════════════════════ -function EmptyState({ onGoToInbox }) { +function EmptyState({ onGoToInbox, level = 'empty' }) { const { t } = useTranslation() - return ( - -

- {t('focus.empty.hint')} -

- - {t('focus.empty.action')} - - -
- ) + {/* 星点装饰 */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ +

+ 宇宙诞生于你的第一个念头 +

+ +
+ + + 从收集箱选择 + +
+
+ ) + } + + // 层级2:今日任务全部完成 + if (level === 'complete') { + return ( + + {/* 恒星装饰 */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ +

+ 今天的宇宙很完整 +

+ +
+ + + 写篇日记 + + + + + 查看明日计划 + +
+
+ ) + } + + return null } // ═══════════════════════════════════════════════════════════════════════════ -// 完成状态 - 完美的圆 +// 过期任务折叠卡片 // ═══════════════════════════════════════════════════════════════════════════ -function CompleteState() { +function OverdueCard({ tasks, onMoveToToday, onMoveToTomorrow, onDelete }) { const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + if (tasks.length === 0) return null return ( - - - -

- {t('focus.complete.hint')} -

+ {/* 头部 */} + + + {/* 展开 */} + + {isExpanded && ( + +
+ {tasks.map(task => ( +
+ + {task.title} + +
+ + + +
+
+ ))} +
+ + {/* 批量操作 */} +
+ + +
+
+ )} +
+
) } @@ -86,6 +312,8 @@ export function FocusView({ onDelete, onGoToInbox, onGoToToday, + onEditTask, + onUpdatePomodoro, className }) { const { t } = useTranslation() @@ -96,9 +324,15 @@ export function FocusView({ const [isFallback, setIsFallback] = useState(false) const [loading, setLoading] = useState(false) - // 选中任务状态 - 用于高亮对应星球 + // 选中任务状态 const [selectedTaskId, setSelectedTaskId] = useState(null) + // 专注模式状态 + const [focusModeTask, setFocusModeTask] = useState(null) + + // 星座系统 + const { stars, addStar } = useConstellation() + // 今日任务(包括过期) const todayTasks = useMemo(() => { return tasks.filter(t => @@ -148,17 +382,42 @@ export function FocusView({ // 处理任务完成 const handleComplete = useCallback((taskId) => { - onComplete?.(taskId) + const task = tasks.find(t => t.id === taskId) + if (task) { + // 标记完成时间 + const completedTask = { ...task, completed: true, completedAt: new Date().toISOString() } + onComplete?.(taskId) + } setRecommendedTasks(prev => prev.filter(t => t.id !== taskId)) - // 清除选中状态 if (selectedTaskId === taskId) setSelectedTaskId(null) - }, [onComplete, selectedTaskId]) + }, [tasks, onComplete, selectedTaskId]) - // 处理任务选择 - 高亮对应星球 + // 处理任务选择 const handleTaskSelect = useCallback((taskId) => { setSelectedTaskId(prev => prev === taskId ? null : taskId) }, []) + // 处理长按进入专注模式 + 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 && completedToday === 0 const isAllDone = todayTasks.length === 0 && completedToday > 0 @@ -172,18 +431,32 @@ export function FocusView({ + {/* 过期任务卡片 */} + + {/* 空状态 */} - {isEmpty && } + {isEmpty && } {/* 完成状态 */} - {isAllDone && } + {isAllDone && } {/* 底部任务气泡区 */} {!isEmpty && !isAllDone && recommendedTasks.length > 0 && ( @@ -197,24 +470,18 @@ export function FocusView({ /> )} - {/* 过期任务提醒 - 左上角 */} - {overdueTasks.length > 0 && ( - -
- - {t('focus.overdueTitle', { count: overdueTasks.length })} -
-
- )} + {/* 专注模式 */} + + {focusModeTask && ( + + )} +
) } diff --git a/src/components/gtd/OrbitPaths.jsx b/src/components/gtd/OrbitPaths.jsx index c9832e4..039079a 100644 --- a/src/components/gtd/OrbitPaths.jsx +++ b/src/components/gtd/OrbitPaths.jsx @@ -1,7 +1,7 @@ /** * [INPUT]: react, gsap * [OUTPUT]: OrbitPaths 组件 - * [POS]: 轨道带 - 椭圆形轨道线(像土星环),深蓝紫色 + * [POS]: 轨道带 - 椭圆形轨道线(像土星环),使用 CSS 变量 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -20,14 +20,12 @@ const ORBIT_ELLIPSES = [ { cx: 400, cy: 300, rx: 580, ry: 220, rotation: -15, opacity: 0.1, width: 0.5, speed: 0.3 }, ] -// 深蓝紫色 -const ORBIT_COLOR = '100, 120, 180' - // ═══════════════════════════════════════════════════════════════════════════ // 轨道带组件 - 椭圆形,呼吸感动效 // ═══════════════════════════════════════════════════════════════════════════ export function OrbitPaths() { const ellipsesRef = useRef([]) + const containerRef = useRef(null) // 入场动画 + 呼吸效果 useEffect(() => { @@ -62,9 +60,14 @@ export function OrbitPaths() { return ( {ORBIT_ELLIPSES.map((orbit, i) => ( diff --git a/src/components/gtd/Planet.jsx b/src/components/gtd/Planet.jsx index 94027cf..a7b82bc 100644 --- a/src/components/gtd/Planet.jsx +++ b/src/components/gtd/Planet.jsx @@ -1,39 +1,306 @@ /** - * [INPUT]: react, gsap, @/lib/utils + * [INPUT]: react, gsap, framer-motion, @/lib/utils, @/assets/plant/* * [OUTPUT]: Planet 组件, PLANET_COLORS 常量 - * [POS]: 基于 SVG 素材的手绘风格行星,支持拖拽移动,呼吸动效 + * [POS]: 手绘风格行星,随机素材渲染,支持坍缩动画、红巨星状态、番茄环渲染、长按专注、右键菜单 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useEffect, useRef, useState, useMemo } from 'react' +import { useEffect, useRef, useState, useMemo, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' import gsap from 'gsap' import { cn } from '@/lib/utils' -import yellowPlanetSVG from '@/assets/yellow_plant.svg?raw' +import plant1SVG from '@/assets/plant/plant1.svg?raw' +import plant2SVG from '@/assets/plant/plant2.svg?raw' +import plant3SVG from '@/assets/plant/plant3.svg?raw' +import starSVG from '@/assets/plant/star.svg?raw' // ═══════════════════════════════════════════════════════════════════════════ -// 行星颜色配置 - 通过 hue-rotate 滤镜实现 +// 星球素材列表 // ═══════════════════════════════════════════════════════════════════════════ +const PLANET_SVGS = [plant1SVG, plant2SVG, plant3SVG, starSVG] + +// 根据任务 ID 确定性地选择素材(保持一致性) +function selectPlanetSVG(taskId) { + // 简单哈希:将 ID 字符转为数字和,然后取模 + const hash = taskId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + return PLANET_SVGS[hash % PLANET_SVGS.length] +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 行星颜色配置 - 扩展颜色选项,通过 hue-rotate 滤镜实现 +// ═══════════════════════════════════════════════════════════════════════════ +const COLOR_KEYS = [ + 'red', 'orange', 'amber', 'yellow', 'lime', 'green', + 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', + 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'cream' +] + export const PLANET_COLORS = { - coral: { - filter: 'hue-rotate(320deg) saturate(1.2)', // 黄→珊瑚橙 - glow: 'rgba(255, 150, 120, 0.6)', // 对应的光晕颜色 - }, - purple: { - filter: 'hue-rotate(200deg) saturate(0.9)', // 黄→紫色 - glow: 'rgba(180, 140, 255, 0.6)', // 紫色光晕 - }, - cyan: { - filter: 'hue-rotate(140deg) saturate(1.1)', // 黄→青色 - glow: 'rgba(120, 200, 220, 0.6)', // 青色光晕 - }, - cream: { - filter: 'none', // 保持原色(黄色/奶油色) - glow: 'rgba(255, 230, 150, 0.6)', // 奶油色光晕 - }, + // 暖色系 + red: { filter: 'hue-rotate(0deg) saturate(1.3)' }, + orange: { filter: 'hue-rotate(30deg) saturate(1.2)' }, + amber: { filter: 'hue-rotate(45deg) saturate(1.3)' }, + yellow: { filter: 'hue-rotate(60deg) saturate(1.2)' }, + lime: { filter: 'hue-rotate(90deg) saturate(1.1)' }, + // 绿色系 + green: { filter: 'hue-rotate(120deg) saturate(1.0)' }, + emerald: { filter: 'hue-rotate(140deg) saturate(1.1)' }, + teal: { filter: 'hue-rotate(170deg) saturate(0.9)' }, + // 冷色系 + cyan: { filter: 'hue-rotate(180deg) saturate(1.0)' }, + sky: { filter: 'hue-rotate(200deg) saturate(1.0)' }, + blue: { filter: 'hue-rotate(220deg) saturate(1.1)' }, + indigo: { filter: 'hue-rotate(250deg) saturate(1.0)' }, + // 紫粉色系 + violet: { filter: 'hue-rotate(270deg) saturate(1.0)' }, + purple: { filter: 'hue-rotate(290deg) saturate(1.0)' }, + fuchsia: { filter: 'hue-rotate(310deg) saturate(1.2)' }, + pink: { filter: 'hue-rotate(330deg) saturate(1.2)' }, + rose: { filter: 'hue-rotate(345deg) saturate(1.3)' }, + // 中性 + cream: { filter: 'hue-rotate(45deg) saturate(0.3) brightness(1.2)' }, + // 紧急状态 + urgent: { filter: 'hue-rotate(0deg) saturate(2.0) brightness(1.2)' }, +} + +// 根据任务 ID 确定性地选择颜色(保持一致性) +function selectPlanetColor(taskId) { + const hash = taskId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + return COLOR_KEYS[hash % COLOR_KEYS.length] +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 粒子效果组件 +// ═══════════════════════════════════════════════════════════════════════════ +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} +
+ )} +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 右键菜单组件 +// ═══════════════════════════════════════════════════════════════════════════ +function ContextMenu({ position, task, onClose, onMoveToToday, onMoveToTomorrow, onDelete, onEdit, onFocus }) { + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + onClose() + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [onClose]) + + if (!position) return null + + return ( +
+ {/* 进入专注 - 首选项 */} + {onFocus && ( + + 进入专注 + + )} + + 编辑任务 + + + 移到今天 + + + 移到明天 + +
+ + 删除 + +
+ ) +} + +function ContextMenuButton({ children, onClick, className }) { + return ( + + {children} + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 坍缩动画完成后的恒星残留 +// ═══════════════════════════════════════════════════════════════════════════ +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 ( +
+ {/* 闪烁的光晕 */} +
+ {/* 核心亮点 */} +
+ {/* 星芒 */} +
+
+
+
+
+ ) } // ═══════════════════════════════════════════════════════════════════════════ -// 基于 SVG 素材的行星 - 支持拖拽 +// 主组件 - 手绘风格行星 // ═══════════════════════════════════════════════════════════════════════════ export function Planet({ task, @@ -43,19 +310,39 @@ export function Planet({ hasRing = false, layer = 'mid', isSelected = false, + isOverdue = false, + pomodoroCount = 0, onClick, - onPositionChange, // 新增:位置变化回调 - onTaskSelect, // 新增:任务选择回调 + onLongPress, + onPositionChange, + onTaskSelect, + onEdit, + onMoveToToday, + onMoveToTomorrow, + onDelete, + onCollapsed, // 新增:坍缩完成回调 className }) { const ref = useRef(null) const [isHovered, setIsHovered] = useState(false) const [isDragging, setIsDragging] = useState(false) - const colorConfig = PLANET_COLORS[colorKey] || PLANET_COLORS.coral + 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 randomColorKey = useMemo(() => selectPlanetColor(task.id), [task.id]) + const effectiveColorKey = isOverdue ? 'urgent' : randomColorKey + const colorConfig = PLANET_COLORS[effectiveColorKey] || PLANET_COLORS.cream + // 层级配置 const layerConfig = useMemo(() => { switch (layer) { @@ -65,8 +352,56 @@ export function Planet({ } }, [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) @@ -74,29 +409,57 @@ export function Planet({ 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 = () => { + const handleMouseUp = (upEvent) => { setIsDragging(false) - onPositionChange?.(localPos) + + // 清理长按计时 + 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) } @@ -105,9 +468,37 @@ export function Planet({ document.addEventListener('mouseup', handleMouseUp) } - // GSAP 呼吸感动画 - 缩放 + 旋转(拖拽时暂停) + // 右键菜单处理 + const handleContextMenu = (e) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPos({ x: e.clientX, y: e.clientY }) + setShowContextMenu(true) + } + + // 点击处理(两次点击完成) + const handleClick = useCallback((e) => { + e.stopPropagation() + + // 如果已坍缩,不处理 + if (collapsed) return + + // 如果正在坍缩,不处理 + if (isCollapsing) return + + if (isSelected) { + // 第二次点击 - 触发坍缩 + triggerCollapse() + onClick?.(task.id) + } else { + // 第一次点击 - 选中 + onTaskSelect?.(task.id) + } + }, [isSelected, collapsed, isCollapsing, triggerCollapse, onClick, onTaskSelect, task.id]) + + // GSAP 呼吸感动画 useEffect(() => { - if (!ref.current) return + if (!ref.current || collapsed) return const duration = 4 + Math.random() * 2 const driftX = (Math.random() - 0.5) * 15 * layerConfig.speed @@ -119,15 +510,27 @@ export function Planet({ { opacity: 1, scale: 1, rotation: 0, duration: 1, delay: Math.random() * 0.5, ease: 'back.out(1.7)' } ) - // 呼吸感动画 - 缩放 + 旋转 - breatheTweenRef.current = gsap.to(ref.current, { - scale: 1.05, - rotation: 3, - duration, - repeat: -1, - yoyo: true, - ease: 'sine.inOut', - }) + // 过期任务的快速脉动 + 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, { @@ -143,96 +546,186 @@ export function Planet({ if (breatheTweenRef.current) breatheTweenRef.current.kill() if (driftTweenRef.current) driftTweenRef.current.kill() } - }, [layerConfig.speed]) - - // 存储动画 tween 引用,用于暂停/恢复 - const breatheTweenRef = useRef(null) - const driftTweenRef = useRef(null) + }, [layerConfig.speed, isOverdue, collapsed]) // 拖拽时暂停呼吸动画 useEffect(() => { if (breatheTweenRef.current) { - if (isDragging) { + if (isDragging || isLongPressing) { breatheTweenRef.current.pause() } else { breatheTweenRef.current.resume() } } if (driftTweenRef.current) { - if (isDragging) { + if (isDragging || isLongPressing) { driftTweenRef.current.pause() } else { driftTweenRef.current.resume() } } - }, [isDragging]) + }, [isDragging, isLongPressing]) + + // 长按进度指示器 + const longPressProgress = useLongPressProgress(isLongPressing) return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onClick={() => { - // 拖拽结束时才触发点击 - if (!isDragging) { - onTaskSelect?.(task?.id) - onClick?.(task?.id) - } - }} - > - {/* 选中状态脉冲光环 - 动态匹配星球颜色 */} - {isSelected && ( + <> +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onContextMenu={handleContextMenu} + onClick={handleClick} + > + {/* 番茄环 */} + + + {/* 选中状态脉冲光环 */} + {isSelected && !collapsed && ( +
+ )} + + {/* 长按进度环 */} + {isLongPressing && ( +
+ + + +
+ )} + + {/* SVG 素材 - 根据任务 ID 随机选择,使用素材自带的 viewBox */}
- )} - {/* SVG 素材 - 用 filter 改变颜色,选中时叠加光晕 */} - - {/* Tooltip */} - {isHovered && task && !isDragging && ( + {/* Tooltip */} + {isHovered && task && !isDragging && !collapsed && ( +
+ {task.title} + {pomodoroCount > 0 && ( + 🍅 {pomodoroCount} + )} +
+ )} +
+ + {/* 坍缩后的恒星残留 */} + {collapsed && (
- {task.title} +
)} -
+ + {/* 右键菜单 */} + + {showContextMenu && ( + setShowContextMenu(false)} + onFocus={() => { onLongPress?.(task); setShowContextMenu(false) }} + onEdit={() => { onEdit?.(task); setShowContextMenu(false) }} + onMoveToToday={() => { onMoveToToday?.(task.id); setShowContextMenu(false) }} + onMoveToTomorrow={() => { onMoveToTomorrow?.(task.id); setShowContextMenu(false) }} + onDelete={() => { onDelete?.(task.id); 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/index.css b/src/index.css index 79dea2e..f79a1a1 100644 --- a/src/index.css +++ b/src/index.css @@ -125,6 +125,48 @@ --shadow-xl: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.04), 0px 8px 10px -1px hsl(225 27.7778% 14.1176% / 0.04); --shadow-2xl: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.10); --tracking-normal: 0em; + + /* ═══════════════════════════════════════════════════════════════════════════ + * Focus View 专用 token - 从设计系统派生 + * ═══════════════════════════════════════════════════════════════════════════ */ + + /* 时间感知背景 - stone 绿色系变体,与 --muted(88.6450°) 一致 */ + --focus-bg-dawn: oklch(0.9850 0.0050 91.4461); + --focus-bg-morning: oklch(0.9650 0.0045 91.4461); + --focus-bg-afternoon: oklch(0.9251 0.0071 88.6450); + --focus-bg-evening: oklch(0.3500 0.0080 88.6450); + --focus-bg-night: oklch(0.1448 0.0050 88.6450); + + /* 文字颜色 - 支持明暗背景 */ + --focus-text-primary: oklch(0.2417 0.0298 269.8827); + --focus-text-secondary: oklch(0.5510 0.0234 264.3637); + --focus-text-muted: oklch(0.5510 0.0234 264.3637 / 70%); + --focus-text-bright: oklch(0.9702 0 0); + + /* 行星颜色 - 保持特色但协调 */ + --focus-planet-coral: oklch(0.7200 0.0800 30.0000); + --focus-planet-coral-glow: oklch(0.7200 0.0800 30.0000 / 60%); + --focus-planet-purple: oklch(0.6500 0.0800 280.0000); + --focus-planet-purple-glow: oklch(0.6500 0.0800 280.0000 / 60%); + --focus-planet-cyan: oklch(0.7000 0.0500 180.0000); + --focus-planet-cyan-glow: oklch(0.7000 0.0500 180.0000 / 60%); + --focus-planet-cream: oklch(0.9000 0.0150 85.0000); + --focus-planet-cream-glow: oklch(0.9000 0.0150 85.0000 / 60%); + --focus-planet-urgent: oklch(0.6500 0.1800 25.0000); + --focus-planet-urgent-glow: oklch(0.6500 0.1800 25.0000 / 80%); + + /* 轨道带 - 基于设计系统的 primary 绿色 */ + --focus-orbit: oklch(0.6333 0.0309 154.9039 / 25%); + + /* UI 元素 */ + --focus-card-bg: oklch(0.2417 0.0298 269.8827 / 90%); + --focus-card-border: oklch(0.2417 0.0298 269.8827 / 20%); + --focus-accent-bg: oklch(0.6333 0.0309 154.9039 / 20%); + + /* 恒星/星座系统 */ + --focus-star-core: oklch(0.9761 0.0041 91.4461); + --focus-star-glow: oklch(0.9761 0.0041 91.4461 / 60%); + --focus-star-line: oklch(0.9761 0.0041 91.4461 / 30%); } .dark { @@ -180,6 +222,17 @@ --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.04), 0px 4px 6px -1px hsl(0 0% 0% / 0.04); --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.04), 0px 8px 10px -1px hsl(0 0% 0% / 0.04); --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.10); + + /* Focus View 深色模式适配 - stone 绿色系 */ + --focus-bg-dawn: oklch(0.2000 0.0050 91.4461); + --focus-bg-morning: oklch(0.1822 0.0045 91.4461); + --focus-bg-afternoon: oklch(0.1600 0.0060 88.6450); + --focus-bg-evening: oklch(0.1448 0.0070 88.6450); + --focus-bg-night: oklch(0.1200 0.0050 88.6450); + --focus-text-primary: oklch(0.9702 0 0); + --focus-text-secondary: oklch(0.7058 0 0); + --focus-text-muted: oklch(0.5510 0.0234 264.3637 / 70%); + --focus-card-bg: oklch(0.9702 0 0 / 90%); } @layer base { @@ -351,3 +404,17 @@ transform: scale(1.1); } } + +/* ═══════════════════════════════════════════════════════════════════════════ + * 番茄环脉冲动画 + * ═══════════════════════════════════════════════════════════════════════════ */ +@keyframes ring-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.6; + } + 50% { + transform: scale(1.05); + opacity: 1; + } +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index ae0675b..5ecef8d 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -298,7 +298,9 @@ "empty": { "title": "No tasks for today", "hint": "Pick some from inbox?", - "action": "Go to Inbox" + "action": "Go to Inbox", + "universe": "The universe begins with your first thought", + "complete": "The universe feels complete today" }, "complete": { "title": "Awesome!", @@ -310,6 +312,16 @@ "dueToday": "Due today", "overdue": "{{days}} days overdue" }, + "pomodoro": { + "selectDuration": "Select focus duration", + "start": "Start Focus", + "abandon": "Abandon", + "completeTask": "Complete Task", + "continue": "Continue Next", + "timeUp": "Time's up!", + "takeBreak": "Take a break, grab some water", + "completedCount": "{{count}} completed" + }, "viewAll": "View all", "viewToday": "View Today", "fromInbox": "From Inbox", @@ -322,6 +334,8 @@ "tomorrow": "Tomorrow", "abandon": "Abandon", "moveAllToToday": "Move all to today", - "abandonAll": "Abandon all" + "abandonAll": "Abandon all", + "writeJournal": "Write Journal", + "viewTomorrow": "View Tomorrow" } } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index f320ebe..1f460d5 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -298,7 +298,9 @@ "empty": { "title": "今天还没有任务", "hint": "从收集箱选几件事?", - "action": "去收集箱" + "action": "去收集箱", + "universe": "宇宙诞生于你的第一个念头", + "complete": "今天的宇宙很完整" }, "complete": { "title": "太棒了!", @@ -310,6 +312,16 @@ "dueToday": "今天到期", "overdue": "过期 {{days}} 天" }, + "pomodoro": { + "selectDuration": "选择专注时长", + "start": "开始专注", + "abandon": "放弃", + "completeTask": "直接完成任务", + "continue": "继续下一个", + "timeUp": "时间到!", + "takeBreak": "休息一下,喝杯水", + "completedCount": "已完成 {{count}} 个" + }, "viewAll": "查看全部", "viewToday": "查看今日任务", "fromInbox": "从收集箱选择", @@ -322,6 +334,8 @@ "tomorrow": "明天", "abandon": "放弃", "moveAllToToday": "全部移到今天", - "abandonAll": "全部放弃" + "abandonAll": "全部放弃", + "writeJournal": "写篇日记", + "viewTomorrow": "查看明日计划" } } From 1a6013baec2dcc111490b81536205fe0d0104ade Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 18:23:13 +0800 Subject: [PATCH 05/32] Fix constellation line parsing for star position values --- src/assets/plant/plant1.svg | 1 + src/components/gtd/Constellation.jsx | 23 ++++++++++++++++++----- src/components/gtd/FloatingTaskBubble.jsx | 4 ++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/assets/plant/plant1.svg b/src/assets/plant/plant1.svg index e69de29..788ca43 100644 --- a/src/assets/plant/plant1.svg +++ b/src/assets/plant/plant1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/gtd/Constellation.jsx b/src/components/gtd/Constellation.jsx index 3b109ff..fad3042 100644 --- a/src/components/gtd/Constellation.jsx +++ b/src/components/gtd/Constellation.jsx @@ -109,11 +109,24 @@ function ConstellationLines({ stars }) { const from = todayStars[i] const to = todayStars[i + 1] - // 解析百分比位置 - const fromX = parseFloat(from.x) / 100 - const fromY = parseFloat(from.y) / 100 - const toX = parseFloat(to.x) / 100 - const toY = parseFloat(to.y) / 100 + // 解析百分比位置 - 处理可能是字符串 "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, diff --git a/src/components/gtd/FloatingTaskBubble.jsx b/src/components/gtd/FloatingTaskBubble.jsx index 27595e5..c9402dd 100644 --- a/src/components/gtd/FloatingTaskBubble.jsx +++ b/src/components/gtd/FloatingTaskBubble.jsx @@ -15,7 +15,7 @@ import { PLANET_COLORS } from './Planet' // 漂浮气泡任务卡片 // ═══════════════════════════════════════════════════════════════════════════ -const COLOR_KEYS = ['coral', 'purple', 'cyan', 'cream'] +const COLOR_KEYS = ['green', 'blue', 'purple', 'orange', 'pink', 'cream'] export function FloatingTaskBubble({ task, @@ -30,7 +30,7 @@ export function FloatingTaskBubble({ // 获取对应的行星颜色 const colorKey = COLOR_KEYS[index % COLOR_KEYS.length] - const color = PLANET_COLORS[colorKey] + const color = PLANET_COLORS[colorKey] || PLANET_COLORS.cream || { filter: 'none' } // 随机动画参数 const animDuration = useMemo(() => 4 + Math.random() * 2, []) From d6e4c76b93198edd02e8fe0053fa288c07be21f6 Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 18:54:08 +0800 Subject: [PATCH 06/32] Add depth layers and parallax effect to FocusCircle --- src/components/gtd/CLAUDE.md | 10 +- src/components/gtd/DarkNebula.jsx | 84 +++++++++++++++ src/components/gtd/DeepSpaceDust.jsx | 134 +++++++++++++++++++++++ src/components/gtd/FocusCircle.jsx | 156 ++++++++++++++++----------- src/components/gtd/OrbitPaths.jsx | 77 ++++--------- src/components/gtd/SpaceGlow.jsx | 151 ++++++++++++++++++++++++++ src/components/gtd/StarDust.jsx | 17 ++- src/components/gtd/ZDepthLayer.jsx | 150 ++++++++++++++++++++++++++ src/index.css | 9 +- 9 files changed, 665 insertions(+), 123 deletions(-) create mode 100644 src/components/gtd/DarkNebula.jsx create mode 100644 src/components/gtd/DeepSpaceDust.jsx create mode 100644 src/components/gtd/SpaceGlow.jsx create mode 100644 src/components/gtd/ZDepthLayer.jsx diff --git a/src/components/gtd/CLAUDE.md b/src/components/gtd/CLAUDE.md index fbc68fb..4cafceb 100644 --- a/src/components/gtd/CLAUDE.md +++ b/src/components/gtd/CLAUDE.md @@ -22,12 +22,16 @@ JournalItem.jsx: 过往日记列表项,显示日期 + 标题 + 预览 + 字数 JournalChip.jsx: 日历内日记小卡片,虚线边框,不可拖拽,BookText 图标 AIPromptCard.jsx: AI 问题卡片,展示生成的引导问题(无 emoji),支持点击插入、悬停删除、刷新,淡入淡出动画,显示加载状态 FocusView.jsx: 专注视图主组件,柔性宇宙插画风格,整合 FocusMode 专注模式 + Constellation 星座系统 + OverdueCard 过期任务卡片 + 两层空状态引导 -FocusCircle.jsx: 专注视图核心 - 柔性宇宙插画,时间感知背景(晨曦/清醒/午后/暮蓝/深空),行星位置 localStorage 持久化,集成 Constellation +FocusCircle.jsx: 专注视图核心 - 深邃宇宙插画,时间感知背景,深度分层(far/mid/near)+ 鼠标视差,集成 DarkNebula/DeepSpaceDust/SpaceGlow/OrbitPaths/StarDust/BlueDust/Constellation Planet.jsx: 手绘风格行星,支持坍缩动画(GSAP 收缩 + 粒子迸发 + 闪白)+ 红巨星状态(过期任务暗红脉动)+ 番茄环渲染(显示已完成番茄钟数量)+ 长按进入专注模式 + 右键菜单(编辑/移到今天或明天/删除)+ 拖拽整理位置 FocusMode.jsx: 全屏专注模式组件,番茄钟计时器(15/25/45分钟可选),倒计时进度,完成番茄钟/直接完成任务按钮,放弃专注,GSAP 入场动画 Constellation.jsx: 完成任务星座系统,已完成任务留下微弱恒星(闪烁动画),当天完成的恒星之间虚线连线,useConstellation hook 管理状态 + localStorage 持久化 -StarDust.jsx: 背景星点层,GSAP 动画,35个微小白色粒子极慢漂浮 -OrbitPaths.jsx: 椭圆轨道带 - 多条同心椭圆(像土星环),深蓝紫色,GSAP 描边动画 +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 实现颗粒感 diff --git a/src/components/gtd/DarkNebula.jsx b/src/components/gtd/DarkNebula.jsx new file mode 100644 index 0000000..3fd01e5 --- /dev/null +++ b/src/components/gtd/DarkNebula.jsx @@ -0,0 +1,84 @@ +/** + * [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 < 24; 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..3e32a7e --- /dev/null +++ b/src/components/gtd/DeepSpaceDust.jsx @@ -0,0 +1,134 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: DeepSpaceDust 组件 + * [POS]: 极微小星点层,创造尺度差,位于 far 层 + * [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 = 200 + +// 冷色系星点颜色 - 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 + + // 极慢闪烁 - 分钟级 + const twinkleDuration = 180 + Math.random() * 120 // 3-5 分钟 + const twinkleDelay = Math.random() * 60 + + gsap.to(ref.current, { + opacity: dust.opacity * (0.3 + Math.random() * 0.4), + duration: twinkleDuration, + delay: twinkleDelay, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + + // 极慢漂移 - 每次持续 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' + }) + }, [dust.opacity]) + + 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/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx index 07f829e..de8623d 100644 --- a/src/components/gtd/FocusCircle.jsx +++ b/src/components/gtd/FocusCircle.jsx @@ -1,7 +1,7 @@ /** - * [INPUT]: react, @/lib/utils, ./StarDust, ./OrbitPaths, ./Planet, ./BlueDust, ./MiniInfo, ./NoiseOverlay, ./Constellation + * [INPUT]: react, @/lib/utils, ./StarDust, ./OrbitPaths, ./Planet, ./BlueDust, ./MiniInfo, ./NoiseOverlay, ./Constellation, ./ZDepthLayer, ./DeepSpaceDust, ./DarkNebula, ./SpaceGlow * [OUTPUT]: FocusCircle 组件 - * [POS]: 专注视图核心 - 柔性宇宙插画,时间感知背景,已完成任务星座 + * [POS]: 专注视图核心 - 柔性宇宙插画,时间感知背景,深度分层 + 视差,已完成任务星座 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -15,6 +15,10 @@ import { BlueDust } from './BlueDust' import { MiniInfo } from './MiniInfo' 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 变量 @@ -189,65 +193,97 @@ export function FocusCircle({ }, [savedPositions]) return ( -
- {/* Layer 1: 背景星点 */} - - - {/* Layer 2: 椭圆轨道带 */} + + {/* 时间感知背景底色 */} +
+ + {/* ═════════════════════════════════════════════════════════════════════════ + Far Layer - 最远景 + zIndex: 3-5, blur: 1px, 视差速度: 0.08 + ═════════════════════════════════════════════════════════════════════════ */} + + {/* 背景星点 - 1-3px */} + + + + {/* 星云 - 移出层测试 */} + + + {/* 极微星点 - 移出层测试 */} + + + {/* ═════════════════════════════════════════════════════════════════════════ + Mid Layer - 中景 + zIndex: 10, blur: 0.3px, 视差速度: 0.2 + ═════════════════════════════════════════════════════════════════════════ */} + + {/* 空间辉光 */} + + + {/* 蓝色粒子 */} + + + {/* 已完成任务星座 */} + t.completed)} /> + + + {/* 轨道 - 移出层测试 */} - {/* Layer 3: 蓝色粒子 */} - - - {/* Layer 4: 已完成任务星座 */} - t.completed)} /> - - {/* Layer 5: 行星 */} - {PLANET_CONFIG.map((config, i) => { - const task = planetTasks[i] - if (!task) return null - - const position = getPlanetPosition(task, config) - - return ( - - ) - })} - - {/* Layer 6: 噪点纹理 */} - - - {/* Layer 7: 右上角信息 */} - -
+ {/* ═════════════════════════════════════════════════════════════════════════ + 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/OrbitPaths.jsx b/src/components/gtd/OrbitPaths.jsx index 039079a..2af1758 100644 --- a/src/components/gtd/OrbitPaths.jsx +++ b/src/components/gtd/OrbitPaths.jsx @@ -1,87 +1,56 @@ /** - * [INPUT]: react, gsap + * [INPUT]: react * [OUTPUT]: OrbitPaths 组件 - * [POS]: 轨道带 - 椭圆形轨道线(像土星环),使用 CSS 变量 + * [POS]: 轨道带 - 简化版本测试 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useEffect, useRef } from 'react' +import { useRef, useEffect } from 'react' import gsap from 'gsap' // ═══════════════════════════════════════════════════════════════════════════ -// 椭圆轨道配置 - 像土星环一样的椭圆 +// 简化版轨道 - 椭圆 // ═══════════════════════════════════════════════════════════════════════════ -const ORBIT_ELLIPSES = [ - { cx: 400, cy: 300, rx: 380, ry: 120, rotation: -15, opacity: 0.4, width: 2.5, speed: 1 }, - { cx: 400, cy: 300, rx: 420, ry: 140, rotation: -15, opacity: 0.35, width: 2, speed: 0.8 }, - { cx: 400, cy: 300, rx: 460, ry: 160, rotation: -15, opacity: 0.25, width: 1.5, speed: 0.6 }, - { cx: 400, cy: 300, rx: 500, ry: 180, rotation: -15, opacity: 0.2, width: 1, speed: 0.5 }, - { cx: 400, cy: 300, rx: 540, ry: 200, rotation: -15, opacity: 0.15, width: 1, speed: 0.4 }, - { cx: 400, cy: 300, rx: 580, ry: 220, rotation: -15, opacity: 0.1, width: 0.5, speed: 0.3 }, + +const ORBITS = [ + { cx: 400, cy: 300, rx: 350, ry: 100, rotation: -15 }, + { cx: 400, cy: 300, rx: 400, ry: 130, rotation: -15 }, + { cx: 400, cy: 300, rx: 450, ry: 160, rotation: -15 }, + { cx: 400, cy: 300, rx: 500, ry: 190, rotation: -15 }, + { cx: 400, cy: 300, rx: 550, ry: 220, rotation: -15 }, ] -// ═══════════════════════════════════════════════════════════════════════════ -// 轨道带组件 - 椭圆形,呼吸感动效 -// ═══════════════════════════════════════════════════════════════════════════ export function OrbitPaths() { - const ellipsesRef = useRef([]) - const containerRef = useRef(null) + const svgRef = useRef(null) - // 入场动画 + 呼吸效果 useEffect(() => { - ellipsesRef.current.forEach((ellipse, i) => { - if (!ellipse) return - - // 入场描边动画 - const rx = ORBIT_ELLIPSES[i].rx - const ry = ORBIT_ELLIPSES[i].ry - const length = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry))) - ellipse.style.strokeDasharray = length - ellipse.style.strokeDashoffset = length - - gsap.to(ellipse, { - strokeDashoffset: 0, - duration: 2.5, - delay: i * 0.12, - ease: 'power2.out', - }) - - // 呼吸感 - 缓慢的透明度脉冲 - gsap.to(ellipse, { - strokeOpacity: ORBIT_ELLIPSES[i].opacity * 0.6, - duration: 3 + i * 0.3, - repeat: -1, - yoyo: true, - ease: 'sine.inOut', - delay: 2, - }) - }) + if (!svgRef.current) return + // 入场动画 + gsap.fromTo(svgRef.current, + { opacity: 0 }, + { opacity: 1, duration: 1 } + ) }, []) return ( - {ORBIT_ELLIPSES.map((orbit, i) => ( + {ORBITS.map((orbit, i) => ( ellipsesRef.current[i] = el} cx={orbit.cx} cy={orbit.cy} rx={orbit.rx} ry={orbit.ry} transform={`rotate(${orbit.rotation} ${orbit.cx} ${orbit.cy})`} - stroke="currentColor" - strokeOpacity={orbit.opacity} - strokeWidth={orbit.width} fill="none" + stroke="#5a6a7a" + strokeWidth="1.5" + strokeOpacity="0.4" /> ))} diff --git a/src/components/gtd/SpaceGlow.jsx b/src/components/gtd/SpaceGlow.jsx new file mode 100644 index 0000000..658f4a0 --- /dev/null +++ b/src/components/gtd/SpaceGlow.jsx @@ -0,0 +1,151 @@ +/** + * [INPUT]: react, gsap + * [OUTPUT]: SpaceGlow 组件 + * [POS]: 空间辉光层,非中心式、不规则光斑,创造"空间本身发光"的感觉 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { useEffect, useRef, useMemo } from 'react' +import gsap from 'gsap' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 空间辉光配置 - 非中心式、极低对比度、大模糊 +// ═══════════════════════════════════════════════════════════════════════════ + +// 生成辉光数据 +function generateGlows() { + return [ + { + id: 'glow-1', + x: 15 + Math.random() * 20, // 15-35% + y: 20 + Math.random() * 20, // 20-40% + size: 25 + Math.random() * 15, // 25-40% + opacity: 0.03 + Math.random() * 0.02, // 3-5% + color: `hsl(${210 + Math.random() * 20}, 25%, ${12 + Math.random() * 6}%)` // 深蓝灰 + }, + { + id: 'glow-2', + x: 65 + Math.random() * 20, // 65-85% + y: 55 + Math.random() * 25, // 55-80% + size: 20 + Math.random() * 15, // 20-35% + opacity: 0.02 + Math.random() * 0.015, // 2-3.5% + color: `hsl(${240 + Math.random() * 20}, 20%, ${10 + Math.random() * 5}%)` // 深紫灰 + }, + { + id: 'glow-3', + x: 35 + Math.random() * 25, // 35-60% + y: 70 + Math.random() * 20, // 70-90% + size: 30 + Math.random() * 10, // 30-40% + opacity: 0.025 + Math.random() * 0.015, // 2.5-4% + color: `hsl(${200 + Math.random() * 15}, 22%, ${11 + Math.random() * 5}%)` // 深青灰 + } + ] +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 单个辉光斑 +// ═══════════════════════════════════════════════════════════════════════════ + +function GlowSpot({ glow, index }) { + const ref = useRef(null) + + useEffect(() => { + if (!ref.current) return + + // 极慢脉动 - 分钟级 + const pulseDuration = 300 + Math.random() * 240 // 5-9 分钟 + const targetOpacity = glow.opacity * (0.6 + Math.random() * 0.3) + + gsap.to(ref.current, { + opacity: targetOpacity, + duration: pulseDuration, + delay: index * 80, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + + // 极慢漂移 + const driftDuration = 480 + Math.random() * 360 // 8-14 分钟 + const driftX = (Math.random() - 0.5) * 15 + const driftY = (Math.random() - 0.5) * 15 + + gsap.to(ref.current, { + x: driftX, + y: driftY, + duration: driftDuration, + repeat: -1, + yoyo: true, + ease: 'sine.inOut' + }) + }, [glow.opacity, index]) + + // 非中心式径向渐变 - 光源不在正中心 + const offsetX = 25 + Math.random() * 50 + const offsetY = 25 + Math.random() * 50 + + return ( +
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 - 空间辉光层 +// ═══════════════════════════════════════════════════════════════════════════ + +export function SpaceGlow({ + glows: customGlows, + className +}) { + const containerRef = useRef(null) + + // 生成辉光数据(只生成一次) + const glows = useMemo(() => customGlows || generateGlows(), [customGlows]) + + useEffect(() => { + if (!containerRef.current) return + + // 整体极慢入场 + gsap.fromTo(containerRef.current, + { opacity: 0 }, + { + opacity: 1, + duration: 6, + ease: 'power1.inOut' + } + ) + }, []) + + return ( +
+ {glows.map((glow, index) => ( + + ))} +
+ ) +} diff --git a/src/components/gtd/StarDust.jsx b/src/components/gtd/StarDust.jsx index 48214b6..f309178 100644 --- a/src/components/gtd/StarDust.jsx +++ b/src/components/gtd/StarDust.jsx @@ -1,13 +1,24 @@ /** * [INPUT]: react, gsap * [OUTPUT]: StarDust 组件 - * [POS]: 背景星点层,GSAP 动画,微小粒子随机漂浮 + * [POS]: 背景星点层,冷色系(80% 灰白,20% 冷蓝),GSAP 动画 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useEffect, useRef, useMemo } from 'react' import gsap from 'gsap' +// ═══════════════════════════════════════════════════════════════════════════ +// 冷色系星点颜色 - 80% 灰白,20% 冷蓝 +// ═══════════════════════════════════════════════════════════════════════════ +function pickStarColor() { + 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% 钢蓝 +} + // ═══════════════════════════════════════════════════════════════════════════ // 背景星点 - GSAP 极慢漂移 // ═══════════════════════════════════════════════════════════════════════════ @@ -19,6 +30,7 @@ export function StarDust({ count = 35 }) { x: Math.random() * 100, y: Math.random() * 100, size: 1 + Math.random() * 2, + color: pickStarColor() })), [count] ) @@ -55,12 +67,13 @@ export function StarDust({ count = 35 }) { {particles.map((p, i) => (
diff --git a/src/components/gtd/ZDepthLayer.jsx b/src/components/gtd/ZDepthLayer.jsx new file mode 100644 index 0000000..f0fbbe9 --- /dev/null +++ b/src/components/gtd/ZDepthLayer.jsx @@ -0,0 +1,150 @@ +/** + * [INPUT]: react + * [OUTPUT]: ZDepthLayer 组件, DEPTH_LAYERS 常量 + * [POS]: 深度层管理器,统一管理 far/mid/near 三层的视差和模糊 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ + +import { createContext, useContext, useState } from 'react' +import { cn } from '@/lib/utils' + +// ═══════════════════════════════════════════════════════════════════════════ +// 深度层配置 - Z 轴三层系统 +// ═══════════════════════════════════════════════════════════════════════════ + +export const DEPTH_LAYERS = { + far: { + zIndex: 3, + blur: 1, + parallaxSpeed: 0.08, // 最慢,最远 + opacity: 0.7 + }, + mid: { + zIndex: 10, + blur: 0.3, + parallaxSpeed: 0.2, // 中速 + opacity: 0.85 + }, + near: { + zIndex: 20, + blur: 0, // 清晰 + parallaxSpeed: 0.4, // 最快,最近 + opacity: 1 + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 视差上下文 +// ═══════════════════════════════════════════════════════════════════════════ + +const ParallaxContext = createContext({ + x: 0, + y: 0 +}) + +// Hook: 获取当前视差偏移 +export function useParallax(speed = 1) { + const { x, y } = useContext(ParallaxContext) + return { + x: x * speed, + y: y * speed, + style: { + transform: `translate(${x * speed}px, ${y * speed}px)` + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 深度层组件 - 包装子元素并应用深度效果 +// ═══════════════════════════════════════════════════════════════════════════ + +export function ZDepthLayer({ + layer = 'mid', // 'far' | 'mid' | 'near' + children, + className, + style = {}, + disableParallax = false +}) { + const config = DEPTH_LAYERS[layer] || DEPTH_LAYERS.mid + const { x, y } = useContext(ParallaxContext) + + // 计算视差偏移 + const parallaxX = disableParallax ? 0 : x * config.parallaxSpeed * 10 + const parallaxY = disableParallax ? 0 : y * config.parallaxSpeed * 10 + + return ( +
+
0 ? `blur(${config.blur}px)` : undefined, + transform: `translate(${parallaxX}px, ${parallaxY}px)`, + willChange: 'transform', + transition: 'transform 0.15s ease-out', + width: '100%', + height: '100%' + }} + > + {children} +
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 视差提供者 - 在最外层监听鼠标移动 +// ═══════════════════════════════════════════════════════════════════════════ + +export function ParallaxProvider({ + children, + className, + intensity = 1 // 视差强度倍数 +}) { + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + + const handleMouseMove = (e) => { + // 归一化到 -1 到 1 + const x = ((e.clientX / window.innerWidth) - 0.5) * 2 * intensity + const y = ((e.clientY / window.innerHeight) - 0.5) * 2 * intensity + setMousePos({ x, y }) + } + + const handleMouseLeave = () => { + setMousePos({ x: 0, y: 0 }) + } + + return ( + +
+ {children} +
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 便捷组件 - 各层快捷方式 +// ═══════════════════════════════════════════════════════════════════════════ + +export function FarLayer(props) { + return +} + +export function MidLayer(props) { + return +} + +export function NearLayer(props) { + return +} diff --git a/src/index.css b/src/index.css index f79a1a1..d40db1e 100644 --- a/src/index.css +++ b/src/index.css @@ -131,11 +131,12 @@ * ═══════════════════════════════════════════════════════════════════════════ */ /* 时间感知背景 - stone 绿色系变体,与 --muted(88.6450°) 一致 */ + /* 更暗的背景,增强深邃感 */ --focus-bg-dawn: oklch(0.9850 0.0050 91.4461); --focus-bg-morning: oklch(0.9650 0.0045 91.4461); --focus-bg-afternoon: oklch(0.9251 0.0071 88.6450); - --focus-bg-evening: oklch(0.3500 0.0080 88.6450); - --focus-bg-night: oklch(0.1448 0.0050 88.6450); + --focus-bg-evening: oklch(0.1800 0.0060 88.6450); /* 从 0.35 降到 0.18 */ + --focus-bg-night: oklch(0.1000 0.0050 88.6450); /* 从 0.1448 降到 0.10 */ /* 文字颜色 - 支持明暗背景 */ --focus-text-primary: oklch(0.2417 0.0298 269.8827); @@ -155,8 +156,8 @@ --focus-planet-urgent: oklch(0.6500 0.1800 25.0000); --focus-planet-urgent-glow: oklch(0.6500 0.1800 25.0000 / 80%); - /* 轨道带 - 基于设计系统的 primary 绿色 */ - --focus-orbit: oklch(0.6333 0.0309 154.9039 / 25%); + /* 轨道带 - 调试 100% */ + --focus-orbit: oklch(0.6333 0.0309 154.9039 / 100%); /* UI 元素 */ --focus-card-bg: oklch(0.2417 0.0298 269.8827 / 90%); From 18f89e4d5dcac5218812d4db54d8e7712514013c Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 19:00:21 +0800 Subject: [PATCH 07/32] Refactor OrbitPaths to use randomly distributed short arcs Replace solid ellipse orbits with fragmented short arcs that are randomly positioned along each elliptical path. Each arc segment has independent opacity animation with GSAP, creating a more natural and organic feel. --- src/components/gtd/OrbitPaths.jsx | 160 ++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 30 deletions(-) diff --git a/src/components/gtd/OrbitPaths.jsx b/src/components/gtd/OrbitPaths.jsx index 2af1758..984531c 100644 --- a/src/components/gtd/OrbitPaths.jsx +++ b/src/components/gtd/OrbitPaths.jsx @@ -1,57 +1,157 @@ /** - * [INPUT]: react + * [INPUT]: react, gsap * [OUTPUT]: OrbitPaths 组件 - * [POS]: 轨道带 - 简化版本测试 + * [POS]: 轨道带 - 独立短弧线片段,随机分布,自然感 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useRef, useEffect } from 'react' +import { useEffect, useRef, useMemo } from 'react' import gsap from 'gsap' // ═══════════════════════════════════════════════════════════════════════════ -// 简化版轨道 - 椭圆 +// 椭圆上的点计算 // ═══════════════════════════════════════════════════════════════════════════ -const ORBITS = [ - { cx: 400, cy: 300, rx: 350, ry: 100, rotation: -15 }, - { cx: 400, cy: 300, rx: 400, ry: 130, rotation: -15 }, - { cx: 400, cy: 300, rx: 450, ry: 160, rotation: -15 }, - { cx: 400, cy: 300, rx: 500, ry: 190, rotation: -15 }, - { cx: 400, cy: 300, rx: 550, ry: 220, rotation: -15 }, -] +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 + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 单个弧线片段 +// ═══════════════════════════════════════════════════════════════════════════ + +function OrbitSegment({ segment, index }) { + const pathRef = useRef(null) + + useEffect(() => { + if (!pathRef.current) return + + // 极慢闪烁 + gsap.to(pathRef.current, { + strokeOpacity: segment.opacity * (0.4 + Math.random() * 0.4), + duration: 6 + Math.random() * 4, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + delay: Math.random() * 3 + }) + }, [segment.opacity]) + + // 计算弧线路径 + 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}` + + 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.6 }, + { rx: 400, ry: 130, count: 10, width: 1.2, blur: 0.5, baseOpacity: 0.5 }, + { rx: 450, ry: 160, count: 8, width: 1, blur: 0.8, baseOpacity: 0.4 }, + { rx: 500, ry: 190, count: 7, width: 0.8, blur: 1, baseOpacity: 0.35 }, + { rx: 550, ry: 220, count: 5, width: 0.5, blur: 1.2, baseOpacity: 0.25 }, + ] + + 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 * (0.6 + Math.random() * 0.4) + }) + } + }) + + return segments +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主组件 +// ═══════════════════════════════════════════════════════════════════════════ export function OrbitPaths() { - const svgRef = useRef(null) + const containerRef = useRef(null) + const segments = useMemo(() => generateOrbitSegments(), []) useEffect(() => { - if (!svgRef.current) return - // 入场动画 - gsap.fromTo(svgRef.current, + if (!containerRef.current) return + gsap.fromTo(containerRef.current, { opacity: 0 }, - { opacity: 1, duration: 1 } + { opacity: 1, duration: 3 } ) }, []) return ( - {ORBITS.map((orbit, i) => ( - + {segments.map((seg, index) => ( + ))} ) From de9b6d2fc495623c8c4440f6b7fa1512ae3c140c Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 19:08:30 +0800 Subject: [PATCH 08/32] Add perspective effect to focus view orbit paths Replace uniform opacity with linear gradients that vary based on vertical position, creating a depth illusion. Remove GSAP animations to improve performance and simplify the visual design. --- src/components/gtd/OrbitPaths.jsx | 65 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/src/components/gtd/OrbitPaths.jsx b/src/components/gtd/OrbitPaths.jsx index 984531c..09ce590 100644 --- a/src/components/gtd/OrbitPaths.jsx +++ b/src/components/gtd/OrbitPaths.jsx @@ -36,22 +36,6 @@ function getEllipsePoint(cx, cy, rx, ry, angle, rotation) { // ═══════════════════════════════════════════════════════════════════════════ function OrbitSegment({ segment, index }) { - const pathRef = useRef(null) - - useEffect(() => { - if (!pathRef.current) return - - // 极慢闪烁 - gsap.to(pathRef.current, { - strokeOpacity: segment.opacity * (0.4 + Math.random() * 0.4), - duration: 6 + Math.random() * 4, - repeat: -1, - yoyo: true, - ease: 'sine.inOut', - delay: Math.random() * 3 - }) - }, [segment.opacity]) - // 计算弧线路径 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) @@ -60,17 +44,34 @@ function OrbitSegment({ segment, index }) { const pathData = `M ${start.x} ${start.y} A ${segment.rx} ${segment.ry} ${segment.rotation} ${largeArcFlag} 1 ${end.x} ${end.y}` + // 根据弧线在椭圆上的位置决定亮度 - 透视效果 + // SVG 坐标系:y=0 在顶部,y 向下增大 + // y 越小(上方/远处)→ 更暗;y 越大(下方/近处)→ 更亮 + const avgY = (start.y + end.y) / 2 + // y 坐标范围大约 100-500,映射到 0.5-1.5 倍亮度 + const positionFactor = (avgY - 150) / 200 + 0.5 + const clampedFactor = Math.max(0.4, Math.min(1.6, positionFactor)) + + const gradientId = `orbit-seg-${index}` + return ( - + <> + + + + + + + + + ) } @@ -84,11 +85,11 @@ function generateOrbitSegments() { // 每条轨道分成 8-12 个随机片段 const orbits = [ - { rx: 350, ry: 100, count: 12, width: 1.5, blur: 0.3, baseOpacity: 0.6 }, - { rx: 400, ry: 130, count: 10, width: 1.2, blur: 0.5, baseOpacity: 0.5 }, - { rx: 450, ry: 160, count: 8, width: 1, blur: 0.8, baseOpacity: 0.4 }, - { rx: 500, ry: 190, count: 7, width: 0.8, blur: 1, baseOpacity: 0.35 }, - { rx: 550, ry: 220, count: 5, width: 0.5, blur: 1.2, baseOpacity: 0.25 }, + { 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) => { @@ -119,7 +120,7 @@ function generateOrbitSegments() { endAngle: startAngle + length, width: orbit.width, blur: orbit.blur, - opacity: orbit.baseOpacity * (0.6 + Math.random() * 0.4) + opacity: orbit.baseOpacity }) } }) From 6200720fc946d200260ca2f247ee4740ee4e5dae Mon Sep 17 00:00:00 2001 From: lyeka Date: Wed, 28 Jan 2026 21:55:48 +0800 Subject: [PATCH 09/32] Optimize focus view performance and visual design --- src/components/gtd/DeepSpaceDust.jsx | 36 ++++++++++--------- src/components/gtd/FocusCircle.jsx | 12 +++---- src/components/gtd/OrbitPaths.jsx | 3 -- src/components/gtd/Planet.jsx | 52 +++++++++++++++++++--------- src/components/gtd/StarDust.jsx | 28 +++++++++++---- src/components/gtd/ZDepthLayer.jsx | 42 +++++++++++++++------- src/index.css | 33 +++++++++++++----- 7 files changed, 136 insertions(+), 70 deletions(-) diff --git a/src/components/gtd/DeepSpaceDust.jsx b/src/components/gtd/DeepSpaceDust.jsx index 3e32a7e..4ac5b55 100644 --- a/src/components/gtd/DeepSpaceDust.jsx +++ b/src/components/gtd/DeepSpaceDust.jsx @@ -1,7 +1,7 @@ /** * [INPUT]: react, gsap * [OUTPUT]: DeepSpaceDust 组件 - * [POS]: 极微小星点层,创造尺度差,位于 far 层 + * [POS]: 极微小星点层,创造尺度差,漂移 + 闪烁动画 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -13,7 +13,7 @@ import { cn } from '@/lib/utils' // 极微小星点 - 0.3-1px,创造尺度差 // ═══════════════════════════════════════════════════════════════════════════ -const MICRO_DUST_COUNT = 200 +const MICRO_DUST_COUNT = 80 // 从200减少到80,提升性能 // 冷色系星点颜色 - 80% 灰白,20% 冷蓝 function pickDustColor() { @@ -45,20 +45,7 @@ function MicroDustParticle({ dust, index }) { useEffect(() => { if (!ref.current) return - // 极慢闪烁 - 分钟级 - const twinkleDuration = 180 + Math.random() * 120 // 3-5 分钟 - const twinkleDelay = Math.random() * 60 - - gsap.to(ref.current, { - opacity: dust.opacity * (0.3 + Math.random() * 0.4), - duration: twinkleDuration, - delay: twinkleDelay, - repeat: -1, - yoyo: true, - ease: 'sine.inOut' - }) - - // 极慢漂移 - 每次持续 5-10 分钟 + // 极慢漂移 - 5-10 分钟 const driftDuration = 300 + Math.random() * 300 // 5-10 分钟 const driftX = (Math.random() - 0.5) * 3 const driftY = (Math.random() - 0.5) * 3 @@ -72,7 +59,22 @@ function MicroDustParticle({ dust, index }) { yoyo: true, ease: 'sine.inOut' }) - }, [dust.opacity]) + + // 只有前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 (
- {/* Tooltip */} + {/* Tooltip - 重新设计 */} {isHovered && task && !isDragging && !collapsed && (
- {task.title} - {pomodoroCount > 0 && ( - 🍅 {pomodoroCount} - )} + {/* 连接线 */} +
+ {/* 内容卡片 */} +
+ {task.title} + {pomodoroCount > 0 && ( + + {pomodoroCount} + + )} +
)} +
{/* 坍缩后的恒星残留 */} diff --git a/src/components/gtd/StarDust.jsx b/src/components/gtd/StarDust.jsx index f309178..4c4b687 100644 --- a/src/components/gtd/StarDust.jsx +++ b/src/components/gtd/StarDust.jsx @@ -1,7 +1,7 @@ /** * [INPUT]: react, gsap * [OUTPUT]: StarDust 组件 - * [POS]: 背景星点层,冷色系(80% 灰白,20% 冷蓝),GSAP 动画 + * [POS]: 背景星点层,冷色系(80% 灰白,20% 冷蓝),GSAP 漂移 + 闪烁动画 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -41,22 +41,38 @@ export function StarDust({ count = 35 }) { const dots = containerRef.current.children const tweens = [] - Array.from(dots).forEach(dot => { + Array.from(dots).forEach((dot, i) => { + // 漂移动画 const duration = 6 + Math.random() * 4 const driftX = (Math.random() - 0.5) * 20 const driftY = (Math.random() - 0.5) * 20 - const tween = gsap.to(dot, { + const driftTween = gsap.to(dot, { x: `+=${driftX}`, y: `+=${driftY}`, - opacity: 0.6, duration, delay: Math.random() * 4, repeat: -1, yoyo: true, ease: 'sine.inOut', }) - tweens.push(tween) + tweens.push(driftTween) + + // 只有前8个星点闪烁,减少性能开销 + if (i < 8) { + const twinkleDuration = 2 + Math.random() * 4 + const targetOpacity = 0.15 + Math.random() * 0.35 + + const twinkleTween = gsap.to(dot, { + opacity: targetOpacity, + duration: twinkleDuration, + delay: Math.random() * 3, + repeat: -1, + yoyo: true, + ease: 'sine.inOut', + }) + tweens.push(twinkleTween) + } }) return () => tweens.forEach(t => t.kill()) @@ -74,7 +90,7 @@ export function StarDust({ count = 35 }) { width: p.size, height: p.size, backgroundColor: p.color, - opacity: 0.2, + opacity: 0.3, }} /> ))} diff --git a/src/components/gtd/ZDepthLayer.jsx b/src/components/gtd/ZDepthLayer.jsx index f0fbbe9..5877407 100644 --- a/src/components/gtd/ZDepthLayer.jsx +++ b/src/components/gtd/ZDepthLayer.jsx @@ -1,12 +1,13 @@ /** - * [INPUT]: react + * [INPUT]: react, gsap * [OUTPUT]: ZDepthLayer 组件, DEPTH_LAYERS 常量 - * [POS]: 深度层管理器,统一管理 far/mid/near 三层的视差和模糊 + * [POS]: 深度层管理器,统一管理 far/mid/near 三层的视差、模糊 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { createContext, useContext, useState } from 'react' +import { createContext, useContext, useState, useRef, useEffect } from 'react' import { cn } from '@/lib/utils' +import gsap from 'gsap' // ═══════════════════════════════════════════════════════════════════════════ // 深度层配置 - Z 轴三层系统 @@ -16,19 +17,19 @@ export const DEPTH_LAYERS = { far: { zIndex: 3, blur: 1, - parallaxSpeed: 0.08, // 最慢,最远 + parallaxSpeed: 0.03, // 最慢,最远 opacity: 0.7 }, mid: { zIndex: 10, blur: 0.3, - parallaxSpeed: 0.2, // 中速 + parallaxSpeed: 0.15, // 中速 opacity: 0.85 }, near: { zIndex: 20, blur: 0, // 清晰 - parallaxSpeed: 0.4, // 最快,最近 + parallaxSpeed: 0.4, // 最快,最近 (是 far 的 13 倍) opacity: 1 } } @@ -55,7 +56,7 @@ export function useParallax(speed = 1) { } // ═══════════════════════════════════════════════════════════════════════════ -// 深度层组件 - 包装子元素并应用深度效果 +// 深度层组件 - 包装子元素并应用深度效果 + 自动漂移 // ═══════════════════════════════════════════════════════════════════════════ export function ZDepthLayer({ @@ -99,7 +100,7 @@ export function ZDepthLayer({ } // ═══════════════════════════════════════════════════════════════════════════ -// 视差提供者 - 在最外层监听鼠标移动 +// 视差提供者 - 在最外层监听鼠标移动,带惯性回弹 // ═══════════════════════════════════════════════════════════════════════════ export function ParallaxProvider({ @@ -107,21 +108,38 @@ export function ParallaxProvider({ className, intensity = 1 // 视差强度倍数 }) { - const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + const [targetPos, setTargetPos] = useState({ x: 0, y: 0 }) // 鼠标目标位置 + const [currentPos, setCurrentPos] = useState({ x: 0, y: 0 }) // 当前缓动位置 + const gsapObjRef = useRef({ x: 0, y: 0 }) const handleMouseMove = (e) => { // 归一化到 -1 到 1 const x = ((e.clientX / window.innerWidth) - 0.5) * 2 * intensity const y = ((e.clientY / window.innerHeight) - 0.5) * 2 * intensity - setMousePos({ x, y }) + setTargetPos({ x, y }) } const handleMouseLeave = () => { - setMousePos({ x: 0, y: 0 }) + setTargetPos({ x: 0, y: 0 }) } + // ═══════════════════════════════════════════════════════════════════════════ + // 惯性回弹 - 鼠标停止后缓动归位 + // ═══════════════════════════════════════════════════════════════════════════ + useEffect(() => { + gsap.to(gsapObjRef.current, { + x: targetPos.x, + y: targetPos.y, + duration: 0.8, + ease: 'power2.out', + onUpdate: () => { + setCurrentPos({ x: gsapObjRef.current.x, y: gsapObjRef.current.y }) + } + }) + }, [targetPos]) + return ( - +
Date: Wed, 28 Jan 2026 23:34:56 +0800 Subject: [PATCH 10/32] Optimize FocusView data flow and parallax performance - Move task filtering logic from FocusView to GTD store for better separation - Precompute todayTasks, completedToday, overdueTasks, and planetTasks in store - Optimize parallax system to use CSS variables instead of React state updates - Memoize OrbitSegment component to prevent unnecessary re-renders - Improve performance by reducing React re-renders during mouse movement --- src/App.jsx | 13 ++++- src/components/gtd/FocusCircle.jsx | 10 ++-- src/components/gtd/FocusView.jsx | 47 ++++++----------- src/components/gtd/OrbitPaths.jsx | 6 +-- src/components/gtd/ZDepthLayer.jsx | 81 +++++++++++++++++++----------- src/stores/gtd.js | 37 +++++++++++++- 6 files changed, 119 insertions(+), 75 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 4083887..98764a6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -68,7 +68,12 @@ function AppContent({ fileSystem, sync }) { toggleComplete, moveTask, deleteTask, - loadTasks + loadTasks, + // 预处理数据 - 供 FocusView 使用 + todayTasks, + completedToday, + overdueTasks, + planetTasks } = useGTD({ fileSystem: fileSystem.isReady ? fileSystem.fs : null }) const { @@ -351,7 +356,11 @@ function AppContent({ fileSystem, sync }) { ) : viewMode === 'focus' ? ( moveTask(id, GTD_LISTS.TODAY)} onMoveToTomorrow={(id) => { diff --git a/src/components/gtd/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx index ffe63d5..b9be33f 100644 --- a/src/components/gtd/FocusCircle.jsx +++ b/src/components/gtd/FocusCircle.jsx @@ -130,7 +130,8 @@ function savePlanetPosition(taskId, position) { export function FocusCircle({ totalCount = 0, completedCount = 0, - tasks = [], + planetTasks = [], + allTasks = [], selectedTaskId = null, onParticleClick, onTaskSelect, @@ -158,11 +159,6 @@ export function FocusCircle({ // 加载保存的位置 const savedPositions = loadSavedPositions() - // 准备行星任务数据 - 只取未完成的任务 - const planetTasks = useMemo(() => { - return tasks.filter(t => !t.completed).slice(0, PLANET_CONFIG.length) - }, [tasks]) - // 处理位置变化 const handlePositionChange = useCallback((taskId, position) => { savePlanetPosition(taskId, position) @@ -230,7 +226,7 @@ export function FocusCircle({ {/* 已完成任务星座 */} - t.completed)} /> + t.completed)} /> {/* 轨道 - 移出层测试 */} diff --git a/src/components/gtd/FocusView.jsx b/src/components/gtd/FocusView.jsx index 0e62c00..56854d0 100644 --- a/src/components/gtd/FocusView.jsx +++ b/src/components/gtd/FocusView.jsx @@ -5,11 +5,10 @@ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' -import { isToday, isPast } from '@/stores/gtd' import { useAI } from '@/stores/ai' import { ChevronRight, Sparkles, Plus, Calendar, BookOpen } from 'lucide-react' import { FocusCircle } from './FocusCircle' @@ -305,7 +304,11 @@ function OverdueCard({ tasks, onMoveToToday, onMoveToTomorrow, onDelete }) { // 主组件 // ═══════════════════════════════════════════════════════════════════════════ export function FocusView({ - tasks = [], + todayTasks = [], + completedCount = 0, + overdueTasks = [], + planetTasks = [], + allTasks = [], onComplete, onMoveToToday, onMoveToTomorrow, @@ -333,27 +336,6 @@ export function FocusView({ // 星座系统 const { stars, addStar } = useConstellation() - // 今日任务(包括过期) - const todayTasks = useMemo(() => { - return tasks.filter(t => - !t.completed && (isToday(t.dueDate) || isPast(t.dueDate)) - ) - }, [tasks]) - - // 已完成任务数 - const completedToday = useMemo(() => { - const today = new Date() - today.setHours(0, 0, 0, 0) - return tasks.filter(t => - t.completed && t.completedAt && new Date(t.completedAt) >= today - ).length - }, [tasks]) - - // 过期任务 - const overdueTasks = useMemo(() => { - return tasks.filter(t => !t.completed && isPast(t.dueDate)) - }, [tasks]) - // 加载推荐任务 const loadRecommendations = useCallback(async () => { if (todayTasks.length === 0) { @@ -382,15 +364,13 @@ export function FocusView({ // 处理任务完成 const handleComplete = useCallback((taskId) => { - const task = tasks.find(t => t.id === taskId) + const task = todayTasks.find(t => t.id === taskId) if (task) { - // 标记完成时间 - const completedTask = { ...task, completed: true, completedAt: new Date().toISOString() } onComplete?.(taskId) } setRecommendedTasks(prev => prev.filter(t => t.id !== taskId)) if (selectedTaskId === taskId) setSelectedTaskId(null) - }, [tasks, onComplete, selectedTaskId]) + }, [todayTasks, onComplete, selectedTaskId]) // 处理任务选择 const handleTaskSelect = useCallback((taskId) => { @@ -419,8 +399,8 @@ export function FocusView({ }, []) // 判断状态 - const isEmpty = todayTasks.length === 0 && completedToday === 0 - const isAllDone = todayTasks.length === 0 && completedToday > 0 + const isEmpty = todayTasks.length === 0 && completedCount === 0 + const isAllDone = todayTasks.length === 0 && completedCount > 0 return (
{/* 柔性宇宙插画 */} ) -} +}) // ═══════════════════════════════════════════════════════════════════════════ // 生成轨道片段 - 随机分布的短弧线 diff --git a/src/components/gtd/ZDepthLayer.jsx b/src/components/gtd/ZDepthLayer.jsx index 5877407..b4f62c7 100644 --- a/src/components/gtd/ZDepthLayer.jsx +++ b/src/components/gtd/ZDepthLayer.jsx @@ -5,7 +5,7 @@ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ -import { createContext, useContext, useState, useRef, useEffect } from 'react' +import { createContext, useContext, useState, useRef, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' import gsap from 'gsap' @@ -39,18 +39,22 @@ export const DEPTH_LAYERS = { // ═══════════════════════════════════════════════════════════════════════════ const ParallaxContext = createContext({ - x: 0, - y: 0 + intensity: 1 }) -// Hook: 获取当前视差偏移 +// Hook: 获取当前视差偏移(已废弃,保留兼容性) export function useParallax(speed = 1) { - const { x, y } = useContext(ParallaxContext) + const { intensity } = useContext(ParallaxContext) + // 通过 CSS 变量应用视差,不在 render 中计算 return { - x: x * speed, - y: y * speed, + x: 0, + y: 0, + intensity, style: { - transform: `translate(${x * speed}px, ${y * speed}px)` + transform: `translate( + calc(var(--parallax-x) * ${speed}), + calc(var(--parallax-y) * ${speed}) + )` } } } @@ -67,11 +71,14 @@ export function ZDepthLayer({ disableParallax = false }) { const config = DEPTH_LAYERS[layer] || DEPTH_LAYERS.mid - const { x, y } = useContext(ParallaxContext) - // 计算视差偏移 - const parallaxX = disableParallax ? 0 : x * config.parallaxSpeed * 10 - const parallaxY = disableParallax ? 0 : y * config.parallaxSpeed * 10 + // 通过 CSS 变量应用视差,不在 render 中计算 + const transformStyle = disableParallax ? {} : { + transform: `translate( + calc(var(--parallax-x) * ${config.parallaxSpeed * 10}), + calc(var(--parallax-y) * ${config.parallaxSpeed * 10}) + )` + } return (
0 ? `blur(${config.blur}px)` : undefined, - transform: `translate(${parallaxX}px, ${parallaxY}px)`, + ...transformStyle, willChange: 'transform', - transition: 'transform 0.15s ease-out', width: '100%', height: '100%' }} @@ -108,42 +114,59 @@ export function ParallaxProvider({ className, intensity = 1 // 视差强度倍数 }) { - const [targetPos, setTargetPos] = useState({ x: 0, y: 0 }) // 鼠标目标位置 - const [currentPos, setCurrentPos] = useState({ x: 0, y: 0 }) // 当前缓动位置 + const containerRef = useRef(null) const gsapObjRef = useRef({ x: 0, y: 0 }) + const targetPosRef = useRef({ x: 0, y: 0 }) - const handleMouseMove = (e) => { + const handleMouseMove = useCallback((e) => { // 归一化到 -1 到 1 const x = ((e.clientX / window.innerWidth) - 0.5) * 2 * intensity const y = ((e.clientY / window.innerHeight) - 0.5) * 2 * intensity - setTargetPos({ x, y }) - } + targetPosRef.current = { x, y } + }, [intensity]) - const handleMouseLeave = () => { - setTargetPos({ x: 0, y: 0 }) - } + const handleMouseLeave = useCallback(() => { + targetPosRef.current = { x: 0, y: 0 } + }, []) // ═══════════════════════════════════════════════════════════════════════════ - // 惯性回弹 - 鼠标停止后缓动归位 + // 惯性回弹 - GSAP 直接操作 CSS 变量,不触发 React 渲染 // ═══════════════════════════════════════════════════════════════════════════ useEffect(() => { - gsap.to(gsapObjRef.current, { - x: targetPos.x, - y: targetPos.y, + if (!containerRef.current) return + + // 创建 GSAP 动画,返回一个 tween 对象 + const tween = gsap.to(gsapObjRef.current, { + x: targetPosRef.current.x, + y: targetPosRef.current.y, duration: 0.8, ease: 'power2.out', onUpdate: () => { - setCurrentPos({ x: gsapObjRef.current.x, y: gsapObjRef.current.y }) + // 直接修改 CSS 变量,不通过 React + if (containerRef.current) { + containerRef.current.style.setProperty('--parallax-x', `${gsapObjRef.current.x}px`) + containerRef.current.style.setProperty('--parallax-y', `${gsapObjRef.current.y}px`) + } } }) - }, [targetPos]) + + // 清理函数 + return () => { + tween.kill() + } + }, [intensity]) // 依赖 intensity 而非 targetPosRef.current return ( - +
{children}
diff --git a/src/stores/gtd.js b/src/stores/gtd.js index afec120..c3c2ef4 100644 --- a/src/stores/gtd.js +++ b/src/stores/gtd.js @@ -465,6 +465,36 @@ export function useGTD(options = {}) { }, {}) }, [tasks]) + // ═══════════════════════════════════════════════════════════════════════════ + // 预处理数据 - 供 FocusView 使用 + // ═══════════════════════════════════════════════════════════════════════════ + + // 今日任务(包括过期) + const todayTasks = useMemo(() => { + return tasks.filter(t => + !t.completed && (isToday(t.dueDate) || isPast(t.dueDate)) + ) + }, [tasks]) + + // 已完成任务数 + const completedToday = useMemo(() => { + const today = new Date() + today.setHours(0, 0, 0, 0) + return tasks.filter(t => + t.completed && t.completedAt && new Date(t.completedAt) >= today + ).length + }, [tasks]) + + // 过期任务 + const overdueTasks = useMemo(() => { + return tasks.filter(t => !t.completed && isPast(t.dueDate)) + }, [tasks]) + + // 行星任务(未完成,最多 6 个) + const planetTasks = useMemo(() => { + return tasks.filter(t => !t.completed).slice(0, 6) + }, [tasks]) + return { tasks, filteredTasks, @@ -478,6 +508,11 @@ export function useGTD(options = {}) { toggleComplete, moveTask, loadTasks: loadTasksFromData, - flush + flush, + // 预处理数据 + todayTasks, + completedToday, + overdueTasks, + planetTasks } } From dc368b4a5c142bca4df7f46d1e5436e9e8694260 Mon Sep 17 00:00:00 2001 From: lyeka Date: Thu, 29 Jan 2026 00:20:34 +0800 Subject: [PATCH 11/32] Optimize focus view performance by reducing particle counts and blur layers --- src/components/gtd/DarkNebula.jsx | 7 +++---- src/components/gtd/DeepSpaceDust.jsx | 2 +- src/components/gtd/FocusCircle.jsx | 2 +- src/components/gtd/FocusView.jsx | 11 +++++++---- src/components/gtd/OrbitPaths.jsx | 2 +- src/components/gtd/Sidebar.jsx | 16 +++++++++++----- src/components/gtd/SpaceGlow.jsx | 6 +++--- src/components/gtd/ZDepthLayer.jsx | 7 ++++--- 8 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/components/gtd/DarkNebula.jsx b/src/components/gtd/DarkNebula.jsx index 3fd01e5..2fa3a6a 100644 --- a/src/components/gtd/DarkNebula.jsx +++ b/src/components/gtd/DarkNebula.jsx @@ -25,8 +25,7 @@ function NebulaParticle({ particle }) { transform: 'translate(-50%, -50%)', borderRadius: '50%', background: `radial-gradient(circle, ${particle.color} 0%, transparent 65%)`, - opacity: particle.opacity, - filter: `blur(${particle.blur}px)` + opacity: particle.opacity }} /> ) @@ -40,7 +39,7 @@ function generateNebulaParticles() { const particles = [] // 20-30 个小光点,形成云雾感 - for (let i = 0; i < 24; i++) { + for (let i = 0; i < 8; i++) { particles.push({ x: 10 + Math.random() * 80, y: 10 + Math.random() * 80, @@ -74,7 +73,7 @@ export function DarkNebula({ className }) {
{particles.map((p, i) => ( diff --git a/src/components/gtd/DeepSpaceDust.jsx b/src/components/gtd/DeepSpaceDust.jsx index 4ac5b55..4719149 100644 --- a/src/components/gtd/DeepSpaceDust.jsx +++ b/src/components/gtd/DeepSpaceDust.jsx @@ -13,7 +13,7 @@ import { cn } from '@/lib/utils' // 极微小星点 - 0.3-1px,创造尺度差 // ═══════════════════════════════════════════════════════════════════════════ -const MICRO_DUST_COUNT = 80 // 从200减少到80,提升性能 +const MICRO_DUST_COUNT = 25 // 从 80 减到 25,提升性能 // 冷色系星点颜色 - 80% 灰白,20% 冷蓝 function pickDustColor() { diff --git a/src/components/gtd/FocusCircle.jsx b/src/components/gtd/FocusCircle.jsx index b9be33f..6f81daf 100644 --- a/src/components/gtd/FocusCircle.jsx +++ b/src/components/gtd/FocusCircle.jsx @@ -212,7 +212,7 @@ export function FocusCircle({ {/* 极微星点 - 移出层测试 */} - + {/* ═════════════════════════════════════════════════════════════════════════ Mid Layer - 中景 diff --git a/src/components/gtd/FocusView.jsx b/src/components/gtd/FocusView.jsx index 56854d0..1d706f3 100644 --- a/src/components/gtd/FocusView.jsx +++ b/src/components/gtd/FocusView.jsx @@ -403,10 +403,13 @@ export function FocusView({ const isAllDone = todayTasks.length === 0 && completedCount > 0 return ( -
+
{/* 柔性宇宙插画 */} ) @@ -147,6 +146,7 @@ export function OrbitPaths() { className="absolute inset-0 w-full h-full pointer-events-none" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice" + style={{ filter: 'blur(0.6px)' }} > {segments.map((seg, index) => ( diff --git a/src/components/gtd/Sidebar.jsx b/src/components/gtd/Sidebar.jsx index 3b039f8..bc2d8d7 100644 --- a/src/components/gtd/Sidebar.jsx +++ b/src/components/gtd/Sidebar.jsx @@ -122,11 +122,17 @@ export function Sidebar({ activeList, onSelect, counts, tasks = [], viewMode, on // 桌面端:侧边栏 return ( -