- 新增项目规范文档,包含语言设置和 Git 提交规范 - 更新 .gitignore 文件,添加环境文件和数据目录 - 新增 Prettier 配置文件和忽略文件 - 更新 package.json,添加 prettier 和 prisma 相关依赖 - 新增 API 路由处理佣金和消费数据 这些更改为项目提供了更好的结构和代码风格规范。
61 KiB
服务员预约系统 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a PC-end waiter reservation management system with a Google Calendar-style monthly view.
Architecture: Single-page Next.js App Router app. Mock data layer with module-level variables (no backend). shadcn/ui for all UI components. Tailwind CSS v4 for styling.
Tech Stack: Next.js 16.2.5, React 19, TypeScript 5, Tailwind CSS v4, shadcn/ui, lucide-react
Task 1: Initialize shadcn/ui and install dependencies
Files:
-
Create:
components.json -
Create:
lib/utils.ts -
Modify:
app/globals.css -
Step 1: Install shadcn/ui dependencies
Run:
pnpm add class-variance-authority clsx tailwind-merge lucide-react
pnpm add @radix-ui/react-dialog @radix-ui/react-select @radix-ui/react-label @radix-ui/react-dropdown-menu @radix-ui/react-popover @radix-ui/react-slot @radix-ui/react-avatar @radix-ui/react-scroll-area
pnpm add -D @types/node
- Step 2: Create
lib/utils.ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
- Step 3: Update
app/globals.css— add CSS variables for shadcn/ui
app/globals.css:
@import 'tailwindcss';
@theme inline {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 84% 4.9%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
--color-primary: hsl(222.2 47.4% 11.2%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(222.2 84% 4.9%);
--radius: 0.5rem;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: Arial, Helvetica, sans-serif;
}
- Step 4: Create
components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
- Step 5: Commit
git add components.json lib/utils.ts app/globals.css package.json pnpm-lock.yaml
git commit -m "feat: initialize shadcn/ui and install dependencies"
Task 2: Create shadcn/ui base components
Files:
-
Create:
components/ui/button.tsx -
Create:
components/ui/dialog.tsx -
Create:
components/ui/input.tsx -
Create:
components/ui/label.tsx -
Create:
components/ui/select.tsx -
Create:
components/ui/dropdown-menu.tsx -
Create:
components/ui/table.tsx -
Create:
components/ui/card.tsx -
Create:
components/ui/badge.tsx -
Create:
components/ui/avatar.tsx -
Create:
components/ui/scroll-area.tsx -
Step 1: Create all shadcn/ui component files
Create each file with standard shadcn/ui component code. These are headless UI primitives with no business logic.
components/ui/button.tsx:
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
components/ui/dialog.tsx:
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
components/ui/input.tsx:
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
components/ui/label.tsx:
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
components/ui/select.tsx:
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
}
components/ui/dropdown-menu.tsx:
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
}
components/ui/table.tsx:
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
)
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)
)
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
)
)
TableBody.displayName = "TableBody"
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
{...props}
/>
)
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
)
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
)
TableCell.displayName = "TableCell"
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
components/ui/card.tsx:
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
export { Card, CardHeader, CardTitle, CardContent }
components/ui/badge.tsx:
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
components/ui/avatar.tsx:
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
components/ui/scroll-area.tsx:
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
- Step 2: Build check
Run: pnpm build
Expected: Build succeeds with no errors
- Step 3: Commit
git add components/ui/
git commit -m "feat: add shadcn/ui base components"
Task 3: Core types and mock data
Files:
-
Create:
lib/types.ts -
Create:
lib/date-utils.ts -
Create:
lib/mock-data.ts -
Step 1: Create
lib/types.ts
export type MediaType = 'image' | 'video'
export interface Media {
id: string
type: MediaType
url: string
}
export interface Waiter {
id: string
name: string
gallery: Media[]
specialty: string
commissionRate: number
}
export interface Reservation {
id: string
customerName: string
date: string
time: string
waiterId: string
package?: string
note?: string
}
export interface CreateReservationInput {
customerName: string
date: string
time: string
waiterId: string
package?: string
note?: string
}
export interface UpdateReservationInput extends Partial<CreateReservationInput> {}
export interface CreateWaiterInput {
name: string
gallery: Media[]
specialty: string
commissionRate: number
}
export interface UpdateWaiterInput extends Partial<CreateWaiterInput> {}
- Step 2: Create
lib/date-utils.ts
export function getMonthGrid(year: number, month: number): Date[][] {
const firstDay = new Date(year, month, 1)
const startDay = firstDay.getDay()
const startOffset = startDay === 0 ? -6 : 1 - startDay
const startDate = new Date(year, month, 1 + startOffset)
const weeks: Date[][] = []
for (let w = 0; w < 6; w++) {
const week: Date[] = []
for (let d = 0; d < 7; d++) {
const date = new Date(startDate)
date.setDate(startDate.getDate() + w * 7 + d)
week.push(date)
}
weeks.push(week)
}
return weeks
}
export function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
export function isCurrentMonth(date: Date, year: number, month: number): boolean {
return date.getFullYear() === year && date.getMonth() === month
}
export function isToday(date: Date): boolean {
return isSameDay(date, new Date())
}
export function formatTimeDisplay(date: Date): string {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
export function getMonthName(month: number): string {
const names = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
return names[month]
}
- Step 3: Create
lib/mock-data.ts
import type {
Reservation,
Waiter,
CreateReservationInput,
UpdateReservationInput,
CreateWaiterInput,
UpdateWaiterInput,
} from './types'
let waiters: Waiter[] = [
{
id: '1',
name: '王小明',
gallery: [
{ id: 'm1', type: 'image', url: '/placeholder.svg' },
{ id: 'm2', type: 'image', url: '/placeholder.svg' },
],
specialty: '宴会/商务',
commissionRate: 30,
},
{
id: '2',
name: '李小红',
gallery: [{ id: 'm3', type: 'image', url: '/placeholder.svg' }],
specialty: '婚宴/生日',
commissionRate: 25,
},
{
id: '3',
name: '张三',
gallery: [],
specialty: '日常接待',
commissionRate: 20,
},
]
let reservations: Reservation[] = [
{
id: 'r1',
customerName: '张伟',
date: '2026-05-16',
time: '11:00',
waiterId: '1',
package: '生日宴',
note: '需要蛋糕',
},
{ id: 'r2', customerName: '李芳', date: '2026-05-16', time: '12:30', waiterId: '2', package: '商务宴请' },
{ id: 'r3', customerName: '赵强', date: '2026-05-16', time: '18:00', waiterId: '1' },
{ id: 'r4', customerName: '刘梅', date: '2026-05-20', time: '11:30', waiterId: '2', package: '婚宴' },
{ id: 'r5', customerName: '陈先生', date: '2026-05-12', time: '10:00', waiterId: '3' },
{ id: 'r6', customerName: '王太太', date: '2026-05-12', time: '14:00', waiterId: '3', package: '下午茶' },
{ id: 'r7', customerName: '林总', date: '2026-05-14', time: '19:00', waiterId: '1', package: '商务宴' },
]
let waiterNextId = 4
let reservationNextId = 8
export function getReservationsByDate(date: string): Reservation[] {
return reservations.filter((r) => r.date === date)
}
export function getReservationsByMonth(year: number, month: number): Reservation[] {
const prefix = `${year}-${String(month + 1).padStart(2, '0')}`
return reservations.filter((r) => r.date.startsWith(prefix))
}
export function createReservation(input: CreateReservationInput): Reservation {
const reservation: Reservation = {
id: `r${reservationNextId++}`,
...input,
}
reservations.push(reservation)
return reservation
}
export function updateReservation(id: string, input: UpdateReservationInput): Reservation | null {
const index = reservations.findIndex((r) => r.id === id)
if (index === -1) return null
reservations[index] = { ...reservations[index], ...input }
return reservations[index]
}
export function deleteReservation(id: string): boolean {
const index = reservations.findIndex((r) => r.id === id)
if (index === -1) return false
reservations.splice(index, 1)
return true
}
export function getWaiters(): Waiter[] {
return [...waiters]
}
export function getWaiterById(id: string): Waiter | undefined {
return waiters.find((w) => w.id === id)
}
export function createWaiter(input: CreateWaiterInput): Waiter {
const waiter: Waiter = {
id: String(waiterNextId++),
...input,
}
waiters.push(waiter)
return waiter
}
export function updateWaiter(id: string, input: UpdateWaiterInput): Waiter | null {
const index = waiters.findIndex((w) => w.id === id)
if (index === -1) return null
waiters[index] = { ...waiters[index], ...input }
return waiters[index]
}
export function deleteWaiter(id: string): boolean {
const index = waiters.findIndex((w) => w.id === id)
if (index === -1) return false
waiters.splice(index, 1)
reservations = reservations.filter((r) => r.waiterId !== id)
return true
}
- Step 4: Commit
git add lib/types.ts lib/date-utils.ts lib/mock-data.ts
git commit -m "feat: add core types and mock data layer"
Task 4: Create custom hooks
Files:
-
Create:
hooks/use-reservations.ts -
Step 1: Create
hooks/use-reservations.ts
'use client'
import { useState, useCallback, useMemo } from 'react'
import {
getReservationsByDate,
getReservationsByMonth,
createReservation,
updateReservation,
deleteReservation,
getWaiters,
getWaiterById,
} from '@/lib/mock-data'
import type { Reservation, Waiter, CreateReservationInput, UpdateReservationInput } from '@/lib/types'
import { createWaiter, updateWaiter, deleteWaiter } from '@/lib/mock-data'
export function useReservations(year: number, month: number) {
const [, forceUpdate] = useState(0)
const rerender = useCallback(() => forceUpdate((n) => n + 1), [])
const monthReservations = useMemo(() => getReservationsByMonth(year, month), [year, month])
const getReservationsForDate = useCallback(
(date: string): Reservation[] => {
return monthReservations.filter((r) => r.date === date)
},
[monthReservations],
)
const addReservation = useCallback(
(input: CreateReservationInput): Reservation => {
const r = createReservation(input)
rerender()
return r
},
[rerender],
)
const editReservation = useCallback(
(id: string, input: UpdateReservationInput): Reservation | null => {
const r = updateReservation(id, input)
rerender()
return r
},
[rerender],
)
const removeReservation = useCallback(
(id: string): boolean => {
const result = deleteReservation(id)
rerender()
return result
},
[rerender],
)
return {
getReservationsForDate,
addReservation,
editReservation,
removeReservation,
}
}
export function useWaiters() {
const [, forceUpdate] = useState(0)
const rerender = useCallback(() => forceUpdate((n) => n + 1), [])
const waiters = useMemo(() => getWaiters(), [])
const waiterList = useCallback(() => getWaiters(), [])
const getWaiter = useCallback((id: string) => getWaiterById(id), [])
const addWaiter = useCallback(
(input: Parameters<typeof createWaiter>[0]) => {
const result = createWaiter(input)
rerender()
return result
},
[rerender],
)
const editWaiter = useCallback(
(id: string, input: UpdateReservationInput) => {
const result = updateWaiter(id, input)
rerender()
return result
},
[rerender],
)
const removeWaiter = useCallback(
(id: string) => {
const result = deleteWaiter(id)
rerender()
return result
},
[rerender],
)
return { waiterList, getWaiter, addWaiter, editWaiter, removeWaiter, waiters }
}
- Step 2: Commit
git add hooks/use-reservations.ts
git commit -m "feat: add use-reservations hook"
Task 5: Create calendar components
Files:
-
Create:
components/reservation-card.tsx -
Create:
components/calendar-cell.tsx -
Create:
components/calendar-grid.tsx -
Create:
components/calendar-header.tsx -
Step 1: Create
components/reservation-card.tsx
'use client'
import type { Reservation } from '@/lib/types'
import { getWaiterById } from '@/lib/mock-data'
interface ReservationCardProps {
reservation: Reservation
onClick: (reservation: Reservation) => void
}
export function ReservationCard({ reservation, onClick }: ReservationCardProps) {
const waiter = getWaiterById(reservation.waiterId)
return (
<button
onClick={(e) => {
e.stopPropagation()
onClick(reservation)
}}
className="w-full text-left text-[11px] leading-tight px-1 py-0.5 rounded-sm bg-blue-100 text-blue-800 hover:bg-blue-200 truncate border-l-2 border-blue-500 mb-0.5 cursor-pointer"
title={`${reservation.customerName} - ${reservation.time}${waiter ? ` (${waiter.name})` : ''}${reservation.package ? ` [${reservation.package}]` : ''}`}
>
{reservation.time} {reservation.customerName}
</button>
)
}
- Step 2: Create
components/calendar-cell.tsx
'use client'
import type { Reservation } from '@/lib/types'
import { isCurrentMonth, isToday } from '@/lib/date-utils'
import { ReservationCard } from './reservation-card'
interface CalendarCellProps {
date: Date
year: number
month: number
reservations: Reservation[]
selectedDate: string | null
onSelectDate: (dateStr: string) => void
onNewReservation: (dateStr: string) => void
onEditReservation: (reservation: Reservation) => void
}
export function CalendarCell({
date,
year,
month,
reservations,
selectedDate,
onSelectDate,
onNewReservation,
onEditReservation,
}: CalendarCellProps) {
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const isOtherMonth = !isCurrentMonth(date, year, month)
const isSelected = selectedDate === dateStr
const today = isToday(date)
const displayMore = reservations.length > 2
return (
<div
onClick={() => onNewReservation(dateStr)}
className={`min-h-[90px] border-r border-b border-gray-200 p-1 cursor-pointer transition-colors
${isOtherMonth ? 'bg-gray-50' : 'bg-white'}
${isSelected ? 'ring-2 ring-inset ring-blue-400' : ''}
hover:bg-gray-50`}
>
<div className="flex justify-between items-start mb-0.5">
<span
className={`inline-flex items-center justify-center w-6 h-6 text-xs rounded-full
${today ? 'bg-blue-600 text-white font-bold' : ''}
${isOtherMonth ? 'text-gray-400' : 'text-gray-700'}`}
>
{date.getDate()}
</span>
</div>
<div className="space-y-0.5">
{reservations.slice(0, 2).map((r) => (
<ReservationCard key={r.id} reservation={r} onClick={onEditReservation} />
))}
{displayMore && (
<span
className="text-[11px] text-gray-500 px-1 cursor-pointer hover:text-gray-700"
onClick={(e) => {
e.stopPropagation()
onSelectDate(dateStr)
}}
>
+{reservations.length - 2} 更多
</span>
)}
</div>
</div>
)
}
- Step 3: Create
components/calendar-grid.tsx
'use client'
import { useMemo } from 'react'
import type { Reservation } from '@/lib/types'
import { getMonthGrid } from '@/lib/date-utils'
import { CalendarCell } from './calendar-cell'
interface CalendarGridProps {
year: number
month: number
reservationsByDate: Record<string, Reservation[]>
selectedDate: string | null
onSelectDate: (dateStr: string) => void
onNewReservation: (dateStr: string) => void
onEditReservation: (reservation: Reservation) => void
}
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
export function CalendarGrid({
year,
month,
reservationsByDate,
selectedDate,
onSelectDate,
onNewReservation,
onEditReservation,
}: CalendarGridProps) {
const weeks = useMemo(() => getMonthGrid(year, month), [year, month])
return (
<div className="flex-1 flex flex-col">
<div className="grid grid-cols-7 border-t border-l border-gray-200">
{WEEKDAY_NAMES.map((name) => (
<div
key={name}
className="px-2 py-2 text-xs font-medium text-gray-500 border-r border-b border-gray-200 bg-gray-50/50 text-center"
>
{name}
</div>
))}
</div>
<div className="grid grid-cols-7 border-l border-gray-200 flex-1">
{weeks.flat().map((date, i) => {
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
return (
<CalendarCell
key={i}
date={date}
year={year}
month={month}
reservations={reservationsByDate[dateStr] || []}
selectedDate={selectedDate}
onSelectDate={onSelectDate}
onNewReservation={onNewReservation}
onEditReservation={onEditReservation}
/>
)
})}
</div>
</div>
)
}
- Step 4: Create
components/calendar-header.tsx
'use client'
import { getMonthName } from '@/lib/date-utils'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface CalendarHeaderProps {
year: number
month: number
onPrevMonth: () => void
onNextMonth: () => void
}
export function CalendarHeader({ year, month, onPrevMonth, onNextMonth }: CalendarHeaderProps) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-gray-900">
{year}年 {getMonthName(month)}
</h1>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={onPrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={onNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
- Step 5: Commit
git add components/calendar-header.tsx components/calendar-grid.tsx components/calendar-cell.tsx components/reservation-card.tsx
git commit -m "feat: add calendar components"
Task 6: Create reservation form dialog
Files:
-
Create:
components/reservation-dialog.tsx -
Step 1: Create
components/reservation-dialog.tsx
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { Reservation, CreateReservationInput } from '@/lib/types'
import { getWaiters } from '@/lib/mock-data'
interface ReservationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (input: CreateReservationInput) => void
onDelete?: () => void
editReservation?: Reservation | null
defaultDate?: string
}
export function ReservationDialog({
open,
onOpenChange,
onSave,
onDelete,
editReservation,
defaultDate,
}: ReservationDialogProps) {
const [customerName, setCustomerName] = useState('')
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [waiterId, setWaiterId] = useState('')
const [packageName, setPackageName] = useState('')
const [note, setNote] = useState('')
useEffect(() => {
if (editReservation) {
setCustomerName(editReservation.customerName)
setDate(editReservation.date)
setTime(editReservation.time)
setWaiterId(editReservation.waiterId)
setPackageName(editReservation.package || '')
setNote(editReservation.note || '')
} else {
setCustomerName('')
setDate(defaultDate || '')
setTime('12:00')
setWaiterId('')
setPackageName('')
setNote('')
}
}, [editReservation, defaultDate, open])
const waiters = getWaiters()
const handleSave = () => {
if (!customerName || !date || !time || !waiterId) return
onSave({
customerName,
date,
time,
waiterId,
package: packageName || undefined,
note: note || undefined,
})
onOpenChange(false)
}
const isEditing = !!editReservation
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{isEditing ? '编辑预约' : '新建预约'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">顾客姓名 *</Label>
<Input
className="col-span-3"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="请输入顾客姓名"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">日期 *</Label>
<Input
className="col-span-3"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">时间 *</Label>
<Input
className="col-span-3"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">服务员 *</Label>
<Select value={waiterId} onValueChange={setWaiterId}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="请选择服务员" />
</SelectTrigger>
<SelectContent>
{waiters.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">套餐</Label>
<Input
className="col-span-3"
value={packageName}
onChange={(e) => setPackageName(e.target.value)}
placeholder="可选"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">备注</Label>
<Input
className="col-span-3"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="可选"
/>
</div>
</div>
<DialogFooter className="gap-2">
{isEditing && onDelete && (
<Button variant="destructive" onClick={onDelete}>
删除
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={!customerName || !date || !time || !waiterId}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
- Step 2: Commit
git add components/reservation-dialog.tsx
git commit -m "feat: add reservation form dialog"
Task 7: Create waiter management dialog
Files:
-
Create:
components/gallery-uploader.tsx -
Create:
components/waiter-form.tsx -
Create:
components/waiter-dialog.tsx -
Step 1: Create
components/gallery-uploader.tsx
'use client'
import type { Media } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { X, Plus, Image, Video } from 'lucide-react'
import { Input } from '@/components/ui/input'
interface GalleryUploaderProps {
media: Media[]
onChange: (media: Media[]) => void
}
let mediaIdCounter = 100
export function GalleryUploader({ media, onChange }: GalleryUploaderProps) {
const addImage = () => {
const url = prompt('请输入图片 URL:')
if (!url) return
onChange([
...media,
{ id: `m${mediaIdCounter++}`, type: 'image', url },
])
}
const addVideo = () => {
const url = prompt('请输入视频 URL:')
if (!url) return
onChange([
...media,
{ id: `m${mediaIdCounter++}`, type: 'video', url },
])
}
const removeMedia = (id: string) => {
onChange(media.filter((m) => m.id !== id))
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{media.map((m) => (
<div key={m.id} className="relative group">
<div className="w-20 h-20 rounded-md border border-gray-200 flex items-center justify-center bg-gray-50 overflow-hidden">
{m.type === 'image' ? (
<img src={m.url} alt="" className="w-full h-full object-cover" />
) : (
<Video className="h-8 w-8 text-gray-400" />
)}
</div>
<button
onClick={() => removeMedia(m.id)}
className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<Button variant="outline" size="icon" className="w-20 h-20" onClick={addImage}>
<Image className="h-5 w-5" />
</Button>
<Button variant="outline" size="icon" className="w-20 h-20" onClick={addVideo}>
<Video className="h-5 w-5" />
</Button>
</div>
</div>
)
}
- Step 2: Create
components/waiter-form.tsx
'use client'
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { GalleryUploader } from './gallery-uploader'
import type { Waiter, CreateWaiterInput, Media } from '@/lib/types'
interface WaiterFormProps {
waiter?: Waiter | null
onSave: (input: CreateWaiterInput) => void
}
export function WaiterForm({ waiter, onSave }: WaiterFormProps) {
const [name, setName] = useState('')
const [specialty, setSpecialty] = useState('')
const [commissionRate, setCommissionRate] = useState(20)
const [gallery, setGallery] = useState<Media[]>([])
useEffect(() => {
if (waiter) {
setName(waiter.name)
setSpecialty(waiter.specialty)
setCommissionRate(waiter.commissionRate)
setGallery([...waiter.gallery])
} else {
setName('')
setSpecialty('')
setCommissionRate(20)
setGallery([])
}
}, [waiter])
const canSubmit = name && specialty
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault()
if (!canSubmit) return
onSave({ name, specialty, commissionRate, gallery })
}
return (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">姓名 *</Label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">擅长领域 *</Label>
<Input
className="col-span-3"
value={specialty}
onChange={(e) => setSpecialty(e.target.value)}
placeholder="如:宴会/商务"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">分成比例 *</Label>
<div className="col-span-3 flex items-center gap-2">
<Input
type="number"
min={0}
max={100}
value={commissionRate}
onChange={(e) => setCommissionRate(Number(e.target.value))}
/>
<span className="text-sm text-gray-500">%</span>
</div>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right pt-2">照片/视频</Label>
<div className="col-span-3">
<GalleryUploader media={gallery} onChange={setGallery} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" onClick={() => handleSubmit()} disabled={!canSubmit}>
保存
</Button>
</div>
</div>
)
}
- Step 3: Create
components/waiter-dialog.tsx
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Edit2, Trash2, Plus } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { WaiterForm } from './waiter-form'
import type { Waiter, CreateWaiterInput, UpdateWaiterInput } from '@/lib/types'
import { getWaiters, createWaiter, updateWaiter, deleteWaiter } from '@/lib/mock-data'
export function WaiterDialog() {
const [open, setOpen] = useState(false)
const [waiters, setWaiters] = useState<Waiter[]>([])
const [editingWaiter, setEditingWaiter] = useState<Waiter | null>(null)
const [showForm, setShowForm] = useState(false)
const refresh = () => {
setWaiters(getWaiters())
}
const handleOpen = () => {
setOpen(true)
refresh()
setShowForm(false)
setEditingWaiter(null)
}
const handleSave = (input: CreateWaiterInput) => {
if (editingWaiter) {
updateWaiter(editingWaiter.id, input as UpdateWaiterInput)
} else {
createWaiter(input)
}
refresh()
setShowForm(false)
setEditingWaiter(null)
}
const handleDelete = (id: string) => {
deleteWaiter(id)
refresh()
}
const handleEdit = (waiter: Waiter) => {
setEditingWaiter(waiter)
setShowForm(true)
}
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) setShowForm(false) }}>
<DialogTrigger asChild>
<Button variant="ghost" onClick={handleOpen}>
服务员管理
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>服务员管理</span>
{!showForm && (
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingWaiter(null)
setShowForm(true)
}}
>
<Plus className="h-4 w-4 mr-1" /> 新增
</Button>
)}
</DialogTitle>
</DialogHeader>
{showForm ? (
<div>
<WaiterForm waiter={editingWaiter} onSave={(input) => { handleSave(input); setShowForm(false) }} />
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={() => { setShowForm(false); setEditingWaiter(null) }}>
返回
</Button>
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>姓名</TableHead>
<TableHead>擅长领域</TableHead>
<TableHead>分成比例</TableHead>
<TableHead className="w-[80px]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{waiters.map((w) => (
<TableRow key={w.id}>
<TableCell className="font-medium">{w.name}</TableCell>
<TableCell>{w.specialty}</TableCell>
<TableCell>{w.commissionRate}%</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(w)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => handleDelete(w.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
)
}
- Step 2: Commit
git add components/gallery-uploader.tsx components/waiter-form.tsx components/waiter-dialog.tsx
git commit -m "feat: add waiter management dialog"
Task 8: Assemble main page
Files:
-
Modify:
app/layout.tsx -
Modify:
app/page.tsx -
Step 1: Update
app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Keeppay - 服务员预约系统",
description: "服务员预约管理系统",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="h-full">{children}</body>
</html>
);
}
- Step 2: Update
app/page.tsx
'use client'
import { useState, useMemo } from 'react'
import { CalendarHeader } from '@/components/calendar-header'
import { CalendarGrid } from '@/components/calendar-grid'
import { ReservationDialog } from '@/components/reservation-dialog'
import { WaiterDialog } from '@/components/waiter-dialog'
import type { Reservation, CreateReservationInput } from '@/lib/types'
import { getReservationsByMonth, createReservation, updateReservation, deleteReservation } from '@/lib/mock-data'
import { Button } from '@/components/ui/button'
const today = new Date()
export default function Home() {
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [reservationState, setReservationState] = useState(0)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogDefaultDate, setDialogDefaultDate] = useState('')
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const refresh = () => setReservationState((n) => n + 1)
const monthReservations = useMemo(() => getReservationsByMonth(year, month), [year, month, reservationState])
const reservationsByDate = useMemo(() => {
const map: Record<string, Reservation[]> = {}
for (const r of monthReservations) {
if (!map[r.date]) map[r.date] = []
map[r.date].push(r)
}
return map
}, [monthReservations])
const handleNewReservation = (dateStr: string) => {
setEditingReservation(null)
setDialogDefaultDate(dateStr)
setDialogOpen(true)
}
const handleEditReservation = (reservation: Reservation) => {
setEditingReservation(reservation)
setDialogDefaultDate(reservation.date)
setDialogOpen(true)
}
const handleSave = (input: CreateReservationInput) => {
if (editingReservation) {
updateReservation(editingReservation.id, input)
} else {
createReservation(input)
}
refresh()
}
const handleDelete = () => {
if (editingReservation) {
deleteReservation(editingReservation.id)
refresh()
setDialogOpen(false)
}
}
return (
<div className="h-full flex flex-col">
{/* Top navigation */}
<header className="flex items-center justify-between px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-8">
<h1 className="text-base font-bold text-gray-900">Keeppay</h1>
<nav className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="text-sm">
预约管理
</Button>
<WaiterDialog />
</nav>
</div>
</header>
{/* Calendar */}
<div className="flex-1 flex flex-col overflow-hidden">
<CalendarHeader
year={year}
month={month}
onPrevMonth={() => {
if (month === 0) {
setYear(year - 1)
setMonth(11)
} else {
setMonth(month - 1)
}
}}
onNextMonth={() => {
if (month === 11) {
setYear(year + 1)
setMonth(0)
} else {
setMonth(month + 1)
}
}}
/>
<CalendarGrid
year={year}
month={month}
reservationsByDate={reservationsByDate}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
onNewReservation={handleNewReservation}
onEditReservation={handleEditReservation}
/>
</div>
{/* Reservation Dialog */}
<ReservationDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSave={handleSave}
onDelete={handleDelete}
editReservation={editingReservation}
defaultDate={dialogDefaultDate}
/>
</div>
)
}
- Step 3: Commit
git add app/layout.tsx app/page.tsx
git commit -m "feat: assemble main page with calendar and dialogs"
Task 9: Build verification
- Step 1: Run build
Run: pnpm build
Expected: Build succeeds with no errors
- Step 2: Fix any build errors (if any)
If errors occur, fix them and re-run build until it passes.
- Step 3: Final commit
git add -A
git commit -m "fix: resolve build issues"