feat: 添加书签模块 API,移除 Todo 示例
This commit is contained in:
@@ -26,25 +26,64 @@ const client: RouterClient = getORPCClient()
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client, {
|
||||
experimental_defaults: {
|
||||
todo: {
|
||||
bookmarks: {
|
||||
category: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
reorder: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.bookmarks.category.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bookmark: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
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() })
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
@@ -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 = {
|
||||
todo,
|
||||
bookmarks,
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as bookmarks from '@/modules/bookmarks/router'
|
||||
import { os } from '../server'
|
||||
import * as todo from './todo.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')
|
||||
}
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
Reference in New Issue
Block a user