diff --git a/.env b/.env new file mode 100644 index 00000000..d91f9588 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL="file:./dev.db" diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1280423c --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma +prisma/dev.db +prisma/dev.db-journal diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts new file mode 100644 index 00000000..34fbf3ad --- /dev/null +++ b/app/api/stats/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getTaskStats } from "@/app/services/taskService"; + +export async function GET() { + try { + const stats = await getTaskStats(); + return NextResponse.json(stats); + } catch (error) { + console.error("Error fetching stats:", error); + return NextResponse.json( + { error: "Failed to fetch stats" }, + { status: 500 } + ); + } +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts new file mode 100644 index 00000000..5616df97 --- /dev/null +++ b/app/api/tasks/[id]/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getTaskById, updateTask, deleteTask } from "@/app/services/taskService"; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const task = await getTaskById(params.id); + + if (!task) { + return NextResponse.json( + { error: "Task not found" }, + { status: 404 } + ); + } + + return NextResponse.json(task); + } catch (error) { + console.error("Error fetching task:", error); + return NextResponse.json( + { error: "Failed to fetch task" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + + const existingTask = await getTaskById(params.id); + if (!existingTask) { + return NextResponse.json( + { error: "Task not found" }, + { status: 404 } + ); + } + + const task = await updateTask(params.id, { + title: body.title, + description: body.description, + status: body.status, + priority: body.priority, + }); + + return NextResponse.json(task); + } catch (error) { + console.error("Error updating task:", error); + return NextResponse.json( + { error: "Failed to update task" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const existingTask = await getTaskById(params.id); + if (!existingTask) { + return NextResponse.json( + { error: "Task not found" }, + { status: 404 } + ); + } + + await deleteTask(params.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting task:", error); + return NextResponse.json( + { error: "Failed to delete task" }, + { status: 500 } + ); + } +} diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 00000000..b8434ae4 --- /dev/null +++ b/app/api/tasks/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getTasks, createTask } from "@/app/services/taskService"; +import { TaskFilters } from "@/app/types"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + + const filters: TaskFilters = { + status: (searchParams.get("status") as TaskFilters["status"]) || "ALL", + priority: (searchParams.get("priority") as TaskFilters["priority"]) || "ALL", + sortBy: (searchParams.get("sortBy") as TaskFilters["sortBy"]) || "createdAt", + sortOrder: (searchParams.get("sortOrder") as TaskFilters["sortOrder"]) || "desc", + }; + + const tasks = await getTasks(filters); + return NextResponse.json(tasks); + } catch (error) { + console.error("Error fetching tasks:", error); + return NextResponse.json( + { error: "Failed to fetch tasks" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + if (!body.title || body.title.trim() === "") { + return NextResponse.json( + { error: "Title is required" }, + { status: 400 } + ); + } + + const task = await createTask({ + title: body.title, + description: body.description, + status: body.status, + priority: body.priority, + }); + + return NextResponse.json(task, { status: 201 }); + } catch (error) { + console.error("Error creating task:", error); + return NextResponse.json( + { error: "Failed to create task" }, + { status: 500 } + ); + } +} diff --git a/app/components/EmptyState.tsx b/app/components/EmptyState.tsx new file mode 100644 index 00000000..42c0de49 --- /dev/null +++ b/app/components/EmptyState.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ClipboardList, Plus } from "lucide-react"; + +interface EmptyStateProps { + onCreateClick: () => void; + hasFilters?: boolean; +} + +export default function EmptyState({ onCreateClick, hasFilters }: EmptyStateProps) { + return ( +
+
+ +
+

+ {hasFilters ? "没有找到匹配的任务" : "还没有任务"} +

+

+ {hasFilters + ? "尝试调整筛选条件,或者创建一个新任务" + : "开始管理你的任务吧,点击下面的按钮创建第一个任务"} +

+ +
+ ); +} diff --git a/app/components/LoadingState.tsx b/app/components/LoadingState.tsx new file mode 100644 index 00000000..0a923450 --- /dev/null +++ b/app/components/LoadingState.tsx @@ -0,0 +1,29 @@ +"use client"; + +export default function LoadingState() { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/components/StatsPanel.tsx b/app/components/StatsPanel.tsx new file mode 100644 index 00000000..3ee41531 --- /dev/null +++ b/app/components/StatsPanel.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { TaskStats } from "@/app/types"; +import { + CheckCircle2, + ListTodo, + AlertCircle, + Clock, + TrendingUp, + Calendar, +} from "lucide-react"; + +interface StatsPanelProps { + stats: TaskStats; + isLoading: boolean; +} + +export default function StatsPanel({ stats, isLoading }: StatsPanelProps) { + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + const statItems = [ + { + label: "总任务", + value: stats.total, + icon: , + color: "bg-blue-100 text-blue-600", + }, + { + label: "已完成", + value: stats.completed, + icon: , + color: "bg-green-100 text-green-600", + }, + { + label: "完成率", + value: `${stats.completionRate}%`, + icon: , + color: "bg-purple-100 text-purple-600", + }, + { + label: "今日新增", + value: stats.todayTasks, + icon: , + color: "bg-orange-100 text-orange-600", + }, + ]; + + return ( +
+
+ {statItems.map((item, index) => ( +
+
+
+ {item.icon} +
+
+

{item.value}

+

{item.label}

+
+
+
+ ))} +
+ +
+

+ + 优先级分布 +

+
+
+

+ {stats.byPriority.HIGH} +

+

高优先级

+
+
+

+ {stats.byPriority.MEDIUM} +

+

中优先级

+
+
+

+ {stats.byPriority.LOW} +

+

低优先级

+
+
+
+
+ ); +} diff --git a/app/components/TaskCard.tsx b/app/components/TaskCard.tsx new file mode 100644 index 00000000..e2673184 --- /dev/null +++ b/app/components/TaskCard.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { Task, Status, Priority } from "@/app/types"; +import { + Calendar, + Clock, + Edit2, + Trash2, + CheckCircle2, + Circle, + PlayCircle, + AlertCircle, +} from "lucide-react"; + +interface TaskCardProps { + task: Task; + onEdit: (task: Task) => void; + onDelete: (id: string) => void; + onStatusChange: (id: string, status: Status) => void; +} + +const statusConfig: Record< + Status, + { label: string; icon: React.ReactNode; color: string; bgColor: string } +> = { + TODO: { + label: "待处理", + icon: , + color: "text-gray-600", + bgColor: "bg-gray-100", + }, + IN_PROGRESS: { + label: "进行中", + icon: , + color: "text-blue-600", + bgColor: "bg-blue-100", + }, + DONE: { + label: "已完成", + icon: , + color: "text-green-600", + bgColor: "bg-green-100", + }, +}; + +const priorityConfig: Record< + Priority, + { label: string; color: string; bgColor: string; icon: React.ReactNode } +> = { + HIGH: { + label: "高优先级", + color: "text-red-600", + bgColor: "bg-red-100", + icon: , + }, + MEDIUM: { + label: "中优先级", + color: "text-yellow-600", + bgColor: "bg-yellow-100", + icon: , + }, + LOW: { + label: "低优先级", + color: "text-green-600", + bgColor: "bg-green-100", + icon: , + }, +}; + +export default function TaskCard({ + task, + onEdit, + onDelete, + onStatusChange, +}: TaskCardProps) { + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + const nextStatus = (currentStatus: Status): Status => { + const statusFlow: Record = { + TODO: "IN_PROGRESS", + IN_PROGRESS: "DONE", + DONE: "TODO", + }; + return statusFlow[currentStatus]; + }; + + return ( +
+
+
+
+

+ {task.title} +

+
+ + {task.description && ( +

+ {task.description} +

+ )} + +
+ + + + {priorityConfig[task.priority].icon} + {priorityConfig[task.priority].label} + + + + + {formatDate(task.createdAt)} + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/app/components/TaskFilters.tsx b/app/components/TaskFilters.tsx new file mode 100644 index 00000000..93c39fd5 --- /dev/null +++ b/app/components/TaskFilters.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { TaskFilters as TaskFiltersType, Status, Priority } from "@/app/types"; +import { Filter, ArrowUpDown, SlidersHorizontal } from "lucide-react"; + +interface TaskFiltersProps { + filters: TaskFiltersType; + onFilterChange: (filters: TaskFiltersType) => void; +} + +export default function TaskFilters({ filters, onFilterChange }: TaskFiltersProps) { + const handleChange = (key: keyof TaskFiltersType, value: string) => { + onFilterChange({ + ...filters, + [key]: value, + }); + }; + + const clearFilters = () => { + onFilterChange({ + status: "ALL", + priority: "ALL", + sortBy: "createdAt", + sortOrder: "desc", + }); + }; + + const hasActiveFilters = + filters.status !== "ALL" || + filters.priority !== "ALL" || + filters.sortBy !== "createdAt" || + filters.sortOrder !== "desc"; + + return ( +
+
+
+ + 筛选与排序 +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {hasActiveFilters && ( + + )} +
+
+ ); +} diff --git a/app/components/TaskForm.tsx b/app/components/TaskForm.tsx new file mode 100644 index 00000000..8f94457f --- /dev/null +++ b/app/components/TaskForm.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Task, CreateTaskInput, Status, Priority } from "@/app/types"; +import { X, Plus, Save } from "lucide-react"; + +interface TaskFormProps { + task?: Task | null; + isOpen: boolean; + onClose: () => void; + onSubmit: (data: CreateTaskInput) => void; +} + +export default function TaskForm({ task, isOpen, onClose, onSubmit }: TaskFormProps) { + const [formData, setFormData] = useState({ + title: "", + description: "", + status: "TODO", + priority: "MEDIUM", + }); + const [errors, setErrors] = useState<{ title?: string }>({}); + + useEffect(() => { + if (task) { + setFormData({ + title: task.title, + description: task.description || "", + status: task.status, + priority: task.priority, + }); + } else { + setFormData({ + title: "", + description: "", + status: "TODO", + priority: "MEDIUM", + }); + } + setErrors({}); + }, [task, isOpen]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.title || formData.title.trim() === "") { + setErrors({ title: "请输入任务标题" }); + return; + } + + onSubmit(formData); + onClose(); + }; + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + if (name === "title" && errors.title) { + setErrors({}); + } + }; + + if (!isOpen) return null; + + const isEditing = !!task; + + return ( +
+
+
+

+ {isEditing ? "编辑任务" : "新建任务"} +

+ +
+ +
+
+ + + {errors.title && ( +

{errors.title}

+ )} +
+ +
+ +