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 { 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'
|
||||
|
||||
// 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 todos = await db.query.todoTable.findMany()
|
||||
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 { data: todos } = useSuspenseQuery({
|
||||
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
|
||||
|
||||
@@ -34,6 +129,29 @@ function Todo() {
|
||||
</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">
|
||||
@@ -92,7 +210,12 @@ function Todo() {
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 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
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||
todo.completed
|
||||
@@ -117,7 +240,7 @@ function Todo() {
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Todo Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -151,8 +274,8 @@ function Todo() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex-shrink-0">
|
||||
{/* 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
|
||||
@@ -162,6 +285,28 @@ function Todo() {
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user