feat: 添加书签模块 API,移除 Todo 示例

This commit is contained in:
2026-03-30 21:28:10 +08:00
parent 1f3028c25b
commit 1494492b95
9 changed files with 216 additions and 291 deletions
+53 -14
View File
@@ -26,25 +26,64 @@ const client: RouterClient = getORPCClient()
export const orpc = createTanstackQueryUtils(client, { export const orpc = createTanstackQueryUtils(client, {
experimental_defaults: { experimental_defaults: {
todo: { bookmarks: {
create: { category: {
mutationOptions: { create: {
onSuccess: (_, __, ___, ctx) => { mutationOptions: {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() }) onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
},
},
reorder: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
}, },
}, },
}, },
update: { bookmark: {
mutationOptions: { create: {
onSuccess: (_, __, ___, ctx) => { mutationOptions: {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() }) onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
}, },
}, },
}, update: {
remove: { mutationOptions: {
mutationOptions: { onSuccess: (_, __, ___, ctx) => {
onSuccess: (_, __, ___, ctx) => { ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() }) },
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
},
},
reorder: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
},
}, },
}, },
}, },
@@ -0,0 +1,35 @@
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import { bookmarkTable, categoryTable } from '@/modules/bookmarks/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const categorySelect = createSelectSchema(categoryTable)
const categoryInsert = createInsertSchema(categoryTable).omit(generatedFieldKeys).omit({ userId: true })
const categoryUpdate = createUpdateSchema(categoryTable).omit(generatedFieldKeys).omit({ userId: true })
const bookmarkSelect = createSelectSchema(bookmarkTable)
const bookmarkInsert = createInsertSchema(bookmarkTable).omit(generatedFieldKeys).omit({ userId: true })
const bookmarkUpdate = createUpdateSchema(bookmarkTable).omit(generatedFieldKeys).omit({ userId: true })
export const category = {
list: oc.input(z.void()).output(z.array(categorySelect.extend({ bookmarks: z.array(bookmarkSelect) }))),
create: oc.input(categoryInsert).output(categorySelect),
update: oc.input(z.object({ id: z.uuid(), data: categoryUpdate })).output(categorySelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
export const bookmark = {
create: oc.input(bookmarkInsert).output(bookmarkSelect),
update: oc.input(z.object({ id: z.uuid(), data: bookmarkUpdate })).output(bookmarkSelect),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
reorder: oc.input(z.array(z.object({ id: z.uuid(), orderId: z.number().int() }))).output(z.void()),
}
+124
View File
@@ -0,0 +1,124 @@
import { ORPCError } from '@orpc/server'
import { and, eq } from 'drizzle-orm'
import { bookmarkTable, categoryTable } from '@/modules/bookmarks/schema'
import { authMiddleware, db } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const category = {
list: os.bookmarks.category.list
.use(db)
.use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.categoryTable.findMany({
where: { userId: context.user.id },
orderBy: { orderId: 'asc' },
with: {
bookmarks: {
orderBy: { orderId: 'asc' },
},
},
})
}),
create: os.bookmarks.category.create
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(categoryTable)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create category' })
return created
}),
update: os.bookmarks.category.update
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(categoryTable)
.set(input.data)
.where(and(eq(categoryTable.id, input.id), eq(categoryTable.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.bookmarks.category.remove
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(categoryTable)
.where(and(eq(categoryTable.id, input.id), eq(categoryTable.userId, context.user.id)))
.returning({ id: categoryTable.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.bookmarks.category.reorder
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(categoryTable)
.set({ orderId: item.orderId })
.where(and(eq(categoryTable.id, item.id), eq(categoryTable.userId, context.user.id)))
}
})
}),
}
export const bookmark = {
create: os.bookmarks.bookmark.create
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db
.insert(bookmarkTable)
.values({ ...input, userId: context.user.id })
.returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create bookmark' })
return created
}),
update: os.bookmarks.bookmark.update
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [updated] = await context.db
.update(bookmarkTable)
.set(input.data)
.where(and(eq(bookmarkTable.id, input.id), eq(bookmarkTable.userId, context.user.id)))
.returning()
if (!updated) throw new ORPCError('NOT_FOUND')
return updated
}),
remove: os.bookmarks.bookmark.remove
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
const [deleted] = await context.db
.delete(bookmarkTable)
.where(and(eq(bookmarkTable.id, input.id), eq(bookmarkTable.userId, context.user.id)))
.returning({ id: bookmarkTable.id })
if (!deleted) throw new ORPCError('NOT_FOUND')
}),
reorder: os.bookmarks.bookmark.reorder
.use(db)
.use(authMiddleware)
.handler(async ({ context, input }) => {
await context.db.transaction(async (tx) => {
for (const item of input) {
await tx
.update(bookmarkTable)
.set({ orderId: item.orderId })
.where(and(eq(bookmarkTable.id, item.id), eq(bookmarkTable.userId, context.user.id)))
}
})
}),
}
-193
View File
@@ -1,193 +0,0 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import type { ChangeEventHandler, SubmitEventHandler } from 'react'
import { useState } from 'react'
import { orpc } from '@/client/orpc'
export const Route = createFileRoute('/')({
component: Todos,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
},
})
function Todos() {
const [newTodoTitle, setNewTodoTitle] = useState('')
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.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 completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight"></h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider"></div>
</div>
</div>
{/* Add Todo Form */}
<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) */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1"></p>
</div>
) : (
todos.map((todo) => (
<div
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.completed ? 'bg-slate-50/50' : ''
}`}
>
<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>
</div>
)
}
@@ -1,7 +1,7 @@
import * as todo from './todo.contract' import * as bookmarks from '@/modules/bookmarks/contract'
export const contract = { export const contract = {
todo, bookmarks,
} }
export type Contract = typeof contract export type Contract = typeof contract
@@ -1,32 +0,0 @@
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import { generatedFieldKeys } from '@/server/db/fields'
import { todoTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(todoTable)
const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
const updateSchema = createUpdateSchema(todoTable).omit(generatedFieldKeys)
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
export const update = oc
.input(
z.object({
id: z.uuid(),
data: updateSchema,
}),
)
.output(selectSchema)
export const remove = oc
.input(
z.object({
id: z.uuid(),
}),
)
.output(z.void())
+2 -2
View File
@@ -1,6 +1,6 @@
import * as bookmarks from '@/modules/bookmarks/router'
import { os } from '../server' import { os } from '../server'
import * as todo from './todo.router'
export const router = os.router({ export const router = os.router({
todo, bookmarks,
}) })
@@ -1,40 +0,0 @@
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
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 }) => {
const todos = await context.db.query.todoTable.findMany({
orderBy: { createdAt: 'desc' },
})
return todos
})
export const create = os.todo.create.use(db).handler(async ({ context, input }) => {
const [newTodo] = await context.db.insert(todoTable).values(input).returning()
if (!newTodo) {
throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create todo' })
}
return newTodo
})
export const update = os.todo.update.use(db).handler(async ({ context, input }) => {
const [updatedTodo] = await context.db.update(todoTable).set(input.data).where(eq(todoTable.id, input.id)).returning()
if (!updatedTodo) {
throw new ORPCError('NOT_FOUND')
}
return updatedTodo
})
export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => {
const [deleted] = await context.db.delete(todoTable).where(eq(todoTable.id, input.id)).returning({ id: todoTable.id })
if (!deleted) {
throw new ORPCError('NOT_FOUND')
}
})
-8
View File
@@ -1,8 +0,0 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
import { generatedFields } from '../fields'
export const todoTable = pgTable('todo', {
...generatedFields,
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
})