refactor: 抽取 UI 组件、改进错误页面、统一导入路径并简化数据库接口
This commit is contained in:
@@ -2,9 +2,7 @@
|
|||||||
"version": "8",
|
"version": "8",
|
||||||
"dialect": "postgres",
|
"dialect": "postgres",
|
||||||
"id": "91ea16cc-3353-493c-bc2c-4fc9dea2fce7",
|
"id": "91ea16cc-3353-493c-bc2c-4fc9dea2fce7",
|
||||||
"prevIds": [
|
"prevIds": ["00000000-0000-0000-0000-000000000000"],
|
||||||
"00000000-0000-0000-0000-000000000000"
|
|
||||||
],
|
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"isRlsEnabled": false,
|
"isRlsEnabled": false,
|
||||||
@@ -78,9 +76,7 @@
|
|||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "todo_pkey",
|
"name": "todo_pkey",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
|
|||||||
@@ -1,3 +1,40 @@
|
|||||||
export function ErrorComponent() {
|
export const ErrorComponent = ({ error, reset }: { error: Error; reset: () => void }) => {
|
||||||
return <div>An unhandled error happened!</div>
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full text-center space-y-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-red-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">出错了</h1>
|
||||||
|
<p className="text-slate-500 mt-2">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<a href="/" className="px-6 py-2.5 text-slate-600 hover:text-slate-900 font-medium transition-colors">
|
||||||
|
返回首页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
export function NotFoundComponent() {
|
import { Link } from '@tanstack/react-router'
|
||||||
return <div>404 - Not Found</div>
|
|
||||||
|
export const NotFoundComponent = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full text-center space-y-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100">
|
||||||
|
<span className="text-3xl font-bold text-slate-400">404</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">页面不存在</h1>
|
||||||
|
<p className="text-slate-500 mt-2">您访问的页面可能已被移除或地址有误</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-block px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { SubmitEventHandler } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface TodoFormProps {
|
||||||
|
onSubmit: (title: string) => void
|
||||||
|
isPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TodoForm = ({ onSubmit, isPending }: TodoFormProps) => {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit: SubmitEventHandler<HTMLFormElement> = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (title.trim()) {
|
||||||
|
onSubmit(title.trim())
|
||||||
|
setTitle('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="relative group z-10">
|
||||||
|
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="添加新任务..."
|
||||||
|
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || !title.trim()}
|
||||||
|
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
||||||
|
>
|
||||||
|
{isPending ? '添加中' : '添加'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { RouterOutputs } from '@/server/api/types'
|
||||||
|
|
||||||
|
type Todo = RouterOutputs['todo']['list'][number]
|
||||||
|
|
||||||
|
interface TodoItemProps {
|
||||||
|
todo: Todo
|
||||||
|
onToggle: (id: string, completed: boolean) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TodoItem = ({ todo, onToggle, onDelete }: TodoItemProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
||||||
|
todo.completed ? 'bg-slate-50/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(todo.id, todo.completed)}
|
||||||
|
className={`shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
||||||
|
todo.completed ? 'bg-indigo-500 border-indigo-500' : 'border-slate-300 hover:border-indigo-500 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todo.completed && (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-lg transition-all duration-200 truncate ${
|
||||||
|
todo.completed ? 'text-slate-400 line-through decoration-slate-300 decoration-2' : 'text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todo.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-linear-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
||||||
|
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
||||||
|
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(todo.id)}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,8 +34,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
errorComponent: () => <ErrorComponent />,
|
errorComponent: ErrorComponent,
|
||||||
notFoundComponent: () => <NotFoundComponent />,
|
notFoundComponent: NotFoundComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||||
|
|||||||
+8
-114
@@ -1,8 +1,8 @@
|
|||||||
import { useMutation, 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 type { ChangeEventHandler, SubmitEventHandler } from 'react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { orpc } from '@/client/orpc'
|
import { orpc } from '@/client/orpc'
|
||||||
|
import { TodoForm } from '@/components/TodoForm'
|
||||||
|
import { TodoItem } from '@/components/TodoItem'
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: Todos,
|
component: Todos,
|
||||||
@@ -12,36 +12,11 @@ export const Route = createFileRoute('/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function Todos() {
|
function Todos() {
|
||||||
const [newTodoTitle, setNewTodoTitle] = useState('')
|
|
||||||
|
|
||||||
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
||||||
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
||||||
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
||||||
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
||||||
|
|
||||||
const handleCreateTodo: SubmitEventHandler<HTMLFormElement> = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (newTodoTitle.trim()) {
|
|
||||||
createMutation.mutate({ title: newTodoTitle.trim() })
|
|
||||||
setNewTodoTitle('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
setNewTodoTitle(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
id,
|
|
||||||
data: { completed: !currentCompleted },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteTodo = (id: string) => {
|
|
||||||
deleteMutation.mutate({ id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const todos = listQuery.data
|
const todos = listQuery.data
|
||||||
const completedCount = todos.filter((todo) => todo.completed).length
|
const completedCount = todos.filter((todo) => todo.completed).length
|
||||||
const totalCount = todos.length
|
const totalCount = todos.length
|
||||||
@@ -65,28 +40,9 @@ function Todos() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Todo Form */}
|
<TodoForm onSubmit={(title) => createMutation.mutate({ title })} isPending={createMutation.isPending} />
|
||||||
<form onSubmit={handleCreateTodo} className="relative group z-10">
|
|
||||||
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newTodoTitle}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="添加新任务..."
|
|
||||||
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
|
||||||
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? '添加中' : '添加'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Progress Bar (Only visible when there are tasks) */}
|
{/* Progress Bar */}
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -116,74 +72,12 @@ function Todos() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
todos.map((todo) => (
|
todos.map((todo) => (
|
||||||
<div
|
<TodoItem
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
todo={todo}
|
||||||
todo.completed ? 'bg-slate-50/50' : ''
|
onToggle={(id, completed) => updateMutation.mutate({ id, data: { completed: !completed } })}
|
||||||
}`}
|
onDelete={(id) => deleteMutation.mutate({ id })}
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
|
||||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
|
||||||
todo.completed
|
|
||||||
? 'bg-indigo-500 border-indigo-500'
|
|
||||||
: 'border-slate-300 hover:border-indigo-500 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.completed && (
|
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={3}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p
|
|
||||||
className={`text-lg transition-all duration-200 truncate ${
|
|
||||||
todo.completed
|
|
||||||
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
|
|
||||||
: 'text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
|
||||||
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
|
||||||
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteTodo(todo.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { os } from '../server'
|
import { os } from '@/server/api/server'
|
||||||
import * as todo from './todo.router'
|
import * as todo from './todo.router'
|
||||||
|
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ORPCError } from '@orpc/server'
|
import { ORPCError } from '@orpc/server'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { db } from '@/server/api/middlewares'
|
||||||
|
import { os } from '@/server/api/server'
|
||||||
import { todoTable } from '@/server/db/schema'
|
import { todoTable } from '@/server/db/schema'
|
||||||
import { db } from '../middlewares'
|
|
||||||
import { os } from '../server'
|
|
||||||
|
|
||||||
export const list = os.todo.list.use(db).handler(async ({ context }) => {
|
export const list = os.todo.list.use(db).handler(async ({ context }) => {
|
||||||
const todos = await context.db.query.todoTable.findMany({
|
const todos = await context.db.query.todoTable.findMany({
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ export type DB = ReturnType<typeof createDB>
|
|||||||
export const getDB = (() => {
|
export const getDB = (() => {
|
||||||
let db: DB | null = null
|
let db: DB | null = null
|
||||||
|
|
||||||
return (singleton = true): DB => {
|
return (): DB => {
|
||||||
if (!singleton) {
|
|
||||||
return createDB()
|
|
||||||
}
|
|
||||||
|
|
||||||
db ??= createDB()
|
db ??= createDB()
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user