From 882a12420232b3147aeb5d9c487a4739e0dd60c6 Mon Sep 17 00:00:00 2001 From: Wenj1NG <69745614+macintoshwan@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:44:16 +0800 Subject: [PATCH] feat: port todolist feature onto main base - Add todo workflow integrated with project tasks - Add project freeze and archive lifecycle controls - Unify frozen/archived badges and place at card front - Hide time display for frozen/archived projects - Add supabase migrations for todos/system/freeze/archive --- src/components/App.tsx | 297 ++++++++++++++++-- src/components/ProjectDetail.tsx | 15 +- src/components/ProjectEditModal.tsx | 3 + src/components/ProjectList.tsx | 58 +++- src/components/TaskEditModal.tsx | 22 +- src/components/TaskList.tsx | 31 +- src/components/TodoList.tsx | 75 +++++ src/components/TodoListContainer.tsx | 57 ++++ src/lib/api.ts | 147 ++++++++- src/store/useProjectsStore.ts | 115 ++++++- src/styles/base.css | 152 ++++++++- src/types/index.ts | 34 +- .../migrations/001_create_todos_table.sql | 51 +++ .../002_add_system_project_flag.sql | 14 + .../003_add_project_freeze_column.sql | 8 + .../004_add_project_archive_column.sql | 6 + 16 files changed, 1015 insertions(+), 70 deletions(-) create mode 100644 src/components/TodoList.tsx create mode 100644 src/components/TodoListContainer.tsx create mode 100644 supabase/migrations/001_create_todos_table.sql create mode 100644 supabase/migrations/002_add_system_project_flag.sql create mode 100644 supabase/migrations/003_add_project_freeze_column.sql create mode 100644 supabase/migrations/004_add_project_archive_column.sql diff --git a/src/components/App.tsx b/src/components/App.tsx index 6425886..a7766d9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -4,7 +4,7 @@ * 职责:认证守卫 + useRealtimeSync 挂载 + 页面路由 */ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { useAuth } from '../hooks/useAuth'; import { useRealtimeSync } from '../hooks/useRealtimeSync'; import { useTheme, type ThemePreset } from '../hooks/useTheme'; @@ -14,11 +14,22 @@ import { fetchCheckinRecordsByDate, insertCheckinTemplate, upsertCheckinRecord, + ensureSystemTodoProject, } from '../lib/api'; import { ProjectList } from './ProjectList'; import { ProjectDetail } from './ProjectDetail'; import { ProjectEditModal } from './ProjectEditModal'; -import type { CheckinTemplate } from '../types'; +import { TaskEditModal } from './TaskEditModal'; +import { TodoList } from './TodoList'; +import type { CheckinTemplate, TodoItem, Project, TaskStatus } from '../types'; +import { useProjectsStore } from '../store/useProjectsStore'; +import { deriveTaskStatus } from '../types'; +import { useTaskActions } from '../hooks/useActions'; + +function isTempProject(project: Project | null | undefined): boolean { + if (!project) return false; + return project.is_system || project.category === 'system' || project.name === '临时'; +} export default function App() { const { user, loading: authLoading, signIn, signUp, signOut } = useAuth(); @@ -28,6 +39,63 @@ export default function App() { // 挂载 Realtime 同步(用户变更时自动 cleanup + 重建) useRealtimeSync(user?.id); + // 从 store 获取最基础的数据 + const { optimisticInsertProject } = useProjectsStore(); + const { setTaskStatus } = useTaskActions(); + const projectIds = useProjectsStore((s) => s.projectIds); + const projects = useProjectsStore((s) => s.projects); + const tasks = useProjectsStore((s) => s.tasks); + const tasksByProject = useProjectsStore((s) => s.tasksByProject); + + // 用 useMemo 计算待办项,只在依赖变化时重新计算 + const todoItems: TodoItem[] = useMemo(() => { + const items: TodoItem[] = []; + const statusOrder: Record = { + 'in-progress': 0, + ready: 1, + backlog: 2, + done: 3, + }; + const projectOrder = new Map(projectIds.map((id, index) => [id, index])); + + // 1. 添加项目中的 backlog / ready / in-progress 任务 + for (const projectId of projectIds) { + const project = projects[projectId]; + if (!project) continue; + if (project.is_frozen || project.is_archived) continue; + const taskIdsArr = tasksByProject[projectId] ?? []; + for (const taskId of taskIdsArr) { + const task = tasks[taskId]; + if (!task) continue; + const status = deriveTaskStatus(task); + if (status !== 'done') { + items.push({ + id: task.id, + name: task.name, + type: 'project-task', + projectId: project.id, + projectName: project.name, + status, + completed: task.completed, + }); + } + } + } + + items.sort((a, b) => { + const byStatus = statusOrder[a.status] - statusOrder[b.status]; + if (byStatus !== 0) return byStatus; + + const aProjectOrder = a.projectId !== undefined ? (projectOrder.get(a.projectId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; + const bProjectOrder = b.projectId !== undefined ? (projectOrder.get(b.projectId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; + if (aProjectOrder !== bProjectOrder) return aProjectOrder - bProjectOrder; + + return a.id - b.id; + }); + + return items; + }, [projectIds, projects, tasks, tasksByProject]); + // 当前查看的项目 ID const [activeProjectId, setActiveProjectId] = useState(null); @@ -42,12 +110,40 @@ export default function App() { // 新建项目浮层 const [isNewProjectOpen, setIsNewProjectOpen] = useState(false); const [isCheckinModalOpen, setIsCheckinModalOpen] = useState(false); + const [isTodoProjectPickerOpen, setIsTodoProjectPickerOpen] = useState(false); + const [todoCreateTarget, setTodoCreateTarget] = useState(null); const [checkins, setCheckins] = useState([]); const [checkinRecords, setCheckinRecords] = useState>({}); const [checkinLoading, setCheckinLoading] = useState(false); const todayKey = getLocalDateKey(); + useEffect(() => { + const userId = user?.id; + if (!userId) return; + const currentUserId = userId as string; + + let cancelled = false; + + async function ensureTempProjectOnLogin() { + try { + const tempProject = await ensureSystemTodoProject(currentUserId); + if (cancelled) return; + const state = useProjectsStore.getState(); + if (!state.projects[tempProject.id]) { + optimisticInsertProject(tempProject); + } + } catch (error) { + console.error('初始化临时项目失败:', error); + } + } + + void ensureTempProjectOnLogin(); + return () => { + cancelled = true; + }; + }, [user?.id, optimisticInsertProject]); + useEffect(() => { const userId = user?.id; if (!userId) { @@ -87,6 +183,56 @@ export default function App() { }; }, [user?.id, todayKey]); + // 按任务流程创建待办:先本地选择项目,再进入任务表单 + const handleStartCreateTodoTask = useCallback(() => { + if (!user?.id) { + alert('请先登录'); + return; + } + setIsTodoProjectPickerOpen(true); + }, [user?.id]); + + const handleChooseTodoProject = useCallback((projectId: number) => { + setTodoCreateTarget(projectId); + setIsTodoProjectPickerOpen(false); + }, []); + + const handleChooseTempTodoProject = useCallback(() => { + setTodoCreateTarget('temp'); + setIsTodoProjectPickerOpen(false); + }, []); + + const handleCloseTodoProjectPicker = useCallback(() => { + setIsTodoProjectPickerOpen(false); + }, []); + + const handleCloseTodoTaskModal = useCallback(() => { + setTodoCreateTarget(null); + }, []); + + const resolveTempTodoProjectId = useCallback(async (): Promise => { + if (!user?.id) { + throw new Error('请先登录'); + } + + const todoProject = await ensureSystemTodoProject(user.id); + const state = useProjectsStore.getState(); + if (!state.projects[todoProject.id]) { + optimisticInsertProject(todoProject); + } + return todoProject.id; + }, [user?.id, optimisticInsertProject]); + + // 处理切换待办任务状态 + const handleChangeTodoStatus = useCallback(async (taskId: number, status: TaskStatus) => { + try { + await setTaskStatus(taskId, status); + } catch (error) { + console.error('更新待办状态失败:', error); + alert('更新待办状态失败,请重试'); + } + }, [setTaskStatus]); + const handleOpenNewProject = useCallback(() => { setIsNewProjectOpen(true); }, []); @@ -103,6 +249,11 @@ export default function App() { setIsCheckinModalOpen(false); }, []); + const todoProjectCandidates = projectIds + .map((id) => projects[id]) + .filter((project): project is Project => project !== undefined); + const visibleTodoProjects = todoProjectCandidates.filter((project) => !isTempProject(project) && !project.is_frozen && !project.is_archived); + const handleCreateCheckin = useCallback(async (name: string) => { if (!user?.id) { alert('请先登录'); @@ -187,23 +338,30 @@ export default function App() { ) : ( <> - - - {checkins.length > 0 && ( - - )} + {checkinLoading &&
正在加载打卡数据...
} -
- - -
+ + +
+ +
+ +
+
{/* 新建项目浮层 */} {isNewProjectOpen && ( @@ -219,6 +377,25 @@ export default function App() { onConfirm={handleCreateCheckin} /> )} + + {isTodoProjectPickerOpen && ( + + )} + + {todoCreateTarget !== null && ( + + )} )} @@ -237,30 +414,40 @@ interface CheckinSectionProps { records: Record; todayKey: string; onCheckin: (id: number) => void; + onCreateCheckin: () => void; } -function CheckinSection({ templates, records, todayKey, onCheckin }: CheckinSectionProps) { +function CheckinSection({ templates, records, todayKey, onCheckin, onCreateCheckin }: CheckinSectionProps) { return (

每日打卡

-
- {templates.map((item) => { - const doneToday = records[item.id] === todayKey; - return ( - - ); - })} + {templates.length === 0 ? ( +
暂无打卡项,请先创建一个打卡
+ ) : ( +
+ {templates.map((item) => { + const doneToday = records[item.id] === todayKey; + return ( + + ); + })} +
+ )} +
+
); @@ -274,6 +461,48 @@ function getLocalDateKey() { return `${y}-${m}-${d}`; } +interface TodoProjectPickerModalProps { + projects: Project[]; + onClose: () => void; + onPick: (projectId: number) => void; + onPickTemp: () => void; +} + +function TodoProjectPickerModal({ projects, onClose, onPick, onPickTemp }: TodoProjectPickerModalProps) { + return ( +
+
e.stopPropagation()}> +
+ +
+
+

选择待办所属项目

+
+ +
+ + {projects.map((project) => ( + + ))} + {projects.length === 0 && ( +
当前没有可选项目
+ )} +
+
+
+
+
+ ); +} + interface CheckinCreateModalProps { onClose: () => void; onConfirm: (name: string) => void; diff --git a/src/components/ProjectDetail.tsx b/src/components/ProjectDetail.tsx index cfed826..6dddcaf 100644 --- a/src/components/ProjectDetail.tsx +++ b/src/components/ProjectDetail.tsx @@ -28,6 +28,8 @@ function fmtDuration(hours: number | null): string { export const ProjectDetail = memo(function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { const project = useProjectsStore(selectProject(projectId)); + const isProjectLocked = Boolean(project?.is_frozen || project?.is_archived); + // useShallow 避免 .map().filter() 返回新数组引用导致无限循环 const tasks = useProjectsStore(useShallow(selectTasksByProject(projectId))); @@ -123,6 +125,11 @@ export const ProjectDetail = memo(function ProjectDetail({ projectId, onBack }: 返回

{project.name}

+ {isProjectLocked && ( +
+ {project.is_archived ? '该项目已归档,当前为只读模式' : '该项目已冻结,当前为只读模式'} +
+ )}
@@ -150,13 +157,13 @@ export const ProjectDetail = memo(function ProjectDetail({ projectId, onBack }:
{/* 力导向任务关系图 */} - + - +
- - + +
{/* 任务编辑/新建浮层 */} diff --git a/src/components/ProjectEditModal.tsx b/src/components/ProjectEditModal.tsx index 80d1671..9c55fd5 100644 --- a/src/components/ProjectEditModal.tsx +++ b/src/components/ProjectEditModal.tsx @@ -237,6 +237,9 @@ export const ProjectEditModal = memo(function ProjectEditModal({ await createProject({ id: Date.now(), user_id: user.id, + is_system: false, + is_frozen: false, + is_archived: false, name: form.name.trim(), plan_start_date: localToIso(form.plan_start_date), plan_end_date: localToIso(form.plan_end_date), diff --git a/src/components/ProjectList.tsx b/src/components/ProjectList.tsx index 2217b8f..c4d6b38 100644 --- a/src/components/ProjectList.tsx +++ b/src/components/ProjectList.tsx @@ -9,6 +9,11 @@ import { useProjectActions } from '../hooks/useActions'; import { useShallow } from 'zustand/react/shallow'; import { formatTimeRemainingCN } from '../utils/formatTime'; +function isTempProject(project: { is_system?: boolean; category?: string | null; name?: string } | undefined): boolean { + if (!project) return false; + return project.is_system === true || project.category === 'system' || project.name === '临时'; +} + // ============================================================ // ProjectCard —— 单个项目卡片(memo 化) // ============================================================ @@ -22,10 +27,11 @@ const ProjectCard = memo(function ProjectCard({ projectId, onSelect }: ProjectCa // 只订阅该 project 的数据,其他 project 变化不会触发 re-render const project = useProjectsStore(selectProject(projectId)); const tasks = useProjectsStore(useShallow(selectTasksByProject(projectId))); - const { removeProject } = useProjectActions(); + const { removeProject, updateProject } = useProjectActions(); // 计算 planEndDate:项目自身有值就用,否则从任务中取最晚的 const planEndDate = useMemo(() => { + if (project?.is_frozen || project?.is_archived) return null; if (project?.plan_end_date) return project.plan_end_date; if (!tasks || tasks.length === 0) return null; @@ -55,12 +61,53 @@ const ProjectCard = memo(function ProjectCard({ projectId, onSelect }: ProjectCa } }; + const handleToggleFreeze = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!project) return; + const allDone = tasks.every((t) => t.completed); + if (!project.is_frozen && allDone) { + alert('项目已完成,无需冻结'); + return; + } + try { + await updateProject(projectId, { is_frozen: !project.is_frozen }); + } catch (err) { + console.error('切换冻结状态失败:', err); + alert('切换冻结状态失败,请重试'); + } + }; + + const handleToggleArchive = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!project) return; + try { + const nextArchived = !project.is_archived; + await updateProject(projectId, { + is_archived: nextArchived, + ...(nextArchived ? { is_frozen: false } : {}), + }); + } catch (err) { + console.error('切换归档状态失败:', err); + alert('切换归档状态失败,请重试'); + } + }; + return (
onSelect(projectId)}>
- {project.name} +
+ {project.is_archived && 已归档} + {project.is_frozen && 已冻结} + {project.name} +
e.stopPropagation()}> + + @@ -110,6 +157,9 @@ export const ProjectList = memo(function ProjectList({ onSelectProject }: Projec // 只订阅 projectIds 数组(shallow 比较避免引用变动) const projectIds = useProjectsStore(useShallow(selectProjectIds)); const { initialized, loading, error } = useProjectsStore(useShallow(selectMeta)); + const projects = useProjectsStore((state) => state.projects); + + const visibleProjectIds = projectIds.filter((id) => !isTempProject(projects[id])); if (loading && !initialized) { return
加载中...
; @@ -119,13 +169,13 @@ export const ProjectList = memo(function ProjectList({ onSelectProject }: Projec return
加载失败: {error}
; } - if (projectIds.length === 0) { + if (visibleProjectIds.length === 0) { return
暂无项目,请创建第一个项目
; } return (
- {projectIds.map((id) => ( + {visibleProjectIds.map((id) => ( ))}
diff --git a/src/components/TaskEditModal.tsx b/src/components/TaskEditModal.tsx index bbb7bd4..7d4dcb0 100644 --- a/src/components/TaskEditModal.tsx +++ b/src/components/TaskEditModal.tsx @@ -34,6 +34,10 @@ interface TaskEditModalProps { projectId: number; /** 关闭回调 */ onClose: () => void; + /** 创建待办任务时默认进入进行中状态 */ + defaultInProgress?: boolean; + /** 延迟解析所属项目(例如“临时”项目在确认创建时再初始化) */ + resolveProjectId?: () => Promise; } interface TaskFormData { @@ -167,6 +171,8 @@ export const TaskEditModal = memo(function TaskEditModal({ taskId, projectId, onClose, + defaultInProgress = false, + resolveProjectId, }: TaskEditModalProps) { const isNew = taskId === null; const task = useProjectsStore(selectTask(taskId ?? -1)); @@ -198,12 +204,13 @@ export const TaskEditModal = memo(function TaskEditModal({ plan_end_date: isoToLocal(new Date(now.getTime() + 3_600_000).toISOString()), plan_duration: '1', category: project?.category ?? '工作', + actual_start_date: defaultInProgress ? isoToLocal(now.toISOString()) : '', }); } else if (task) { setForm(taskToForm(task)); } isInitRef.current = true; - }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [taskId, defaultInProgress]); // eslint-disable-line react-hooks/exhaustive-deps // 从 store 同步外部变更(realtime),但不覆盖用户正在编辑的字段 useEffect(() => { @@ -343,9 +350,18 @@ export const TaskEditModal = memo(function TaskEditModal({ if (!user) { alert('请先登录'); return; } try { + let finalProjectId = projectId; + if (resolveProjectId && finalProjectId <= 0) { + finalProjectId = await resolveProjectId(); + } + if (finalProjectId <= 0) { + alert('请选择任务所属项目'); + return; + } + await createTask({ id: Date.now(), - project_id: projectId, + project_id: finalProjectId, user_id: user.id, ...buildPayload(form), }); @@ -354,7 +370,7 @@ export const TaskEditModal = memo(function TaskEditModal({ console.error('创建任务失败:', err); alert('创建任务失败,请重试'); } - }, [form, projectId, user, createTask, onClose, buildPayload]); + }, [form, projectId, resolveProjectId, user, createTask, onClose, buildPayload]); const handleAiFillDescription = useCallback(async () => { const title = form.name.trim(); diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx index c2963ac..ea3fe16 100644 --- a/src/components/TaskList.tsx +++ b/src/components/TaskList.tsx @@ -20,18 +20,19 @@ import { formatTimeRemainingCN } from '../utils/formatTime'; // ============================================================ const STATUS_OPTIONS: { key: TaskStatus; label: string }[] = [ - { key: 'backlog', label: 'backlog' }, + { key: 'backlog', label: 'back' }, { key: 'ready', label: 'ready' }, - { key: 'in-progress', label: 'in progress' }, + { key: 'in-progress', label: 'inprocess' }, { key: 'done', label: 'done' }, ]; interface TaskStatusBarProps { currentStatus: TaskStatus; onChangeStatus: (status: TaskStatus) => void; + disabled?: boolean; } -const TaskStatusBar = memo(function TaskStatusBar({ currentStatus, onChangeStatus }: TaskStatusBarProps) { +const TaskStatusBar = memo(function TaskStatusBar({ currentStatus, onChangeStatus, disabled = false }: TaskStatusBarProps) { return (
{STATUS_OPTIONS.map(({ key, label }) => ( @@ -44,6 +45,7 @@ const TaskStatusBar = memo(function TaskStatusBar({ currentStatus, onChangeStatu e.stopPropagation(); onChangeStatus(key); }} + disabled={disabled} > {label} @@ -59,30 +61,33 @@ const TaskStatusBar = memo(function TaskStatusBar({ currentStatus, onChangeStatu interface TaskItemProps { taskId: number; onEdit?: (taskId: number) => void; + isProjectFrozen?: boolean; } -const TaskItem = memo(function TaskItem({ taskId, onEdit }: TaskItemProps) { +const TaskItem = memo(function TaskItem({ taskId, onEdit, isProjectFrozen = false }: TaskItemProps) { const task = useProjectsStore(selectTask(taskId)); const { removeTask, setTaskStatus } = useTaskActions(); const handleStatusChange = useCallback( async (status: TaskStatus) => { + if (isProjectFrozen) return; try { await setTaskStatus(taskId, status); } catch (err) { console.error('设置任务状态失败:', err); } }, - [taskId, setTaskStatus], + [taskId, setTaskStatus, isProjectFrozen], ); const handleDelete = useCallback(async () => { + if (isProjectFrozen) return; try { await removeTask(taskId); } catch (err) { console.error('删除任务失败:', err); } - }, [taskId, removeTask]); + }, [taskId, removeTask, isProjectFrozen]); if (!task) return null; @@ -90,17 +95,20 @@ const TaskItem = memo(function TaskItem({ taskId, onEdit }: TaskItemProps) { return (
- + onEdit?.(taskId)} + onClick={() => { + if (isProjectFrozen) return; + onEdit?.(taskId); + }} > {task.name} {task.plan_end_date && !task.actual_end_date && ( )} -
@@ -137,9 +145,10 @@ function TimeRemainingTask({ planEndDate }: { planEndDate: string }) { interface TaskListProps { projectId: number; onEditTask?: (taskId: number) => void; + isProjectFrozen?: boolean; } -export const TaskList = memo(function TaskList({ projectId, onEditTask }: TaskListProps) { +export const TaskList = memo(function TaskList({ projectId, onEditTask, isProjectFrozen = false }: TaskListProps) { // 只订阅该 project 的 task id 列表 const taskIds = useProjectsStore(useShallow(selectTaskIdsByProject(projectId))); @@ -150,7 +159,7 @@ export const TaskList = memo(function TaskList({ projectId, onEditTask }: TaskLi return (
{taskIds.map((id) => ( - + ))}
); diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000..6fb2677 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react'; +import type { TaskStatus, TodoItem } from '../types'; + +const STATUS_OPTIONS: { key: TaskStatus; label: string }[] = [ + { key: 'backlog', label: 'back' }, + { key: 'ready', label: 'ready' }, + { key: 'in-progress', label: 'inprocess' }, + { key: 'done', label: 'done' }, +]; + +interface TodoListProps { + items: TodoItem[]; + onCreateTodoTask: () => void; + onChangeTodoStatus: (taskId: number, status: TaskStatus) => Promise; +} + +export function TodoList({ + items, + onCreateTodoTask, + onChangeTodoStatus, +}: TodoListProps) { + const handleChangeStatus = useCallback( + async (taskId: number, status: TaskStatus) => { + try { + await onChangeTodoStatus(taskId, status); + } catch (error) { + console.error('更新待办状态失败:', error); + } + }, + [onChangeTodoStatus], + ); + + return ( +
+

待办事项

+ + {items.length === 0 ? ( +
暂无待办事项
+ ) : ( +
    + {items.map((item) => ( +
  • +
    + {STATUS_OPTIONS.map(({ key, label }) => ( + + ))} +
    + + {item.name} + + {item.projectName && ( + {item.projectName} + )} +
  • + ))} +
+ )} + +
+ +
+
+ ); +} diff --git a/src/components/TodoListContainer.tsx b/src/components/TodoListContainer.tsx new file mode 100644 index 0000000..0fee93e --- /dev/null +++ b/src/components/TodoListContainer.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { useProjectsStore, selectProjectIds } from '../store/useProjectsStore'; +import { deriveTaskStatus } from '../types'; +import { TodoList } from './TodoList'; +import type { TodoItem } from '../types'; +import type { TaskStatus } from '../types'; + +interface TodoListContainerProps { + onCreateTodoTask: () => void; + onChangeTodoStatus: (taskId: number, status: TaskStatus) => Promise; +} + +export function TodoListContainer({ + onCreateTodoTask, + onChangeTodoStatus, +}: TodoListContainerProps) { + const projectIds = useProjectsStore(selectProjectIds); + const projects = useProjectsStore((state) => state.projects); + const tasks = useProjectsStore((state) => state.tasks); + const tasksByProject = useProjectsStore((state) => state.tasksByProject); + + const todoItems = useMemo(() => { + const items: TodoItem[] = []; + + for (const projectId of projectIds) { + const project = projects[projectId]; + if (!project) continue; + const taskIds = tasksByProject[projectId] ?? []; + for (const taskId of taskIds) { + const task = tasks[taskId]; + if (!task) continue; + const status = deriveTaskStatus(task); + if (status !== 'done') { + items.push({ + id: task.id, + name: task.name, + type: 'project-task', + projectId: project.id, + projectName: project.name, + status, + completed: task.completed, + }); + } + } + } + + return items; + }, [projectIds, projects, tasks, tasksByProject]); + + return ( + + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 3418d2f..3916b09 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,7 +6,7 @@ import type { RealtimeChannel } from '@supabase/supabase-js'; import { supabase } from './supabaseClient'; -import type { Project, Task, ProjectWithTasks, RealtimePayload, CheckinTemplate, CheckinRecord } from '../types'; +import type { Project, Task, ProjectWithTasks, RealtimePayload, CheckinTemplate, CheckinRecord, Todo } from '../types'; export interface AiTaskDescriptionSuggestion { description: string; @@ -70,6 +70,110 @@ export async function fetchAllUserData(userId: string): Promise { + const { data: existing, error: fetchError } = await supabase + .from('projects') + .select('*') + .eq('user_id', userId) + .eq('is_system', true) + .maybeSingle(); + + if (fetchError) { + const missingSystemColumn = + fetchError.message.includes('projects.is_system') || + fetchError.message.includes('column') && fetchError.message.includes('is_system'); + + if (missingSystemColumn) { + const { data: legacyExisting, error: legacyFetchError } = await supabase + .from('projects') + .select('*') + .eq('user_id', userId) + .eq('category', 'system') + .eq('name', '临时') + .maybeSingle(); + + if (legacyFetchError) { + throw new Error(`ensureSystemTodoProject legacy fetch: ${legacyFetchError.message}`); + } + + if (legacyExisting) { + return { ...(legacyExisting as Project), is_system: true, is_frozen: false, is_archived: false }; + } + + const { data: legacyInserted, error: legacyInsertError } = await supabase + .from('projects') + .insert({ + id: Date.now(), + user_id: userId, + name: '临时', + plan_start_date: null, + plan_end_date: null, + plan_duration: null, + actual_start_date: null, + actual_end_date: null, + actual_duration: null, + priority: null, + category: 'system', + bounty: null, + }) + .select() + .single(); + + if (legacyInsertError) { + throw new Error(`ensureSystemTodoProject legacy insert: ${legacyInsertError.message}`); + } + + return { ...(legacyInserted as Project), is_system: true, is_frozen: false, is_archived: false }; + } + + throw new Error(`ensureSystemTodoProject fetch: ${fetchError.message}`); + } + + if (existing) { + if (existing.name !== '临时') { + const { data: renamed, error: renameError } = await supabase + .from('projects') + .update({ name: '临时' }) + .eq('id', existing.id) + .select() + .single(); + + if (renameError) { + throw new Error(`ensureSystemTodoProject rename: ${renameError.message}`); + } + + return renamed as Project; + } + + return existing as Project; + } + + const { data, error } = await supabase + .from('projects') + .insert({ + id: Date.now(), + user_id: userId, + name: '临时', + plan_start_date: null, + plan_end_date: null, + plan_duration: null, + actual_start_date: null, + actual_end_date: null, + actual_duration: null, + priority: null, + category: 'system', + bounty: null, + is_system: true, + is_frozen: false, + is_archived: false, + }) + .select() + .single(); + + if (error) throw new Error(`ensureSystemTodoProject insert: ${error.message}`); + return data as Project; +} + // ============================================================ // 单表 CRUD // ============================================================ @@ -258,3 +362,44 @@ export function subscribeRealtime(callbacks: RealtimeCallbacks): () => void { return cleanup; } + +// ============================================================ +// Todo CRUD +// ============================================================ + +export async function fetchTodos(userId: string): Promise { + const { data, error } = await supabase + .from('todos') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) throw new Error(`fetchTodos: ${error.message}`); + return (data ?? []) as Todo[]; +} + +export async function insertTodo(todo: Omit): Promise { + const { data, error } = await supabase + .from('todos') + .insert(todo) + .select() + .single(); + if (error) throw new Error(`insertTodo: ${error.message}`); + return data as Todo; +} + +export async function updateTodo(id: number, patch: Partial): Promise { + const { data, error } = await supabase + .from('todos') + .update(patch) + .eq('id', id) + .select() + .single(); + if (error) throw new Error(`updateTodo: ${error.message}`); + return data as Todo; +} + +export async function deleteTodo(id: number): Promise { + const { error } = await supabase.from('todos').delete().eq('id', id); + if (error) throw new Error(`deleteTodo: ${error.message}`); +} diff --git a/src/store/useProjectsStore.ts b/src/store/useProjectsStore.ts index a6c3223..e97e2cc 100644 --- a/src/store/useProjectsStore.ts +++ b/src/store/useProjectsStore.ts @@ -35,7 +35,7 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; -import type { NormalizedState, Project, Task, RealtimePayload, ProjectWithTasks } from '../types'; +import type { NormalizedState, Project, Task, RealtimePayload, ProjectWithTasks, Todo } from '../types'; // ============================================================ // Store 类型 @@ -59,6 +59,14 @@ interface ProjectsActions { optimisticDeleteProject: (id: number) => { project: Project; tasks: Task[] } | undefined; optimisticDeleteTask: (id: number) => Task | undefined; + // ---- Todo 操作 ---- + hydrateTodos: (todos: Todo[]) => void; + optimisticInsertTodo: (todo: Todo) => void; + optimisticUpdateTodo: (id: number, patch: Partial) => Todo | undefined; + optimisticDeleteTodo: (id: number) => Todo | undefined; + rollbackTodo: (todo: Todo) => void; + restoreDeletedTodo: (todo: Todo) => void; + // ---- 回滚 ---- rollbackProject: (project: Project) => void; rollbackTask: (task: Task) => void; @@ -80,6 +88,8 @@ const INITIAL_STATE: NormalizedState = { tasks: {}, tasksByProject: {}, projectIds: [], + todos: {}, + todoIds: [], initialized: false, loading: false, error: null, @@ -323,6 +333,60 @@ export const useProjectsStore = create()( }); }, + // ─────────────────── Todo 操作 ─────────────────── + + hydrateTodos(todos: Todo[]) { + const todoMap: Record = {}; + const todoIdList: number[] = []; + for (const t of todos) { + todoMap[t.id] = t; + todoIdList.push(t.id); + } + set({ todos: todoMap, todoIds: todoIdList }); + }, + + optimisticInsertTodo(todo: Todo) { + const state = get(); + set({ + todos: { ...state.todos, [todo.id]: todo }, + todoIds: [todo.id, ...state.todoIds], + }); + }, + + optimisticUpdateTodo(id: number, patch: Partial) { + const state = get(); + const prev = state.todos[id]; + if (!prev) return undefined; + set({ todos: { ...state.todos, [id]: { ...prev, ...patch } } }); + return prev; + }, + + optimisticDeleteTodo(id: number) { + const state = get(); + const todo = state.todos[id]; + if (!todo) return undefined; + + const { [id]: _, ...restTodos } = state.todos; + set({ + todos: restTodos, + todoIds: state.todoIds.filter((tid) => tid !== id), + }); + return todo; + }, + + rollbackTodo(todo: Todo) { + const state = get(); + set({ todos: { ...state.todos, [todo.id]: todo } }); + }, + + restoreDeletedTodo(todo: Todo) { + const state = get(); + set({ + todos: { ...state.todos, [todo.id]: todo }, + todoIds: [todo.id, ...state.todoIds], + }); + }, + // ─────────────────── 工具 ─────────────────── reset() { @@ -360,3 +424,52 @@ export const selectMeta = (state: ProjectsStore) => ({ loading: state.loading, error: state.error, }); + +/** 获取所有 todo */ +export const selectTodos = (state: ProjectsStore) => + state.todoIds.map((id) => state.todos[id]).filter((t): t is Todo => t !== null && t !== undefined); + +/** 获取单个 todo */ +export const selectTodo = (id: number) => (state: ProjectsStore) => state.todos[id]; + +/** 从 store 中获取所有需要显示的 TodoItem */ +export const selectTodoItems = (deriveTaskStatus: (task: any) => string) => (state: ProjectsStore) => { + const items: any[] = []; + + // 添加项目中的 in-progress 任务 + for (const projectId of state.projectIds) { + const project = state.projects[projectId]; + if (!project) continue; + const taskIds = state.tasksByProject[projectId] ?? []; + for (const taskId of taskIds) { + const task = state.tasks[taskId]; + if (!task) continue; + const status = deriveTaskStatus(task); + if (status === 'in-progress') { + items.push({ + id: task.id, + name: task.name, + type: 'project-task', + projectId: project.id, + projectName: project.name, + completed: task.completed, + }); + } + } + } + + // 添加独立 todo + for (const id of state.todoIds) { + const todo = state.todos[id]; + if (todo) { + items.push({ + id: todo.id, + name: todo.name, + type: 'standalone', + completed: todo.completed, + }); + } + } + + return items; +}; diff --git a/src/styles/base.css b/src/styles/base.css index 4708a75..f62fe32 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -751,12 +751,32 @@ button:hover { margin-bottom: var(--spacing-md); } +.project-header-main { + display: flex; + align-items: center; + gap: var(--spacing-sm); + min-width: 0; + flex: 1; +} + /* 项目标题 - 黑色文字(在青绿色模块上) */ .project-title { font-size: var(--font-size-xl); font-weight: 600; color: var(--text-on-module); flex: 1; + min-width: 0; +} + +.project-state-tag { + display: inline-block; + padding: 2px 8px; + border: 1px solid var(--status-backlog-bg); + border-radius: var(--radius-small); + font-size: var(--font-size-small); + color: var(--status-chip-text-on); + background: var(--status-backlog-bg); + flex-shrink: 0; } /* 项目卡片上的剩余时间 */ @@ -820,6 +840,11 @@ button:hover { background: var(--bg-module); } +.task-status-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .task-status-btn.active { background: var(--text-primary); color: var(--bg-page); @@ -930,21 +955,21 @@ button:hover { opacity: 0.7; } -/* ========== 下方新建项目按钮区域 ========== */ -.create-project-section { +.project-section { + margin-bottom: var(--spacing-xl); +} + +.section-footer { display: flex; - justify-content: center; - gap: var(--spacing-md); - margin-top: var(--spacing-xl); - padding: var(--spacing-lg) 0; + justify-content: flex-end; + margin-top: var(--spacing-md); } -.btn-create-project { +.btn-create-action { padding: var(--spacing-md) 40px; font-size: var(--font-size-large); font-weight: 600; - background: transparent; - color: var(--text-color); + white-space: nowrap; } /* ========== 打卡模块 ========== */ @@ -957,7 +982,7 @@ button:hover { } .checkin-title { - margin: 0 0 var(--spacing-md); + margin: 0; color: var(--text-on-module); font-size: var(--font-size-large); } @@ -1967,9 +1992,114 @@ input[readonly] { font-size: var(--font-size-base); color: var(--text-color); min-height: 60px; +} + +/* ========== Todo List 样式 ========== */ +.todo-section { + margin-bottom: var(--spacing-xl); + padding: var(--spacing-lg); + border: var(--border-width-module) solid var(--border-module); + border-radius: var(--radius-large); + background: var(--bg-card); +} + +.todo-title { + margin: 0 0 var(--spacing-md); + color: var(--text-on-module); + font-size: var(--font-size-large); +} + +.todo-input-container { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); +} + +.todo-input { + flex: 1; +} + +.btn-add-todo { + padding: 12px var(--spacing-lg); + font-size: var(--font-size-base); + font-weight: 600; + background: var(--color-success); + color: var(--color-on-primary); + border-color: var(--border-module); + white-space: nowrap; +} + +.btn-add-todo:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.todo-empty { + text-align: center; + color: var(--text-muted); + padding: var(--spacing-xl); + font-size: var(--font-size-base); + opacity: 0.7; +} + +.todo-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.todo-item { display: flex; align-items: center; - justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--bg-task); + border: var(--border-width-task) solid var(--border-module); + border-radius: var(--radius-base); +} + +.todo-checkbox { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--color-primary); +} + +.todo-text { + flex: 1; + color: var(--text-on-module); + font-size: var(--font-size-base); +} + +.todo-text.completed { + text-decoration: line-through; + opacity: 0.6; +} + +.todo-project-tag { + font-size: var(--font-size-small); + padding: 2px 8px; + border-radius: var(--radius-small); + background: var(--color-cyan); + color: var(--color-on-primary); + flex-shrink: 0; +} + +.btn-delete-todo { + padding: 4px 8px; + font-size: var(--font-size-small); + background: transparent; + color: var(--color-danger); + border-color: var(--color-danger); + flex-shrink: 0; +} + +.btn-delete-todo:hover { + background: var(--color-danger); + color: var(--text-white); } /* 优先级按钮显示 */ diff --git a/src/types/index.ts b/src/types/index.ts index 0e1a898..ad0fad2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,10 +11,13 @@ export interface Project { plan_duration: number | null; actual_start_date: string | null; actual_end_date: string | null; - actual_duration: number | null; + actual_duration: number | null; priority: string | null; category: string | null; bounty: number | null; + is_system: boolean; + is_frozen: boolean; + is_archived: boolean; created_at: string; user_id: string; // uuid } @@ -67,6 +70,10 @@ export interface NormalizedState { tasksByProject: Record; /** 所有 project id (有序,按 created_at DESC) */ projectIds: number[]; + /** todo id → Todo */ + todos: Record; + /** 所有 todo id (有序,按 created_at DESC) */ + todoIds: number[]; /** 数据是否已完成首次加载 */ initialized: boolean; /** 加载中 */ @@ -119,3 +126,28 @@ export interface CheckinRecord { created_at: string; user_id: string; } + +// ============================================================ +// Todo List Types +// ============================================================ + +/** todos 表行类型(独立任务,不隶属于任何项目) */ +export interface Todo { + id: number; + name: string; + completed: boolean; + created_at: string; + user_id: string; +} + +/** 待办事项列表项(包含 backlog / ready / in-progress 任务) */ +export interface TodoItem { + id: number; + name: string; + type: 'project-task'; + projectId?: number; + projectName?: string; + status: TaskStatus; + completed: boolean; +} + diff --git a/supabase/migrations/001_create_todos_table.sql b/supabase/migrations/001_create_todos_table.sql new file mode 100644 index 0000000..5b5136f --- /dev/null +++ b/supabase/migrations/001_create_todos_table.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- Create todos table and enable RLS +-- ============================================================ + +-- Create the todos table +CREATE TABLE public.todos ( + id bigserial PRIMARY KEY, + name text NOT NULL, + completed boolean DEFAULT false, + created_at timestamp with time zone DEFAULT now(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE +); + +-- Add index for query performance +CREATE INDEX idx_todos_user_id ON public.todos(user_id, created_at DESC); + +-- ============================================================ +-- Row Level Security (RLS) +-- ============================================================ + +ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; + +-- Users can view their own todos +CREATE POLICY "Users can view their own todos" + ON public.todos + FOR SELECT + USING (auth.uid() = user_id); + +-- Users can insert their own todos +CREATE POLICY "Users can insert their own todos" + ON public.todos + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Users can update their own todos +CREATE POLICY "Users can update their own todos" + ON public.todos + FOR UPDATE + USING (auth.uid() = user_id); + +-- Users can delete their own todos +CREATE POLICY "Users can delete their own todos" + ON public.todos + FOR DELETE + USING (auth.uid() = user_id); + +-- ============================================================ +-- Enable Realtime +-- ============================================================ + +ALTER PUBLICATION supabase_realtime ADD TABLE public.todos; diff --git a/supabase/migrations/002_add_system_project_flag.sql b/supabase/migrations/002_add_system_project_flag.sql new file mode 100644 index 0000000..0f54de1 --- /dev/null +++ b/supabase/migrations/002_add_system_project_flag.sql @@ -0,0 +1,14 @@ +-- ============================================================ +-- Add system project flag for hidden todo container +-- ============================================================ + +ALTER TABLE public.projects + ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false; + +-- Prevent duplicate hidden todo containers per user +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_user_system_unique + ON public.projects (user_id, is_system) + WHERE is_system = true; + +-- Optional: keep the hidden project clearly identifiable in the database +COMMENT ON COLUMN public.projects.is_system IS 'Marks hidden system projects such as the todo container.'; diff --git a/supabase/migrations/003_add_project_freeze_column.sql b/supabase/migrations/003_add_project_freeze_column.sql new file mode 100644 index 0000000..b13b31e --- /dev/null +++ b/supabase/migrations/003_add_project_freeze_column.sql @@ -0,0 +1,8 @@ +-- ============================================================ +-- Add freeze flag for projects +-- ============================================================ + +ALTER TABLE public.projects + ADD COLUMN IF NOT EXISTS is_frozen boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.projects.is_frozen IS 'Freeze unfinished projects without deleting or archiving them.'; diff --git a/supabase/migrations/004_add_project_archive_column.sql b/supabase/migrations/004_add_project_archive_column.sql new file mode 100644 index 0000000..ce844b9 --- /dev/null +++ b/supabase/migrations/004_add_project_archive_column.sql @@ -0,0 +1,6 @@ +-- Add archive flag for projects +ALTER TABLE public.projects +ADD COLUMN IF NOT EXISTS is_archived BOOLEAN NOT NULL DEFAULT false; + +-- Optional index to speed up active project queries +CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON public.projects(is_archived);