forked from imbytecat/fullstack-starter
feat: 添加任务管理功能并优化交互体验
- 添加任务创建、完成状态切换和删除功能,优化界面交互并集成表单验证与加载状态反馈。 - 添加DOM和DOM.Iterable库支持以增强类型定义和浏览器API兼容性。
This commit is contained in:
@@ -1,23 +1,118 @@
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { createServerFn } from '@tanstack/react-start'
|
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 { db } from '@/db'
|
||||||
|
import { todoTable } from '@/db/schema'
|
||||||
|
|
||||||
|
// Zod Schemas
|
||||||
|
const createTodoSchema = z.object({
|
||||||
|
title: z.string().min(1, '标题不能为空'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateTodoSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
completed: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteTodoSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server Functions - CRUD 操作
|
||||||
const getTodos = createServerFn({ method: 'GET' }).handler(async () => {
|
const getTodos = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
const todos = await db.query.todoTable.findMany()
|
const todos = await db.query.todoTable.findMany({
|
||||||
|
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
|
||||||
|
})
|
||||||
return todos
|
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')({
|
export const Route = createFileRoute('/todo')({
|
||||||
component: Todo,
|
component: Todo,
|
||||||
})
|
})
|
||||||
|
|
||||||
function Todo() {
|
function Todo() {
|
||||||
const { data: todos } = useSuspenseQuery({
|
const [newTodoTitle, setNewTodoTitle] = useState('')
|
||||||
|
const { data: todos, refetch } = useSuspenseQuery({
|
||||||
queryKey: ['todos'],
|
queryKey: ['todos'],
|
||||||
queryFn: () => getTodos(),
|
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 completedCount = todos.filter((todo) => todo.completed).length
|
||||||
const totalCount = todos.length
|
const totalCount = todos.length
|
||||||
|
|
||||||
@@ -34,6 +129,29 @@ function Todo() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Progress Bar */}
|
||||||
<div className="mb-8 bg-white rounded-lg shadow-sm p-4">
|
<div className="mb-8 bg-white rounded-lg shadow-sm p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -92,7 +210,12 @@ function Todo() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
<div className="flex-shrink-0 pt-1">
|
<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
|
<div
|
||||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||||
todo.completed
|
todo.completed
|
||||||
@@ -117,7 +240,7 @@ function Todo() {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Todo Content */}
|
{/* Todo Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -151,8 +274,8 @@ function Todo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Badge */}
|
{/* Status Badge and Delete Button */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
todo.completed
|
todo.completed
|
||||||
@@ -162,6 +285,28 @@ function Todo() {
|
|||||||
>
|
>
|
||||||
{todo.completed ? '已完成' : '进行中'}
|
{todo.completed ? '已完成' : '进行中'}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user