管理后台
This commit is contained in:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
110
admin/src/components/ErrorBoundary.tsx
Normal file
110
admin/src/components/ErrorBoundary.tsx
Normal 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
|
||||
80
admin/src/components/Loading.tsx
Normal file
80
admin/src/components/Loading.tsx
Normal 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
|
||||
91
admin/src/components/ProtectedRoute.tsx
Normal file
91
admin/src/components/ProtectedRoute.tsx
Normal 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
|
||||
24
admin/src/components/Toaster.tsx
Normal file
24
admin/src/components/Toaster.tsx
Normal 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
|
||||
36
admin/src/components/ui/badge.tsx
Normal file
36
admin/src/components/ui/badge.tsx
Normal 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 }
|
||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal 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 }
|
||||
79
admin/src/components/ui/card.tsx
Normal file
79
admin/src/components/ui/card.tsx
Normal 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 }
|
||||
120
admin/src/components/ui/dialog.tsx
Normal file
120
admin/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
176
admin/src/components/ui/form.tsx
Normal file
176
admin/src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
25
admin/src/components/ui/input.tsx
Normal file
25
admin/src/components/ui/input.tsx
Normal 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 }
|
||||
26
admin/src/components/ui/progress.tsx
Normal file
26
admin/src/components/ui/progress.tsx
Normal 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 }
|
||||
117
admin/src/components/ui/table.tsx
Normal file
117
admin/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
127
admin/src/components/ui/toast.tsx
Normal file
127
admin/src/components/ui/toast.tsx
Normal 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
59
admin/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
48
admin/src/pages/NotFound.tsx
Normal file
48
admin/src/pages/NotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
420
admin/src/pages/PhotoUpload.tsx
Normal file
420
admin/src/pages/PhotoUpload.tsx
Normal 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">
|
||||
支持 JPG、PNG、GIF、WebP 格式,单个文件最大 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
136
admin/src/types/index.ts
Normal 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
11
admin/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user