forked from imbytecat/fullstack-starter
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||
import { createFileRoute } from '@tanstack/react-router'
|
||
import { createServerFn } from '@tanstack/react-start'
|
||
import { eq } from 'drizzle-orm'
|
||
import type { ChangeEvent, FormEvent } from 'react'
|
||
import { useState } from 'react'
|
||
import { z } from 'zod'
|
||
import { db } from '@/db'
|
||
import { todoTable } from '@/db/schema'
|
||
|
||
const createTodoSchema = z.object({
|
||
title: z.string().min(1, '标题不能为空'),
|
||
})
|
||
|
||
const updateTodoSchema = z.object({
|
||
id: z.uuid(),
|
||
completed: z.boolean(),
|
||
})
|
||
|
||
const deleteTodoSchema = z.object({
|
||
id: z.uuid(),
|
||
})
|
||
|
||
// Server Functions - CRUD 操作
|
||
const getTodos = createServerFn({ method: 'GET' }).handler(async () => {
|
||
const todos = await db.query.todoTable.findMany({
|
||
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
|
||
})
|
||
return todos
|
||
})
|
||
|
||
const createTodo = createServerFn({ method: 'POST' })
|
||
.inputValidator(createTodoSchema)
|
||
.handler(async ({ data }) => {
|
||
const [newTodo] = await db
|
||
.insert(todoTable)
|
||
.values({ title: data.title })
|
||
.returning()
|
||
return newTodo
|
||
})
|
||
|
||
const updateTodo = createServerFn({ method: 'POST' })
|
||
.inputValidator(updateTodoSchema)
|
||
.handler(async ({ data }) => {
|
||
const [updatedTodo] = await db
|
||
.update(todoTable)
|
||
.set({ completed: data.completed })
|
||
.where(eq(todoTable.id, data.id))
|
||
.returning()
|
||
return updatedTodo
|
||
})
|
||
|
||
const deleteTodo = createServerFn({ method: 'POST' })
|
||
.inputValidator(deleteTodoSchema)
|
||
.handler(async ({ data }) => {
|
||
await db.delete(todoTable).where(eq(todoTable.id, data.id))
|
||
return { success: true }
|
||
})
|
||
|
||
export const Route = createFileRoute('/todo')({
|
||
component: Todo,
|
||
})
|
||
|
||
function Todo() {
|
||
const [newTodoTitle, setNewTodoTitle] = useState('')
|
||
const { data: todos, refetch } = useSuspenseQuery({
|
||
queryKey: ['todos'],
|
||
queryFn: () => getTodos(),
|
||
})
|
||
|
||
// Mutations
|
||
const createMutation = useMutation({
|
||
mutationFn: (title: string) => createTodo({ data: { title } }),
|
||
onSuccess: () => {
|
||
setNewTodoTitle('')
|
||
refetch()
|
||
},
|
||
})
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
|
||
updateTodo({ data: { id, completed } }),
|
||
onSuccess: () => {
|
||
refetch()
|
||
},
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteTodo({ data: { id } }),
|
||
onSuccess: () => {
|
||
refetch()
|
||
},
|
||
})
|
||
|
||
// Handlers
|
||
const handleCreateTodo = (e: FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault()
|
||
if (newTodoTitle.trim()) {
|
||
createMutation.mutate(newTodoTitle.trim())
|
||
}
|
||
}
|
||
|
||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
setNewTodoTitle(e.target.value)
|
||
}
|
||
|
||
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
||
updateMutation.mutate({ id, completed: !currentCompleted })
|
||
}
|
||
|
||
const handleDeleteTodo = (id: string) => {
|
||
deleteMutation.mutate(id)
|
||
}
|
||
|
||
const completedCount = todos.filter((todo) => todo.completed).length
|
||
const totalCount = todos.length
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50 py-12 px-4 sm:px-6 lg:px-8">
|
||
<div className="max-w-3xl mx-auto">
|
||
{/* Header */}
|
||
<div className="text-center mb-8">
|
||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||
我的待办事项
|
||
</h1>
|
||
<p className="text-gray-600">
|
||
已完成 {completedCount} / {totalCount} 项任务
|
||
</p>
|
||
</div>
|
||
|
||
{/* Add Todo Form */}
|
||
<form onSubmit={handleCreateTodo} className="mb-8">
|
||
<div className="bg-white rounded-lg shadow-sm p-4">
|
||
<div className="flex gap-3">
|
||
<input
|
||
type="text"
|
||
value={newTodoTitle}
|
||
onChange={handleInputChange}
|
||
placeholder="添加新任务..."
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||
disabled={createMutation.isPending}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
||
className="px-6 py-2 bg-gradient-to-r from-indigo-500 to-purple-600 text-white rounded-lg font-medium hover:from-indigo-600 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||
>
|
||
{createMutation.isPending ? '添加中...' : '添加'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Progress Bar */}
|
||
<div className="mb-8 bg-white rounded-lg shadow-sm p-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-medium text-gray-700">完成进度</span>
|
||
<span className="text-sm font-medium text-indigo-600">
|
||
{totalCount > 0
|
||
? Math.round((completedCount / totalCount) * 100)
|
||
: 0}
|
||
%
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||
<div
|
||
className="bg-gradient-to-r from-indigo-500 to-purple-600 h-2.5 rounded-full transition-all duration-300"
|
||
style={{
|
||
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Todo List */}
|
||
<div className="space-y-3">
|
||
{todos.length === 0 ? (
|
||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||
<div className="text-gray-400 mb-4">
|
||
<svg
|
||
className="mx-auto h-12 w-12"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<p className="text-gray-500 text-lg">暂无待办事项</p>
|
||
<p className="text-gray-400 text-sm mt-2">
|
||
添加你的第一个任务开始吧!
|
||
</p>
|
||
</div>
|
||
) : (
|
||
todos.map((todo) => (
|
||
<div
|
||
key={todo.id}
|
||
className={`bg-white rounded-lg shadow-sm hover:shadow-md transition-all duration-200 p-5 border-l-4 ${
|
||
todo.completed
|
||
? 'border-green-500 bg-gray-50'
|
||
: 'border-indigo-500'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-4">
|
||
{/* Checkbox */}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
||
disabled={updateMutation.isPending}
|
||
className="flex-shrink-0 pt-1 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 rounded-full disabled:opacity-50"
|
||
>
|
||
<div
|
||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||
todo.completed
|
||
? 'bg-green-500 border-green-500'
|
||
: 'border-gray-300 hover:border-indigo-500'
|
||
}`}
|
||
>
|
||
{todo.completed && (
|
||
<svg
|
||
className="w-4 h-4 text-white"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={3}
|
||
d="M5 13l4 4L19 7"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
</button>
|
||
|
||
{/* Todo Content */}
|
||
<div className="flex-1 min-w-0">
|
||
<h3
|
||
className={`text-lg font-medium ${
|
||
todo.completed
|
||
? 'text-gray-500 line-through'
|
||
: 'text-gray-900'
|
||
}`}
|
||
>
|
||
{todo.title}
|
||
</h3>
|
||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||
<span className="inline-flex items-center gap-1">
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Badge and Delete Button */}
|
||
<div className="flex-shrink-0 flex items-center gap-2">
|
||
<span
|
||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||
todo.completed
|
||
? 'bg-green-100 text-green-800'
|
||
: 'bg-indigo-100 text-indigo-800'
|
||
}`}
|
||
>
|
||
{todo.completed ? '已完成' : '进行中'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteTodo(todo.id)}
|
||
disabled={deleteMutation.isPending}
|
||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50"
|
||
title="删除任务"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer Stats */}
|
||
{todos.length > 0 && (
|
||
<div className="mt-8 grid grid-cols-2 gap-4">
|
||
<div className="bg-white rounded-lg shadow-sm p-4 text-center">
|
||
<p className="text-2xl font-bold text-indigo-600">
|
||
{totalCount - completedCount}
|
||
</p>
|
||
<p className="text-sm text-gray-600 mt-1">待完成</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg shadow-sm p-4 text-center">
|
||
<p className="text-2xl font-bold text-green-600">
|
||
{completedCount}
|
||
</p>
|
||
<p className="text-sm text-gray-600 mt-1">已完成</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|