管理后台

This commit is contained in:
xujiang
2025-07-09 17:50:29 +08:00
parent 0651b6626a
commit 5f2152c7a6
40 changed files with 3839 additions and 795 deletions

View File

@ -1,9 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import ErrorBoundary from './components/ErrorBoundary'
import ProtectedRoute from './components/ProtectedRoute'
import LoginPage from './pages/LoginPage'
import NotFound from './pages/NotFound'
import DashboardLayout from './components/DashboardLayout'
import Dashboard from './pages/Dashboard'
import Photos from './pages/Photos'
import PhotoUpload from './pages/PhotoUpload'
import Categories from './pages/Categories'
import Tags from './pages/Tags'
import Users from './pages/Users'
@ -13,21 +17,43 @@ function App() {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <LoginPage />
return (
<ErrorBoundary>
<LoginPage />
</ErrorBoundary>
)
}
return (
<DashboardLayout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/photos" element={<Photos />} />
<Route path="/categories" element={<Categories />} />
<Route path="/tags" element={<Tags />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</DashboardLayout>
<ErrorBoundary>
<DashboardLayout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/photos" element={<Photos />} />
<Route path="/photos/upload" element={<PhotoUpload />} />
<Route path="/categories" element={<Categories />} />
<Route path="/tags" element={<Tags />} />
<Route
path="/users"
element={
<ProtectedRoute requiredRole="admin">
<Users />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requiredRole="admin">
<Settings />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</DashboardLayout>
</ErrorBoundary>
)
}

View File

@ -0,0 +1,110 @@
import { Component, ErrorInfo, ReactNode } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
errorInfo?: ErrorInfo
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({
error,
errorInfo
})
// 这里可以将错误发送到日志服务
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined })
}
handleGoHome = () => {
window.location.href = '/dashboard'
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<AlertTriangle className="h-12 w-12 text-red-500" />
</div>
<CardTitle className="text-xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
{import.meta.env.DEV && this.state.error && (
<div className="p-4 bg-gray-100 rounded-md">
<h4 className="font-medium text-sm mb-2"></h4>
<pre className="text-xs text-red-600 whitespace-pre-wrap">
{this.state.error.toString()}
</pre>
{this.state.errorInfo && (
<pre className="text-xs text-gray-600 mt-2 whitespace-pre-wrap">
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
)}
<div className="flex gap-2">
<Button
onClick={this.handleRetry}
className="flex-1"
variant="outline"
>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
<Button
onClick={this.handleGoHome}
className="flex-1"
>
<Home className="h-4 w-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary

View File

@ -0,0 +1,80 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LoadingProps {
size?: 'sm' | 'md' | 'lg'
text?: string
className?: string
fullscreen?: boolean
}
export function Loading({
size = 'md',
text = '加载中...',
className,
fullscreen = false
}: LoadingProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8'
}
const textSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
}
if (fullscreen) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<Loader2 className={cn(sizeClasses[size], "animate-spin text-primary")} />
<p className={cn(textSizeClasses[size], "text-muted-foreground")}>{text}</p>
</div>
</div>
)
}
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
<Loader2 className={cn(sizeClasses[size], "animate-spin text-primary")} />
<span className={cn(textSizeClasses[size], "text-muted-foreground")}>{text}</span>
</div>
)
}
export function LoadingSpinner({ size = 'md', className }: { size?: 'sm' | 'md' | 'lg', className?: string }) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8'
}
return (
<Loader2 className={cn(sizeClasses[size], "animate-spin text-primary", className)} />
)
}
export function PageLoading({ text = '页面加载中...' }: { text?: string }) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground">{text}</p>
</div>
</div>
)
}
export function InlineLoading({ text = '加载中...' }: { text?: string }) {
return (
<div className="flex items-center gap-2 p-4">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">{text}</span>
</div>
)
}
export default Loading

View File

@ -0,0 +1,91 @@
import { ReactNode } from 'react'
import { useAuthStore } from '@/stores/authStore'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Shield, Home } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
interface ProtectedRouteProps {
children: ReactNode
requiredRole?: 'admin' | 'editor' | 'user'
fallback?: ReactNode
}
export function ProtectedRoute({
children,
requiredRole,
fallback
}: ProtectedRouteProps) {
const { user, isAuthenticated } = useAuthStore()
const navigate = useNavigate()
// 如果未登录,这个检查应该在 App.tsx 层面处理
if (!isAuthenticated || !user) {
return null
}
// 角色权限检查
if (requiredRole) {
const roleHierarchy = {
'user': 0,
'editor': 1,
'admin': 2
}
const userRoleLevel = roleHierarchy[user.role] || 0
const requiredRoleLevel = roleHierarchy[requiredRole] || 0
if (userRoleLevel < requiredRoleLevel) {
if (fallback) {
return <>{fallback}</>
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="flex justify-center mb-4">
<Shield className="h-16 w-16 text-orange-400" />
</div>
<CardTitle className="text-xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
访
</p>
<p className="text-sm text-muted-foreground">
: <span className="font-medium">{getRoleText(user.role)}</span><br />
: <span className="font-medium">{getRoleText(requiredRole)}</span>
</p>
<Button
onClick={() => navigate('/dashboard')}
className="w-full"
>
<Home className="h-4 w-4 mr-2" />
</Button>
<div className="text-sm text-muted-foreground">
错误代码: 403
</div>
</CardContent>
</Card>
</div>
)
}
}
return <>{children}</>
}
function getRoleText(role: string) {
const roleTexts = {
'admin': '管理员',
'editor': '编辑者',
'user': '用户'
}
return roleTexts[role as keyof typeof roleTexts] || role
}
export default ProtectedRoute

View File

@ -0,0 +1,24 @@
import { Toaster as SonnerToaster } from 'sonner'
export function Toaster() {
return (
<SonnerToaster
position="top-right"
expand={false}
richColors
closeButton
toastOptions={{
duration: 4000,
style: {
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border))',
},
className: 'group',
descriptionClassName: 'group-[.toaster]:text-muted-foreground',
}}
/>
)
}
export default Toaster

View File

@ -0,0 +1,36 @@
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-full 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 hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
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 }

View File

@ -0,0 +1,56 @@
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
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 }

View File

@ -0,0 +1,79 @@
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-lg border bg-card text-card-foreground shadow-sm",
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(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
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"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,120 @@
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-background/80 backdrop-blur-sm 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,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,117 @@
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 TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
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-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

59
admin/src/index.css Normal file
View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -53,7 +53,7 @@ export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)

View File

@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import Toaster from './components/Toaster'
import './index.css'
const queryClient = new QueryClient({
@ -20,6 +21,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster />
<ReactQueryDevtools initialIsOpen={false} />
</BrowserRouter>
</QueryClientProvider>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@ -9,12 +9,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import {
FolderOpen,
FolderPlus,
MoreVerticalIcon,
EditIcon,
TrashIcon,
PlusIcon,
SearchIcon,
TreePineIcon
MoreVertical,
Edit,
Trash,
Plus,
Search,
TreePine
} from 'lucide-react'
import { toast } from 'sonner'
import { categoryService } from '@/services/categoryService'
@ -79,12 +79,12 @@ export default function Categories() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="h-4 w-4" />
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<EditIcon className="h-4 w-4 mr-2" />
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem>
@ -95,7 +95,7 @@ export default function Categories() {
onClick={() => handleDeleteCategory(category.id)}
disabled={category.photoCount > 0 || category.children?.length > 0}
>
<TrashIcon className="h-4 w-4 mr-2" />
<Trash className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
@ -122,7 +122,7 @@ export default function Categories() {
</p>
</div>
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
@ -146,7 +146,7 @@ export default function Categories() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TreePineIcon className="h-4 w-4 text-muted-foreground" />
<TreePine className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{statsLoading ? (
@ -193,7 +193,7 @@ export default function Categories() {
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="搜索分类..."
value={search}
@ -202,7 +202,7 @@ export default function Categories() {
/>
</div>
<Button variant="outline">
<TreePineIcon className="h-4 w-4 mr-2" />
<TreePine className="h-4 w-4 mr-2" />
</Button>
</div>
@ -240,7 +240,7 @@ export default function Categories() {
</p>
<Button>
<PlusIcon className="h-4 w-4 mr-2" />
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>

View File

@ -1,10 +1,10 @@
import React from 'react'
// Dashboard page
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PhotoIcon, FolderIcon, TagIcon, PlusIcon, TrendingUpIcon } from 'lucide-react'
import { Camera, Folder, Tag, Plus, TrendingUp } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { photoService } from '@/services/photoService'
import { categoryService } from '@/services/categoryService'
@ -74,7 +74,7 @@ export default function Dashboard() {
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold"></h1>
<Button onClick={() => navigate('/photos/upload')} className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
@ -84,7 +84,7 @@ export default function Dashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<PhotoIcon className="h-4 w-4 text-muted-foreground" />
<Camera className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{photoStatsLoading ? (
@ -101,7 +101,7 @@ export default function Dashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUpIcon className="h-4 w-4 text-muted-foreground" />
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{photoStatsLoading ? (
@ -118,7 +118,7 @@ export default function Dashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<FolderIcon className="h-4 w-4 text-muted-foreground" />
<Folder className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{categoryStatsLoading ? (
@ -135,7 +135,7 @@ export default function Dashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TagIcon className="h-4 w-4 text-muted-foreground" />
<Tag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{tagStatsLoading ? (
@ -198,7 +198,7 @@ export default function Dashboard() {
{recentPhotos.photos.map((photo) => (
<div key={photo.id} className="flex items-center gap-4">
<div className="h-12 w-12 bg-gray-100 rounded flex items-center justify-center">
<PhotoIcon className="h-6 w-6 text-gray-400" />
<Camera className="h-6 w-6 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{photo.title}</div>
@ -214,7 +214,7 @@ export default function Dashboard() {
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<PhotoIcon className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<Camera className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p></p>
<Button
variant="outline"
@ -253,7 +253,7 @@ export default function Dashboard() {
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<TagIcon className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<Tag className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p></p>
<Button
variant="outline"

View File

@ -0,0 +1,48 @@
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { FileQuestion, Home, ArrowLeft } from 'lucide-react'
export default function NotFound() {
const navigate = useNavigate()
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="flex justify-center mb-4">
<FileQuestion className="h-16 w-16 text-gray-400" />
</div>
<CardTitle className="text-2xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
访
</p>
<div className="space-y-2">
<Button
onClick={() => navigate('/dashboard')}
className="w-full"
>
<Home className="h-4 w-4 mr-2" />
</Button>
<Button
onClick={() => navigate(-1)}
variant="outline"
className="w-full"
>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="text-sm text-muted-foreground">
错误代码: 404
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,420 @@
import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Upload,
X,
Image as ImageIcon,
Check,
AlertTriangle,
ArrowLeft,
Save
} from 'lucide-react'
import { toast } from 'sonner'
import { photoService } from '@/services/photoService'
import { categoryService } from '@/services/categoryService'
import { tagService } from '@/services/tagService'
interface UploadFile extends File {
id: string
preview?: string
progress?: number
status?: 'pending' | 'uploading' | 'completed' | 'error'
error?: string
}
export default function PhotoUpload() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [files, setFiles] = useState<UploadFile[]>([])
const [uploadProgress, setUploadProgress] = useState(0)
const [isUploading, setIsUploading] = useState(false)
// 表单数据
const [formData, setFormData] = useState({
title: '',
description: '',
categoryIds: [] as string[],
tagIds: [] as string[],
status: 'draft' as 'draft' | 'published'
})
// 获取分类和标签
const { data: categories } = useQuery({
queryKey: ['categories-all'],
queryFn: () => categoryService.getCategories()
})
const { data: tags } = useQuery({
queryKey: ['tags-all'],
queryFn: () => tagService.getAllTags()
})
// 上传照片
const uploadMutation = useMutation({
mutationFn: async (uploadData: { files: UploadFile[], metadata: any }) => {
setIsUploading(true)
const results = []
for (let i = 0; i < uploadData.files.length; i++) {
const file = uploadData.files[i]
try {
// 更新文件状态
setFiles(prev => prev.map(f =>
f.id === file.id ? { ...f, status: 'uploading' as const } : f
))
// 模拟上传进度
const result = await photoService.uploadPhoto(file, {
...uploadData.metadata,
title: uploadData.metadata.title || file.name.split('.')[0],
onProgress: (progress: number) => {
setFiles(prev => prev.map(f =>
f.id === file.id ? { ...f, progress } : f
))
// 更新总进度
const totalProgress = ((i + progress / 100) / uploadData.files.length) * 100
setUploadProgress(totalProgress)
}
})
// 更新文件状态为完成
setFiles(prev => prev.map(f =>
f.id === file.id ? { ...f, status: 'completed' as const, progress: 100 } : f
))
results.push(result)
} catch (error: any) {
// 更新文件状态为错误
setFiles(prev => prev.map(f =>
f.id === file.id ? {
...f,
status: 'error' as const,
error: error.message || '上传失败'
} : f
))
}
}
setIsUploading(false)
return results
},
onSuccess: (results) => {
queryClient.invalidateQueries({ queryKey: ['photos'] })
const successCount = results.length
const failedCount = files.length - successCount
if (successCount > 0) {
toast.success(`成功上传 ${successCount} 张照片${failedCount > 0 ? `${failedCount} 张失败` : ''}`)
}
if (failedCount === 0) {
// 全部成功,返回列表页
setTimeout(() => navigate('/photos'), 1000)
}
},
onError: (error: any) => {
setIsUploading(false)
toast.error(error?.message || '上传失败')
}
})
// 文件拖放处理
const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles = acceptedFiles.map(file => ({
...file,
id: Math.random().toString(36),
preview: URL.createObjectURL(file),
status: 'pending' as const,
progress: 0
}))
setFiles(prev => [...prev, ...newFiles])
}, [])
// 移除文件
const removeFile = (id: string) => {
setFiles(prev => {
const file = prev.find(f => f.id === id)
if (file?.preview) {
URL.revokeObjectURL(file.preview)
}
return prev.filter(f => f.id !== id)
})
}
// 处理文件选择
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || [])
onDrop(selectedFiles)
}
// 开始上传
const handleUpload = () => {
if (files.length === 0) {
toast.error('请选择要上传的文件')
return
}
uploadMutation.mutate({
files,
metadata: formData
})
}
// 更新表单数据
const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
return (
<div className="container mx-auto px-4 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate('/photos')}
>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 文件上传区域 */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium mb-2"></p>
<p className="text-sm text-muted-foreground mb-4">
JPGPNGGIFWebP 10MB
</p>
<input
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload">
<Button asChild>
<span></span>
</Button>
</label>
</div>
</CardContent>
</Card>
{/* 文件列表 */}
{files.length > 0 && (
<Card>
<CardHeader>
<CardTitle> ({files.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((file) => (
<div key={file.id} className="flex items-center gap-4 p-4 border rounded-lg">
{file.preview ? (
<img
src={file.preview}
alt={file.name}
className="w-12 h-12 object-cover rounded"
/>
) : (
<div className="w-12 h-12 bg-gray-100 rounded flex items-center justify-center">
<ImageIcon className="h-6 w-6 text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
{file.status === 'uploading' && file.progress !== undefined && (
<Progress value={file.progress} className="mt-2" />
)}
{file.status === 'error' && file.error && (
<Alert variant="destructive" className="mt-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs">
{file.error}
</AlertDescription>
</Alert>
)}
</div>
<div className="flex items-center gap-2">
{file.status === 'completed' && (
<Badge variant="default">
<Check className="h-3 w-3 mr-1" />
</Badge>
)}
{file.status === 'error' && (
<Badge variant="destructive">
<AlertTriangle className="h-3 w-3 mr-1" />
</Badge>
)}
{file.status === 'pending' && (
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(file.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 上传进度 */}
{isUploading && (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} />
</div>
</CardContent>
</Card>
)}
</div>
{/* 右侧元数据设置 */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="title"></Label>
<Input
id="title"
value={formData.title}
onChange={(e) => updateFormData('title', e.target.value)}
placeholder="照片标题"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => updateFormData('description', e.target.value)}
placeholder="照片描述"
rows={3}
/>
</div>
<div>
<Label htmlFor="status"></Label>
<Select value={formData.status} onValueChange={(value) => updateFormData('status', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="published"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<div className="text-sm text-muted-foreground mb-2">
</div>
{/* 这里应该有一个多选组件,暂时简化 */}
<p className="text-sm text-muted-foreground">
{categories?.length || 0}
</p>
</div>
<div>
<Label></Label>
<div className="text-sm text-muted-foreground mb-2">
</div>
{/* 这里应该有一个标签选择组件,暂时简化 */}
<p className="text-sm text-muted-foreground">
{tags?.length || 0}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<Button
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
className="w-full"
>
{isUploading ? (
<>
<Upload className="h-4 w-4 mr-2 animate-pulse" />
...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
({files.length})
</>
)}
</Button>
{files.length > 0 && !isUploading && (
<Button
variant="outline"
onClick={() => setFiles([])}
className="w-full mt-2"
>
<X className="h-4 w-4 mr-2" />
</Button>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@ -9,23 +9,21 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
PhotoIcon,
PlusIcon,
SearchIcon,
FilterIcon,
MoreVerticalIcon,
EditIcon,
TrashIcon,
EyeIcon,
DownloadIcon,
GridIcon,
ListIcon
Camera,
Plus,
Search,
MoreVertical,
Edit,
Trash,
Eye,
Grid,
List
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { photoService } from '@/services/photoService'
import { categoryService } from '@/services/categoryService'
import { tagService } from '@/services/tagService'
type ViewMode = 'grid' | 'list'
@ -73,11 +71,6 @@ export default function Photos() {
queryFn: () => categoryService.getCategories()
})
// 获取标签列表
const { data: tags } = useQuery({
queryKey: ['tags-all'],
queryFn: () => tagService.getAllTags()
})
// 删除照片
const deletePhotoMutation = useMutation({
@ -194,7 +187,7 @@ export default function Photos() {
</p>
</div>
<Button onClick={() => navigate('/photos/upload')} className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
@ -205,7 +198,7 @@ export default function Photos() {
<div className="flex flex-col lg:flex-row gap-4">
{/* 搜索 */}
<div className="flex-1 relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="搜索照片..."
value={filters.search}
@ -251,14 +244,14 @@ export default function Photos() {
size="sm"
onClick={() => setViewMode('grid')}
>
<GridIcon className="h-4 w-4" />
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
>
<ListIcon className="h-4 w-4" />
<List className="h-4 w-4" />
</Button>
</div>
</div>
@ -342,7 +335,7 @@ export default function Photos() {
<CardContent className="p-4">
<div className="relative mb-4">
<div className="aspect-square bg-gray-100 rounded-lg flex items-center justify-center">
<PhotoIcon className="h-12 w-12 text-gray-400" />
<Camera className="h-12 w-12 text-gray-400" />
</div>
{/* 复选框 */}
@ -359,20 +352,20 @@ export default function Photos() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 w-8 p-0 bg-white">
<MoreVerticalIcon className="h-4 w-4" />
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}`)}>
<EyeIcon className="h-4 w-4 mr-2" />
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}/edit`)}>
<EditIcon className="h-4 w-4 mr-2" />
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeletePhoto(photo.id)}>
<TrashIcon className="h-4 w-4 mr-2" />
<Trash className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
@ -410,7 +403,7 @@ export default function Photos() {
/>
<div className="h-16 w-16 bg-gray-100 rounded flex items-center justify-center flex-shrink-0">
<PhotoIcon className="h-8 w-8 text-gray-400" />
<Camera className="h-8 w-8 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
@ -434,20 +427,20 @@ export default function Photos() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVerticalIcon className="h-4 w-4" />
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}`)}>
<EyeIcon className="h-4 w-4 mr-2" />
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}/edit`)}>
<EditIcon className="h-4 w-4 mr-2" />
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeletePhoto(photo.id)}>
<TrashIcon className="h-4 w-4 mr-2" />
<Trash className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
@ -500,13 +493,13 @@ export default function Photos() {
<Card>
<CardContent className="py-12">
<div className="text-center">
<PhotoIcon className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<Camera className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
<Button onClick={() => navigate('/photos/upload')}>
<PlusIcon className="h-4 w-4 mr-2" />
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@ -7,27 +7,19 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Settings as SettingsIcon,
Globe,
Database,
Shield,
Bell,
Palette,
Upload,
Server,
RefreshCw,
Save,
Download,
Trash2,
AlertTriangle,
CheckCircle,
XCircle,
HardDrive,
Cpu,
MemoryStick,
WifiIcon
} from 'lucide-react'
import { toast } from 'sonner'
import { settingsService } from '@/services/settingsService'
@ -115,13 +107,13 @@ export default function Settings() {
const [isDirty, setIsDirty] = useState(false)
// 获取系统设置
const { data: settings, isLoading: settingsLoading } = useQuery<SystemSettings>({
const { data: settings } = useQuery<SystemSettings>({
queryKey: ['settings'],
queryFn: settingsService.getSettings
})
// 获取系统状态
const { data: status, isLoading: statusLoading } = useQuery<SystemStatus>({
const { data: status } = useQuery<SystemStatus>({
queryKey: ['system-status'],
queryFn: settingsService.getSystemStatus,
refetchInterval: 30000 // 30秒刷新一次
@ -143,7 +135,7 @@ export default function Settings() {
// 系统操作
const systemOperationMutation = useMutation({
mutationFn: settingsService.performSystemOperation,
onSuccess: (data, variables) => {
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['system-status'] })
toast.success(`${variables.operation} 操作成功`)
},
@ -164,7 +156,7 @@ export default function Settings() {
}
}
const updateSetting = (section: string, key: string, value: any) => {
const updateSetting = (_section: string, _key: string, _value: any) => {
// 这里应该更新本地状态
setIsDirty(true)
}
@ -550,7 +542,7 @@ export default function Settings() {
onClick={() => handleSystemOperation('optimize')}
className="flex items-center gap-2"
>
<Settings className="h-4 w-4" />
<SettingsIcon className="h-4 w-4" />
</Button>
</div>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@ -8,17 +8,17 @@ import { Skeleton } from '@/components/ui/skeleton'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
Tag,
TagIcon,
MoreVerticalIcon,
EditIcon,
TrashIcon,
PlusIcon,
SearchIcon,
FilterIcon,
SortAscIcon,
SortDescIcon,
TrendingUpIcon,
HashIcon
Hash,
MoreVertical,
Edit,
Trash,
Plus,
Search,
Filter,
ArrowUp,
ArrowDown,
TrendingUp,
Hash as HashTag
} from 'lucide-react'
import { toast } from 'sonner'
import { tagService } from '@/services/tagService'
@ -34,41 +34,31 @@ interface TagData {
updatedAt: string
}
interface TagStats {
total: number
active: number
popular: number
avgPhotosPerTag: number
topTags: Array<{
name: string
photoCount: number
color: string
}>
}
export default function Tags() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'photoCount' | 'createdAt'>('name')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const [filterActive, setFilterActive] = useState<boolean | null>(null)
const [filterActive, setFilterActive] = useState<boolean | undefined>(undefined)
// 获取标签列表
const { data: tags, isLoading: tagsLoading } = useQuery<TagData[]>({
const { data: tagsData, isLoading: tagsLoading } = useQuery({
queryKey: ['tags', { search, sortBy, sortOrder, filterActive }],
queryFn: () => tagService.getTags({
search,
sortBy,
sortOrder,
isActive: filterActive
})
})
queryFn: () => tagService.getAllTags() as Promise<TagData[]>
})
// 获取标签统计
const { data: stats, isLoading: statsLoading } = useQuery<TagStats>({
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['tag-stats'],
queryFn: tagService.getStats
})
// 获取热门标签
const { data: popularTags } = useQuery({
queryKey: ['popular-tags'],
queryFn: () => tagService.getPopularTags(10)
})
// 删除标签
const deleteTagMutation = useMutation({
@ -121,9 +111,10 @@ export default function Tags() {
}
}
const filteredTags = tags?.filter(tag => {
const tags = Array.isArray(tagsData) ? tagsData : []
const filteredTags = tags.filter((tag: any) => {
const matchesSearch = tag.name.toLowerCase().includes(search.toLowerCase())
const matchesFilter = filterActive === null || tag.isActive === filterActive
const matchesFilter = filterActive === undefined || tag.isActive === filterActive
return matchesSearch && matchesFilter
})
@ -138,7 +129,7 @@ export default function Tags() {
</p>
</div>
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
@ -148,7 +139,7 @@ export default function Tags() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TagIcon className="h-4 w-4 text-muted-foreground" />
<Hash className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{statsLoading ? (
@ -162,7 +153,7 @@ export default function Tags() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUpIcon className="h-4 w-4 text-muted-foreground" />
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{statsLoading ? (
@ -175,14 +166,14 @@ export default function Tags() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<HashIcon className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">使</CardTitle>
<HashTag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{statsLoading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold text-orange-600">{stats?.popular || 0}</div>
<div className="text-2xl font-bold text-orange-600">{stats?.used || 0}</div>
)}
</CardContent>
</Card>
@ -205,24 +196,24 @@ export default function Tags() {
</div>
{/* 热门标签 */}
{stats?.topTags && stats.topTags.length > 0 && (
{popularTags && popularTags.length > 0 && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUpIcon className="h-5 w-5" />
<TrendingUp className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{stats.topTags.map((tag, index) => (
{popularTags.map((tag: any, index: number) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1 px-3 py-1 text-sm"
style={{ backgroundColor: tag.color + '20', color: tag.color }}
style={{ backgroundColor: (tag.color || '#666') + '20', color: tag.color || '#666' }}
>
<TagIcon className="h-3 w-3" />
<Hash className="h-3 w-3" />
{tag.name}
<span className="text-xs opacity-70">({tag.photoCount})</span>
</Badge>
@ -237,7 +228,7 @@ export default function Tags() {
<CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="搜索标签..."
value={search}
@ -248,11 +239,11 @@ export default function Tags() {
<div className="flex gap-2">
<Button
variant={filterActive === null ? "default" : "outline"}
variant={filterActive === undefined ? "default" : "outline"}
size="sm"
onClick={() => setFilterActive(null)}
onClick={() => setFilterActive(undefined)}
>
<FilterIcon className="h-4 w-4 mr-1" />
<Filter className="h-4 w-4 mr-1" />
</Button>
<Button
@ -288,7 +279,7 @@ export default function Tags() {
>
{sortBy === 'name' && (
sortOrder === 'asc' ? <SortAscIcon className="h-3 w-3" /> : <SortDescIcon className="h-3 w-3" />
sortOrder === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
)}
</Button>
<Button
@ -299,7 +290,7 @@ export default function Tags() {
>
{sortBy === 'photoCount' && (
sortOrder === 'asc' ? <SortAscIcon className="h-3 w-3" /> : <SortDescIcon className="h-3 w-3" />
sortOrder === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
)}
</Button>
<Button
@ -310,7 +301,7 @@ export default function Tags() {
>
{sortBy === 'createdAt' && (
sortOrder === 'asc' ? <SortAscIcon className="h-3 w-3" /> : <SortDescIcon className="h-3 w-3" />
sortOrder === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
)}
</Button>
</div>
@ -332,7 +323,7 @@ export default function Tags() {
</div>
) : filteredTags?.length ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTags.map((tag) => (
{filteredTags.map((tag: any) => (
<div key={tag.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent/50 transition-colors">
<div className="flex items-center space-x-3">
<div
@ -363,12 +354,12 @@ export default function Tags() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVerticalIcon className="h-4 w-4" />
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<EditIcon className="h-4 w-4 mr-2" />
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
@ -381,7 +372,7 @@ export default function Tags() {
disabled={tag.photoCount > 0}
className="text-red-600"
>
<TrashIcon className="h-4 w-4 mr-2" />
<Trash className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
@ -391,7 +382,7 @@ export default function Tags() {
</div>
) : (
<div className="text-center py-12">
<TagIcon className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<Hash className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium mb-2">
{search ? '没有找到匹配的标签' : '暂无标签'}
</h3>
@ -399,7 +390,7 @@ export default function Tags() {
{search ? '尝试调整搜索条件' : '创建您的第一个标签来标记照片'}
</p>
<Button>
<PlusIcon className="h-4 w-4 mr-2" />
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'

View File

@ -1,26 +1,5 @@
import api from './api'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
user: User
access_token: string
refresh_token: string
expires_in: number
}
export interface User {
id: number
username: string
email: string
role: string
is_active: boolean
created_at: string
updated_at: string
}
import { User, LoginRequest, LoginResponse } from '@/types'
export interface RefreshTokenRequest {
refresh_token: string
@ -59,7 +38,18 @@ class AuthService {
async getCurrentUser(): Promise<User> {
const response = await api.get('/me')
return response.data
const userData = response.data
// 转换后端响应格式到前端类型
return {
id: userData.id?.toString() || '',
username: userData.username || '',
email: userData.email || '',
role: userData.role || 'user',
isActive: userData.is_active ?? true,
createdAt: userData.created_at || '',
updatedAt: userData.updated_at || ''
}
}
async updateProfile(data: UpdateProfileRequest): Promise<User> {

View File

@ -1,15 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface User {
id: number
username: string
email: string
role: string
isActive: boolean
createdAt: string
updatedAt: string
}
import { User } from '@/types'
interface AuthState {
user: User | null
@ -22,7 +13,7 @@ interface AuthState {
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
(set) => ({
user: null,
token: null,
isAuthenticated: false,

136
admin/src/types/index.ts Normal file
View File

@ -0,0 +1,136 @@
// 通用类型定义
export interface User {
id: string
username: string
email: string
role: 'admin' | 'editor' | 'user'
isActive: boolean
createdAt: string
updatedAt: string
}
export interface Photo {
id: string
title: string
description: string
url: string
thumbnailUrl?: string
originalFilename: string
fileSize: number
status: 'draft' | 'published' | 'archived' | 'processing'
categories: Category[]
tags: Tag[]
userId: string
createdAt: string
updatedAt: string
}
export interface Category {
id: string
name: string
slug: string
description?: string
parentId?: string
isActive: boolean
photoCount: number
createdAt: string
updatedAt: string
}
export interface Tag {
id: string
name: string
slug: string
description?: string
photoCount: number
createdAt: string
updatedAt: string
}
// API 响应类型
export interface ApiResponse<T> {
data: T
message?: string
success: boolean
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}
export interface PhotoListResponse extends PaginatedResponse<Photo> {
photos: Photo[]
}
export interface CategoryStats {
total: number
active: number
popular: Category[]
}
export interface TagStats {
total: number
active: number
popular: Tag[]
topTags: Tag[]
avgPhotosPerTag: number
}
export interface PhotoStats {
total: number
totalSize: number
thisMonth: number
today: number
statusStats: Record<string, number>
}
// 请求参数类型
export interface PhotoListParams {
page?: number
limit?: number
search?: string
categoryId?: string
tagId?: string
status?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
user: User
access_token: string
refresh_token?: string
expires_in: number
}
// 表单数据类型
export interface CategoryFormData {
name: string
slug: string
description?: string
parentId?: string
isActive: boolean
}
export interface TagFormData {
name: string
slug: string
description?: string
}
export interface PhotoFormData {
title: string
description?: string
categoryIds: string[]
tagIds: string[]
status: 'draft' | 'published' | 'archived'
}

11
admin/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
readonly VITE_UPLOAD_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}