Files
photography/.cursor/rules/admin/react-vite.mdc
xujiang 010fe2a8c7
Some checks failed
部署后端服务 / 🧪 测试后端 (push) Failing after 5m8s
部署后端服务 / 🚀 构建并部署 (push) Has been skipped
部署后端服务 / 🔄 回滚部署 (push) Has been skipped
fix
2025-07-10 18:09:11 +08:00

625 lines
16 KiB
Plaintext

# 管理后台 React + Vite 开发规则
## 🏗️ 项目架构
### 目录结构
```
admin/
├── src/
│ ├── components/ # React 组件
│ │ ├── ui/ # shadcn/ui 基础组件
│ │ ├── DashboardLayout.tsx # 布局组件
│ │ ├── ProtectedRoute.tsx # 路由守卫
│ │ └── ErrorBoundary.tsx # 错误边界
│ ├── pages/ # 页面组件
│ │ ├── Dashboard.tsx # 仪表盘
│ │ ├── Photos.tsx # 照片管理
│ │ ├── Categories.tsx # 分类管理
│ │ ├── Users.tsx # 用户管理
│ │ └── LoginPage.tsx # 登录页
│ ├── services/ # API 服务
│ │ ├── api.ts # API 基础配置
│ │ ├── authService.ts # 认证服务
│ │ ├── photoService.ts # 照片服务
│ │ └── categoryService.ts # 分类服务
│ ├── stores/ # 状态管理
│ │ └── authStore.ts # 认证状态
│ ├── types/ # TypeScript 类型
│ │ └── index.ts # 类型定义
│ ├── utils/ # 工具函数
│ ├── lib/ # 库配置
│ │ └── utils.ts # shadcn utils
│ ├── App.tsx # 应用根组件
│ └── main.tsx # 应用入口
├── public/ # 静态资源
├── index.html # HTML 模板
├── vite.config.ts # Vite 配置
└── package.json # 依赖配置
```
## 🎯 开发规范
### 应用入口配置
```tsx
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
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 './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5分钟
retry: 2,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>
)
```
### 应用根组件
```tsx
// App.tsx
import { Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from '@/components/ui/toaster'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { DashboardLayout } from '@/components/DashboardLayout'
import { LoginPage } from '@/pages/LoginPage'
import { Dashboard } from '@/pages/Dashboard'
import { Photos } from '@/pages/Photos'
import { PhotoUpload } from '@/pages/PhotoUpload'
import { Categories } from '@/pages/Categories'
import { Users } from '@/pages/Users'
import { Settings } from '@/pages/Settings'
function App() {
return (
<ErrorBoundary>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<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="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</DashboardLayout>
</ProtectedRoute>
}
/>
</Routes>
<Toaster />
</ErrorBoundary>
)
}
export default App
```
## 🧩 核心组件
### 布局组件
```tsx
// components/DashboardLayout.tsx
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Image,
FolderOpen,
Users,
Settings,
LogOut,
Menu
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/stores/authStore'
const navigation = [
{ name: '仪表盘', href: '/dashboard', icon: LayoutDashboard },
{ name: '照片管理', href: '/photos', icon: Image },
{ name: '分类管理', href: '/categories', icon: FolderOpen },
{ name: '用户管理', href: '/users', icon: Users },
{ name: '系统设置', href: '/settings', icon: Settings },
]
interface DashboardLayoutProps {
children: React.ReactNode
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false)
const location = useLocation()
const { user, logout } = useAuthStore()
return (
<div className="flex h-screen bg-gray-100">
{/* 侧边栏 */}
<div className={`
bg-white shadow-lg transition-all duration-300
${sidebarOpen ? 'w-64' : 'w-16'}
`}>
<div className="flex items-center justify-between p-4">
<h1 className={`font-bold text-xl ${!sidebarOpen && 'hidden'}`}>
摄影管理
</h1>
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
<Menu className="h-4 w-4" />
</Button>
</div>
<nav className="mt-8">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`
flex items-center px-4 py-3 text-sm font-medium
${location.pathname === item.href
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-600 hover:bg-gray-50'
}
`}
>
<item.icon className="h-5 w-5 mr-3" />
{sidebarOpen && item.name}
</Link>
))}
</nav>
<div className="absolute bottom-4 left-4 right-4">
<Button
variant="ghost"
onClick={logout}
className="w-full justify-start"
>
<LogOut className="h-4 w-4 mr-2" />
{sidebarOpen && '退出登录'}
</Button>
</div>
</div>
{/* 主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部导航 */}
<header className="bg-white shadow-sm border-b">
<div className="flex items-center justify-between px-6 py-4">
<h2 className="text-lg font-semibold text-gray-900">
{navigation.find(item => item.href === location.pathname)?.name || '管理后台'}
</h2>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
欢迎, {user?.username}
</span>
</div>
</div>
</header>
{/* 页面内容 */}
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</div>
</div>
)
}
```
### 路由守卫
```tsx
// components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
import { Loading } from '@/components/Loading'
interface ProtectedRouteProps {
children: React.ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { token, isLoading } = useAuthStore()
if (isLoading) {
return <Loading />
}
if (!token) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
```
## 🎛️ 状态管理 (Zustand)
### 认证状态管理
```typescript
// stores/authStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { authService } from '@/services/authService'
interface User {
id: string
username: string
email: string
role: string
}
interface AuthState {
user: User | null
token: string | null
isLoading: boolean
// Actions
login: (credentials: LoginCredentials) => Promise<void>
logout: () => void
checkAuth: () => Promise<void>
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isLoading: false,
login: async (credentials) => {
set({ isLoading: true })
try {
const response = await authService.login(credentials)
set({
user: response.user,
token: response.token,
isLoading: false,
})
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: () => {
set({ user: null, token: null })
// 清除持久化数据
localStorage.removeItem('auth-storage')
},
checkAuth: async () => {
const { token } = get()
if (!token) return
try {
const user = await authService.getCurrentUser()
set({ user })
} catch (error) {
// Token 无效,清除状态
get().logout()
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
token: state.token,
user: state.user
}),
}
)
)
```
## 🌐 API 服务层
### API 基础配置
```typescript
// services/api.ts
import axios, { AxiosError } from 'axios'
import { useAuthStore } from '@/stores/authStore'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8888/api/v1',
timeout: 10000,
})
// 请求拦截器
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// API 响应类型
export interface ApiResponse<T> {
code: number
msg: string
data: T
}
```
### 照片服务
```typescript
// services/photoService.ts
import { api, ApiResponse } from './api'
export interface Photo {
id: string
title: string
description?: string
filename: string
thumbnail: string
category_id?: string
user_id: string
created_at: string
updated_at: string
}
export interface PhotoListResponse {
photos: Photo[]
total: number
page: number
limit: number
}
export const photoService = {
// 获取照片列表
getPhotos: async (params: {
page?: number
limit?: number
category_id?: string
keyword?: string
}): Promise<PhotoListResponse> => {
const { data } = await api.get<ApiResponse<PhotoListResponse>>('/photos', {
params,
})
return data.data
},
// 上传照片
uploadPhoto: async (formData: FormData): Promise<Photo> => {
const { data } = await api.post<ApiResponse<Photo>>('/photos', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return data.data
},
// 更新照片
updatePhoto: async (id: string, updates: Partial<Photo>): Promise<void> => {
await api.put(`/photos/${id}`, updates)
},
// 删除照片
deletePhoto: async (id: string): Promise<void> => {
await api.delete(`/photos/${id}`)
},
// 获取照片详情
getPhoto: async (id: string): Promise<Photo> => {
const { data } = await api.get<ApiResponse<Photo>>(`/photos/${id}`)
return data.data
},
}
```
## 📱 页面组件
### 照片管理页面
```tsx
// pages/Photos.tsx
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Edit, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/hooks/use-toast'
import { photoService } from '@/services/photoService'
export function Photos() {
const [page, setPage] = useState(1)
const { toast } = useToast()
const queryClient = useQueryClient()
// 获取照片列表
const { data, isLoading, error } = useQuery({
queryKey: ['photos', page],
queryFn: () => photoService.getPhotos({ page, limit: 12 }),
})
// 删除照片
const deleteMutation = useMutation({
mutationFn: photoService.deletePhoto,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photos'] })
toast({
title: '成功',
description: '照片已删除',
})
},
onError: (error) => {
toast({
title: '错误',
description: '删除失败',
variant: 'destructive',
})
},
})
const handleDelete = (id: string) => {
if (confirm('确认删除这张照片吗?')) {
deleteMutation.mutate(id)
}
}
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">照片管理</h1>
<Button>
<Plus className="h-4 w-4 mr-2" />
上传照片
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{data?.photos.map((photo) => (
<Card key={photo.id} className="group">
<CardContent className="p-0">
<div className="relative aspect-square overflow-hidden rounded-t-lg">
<img
src={photo.thumbnail}
alt={photo.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex space-x-1">
<Button size="sm" variant="secondary">
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(photo.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div className="p-4">
<h3 className="font-semibold truncate">{photo.title}</h3>
{photo.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{photo.description}
</p>
)}
<div className="flex justify-between items-center mt-2">
<Badge variant="secondary">
{photo.category_id ? '已分类' : '未分类'}
</Badge>
<span className="text-xs text-gray-500">
{new Date(photo.created_at).toLocaleDateString()}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* 分页 */}
{data && data.total > 12 && (
<div className="flex justify-center space-x-2">
<Button
variant="outline"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
上一页
</Button>
<Button
variant="outline"
disabled={page * 12 >= data.total}
onClick={() => setPage(page + 1)}
>
下一页
</Button>
</div>
)}
</div>
)
}
```
## 🔧 开发工具配置
### Vite 配置
```typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})
```
### 环境变量
```bash
# .env.local
VITE_API_URL=http://localhost:8888/api/v1
VITE_APP_TITLE=摄影作品管理后台
```
参考 [TASK_PROGRESS.md](mdc:TASK_PROGRESS.md) 了解管理后台开发进度。
description:
globs:
alwaysApply: false
---