Files
fullstack-starter/src/routes/todo.tsx
imbytecat a4a9e0889a refactor: 优化验证模式,直接使用 UUID 类型提升校验准确性
- 优化验证模式,移除字符串类型转换,直接使用 UUID 类型以提升数据校验准确性。
2026-01-17 03:10:19 +08:00

337 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}