12 2 months ago
parent 54bd38a651
commit b9bdbe1262

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

@ -0,0 +1,39 @@
# Dependencies
node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Production
/dist
/build
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# TypeScript
*.tsbuildinfo

@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>校园食堂推荐</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

@ -0,0 +1,30 @@
{
"name": "campus-canteen-app",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"lucide-react": "^0.294.0",
"echarts": "^5.4.3",
"echarts-for-react": "^3.0.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

@ -0,0 +1,145 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { getToken, removeToken } from './api/request'
import { getCurrentUser } from './api'
// 页面组件
import SplashPage from './pages/Splash'
import LoginPage from './pages/Login'
import RegisterPage from './pages/Register'
import HomePage from './pages/Home'
import MenuPage from './pages/Menu'
import PersonalFilePage from './pages/PersonalFile'
import ReportPage from './pages/Report'
import ProfilePage from './pages/Profile'
import ProfileEditPage from './pages/ProfileEdit'
import MealDetailPage from './pages/MealDetail'
import SearchPage from './pages/Search'
import FavoritesPage from './pages/Favorites'
import MealRecordsPage from './pages/MealRecords'
import ReviewsPage from './pages/Reviews'
import SettingsPage from './pages/Settings'
import NotFoundPage from './pages/NotFound'
import AddDishPage from './pages/AddDish'
import MyDishesPage from './pages/MyDishes'
// 管理员页面
import AdminLoginPage from './pages/AdminLogin'
import AdminDashboardPage from './pages/AdminDashboard'
import AdminUsersPage from './pages/AdminUsers'
import AdminDishesPage from './pages/AdminDishes'
// Layout
import MainLayout from './components/Layout/MainLayout'
import AdminLayout from './components/Layout/AdminLayout'
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [isChecking, setIsChecking] = useState(true)
const [showSplash, setShowSplash] = useState(true)
useEffect(() => {
const timer = setTimeout(() => {
setShowSplash(false)
}, 2000)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
// 检查登录状态
checkLoginStatus()
}, [])
const checkLoginStatus = async () => {
try {
// 先检查本地是否有token
const token = getToken()
// 如果没有token直接设置为未登录状态
if (!token) {
setIsLoggedIn(false)
setIsChecking(false)
return
}
// 验证token是否有效
const response = await getCurrentUser()
setIsLoggedIn(response.success)
// 同步更新本地存储的用户状态
if (!response.success) {
removeToken()
}
} catch (error) {
console.log('当前未登录或token已过期使用游客模式')
// 清除可能存在的无效token
removeToken()
setIsLoggedIn(false)
} finally {
setIsChecking(false)
}
}
const handleLogin = () => {
setIsLoggedIn(true)
}
const handleLogout = () => {
removeToken()
// 清除可能存在的管理员状态
localStorage.removeItem('isAdmin')
setIsLoggedIn(false)
// 确保退出登录后立即跳转到登录页面使用window.location.href进行完全刷新和跳转
window.location.href = '/login'
}
if (showSplash || isChecking) {
return <SplashPage />
}
return (
<Router>
<Routes>
<Route path="/login" element={
isLoggedIn ? <Navigate to="/home" replace /> : <LoginPage onLogin={handleLogin} />
} />
<Route path="/register" element={<RegisterPage />} />
{/* 管理员登录(独立路由,不需要登录) */}
<Route path="/admin/login" element={<AdminLoginPage />} />
{/* 管理员后台使用独立的AdminLayout */}
<Route path="/admin" element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="dishes" element={<AdminDishesPage />} />
</Route>
{/* 需要登录的用户页面使用MainLayout */}
<Route path="/" element={
isLoggedIn ? <MainLayout onLogout={handleLogout} /> : <Navigate to="/login" replace />
}>
<Route index element={<Navigate to="/home" replace />} />
<Route path="home" element={<HomePage />} />
<Route path="menu" element={<MenuPage />} />
<Route path="personal-file" element={<PersonalFilePage />} />
<Route path="report" element={<ReportPage />} />
<Route path="profile" element={<ProfilePage onLogout={handleLogout} />} />
<Route path="profile/edit" element={<ProfileEditPage />} />
<Route path="meal/:id" element={<MealDetailPage />} />
<Route path="search" element={<SearchPage />} />
<Route path="favorites" element={<FavoritesPage />} />
<Route path="meal-records" element={<MealRecordsPage />} />
<Route path="reviews" element={<ReviewsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="add-dish" element={<AddDishPage />} />
<Route path="my-dishes" element={<MyDishesPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Router>
)
}
export default App

@ -0,0 +1,94 @@
import { get, post, put, del } from './request';
// ============= 待审核菜品管理 =============
// 获取待审核菜品列表
export async function getPendingDishes(status = 'pending') {
return get(`/admin/pending-dishes?status=${status}`);
}
// 审核通过菜品
export async function approveDish(id: number) {
return put(`/admin/dishes/${id}/approve`, {});
}
// 拒绝菜品
export async function rejectDish(id: number, reason: string) {
return put(`/admin/dishes/${id}/reject`, { reason });
}
// ============= 用户管理 =============
// 获取用户列表
export async function getUsers(params?: {
page?: number;
limit?: number;
keyword?: string;
}) {
const query = new URLSearchParams(params as Record<string, string>).toString();
return get(`/admin/users?${query}`);
}
// 更新用户信息
export async function updateUser(id: number, data: {
role?: string;
name?: string;
phone?: string;
email?: string;
}) {
return put(`/admin/users/${id}`, data);
}
// 重置用户密码
export async function resetUserPassword(id: number, newPassword: string) {
return put(`/admin/users/${id}/reset-password`, { newPassword });
}
// ============= 菜品管理 =============
// 获取所有菜品列表
export async function getAllDishes(params?: {
page?: number;
limit?: number;
keyword?: string;
approval_status?: string;
}) {
const query = new URLSearchParams(params as Record<string, string>).toString();
return get(`/admin/dishes?${query}`);
}
// 更新菜品信息
export async function updateDish(id: number, data: any) {
return put(`/admin/dishes/${id}`, data);
}
// 删除菜品
export async function deleteDish(id: number) {
return del(`/admin/dishes/${id}`);
}
// 创建菜品
export async function createDish(data: {
name: string;
description?: string;
category_id?: number | null;
price: number;
canteen_id?: number | null;
image?: string | null;
calories?: number | null;
protein?: number | null;
fat?: number | null;
carbs?: number | null;
tags?: string | null;
status?: string;
}) {
return post('/admin/dishes', data);
}
// ============= 统计信息 =============
// 获取管理员仪表板统计信息
export async function getDashboardStats() {
return get('/admin/dashboard/stats');
}

@ -0,0 +1,40 @@
import { get, post, setToken } from './request';
// 登录
export async function login(username: string, password: string) {
const res = await post('/auth/login', { username, password });
if (res.success && res.data.token) {
setToken(res.data.token);
}
return res;
}
// 注册
export async function register(userData: {
username: string;
password: string;
name: string;
phone: string;
email?: string;
// 健康档案信息
gender?: string;
height?: number | null;
weight?: number | null;
age?: number | null;
healthGoal?: string;
activityLevel?: string;
dietaryPreferences?: string[];
allergies?: string[];
}) {
const res = await post('/auth/register', userData);
if (res.success && res.data.token) {
setToken(res.data.token);
}
return res;
}
// 获取当前用户信息
export function getCurrentUser() {
return get('/auth/me');
}

@ -0,0 +1,13 @@
import { get } from './request';
// 获取轮播图
export function getBanners() {
return get('/banners');
}

@ -0,0 +1,18 @@
import { get } from './request';
// 获取所有食堂
export function getCanteens() {
return get('/canteens');
}
// 获取食堂详情
export function getCanteenDetail(id: number | string) {
return get(`/canteens/${id}`);
}

@ -0,0 +1,13 @@
import { get } from './request';
// 获取所有分类
export function getCategories() {
return get('/categories');
}

@ -0,0 +1,43 @@
import { get } from './request';
// 获取菜品列表
export function getDishes(params: {
category?: string;
canteen_id?: number;
search?: string;
limit?: number;
offset?: number;
} = {}) {
return get('/dishes', params);
}
// 获取推荐菜品(基于健康档案的个性化推荐)
export function getRecommendedDishes(limit: number = 6, category?: string, random?: boolean) {
return get('/dishes/recommended', {
limit,
category,
random: random ? 'true' : undefined
});
}
// 获取热销榜单
export function getHotDishes(limit: number = 5) {
return get('/dishes/hot', { limit });
}
// 获取菜品详情
export function getDishDetail(id: number | string) {
return get(`/dishes/${id}`);
}
// 搜索菜品
export function searchDishes(keyword: string) {
return get(`/dishes/search/${keyword}`);
}

@ -0,0 +1,21 @@
import { get, post, del } from './request';
// 获取收藏列表
export async function getFavorites() {
return get('/favorites');
}
// 添加收藏
export async function addFavorite(dishId: number) {
return post('/favorites', { dishId });
}
// 取消收藏
export async function removeFavorite(dishId: number) {
return del(`/favorites/${dishId}`);
}
// 检查是否已收藏
export async function checkFavorite(dishId: number) {
return get(`/favorites/check/${dishId}`);
}

@ -0,0 +1,81 @@
import { get, post, put } from './request';
// 获取健康档案
export function getHealthProfile() {
return get('/health/profile');
}
// 更新健康档案
export function updateHealthProfile(data: {
height?: number;
weight?: number;
age?: number;
gender?: string;
allergies?: string[];
chronic_diseases?: string[];
dietary_preferences?: string[];
target_calories?: number;
target_protein?: number;
target_fat?: number;
target_carbs?: number;
}) {
return put('/health/profile', data);
}
// 获取营养报告列表
export function getNutritionReports(params: {
start_date?: string;
end_date?: string;
limit?: number;
offset?: number;
} = {}) {
return get('/health/reports', params);
}
// 获取营养报告详情
export function getNutritionReportDetail(id: number | string) {
return get(`/health/reports/${id}`);
}
// 获取今日营养摄入
export function getTodayNutrition() {
return get('/health/today-nutrition');
}
// 记录用餐
export const recordMeal = (data: {
dish_id: number
quantity?: number
meal_time?: string
meal_type?: string
}) => {
return post('/health/meal-records', data)
}
// 获取用餐记录
export const getMealRecords = (params: {
start_date?: string
end_date?: string
limit?: number
offset?: number
} = {}) => {
return get('/health/meal-records', params)
}
// 获取周/月报告统计
export const getPeriodReport = (type: 'week' | 'month') => {
return get(`/health/reports/period/${type}`)
}
// 生成营养报告
export const generateReport = (data: {
report_date?: string
report_type?: 'daily' | 'weekly' | 'monthly'
}) => {
return post('/health/reports/generate', data)
}

@ -0,0 +1,18 @@
// API统一导出
export * from './auth';
export * from './dishes';
export * from './canteens';
export * from './categories';
export * from './banners';
export * from './favorites';
export * from './users';
export * from './health';
export * from './reviews';
export * from './request';

@ -0,0 +1,144 @@
// API请求基础配置
const API_BASE_URL = 'http://localhost:3000/api';
// Token管理
export const getToken = (): string | null => {
const token = localStorage.getItem('token');
// 如果没有token返回一个包含test_user的测试token用于开发测试
if (!token) {
return 'test_user_token_for_development';
}
return token;
};
export const setToken = (token: string): void => {
localStorage.setItem('token', token);
};
export const removeToken = (): void => {
localStorage.removeItem('token');
localStorage.removeItem('isAdmin');
};
// 获取是否为管理员
export const isAdmin = (): boolean => {
return localStorage.getItem('isAdmin') === 'true';
};
// 设置管理员状态
export const setAdminStatus = (admin: boolean): void => {
localStorage.setItem('isAdmin', String(admin));
};
// 通用请求方法
async function request<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
});
const data = await response.json();
// 处理401错误未授权- 不要自动跳转,让调用方决定如何处理
if (response.status === 401) {
// 只清除token不跳转
removeToken();
// 返回友好的错误信息,让页面可以优雅处理
return {
success: false,
message: '需要登录',
isAuthenticated: false
} as unknown as T;
}
if (!response.ok) {
throw new Error(data.message || '请求失败');
}
return data;
} catch (error) {
console.log('API请求错误但系统将继续运行:', error);
// 对于非关键请求,返回一个默认值而不是抛出错误
if (options.method === 'GET') {
return {
success: false,
message: '获取数据失败,显示默认内容',
data: []
} as unknown as T;
}
throw error;
}
}
// GET请求
export function get<T = any>(url: string, params: Record<string, any> = {}): Promise<T> {
const queryString = new URLSearchParams(
Object.entries(params).reduce((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value);
}
return acc;
}, {} as Record<string, string>)
).toString();
const fullUrl = queryString ? `${url}?${queryString}` : url;
return request<T>(fullUrl, {
method: 'GET',
});
}
// POST请求
export function post<T = any>(url: string, data: any = {}): Promise<T> {
return request<T>(url, {
method: 'POST',
body: JSON.stringify(data),
});
}
// PUT请求
export function put<T = any>(url: string, data: any = {}): Promise<T> {
return request<T>(url, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// DELETE请求
export function del<T = any>(url: string): Promise<T> {
return request<T>(url, {
method: 'DELETE',
});
}
export default {
get,
post,
put,
del,
setToken,
removeToken,
getToken,
};

@ -0,0 +1,26 @@
import { get, post } from './request';
// 获取菜品评论
export function getDishReviews(dish_id: number, params: {
limit?: number;
offset?: number;
} = {}) {
return get(`/reviews/dish/${dish_id}`, params);
}
// 创建评论
export function createReview(reviewData: {
dish_id: number;
rating: number;
comment?: string;
images?: string[];
}) {
return post('/reviews', reviewData);
}

@ -0,0 +1,40 @@
import { get, post, del } from './request';
// 用户上传菜品数据类型
export interface UserDishData {
name: string;
description?: string;
category?: string;
price?: number;
canteen_id?: number;
window_number?: string;
image_url?: string;
calories?: number;
protein?: number;
fat?: number;
carbs?: number;
ingredients?: string[] | string;
allergens?: string[] | string;
spicy_level?: number;
}
// 获取用户上传的菜品列表
export async function getUserDishes() {
return get('/user-dishes');
}
// 上传新菜品
export async function uploadDish(dishData: UserDishData) {
return post('/user-dishes', dishData);
}
// 获取单个上传菜品详情
export async function getUserDishDetail(id: number) {
return get(`/user-dishes/${id}`);
}
// 删除上传的菜品(仅限待审核状态)
export async function deleteUserDish(id: number) {
return del(`/user-dishes/${id}`);
}

@ -0,0 +1,33 @@
import { get, put } from './request';
// 获取用户资料
export function getUserProfile() {
return get('/users/profile');
}
// 更新用户资料
export function updateUserProfile(userData: {
nickname?: string;
avatar?: string;
phone?: string;
email?: string;
school?: string;
student_id?: string;
college?: string;
class_name?: string;
bio?: string;
}) {
return put('/users/profile', userData);
}
// 获取用户统计数据
export function getUserStats() {
return get('/users/stats');
}

@ -0,0 +1,54 @@
import { ReactNode } from 'react'
interface ButtonProps {
children: ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
fullWidth?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
className?: string
}
const Button = ({
children,
onClick,
variant = 'primary',
size = 'md',
fullWidth = false,
disabled = false,
type = 'button',
className = '',
}: ButtonProps) => {
const baseClasses = 'rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed'
const variantClasses = {
primary: 'bg-gradient-to-r from-primary to-pink-500 text-white hover:shadow-lg',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
outline: 'border-2 border-primary text-primary hover:bg-primary hover:text-white',
ghost: 'text-gray-700 hover:bg-gray-100',
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-6 py-2.5 text-base',
lg: 'px-8 py-3.5 text-lg',
}
const widthClass = fullWidth ? 'w-full' : ''
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${className}`}
>
{children}
</button>
)
}
export default Button

@ -0,0 +1,28 @@
import { ReactNode } from 'react'
import Button from './Button'
interface EmptyStateProps {
icon: ReactNode
title: string
description: string
actionText?: string
onAction?: () => void
}
const EmptyState = ({ icon, title, description, actionText, onAction }: EmptyStateProps) => {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="text-gray-300 mb-4">{icon}</div>
<h3 className="text-xl font-bold text-gray-800 mb-2">{title}</h3>
<p className="text-gray-500 mb-6">{description}</p>
{actionText && onAction && (
<Button onClick={onAction} variant="primary">
{actionText}
</Button>
)}
</div>
)
}
export default EmptyState

@ -0,0 +1,57 @@
import { ReactNode } from 'react'
interface InputProps {
label?: string
type?: string
value: string
onChange: (value: string) => void
placeholder?: string
icon?: ReactNode
error?: string
required?: boolean
disabled?: boolean
}
const Input = ({
label,
type = 'text',
value,
onChange,
placeholder,
icon,
error,
required,
disabled,
}: InputProps) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{icon}
</div>
)}
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-4 py-3 ${icon ? 'pl-11' : ''} border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all ${
error ? 'border-red-500' : 'border-gray-300'
} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
/>
</div>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
)
}
export default Input

@ -0,0 +1,99 @@
import { Heart, MapPin, Star } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
interface MealCardProps {
id: string
name: string
image: string
price: number
location: string
rating: number
calories: number
protein: number
isFavorite?: boolean
matchRate?: number
onToggleFavorite?: () => void
onMarkEaten?: () => void
}
const MealCard = ({
id,
name,
image,
price,
location,
rating,
calories,
protein,
isFavorite = false,
matchRate,
onToggleFavorite,
onMarkEaten,
}: MealCardProps) => {
const [favorite, setFavorite] = useState<boolean>(isFavorite)
const navigate = useNavigate()
const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setFavorite(!favorite)
onToggleFavorite?.()
}
const handleCardClick = () => {
navigate(`/meal/${id}`)
}
return (
<div
onClick={handleCardClick}
className="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
>
{/* 图片区域 */}
<div className="relative h-40">
<img src={image} alt={name} className="w-full h-full object-cover" />
{matchRate && (
<div className="absolute top-2 left-2 bg-gradient-to-r from-orange-500 to-pink-500 text-white px-3 py-1 rounded-full text-xs font-bold">
{matchRate}%
</div>
)}
<button
onClick={handleFavoriteClick}
className="absolute top-2 right-2 bg-white/90 p-2 rounded-full hover:bg-white transition-colors"
>
<Heart
size={18}
className={favorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}
/>
</button>
</div>
{/* 信息区域 */}
<div className="p-4">
<h3 className="font-bold text-lg mb-2 line-clamp-1">{name}</h3>
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
<MapPin size={14} />
<span className="line-clamp-1">{location}</span>
</div>
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center gap-1">
<Star size={14} className="fill-yellow-400 text-yellow-400" />
<span className="text-sm font-medium">{rating}</span>
</div>
<span className="text-primary font-bold text-lg">¥{price}</span>
</div>
{/* 营养信息 */}
<div className="flex gap-4 text-xs text-gray-600 pt-3 border-t">
<span>: {calories}</span>
<span>: {protein}g</span>
</div>
</div>
</div>
)
}
export default MealCard

@ -0,0 +1,200 @@
import { useNavigate, Outlet, useLocation } from 'react-router-dom'
import {
LayoutDashboard, Users, UtensilsCrossed,
LogOut, Menu, X
} from 'lucide-react'
import { useState } from 'react'
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const handleLogout = () => {
if (confirm('确定要退出管理员后台吗?')) {
localStorage.removeItem('isAdmin')
localStorage.removeItem('token')
// 使用window.location.href进行完全刷新和跳转确保直接返回管理员登录界面
window.location.href = '/admin/login'
}
}
const menuItems = [
{
icon: LayoutDashboard,
label: '仪表板',
path: '/admin/dashboard',
description: '系统概览与统计'
},
{
icon: UtensilsCrossed,
label: '菜品管理',
path: '/admin/dishes',
description: '管理所有菜品'
},
{
icon: Users,
label: '用户管理',
path: '/admin/users',
description: '管理用户账号'
},
]
const isActive = (path: string) => location.pathname === path
return (
<div className="min-h-screen bg-gray-100 flex">
{/* 侧边栏 - 桌面版 */}
<aside className="hidden lg:flex lg:flex-col lg:w-64 bg-white border-r border-gray-200">
{/* Logo */}
<div className="h-16 flex items-center justify-center border-b border-gray-200 bg-gradient-to-r from-primary to-primary/80">
<div className="flex items-center gap-2 text-white">
<LayoutDashboard className="w-8 h-8" />
<div>
<h1 className="font-bold text-lg"></h1>
<p className="text-xs opacity-90">Campus Canteen Admin</p>
</div>
</div>
</div>
{/* 菜单 */}
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{menuItems.map((item) => (
<button
key={item.path}
onClick={() => {
navigate(item.path)
setSidebarOpen(false)
}}
className={`w-full flex items-start gap-3 p-3 rounded-lg transition-all ${
isActive(item.path)
? 'bg-primary text-white shadow-lg'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<item.icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${
isActive(item.path) ? 'text-white' : 'text-gray-500'
}`} />
<div className="flex-1 text-left">
<p className="font-medium">{item.label}</p>
<p className={`text-xs mt-0.5 ${
isActive(item.path) ? 'text-white/80' : 'text-gray-500'
}`}>
{item.description}
</p>
</div>
</button>
))}
</nav>
{/* 底部退出按钮 */}
<div className="p-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 p-3 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">退</span>
</button>
</div>
</aside>
{/* 移动端侧边栏遮罩 */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 移动端侧边栏 */}
<aside
className={`fixed inset-y-0 left-0 w-64 bg-white border-r border-gray-200 transform transition-transform duration-300 z-50 lg:hidden ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200 bg-gradient-to-r from-primary to-primary/80">
<div className="flex items-center gap-2 text-white">
<LayoutDashboard className="w-6 h-6" />
<div>
<h1 className="font-bold"></h1>
<p className="text-xs opacity-90">Admin Panel</p>
</div>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="p-1 text-white hover:bg-white/20 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 菜单 */}
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{menuItems.map((item) => (
<button
key={item.path}
onClick={() => {
navigate(item.path)
setSidebarOpen(false)
}}
className={`w-full flex items-start gap-3 p-3 rounded-lg transition-all ${
isActive(item.path)
? 'bg-primary text-white shadow-lg'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<item.icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${
isActive(item.path) ? 'text-white' : 'text-gray-500'
}`} />
<div className="flex-1 text-left">
<p className="font-medium">{item.label}</p>
<p className={`text-xs mt-0.5 ${
isActive(item.path) ? 'text-white/80' : 'text-gray-500'
}`}>
{item.description}
</p>
</div>
</button>
))}
</nav>
{/* 退出按钮 */}
<div className="p-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 p-3 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">退</span>
</button>
</div>
</aside>
{/* 主内容区 */}
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部导航栏 - 移动端 */}
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<Menu className="w-6 h-6 text-gray-700" />
</button>
<div className="flex items-center gap-2 text-primary">
<LayoutDashboard className="w-6 h-6" />
<span className="font-bold"></span>
</div>
<div className="w-10" /> {/* 占位 */}
</header>
{/* 页面内容 */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
)
}

@ -0,0 +1,57 @@
import { Outlet, useLocation, Link } from 'react-router-dom'
import { Home, Book, FileText, User, ClipboardList } from 'lucide-react'
import { LucideIcon } from 'lucide-react'
interface MainLayoutProps {
onLogout: () => void
}
interface NavItem {
path: string
icon: LucideIcon
label: string
}
const MainLayout = ({ onLogout }: MainLayoutProps) => {
const location = useLocation()
const navItems: NavItem[] = [
{ path: '/home', icon: Home, label: '首页' },
{ path: '/personal-file', icon: ClipboardList, label: '个人档案' },
{ path: '/report', icon: FileText, label: '报告' },
]
const isActive = (path: string) => location.pathname === path
return (
<div className="min-h-screen flex justify-center items-center bg-gradient-to-br from-purple-100 to-blue-100 p-4">
{/* 手机容器 - 严格9:20比例 (9/20 = 0.45, 所以宽度×20/9=高度) */}
<div className="relative w-[90vmin] max-w-[414px] bg-white shadow-2xl rounded-3xl overflow-hidden flex flex-col"
style={{ height: 'calc(90vmin * 20 / 9)', maxHeight: '920px' }}>
{/* 主内容区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<Outlet />
</div>
{/* 底部导航栏 - 固定在手机容器内底部5个导航项 */}
<nav className="bg-white border-t border-gray-200 px-3 py-3 flex justify-around items-center shadow-lg shrink-0">
{navItems.map(({ path, icon: Icon, label }) => (
<Link
key={path}
to={path}
className={`flex flex-col items-center gap-1 transition-colors ${
isActive(path) ? 'text-primary' : 'text-gray-500'
}`}
>
<Icon size={22} strokeWidth={isActive(path) ? 2.5 : 2} />
<span className="text-[10px] font-medium">{label}</span>
</Link>
))}
</nav>
</div>
</div>
)
}
export default MainLayout

@ -0,0 +1,75 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f5f5f5;
overflow-x: hidden;
}
#root {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* 动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

@ -0,0 +1,478 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Upload, Plus, X } from 'lucide-react'
import { uploadDish } from '../api/userDishes'
export default function AddDishPage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
// 表单数据
const [formData, setFormData] = useState({
name: '',
description: '',
category: '',
price: '',
window_number: '',
image_url: '',
calories: '',
protein: '',
fat: '',
carbs: '',
spicy_level: '0',
tags: '',
})
const [ingredients, setIngredients] = useState<string[]>([])
const [newIngredient, setNewIngredient] = useState('')
const [allergens, setAllergens] = useState<string[]>([])
const [newAllergen, setNewAllergen] = useState('')
// 更新表单字段
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
// 添加原料
const addIngredient = () => {
if (newIngredient.trim()) {
setIngredients([...ingredients, newIngredient.trim()])
setNewIngredient('')
}
}
// 删除原料
const removeIngredient = (index: number) => {
setIngredients(ingredients.filter((_, i) => i !== index))
}
// 添加过敏原
const addAllergen = () => {
if (newAllergen.trim()) {
setAllergens([...allergens, newAllergen.trim()])
setNewAllergen('')
}
}
// 删除过敏原
const removeAllergen = (index: number) => {
setAllergens(allergens.filter((_, i) => i !== index))
}
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name.trim()) {
alert('请输入菜品名称')
return
}
try {
setLoading(true)
const dishData = {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
category: formData.category.trim() || undefined,
price: formData.price ? parseFloat(formData.price) : undefined,
window_number: formData.window_number.trim() || undefined,
image_url: formData.image_url.trim() || undefined,
calories: formData.calories ? parseInt(formData.calories) : undefined,
protein: formData.protein ? parseFloat(formData.protein) : undefined,
fat: formData.fat ? parseFloat(formData.fat) : undefined,
carbs: formData.carbs ? parseFloat(formData.carbs) : undefined,
spicy_level: parseInt(formData.spicy_level),
ingredients: ingredients.length > 0 ? ingredients : undefined,
allergens: allergens.length > 0 ? allergens : undefined,
}
const response = await uploadDish(dishData)
if (response.success) {
alert('菜品已提交,等待管理员审核!')
navigate('/profile')
} else {
alert('提交失败:' + response.message)
}
} catch (error) {
console.error('提交菜品失败:', error)
alert('提交失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* 顶部导航栏 */}
<div className="sticky top-0 z-10 bg-white border-b px-4 py-3 flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-bold flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
</h1>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* 基本信息 */}
<div className="bg-white rounded-xl p-4 space-y-4">
<h2 className="font-bold text-base text-gray-800 mb-3"></h2>
{/* 菜品名称 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="请输入菜品名称"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
required
/>
</div>
{/* 菜品描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="请描述菜品的特色、口感等"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
/>
</div>
{/* 分类和价格 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
name="category"
value={formData.category}
onChange={handleChange}
placeholder="如:主食、汤类"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
name="price"
value={formData.price}
onChange={handleChange}
placeholder="0.00"
step="0.01"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
{/* 窗口号和辣度 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
name="window_number"
value={formData.window_number}
onChange={handleChange}
placeholder="如A1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
name="spicy_level"
value={formData.spicy_level}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="4"></option>
</select>
</div>
</div>
{/* 图片URL */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="url"
name="image_url"
value={formData.image_url}
onChange={handleChange}
placeholder="请输入菜品图片链接"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
{formData.image_url && (
<div className="mt-2">
<img
src={formData.image_url}
alt="预览"
className="w-32 h-32 object-cover rounded-lg"
onError={(e) => {
e.currentTarget.src = 'https://picsum.photos/128'
}}
/>
</div>
)}
</div>
{/* 健康标签 */}
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{/* 预设的健康标签 */}
{['减脂', '增肌', '高蛋白', '低碳水', '低脂肪', '低热量', '均衡营养', '维生素丰富', '膳食纤维', '健身推荐'].map(tag => (
<button
key={tag}
type="button"
onClick={() => {
const currentTags = formData.tags?.split(',') || []
if (currentTags.includes(tag)) {
// 移除标签
setFormData({
...formData,
tags: currentTags.filter(t => t !== tag).join(',')
})
} else {
// 添加标签
setFormData({
...formData,
tags: [...(formData.tags?.split(',') || []), tag].join(',')
})
}
}}
className={`px-3 py-1 rounded-full text-sm ${(
formData.tags?.split(',').includes(tag)
) ? 'bg-primary text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
{tag}
</button>
))}
</div>
<input
type="text"
name="tags"
value={formData.tags}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="自定义标签,多个标签用逗号分隔"
/>
</div>
</div>
</div>
{/* 营养信息 */}
<div className="bg-white rounded-xl p-4 space-y-4">
<h2 className="font-bold text-base text-gray-800 mb-3"></h2>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
name="calories"
value={formData.calories}
onChange={handleChange}
placeholder="0"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
name="protein"
value={formData.protein}
onChange={handleChange}
placeholder="0.0"
step="0.1"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
name="fat"
value={formData.fat}
onChange={handleChange}
placeholder="0.0"
step="0.1"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
name="carbs"
value={formData.carbs}
onChange={handleChange}
placeholder="0.0"
step="0.1"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
</div>
{/* 原料成分 */}
<div className="bg-white rounded-xl p-4 space-y-3">
<h2 className="font-bold text-base text-gray-800"></h2>
<div className="flex gap-2">
<input
type="text"
value={newIngredient}
onChange={(e) => setNewIngredient(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addIngredient())}
placeholder="输入原料,如:鸡蛋"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
type="button"
onClick={addIngredient}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-1"
>
<Plus className="w-4 h-4" />
</button>
</div>
{ingredients.length > 0 && (
<div className="flex flex-wrap gap-2">
{ingredients.map((ingredient, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm"
>
{ingredient}
<button
type="button"
onClick={() => removeIngredient(index)}
className="hover:bg-blue-100 rounded-full p-0.5 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* 过敏原信息 */}
<div className="bg-white rounded-xl p-4 space-y-3">
<h2 className="font-bold text-base text-gray-800"></h2>
<div className="flex gap-2">
<input
type="text"
value={newAllergen}
onChange={(e) => setNewAllergen(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addAllergen())}
placeholder="输入过敏原,如:花生"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
type="button"
onClick={addAllergen}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-1"
>
<Plus className="w-4 h-4" />
</button>
</div>
{allergens.length > 0 && (
<div className="flex flex-wrap gap-2">
{allergens.map((allergen, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-3 py-1 bg-red-50 text-red-600 rounded-full text-sm"
>
{allergen}
<button
type="button"
onClick={() => removeAllergen(index)}
className="hover:bg-red-100 rounded-full p-0.5 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* 提交按钮 */}
<div className="sticky bottom-0 bg-white border-t px-4 py-3">
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-primary text-white font-medium rounded-xl hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '提交中...' : '提交审核'}
</button>
<p className="text-xs text-gray-500 text-center mt-2">
</p>
</div>
</form>
</div>
)
}

@ -0,0 +1,138 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
LayoutDashboard, Users, UtensilsCrossed,
TrendingUp
} from 'lucide-react'
import { getDashboardStats } from '../api/admin'
interface Stats {
userCount: number
dishCount: number
todayNewUsers: number
}
export default function AdminDashboard() {
const navigate = useNavigate()
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// 检查管理员权限
if (localStorage.getItem('isAdmin') !== 'true') {
navigate('/admin/login')
return
}
loadData()
}, [navigate])
const loadData = async () => {
try {
setLoading(true)
// 加载统计信息
const statsResponse = await getDashboardStats()
if (statsResponse.success) {
setStats(statsResponse.data)
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-gray-500">...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6 space-y-6">
{/* 页面标题 */}
<div className="flex items-center gap-3">
<LayoutDashboard className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-800 mt-1">
{stats?.userCount || 0}
</p>
</div>
<Users className="w-10 h-10 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-800 mt-1">
{stats?.dishCount || 0}
</p>
</div>
<UtensilsCrossed className="w-10 h-10 text-green-500" />
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-800 mt-1">
{stats?.todayNewUsers || 0}
</p>
</div>
<TrendingUp className="w-10 h-10 text-purple-500" />
</div>
</div>
</div>
{/* 快捷操作 */}
<div className="bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-4"></h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<button
onClick={() => navigate('/admin/users')}
className="p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors"
>
<Users className="w-6 h-6 mx-auto text-primary mb-2" />
<p className="text-sm font-medium"></p>
</button>
<button
onClick={() => navigate('/admin/dishes')}
className="p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors"
>
<UtensilsCrossed className="w-6 h-6 mx-auto text-primary mb-2" />
<p className="text-sm font-medium"></p>
</button>
<button
onClick={() => navigate('/home')}
className="p-4 border border-gray-200 rounded-lg hover:border-primary hover:bg-primary/5 transition-colors"
>
<LayoutDashboard className="w-6 h-6 mx-auto text-primary mb-2" />
<p className="text-sm font-medium"></p>
</button>
</div>
</div>
</div>
</div>
)
}

@ -0,0 +1,749 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
UtensilsCrossed, Search, Edit,
Trash2, X, Plus
} from 'lucide-react'
import { getAllDishes, updateDish, deleteDish, createDish } from '../api/admin'
// 数字标签映射规则
const tagMap: Record<string, string> = {
'1': '减脂',
'2': '增肌',
'3': '高蛋白',
'4': '低碳水',
'5': '低脂肪',
'6': '低热量',
'7': '均衡营养',
'8': '维生素丰富',
'9': '膳食纤维',
'10': '健身推荐'
}
interface Dish {
id: number
name: string
price: number
original_price: number
image: string
rating: number
sales_count: number
category_id: number
canteen_id: number
canteen_name: string
status: string
approval_status: string
created_at: string
}
export default function AdminDishesPage() {
const navigate = useNavigate()
const [dishes, setDishes] = useState<Dish[]>([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [showEditModal, setShowEditModal] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const [currentDish, setCurrentDish] = useState<Dish | null>(null)
const [editForm, setEditForm] = useState({
name: '',
price: '',
original_price: '',
status: 'available'
})
const [addForm, setAddForm] = useState({
name: '',
description: '',
price: '',
category_id: null,
canteen_id: null,
image: '',
calories: null,
protein: null,
fat: null,
carbs: null,
tags: '',
status: 'available'
})
const [addLoading, setAddLoading] = useState(false)
const limit = 20
useEffect(() => {
loadDishes()
}, [page, keyword, navigate])
const loadDishes = async () => {
try {
setLoading(true)
const response = await getAllDishes({
page,
limit,
keyword
})
// 确保从response.data.dishes中获取菜品数组
const dishesData = response?.data?.dishes || []
setDishes(Array.isArray(dishesData) ? dishesData : [])
setTotal(response?.data?.total || 0)
} catch (error) {
console.error('加载菜品失败:', error)
// 出错时确保dishes是一个空数组
setDishes([])
} finally {
setLoading(false)
}
}
const handleSearch = () => {
setPage(1)
loadDishes()
}
const handleEdit = (dish: Dish) => {
setCurrentDish(dish)
setEditForm({
name: dish.name,
price: dish.price.toString(),
original_price: dish.original_price?.toString() || '',
status: dish.status
})
setShowEditModal(true)
}
const handleSaveEdit = async () => {
if (!currentDish) return
try {
await updateDish(currentDish.id, {
name: editForm.name,
price: Number(editForm.price),
original_price: editForm.original_price ? Number(editForm.original_price) : null,
status: editForm.status
})
setShowEditModal(false)
loadDishes()
} catch (error) {
console.error('更新菜品失败:', error)
}
}
const handleDelete = async (dish: Dish) => {
if (window.confirm(`确定要删除菜品「${dish.name}」吗?`)) {
try {
await deleteDish(dish.id)
loadDishes()
} catch (error) {
console.error('删除菜品失败:', error)
}
}
}
const handleAddFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target
setAddForm(prev => ({
...prev,
[name]: name === 'tags' ? value : value === '' ? null : value
}))
}
const handleCreateDish = async () => {
setAddLoading(true)
try {
// 表单验证
if (!addForm.name || !addForm.price || !addForm.category_id) {
alert('菜品名称、价格和分类ID为必填项')
return
}
if (isNaN(parseFloat(addForm.price)) || parseFloat(addForm.price) < 0) {
alert('请输入有效的价格')
return
}
if (isNaN(parseInt(addForm.category_id)) || parseInt(addForm.category_id) < 0) {
alert('请输入有效的分类ID')
return
}
if (addForm.canteen_id && (isNaN(parseInt(addForm.canteen_id)) || parseInt(addForm.canteen_id) < 0)) {
alert('请输入有效的食堂ID')
return
}
// 将表单数据转换为正确的类型
const dishData = {
name: addForm.name.trim(),
description: addForm.description?.trim() || null,
price: parseFloat(addForm.price),
category_id: parseInt(addForm.category_id),
canteen_id: addForm.canteen_id ? parseInt(addForm.canteen_id) : null,
image: addForm.image?.trim() || null,
calories: addForm.calories ? parseInt(addForm.calories) : null,
protein: addForm.protein ? parseFloat(addForm.protein) : null,
fat: addForm.fat ? parseFloat(addForm.fat) : null,
carbs: addForm.carbs ? parseFloat(addForm.carbs) : null,
tags: addForm.tags?.trim() || null,
status: addForm.status || 'available'
}
console.log('提交的菜品数据:', dishData)
await createDish(dishData)
alert('菜品添加成功')
setShowAddModal(false)
loadDishes()
// 重置表单
setAddForm({
name: '',
description: '',
price: '',
category_id: null,
canteen_id: null,
image: '',
calories: null,
protein: null,
fat: null,
carbs: null,
tags: '',
status: 'available'
})
} catch (error: any) {
console.error('创建菜品失败:', error)
// 显示更友好的错误消息
alert(`添加菜品失败: ${error.message || '请稍后重试'}`)
} finally {
setAddLoading(false)
}
}
const totalPages = Math.ceil(total / limit)
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6 space-y-6">
{/* 页面标题 */}
<div className="flex items-center gap-3">
<UtensilsCrossed className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
{/* 搜索和添加按钮 */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="搜索菜品名称"
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleSearch}
className="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
</button>
<button
onClick={() => setShowAddModal(true)}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<p className="text-sm text-gray-600"> {total} </p>
</div>
{/* 菜品列表 */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b bg-gray-50">
<h2 className="font-bold text-gray-800"></h2>
</div>
{loading ? (
<div className="text-center py-20">
<div className="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-gray-500">...</p>
</div>
) : !Array.isArray(dishes) || dishes.length === 0 ? (
<div className="text-center py-20 text-gray-500">
{keyword ? '未找到匹配的菜品' : '暂无菜品'}
</div>
) : (
<div className="divide-y">
{dishes.map((dish) => (
<div
key={dish.id}
className="p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex gap-4">
{/* 菜品图片 */}
<div className="flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden bg-gray-100">
<img
src={dish.image || 'https://picsum.photos/100'}
alt={dish.name}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.src = 'https://picsum.photos/100'
}}
/>
</div>
{/* 菜品信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold text-gray-800 text-lg mb-1">{dish.name}</h3>
{/* 价格信息 */}
<div className="flex items-center gap-3 text-sm text-gray-600 mb-2">
<span className="text-primary font-bold text-lg">¥{Number(dish.price).toFixed(2)}</span>
{dish.original_price && Number(dish.original_price) > Number(dish.price) && (
<span className="text-gray-400 line-through">¥{Number(dish.original_price).toFixed(2)}</span>
)}
</div>
{/* 评分和食堂信息 */}
<div className="flex items-center gap-4 text-sm text-gray-500">
<span> {dish.rating ? Number(dish.rating).toFixed(1) : '5.0'}</span>
{dish.canteen_name && <span>📍 {dish.canteen_name}</span>}
</div>
{/* 状态标签 */}
<div className="flex items-center gap-2 mt-2">
<span className={`inline-block px-2 py-1 text-xs rounded-full ${
dish.status === 'available'
? 'bg-green-100 text-green-600'
: 'bg-red-100 text-red-600'
}`}>
{dish.status === 'available' ? '在售' : '售罄'}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 ml-4">
<button
onClick={() => handleEdit(dish)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="编辑"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(dish)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="px-4 py-3 border-t bg-gray-50 flex items-center justify-between">
<p className="text-sm text-gray-600">
{page} / {totalPages} {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
</div>
</div>
{/* 添加菜品模态框 */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold"></h2>
<button
onClick={() => setShowAddModal(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* 基本信息 */}
<div className="space-y-3">
<h3 className="text-lg font-medium"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm font-medium"> <span className="text-red-500">*</span></label>
<input
type="text"
name="name"
value={addForm.name}
onChange={handleAddFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入菜品名称"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium"> <span className="text-red-500">*</span></label>
<input
type="number"
name="price"
value={addForm.price}
onChange={handleAddFormChange}
min="0"
step="0.01"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入价格"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium">ID</label>
<input
type="number"
name="category_id"
value={addForm.category_id || ''}
onChange={handleAddFormChange}
min="1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入分类ID"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium">ID</label>
<input
type="number"
name="canteen_id"
value={addForm.canteen_id || ''}
onChange={handleAddFormChange}
min="1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入食堂ID"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="block text-sm font-medium"></label>
<textarea
name="description"
value={addForm.description}
onChange={handleAddFormChange}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入菜品描述"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="block text-sm font-medium">URL</label>
<input
type="text"
name="image"
value={addForm.image}
onChange={handleAddFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入图片URL"
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="block text-sm font-medium"></label>
<div className="space-y-2">
<div className="text-xs text-gray-500 mb-2">
1()2()3()4()5()6()7()8()9()10()
</div>
<div className="flex flex-wrap gap-2">
{/* 数字标签选择 */}
{Object.entries(tagMap).map(([num, tag]) => {
// 获取当前已选的数字标签
const currentNumTags = addForm.tags?.split(',').filter(t => tagMap[t]) || [];
const isSelected = currentNumTags.includes(num);
return (
<button
key={num}
type="button"
onClick={() => {
const currentNumTags = addForm.tags?.split(',').filter(t => tagMap[t]) || [];
let newNumTags;
if (isSelected) {
// 移除标签
newNumTags = currentNumTags.filter(t => t !== num);
} else {
// 添加标签
newNumTags = [...currentNumTags, num];
}
// 更新表单
setAddForm(prev => ({
...prev,
tags: newNumTags.join(',')
}))
}}
className={`px-3 py-1 rounded-full text-sm ${(
isSelected
) ? 'bg-primary text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
{num}: {tag}
</button>
);
})}
</div>
<input
type="text"
name="tags"
value={addForm.tags}
onChange={(e) => {
// 只允许输入数字和逗号
const value = e.target.value.replace(/[^0-9,]/g, '');
// 确保数字在1-10范围内
const validNums = value.split(',').filter(num =>
num && parseInt(num) >= 1 && parseInt(num) <= 10
).join(',');
setAddForm(prev => ({
...prev,
tags: validNums
}));
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="输入数字1-10多个数字用逗号分隔"
/>
{/* 显示当前选择的标签对应的中文名称 */}
{addForm.tags && (
<div className="text-sm text-gray-600">
<span className="font-medium"></span>
{addForm.tags.split(',').map(num => tagMap[num]).filter(Boolean).join('、')}
</div>
)}
</div>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium"></label>
<select
name="status"
value={addForm.status}
onChange={handleAddFormChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="available"></option>
<option value="sold_out"></option>
</select>
</div>
</div>
</div>
{/* 营养信息 */}
<div className="space-y-3 pt-2">
<h3 className="text-lg font-medium"></h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm font-medium"> ()</label>
<input
type="number"
name="calories"
value={addForm.calories || ''}
onChange={handleAddFormChange}
min="0"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入热量"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium"> (g)</label>
<input
type="number"
name="protein"
value={addForm.protein || ''}
onChange={handleAddFormChange}
min="0"
step="0.1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入蛋白质"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium"> (g)</label>
<input
type="number"
name="fat"
value={addForm.fat || ''}
onChange={handleAddFormChange}
min="0"
step="0.1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入脂肪"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium"> (g)</label>
<input
type="number"
name="carbs"
value={addForm.carbs || ''}
onChange={handleAddFormChange}
min="0"
step="0.1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="请输入碳水化合物"
/>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowAddModal(false)}
className="flex-1 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleCreateDish}
disabled={addLoading}
className="flex-1 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-70"
>
{addLoading ? '添加中...' : '添加'}
</button>
</div>
</div>
</div>
</div>
)}
{/* 编辑菜品模态框 */}
{showEditModal && currentDish && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold"></h2>
<button
onClick={() => setShowEditModal(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
step="0.01"
value={editForm.price}
onChange={(e) => setEditForm({ ...editForm, price: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
step="0.01"
value={editForm.original_price}
onChange={(e) => setEditForm({ ...editForm, original_price: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={editForm.status}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="available"></option>
<option value="sold_out"></option>
</select>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowEditModal(false)}
className="flex-1 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleSaveEdit}
className="flex-1 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}

@ -0,0 +1,106 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Shield } from 'lucide-react'
import { login } from '../api/auth'
export default function AdminLoginPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!username || !password) {
alert('请输入用户名和密码')
return
}
try {
setLoading(true)
const response = await login(username, password)
if (response.success && response.data?.user?.role === 'admin') {
// 保存管理员token
localStorage.setItem('isAdmin', 'true')
alert('登录成功!')
navigate('/admin/dashboard')
} else if (response.success) {
alert('此账号不是管理员账号')
} else {
alert('登录失败:' + response.message)
}
} catch (error) {
console.error('登录失败:', error)
alert('登录失败,请重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-full mb-4">
<Shield className="w-8 h-8 text-primary" />
</div>
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-gray-500 mt-2">使</p>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入管理员用户名"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
{/* 返回普通登录 */}
<button
onClick={() => navigate('/login')}
className="w-full mt-4 py-2 text-gray-600 hover:text-primary transition-colors"
>
</button>
</div>
</div>
</div>
)
}

@ -0,0 +1,481 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
FileCheck, AlertCircle, CheckCircle, XCircle, Eye, X
} from 'lucide-react'
import { getPendingDishes, approveDish, rejectDish } from '../api/admin'
interface PendingDish {
id: number
user_id: number
name: string
description: string
category: string
price: number
window_number: string
image_url: string
calories: number
protein: number
fat: number
carbs: number
ingredients: string
allergens: string
spicy_level: number
status: string
reject_reason: string
created_at: string
username: string
user_name: string
}
export default function AdminPendingDishesPage() {
const navigate = useNavigate()
const [dishes, setDishes] = useState<PendingDish[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'pending' | 'approved' | 'rejected'>('pending')
const [showDetailModal, setShowDetailModal] = useState(false)
const [currentDish, setCurrentDish] = useState<PendingDish | null>(null)
useEffect(() => {
// 检查管理员权限
if (localStorage.getItem('isAdmin') !== 'true') {
navigate('/admin/login')
return
}
loadDishes()
}, [filter, navigate])
const loadDishes = async () => {
try {
setLoading(true)
const response = await getPendingDishes(filter)
if (response.success) {
setDishes(response.data)
} else {
alert('加载待审核菜品失败:' + response.message)
}
} catch (error) {
console.error('加载待审核菜品失败:', error)
alert('加载待审核菜品失败,请重试')
} finally {
setLoading(false)
}
}
const handleApprove = async (dish: PendingDish) => {
if (!confirm(`确定要通过菜品"${dish.name}"吗?`)) {
return
}
try {
const response = await approveDish(dish.id)
if (response.success) {
alert('审核通过!菜品已添加到数据库')
loadDishes()
} else {
alert('操作失败:' + response.message)
}
} catch (error) {
console.error('审核失败:', error)
alert('操作失败,请重试')
}
}
const handleReject = async (dish: PendingDish) => {
const reason = prompt('请输入拒绝原因:', '不符合菜品上传规范')
if (!reason) {
return
}
try {
const response = await rejectDish(dish.id, reason)
if (response.success) {
alert('已拒绝该菜品')
loadDishes()
} else {
alert('操作失败:' + response.message)
}
} catch (error) {
console.error('拒绝失败:', error)
alert('操作失败,请重试')
}
}
const handleViewDetail = (dish: PendingDish) => {
setCurrentDish(dish)
setShowDetailModal(true)
}
const parseJsonField = (field: string) => {
try {
return JSON.parse(field)
} catch {
return []
}
}
const spicyLevelText = ['不辣', '微辣', '中辣', '重辣', '特辣']
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6 space-y-6">
{/* 页面标题 */}
<div className="flex items-center gap-3">
<FileCheck className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
{/* 筛选器 */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex gap-2">
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'pending'
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
</button>
<button
onClick={() => setFilter('approved')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'approved'
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
</button>
<button
onClick={() => setFilter('rejected')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'rejected'
? 'bg-red-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
</button>
</div>
</div>
{/* 统计卡片 */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-orange-600 mb-2">
<AlertCircle className="w-5 h-5" />
<span className="font-bold">
{filter === 'pending' && `${dishes.length} 个菜品等待审核`}
{filter === 'approved' && `${dishes.length} 个菜品已通过审核`}
{filter === 'rejected' && `${dishes.length} 个菜品已被拒绝`}
</span>
</div>
</div>
{/* 菜品列表 */}
<div className="space-y-3">
{loading ? (
<div className="text-center py-20 bg-white rounded-xl">
<div className="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-gray-500">...</p>
</div>
) : dishes.length === 0 ? (
<div className="text-center py-20 bg-white rounded-xl">
<FileCheck className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">
{filter === 'pending' && '暂无待审核菜品'}
{filter === 'approved' && '暂无已通过的菜品'}
{filter === 'rejected' && '暂无已拒绝的菜品'}
</p>
</div>
) : (
dishes.map((dish) => (
<div
key={dish.id}
className="bg-white rounded-xl shadow-sm overflow-hidden"
>
<div className="p-4">
<div className="flex gap-4">
{/* 菜品图片 */}
<div className="flex-shrink-0 w-32 h-32 rounded-lg overflow-hidden bg-gray-100">
<img
src={dish.image_url || 'https://picsum.photos/150'}
alt={dish.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = 'https://picsum.photos/150'
}}
/>
</div>
{/* 菜品信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="font-bold text-lg text-gray-800 mb-1">{dish.name}</h3>
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{dish.description || '暂无描述'}
</p>
</div>
{/* 状态标签 */}
<span className={`ml-3 px-3 py-1 text-sm rounded-full whitespace-nowrap ${
dish.status === 'pending'
? 'bg-orange-100 text-orange-600'
: dish.status === 'approved'
? 'bg-green-100 text-green-600'
: 'bg-red-100 text-red-600'
}`}>
{dish.status === 'pending' && '待审核'}
{dish.status === 'approved' && '已通过'}
{dish.status === 'rejected' && '已拒绝'}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-2">
<div>👤 : {dish.user_name || dish.username}</div>
<div>🏷 : {dish.category || '未分类'}</div>
{dish.price && <div>💰 : ¥{Number(dish.price).toFixed(2)}</div>}
{dish.window_number && <div>🪟 : {dish.window_number}</div>}
{dish.spicy_level !== undefined && (
<div>🌶 : {spicyLevelText[dish.spicy_level] || '未知'}</div>
)}
</div>
{/* 营养信息 */}
{(dish.calories || dish.protein || dish.fat || dish.carbs) && (
<div className="flex items-center gap-3 text-xs text-gray-500 mb-2">
{dish.calories && <span>🔥 {dish.calories}</span>}
{dish.protein && <span> {dish.protein}g</span>}
{dish.fat && <span> {dish.fat}g</span>}
{dish.carbs && <span> {dish.carbs}g</span>}
</div>
)}
{/* 拒绝原因 */}
{dish.status === 'rejected' && dish.reject_reason && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">
<span className="font-medium"></span>
{dish.reject_reason}
</p>
</div>
)}
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<p className="text-xs text-gray-400">
{new Date(dish.created_at).toLocaleString('zh-CN')}
</p>
{/* 操作按钮 */}
<div className="flex gap-2">
<button
onClick={() => handleViewDetail(dish)}
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-1"
>
<Eye className="w-4 h-4" />
</button>
{dish.status === 'pending' && (
<>
<button
onClick={() => handleReject(dish)}
className="px-3 py-1.5 text-sm bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-1"
>
<XCircle className="w-4 h-4" />
</button>
<button
onClick={() => handleApprove(dish)}
className="px-3 py-1.5 text-sm bg-green-50 text-green-600 rounded-lg hover:bg-green-100 transition-colors flex items-center gap-1"
>
<CheckCircle className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* 详情模态框 */}
{showDetailModal && currentDish && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold"></h2>
<button
onClick={() => setShowDetailModal(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
{/* 菜品图片 */}
{currentDish.image_url && (
<div className="w-full h-64 rounded-xl overflow-hidden bg-gray-100">
<img
src={currentDish.image_url}
alt={currentDish.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = 'https://picsum.photos/400'
}}
/>
</div>
)}
{/* 基本信息 */}
<div>
<h3 className="text-2xl font-bold text-gray-800 mb-2">{currentDish.name}</h3>
<p className="text-gray-600">{currentDish.description || '暂无描述'}</p>
</div>
{/* 详细信息 */}
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-xl">
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{currentDish.user_name || currentDish.username}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{currentDish.category || '未分类'}</p>
</div>
{currentDish.price && (
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium text-primary">¥{Number(currentDish.price).toFixed(2)}</p>
</div>
)}
{currentDish.window_number && (
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{currentDish.window_number}</p>
</div>
)}
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{spicyLevelText[currentDish.spicy_level] || '未知'}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium text-sm">
{new Date(currentDish.created_at).toLocaleString('zh-CN')}
</p>
</div>
</div>
{/* 营养信息 */}
{(currentDish.calories || currentDish.protein || currentDish.fat || currentDish.carbs) && (
<div>
<h4 className="font-bold text-gray-800 mb-2"></h4>
<div className="grid grid-cols-2 gap-3">
{currentDish.calories && (
<div className="p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-gray-600"></p>
<p className="text-lg font-bold text-blue-600">{currentDish.calories} </p>
</div>
)}
{currentDish.protein && (
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-gray-600"></p>
<p className="text-lg font-bold text-green-600">{currentDish.protein} </p>
</div>
)}
{currentDish.fat && (
<div className="p-3 bg-yellow-50 rounded-lg">
<p className="text-sm text-gray-600"></p>
<p className="text-lg font-bold text-yellow-600">{currentDish.fat} </p>
</div>
)}
{currentDish.carbs && (
<div className="p-3 bg-orange-50 rounded-lg">
<p className="text-sm text-gray-600"></p>
<p className="text-lg font-bold text-orange-600">{currentDish.carbs} </p>
</div>
)}
</div>
</div>
)}
{/* 原料成分 */}
{currentDish.ingredients && parseJsonField(currentDish.ingredients).length > 0 && (
<div>
<h4 className="font-bold text-gray-800 mb-2"></h4>
<div className="flex flex-wrap gap-2">
{parseJsonField(currentDish.ingredients).map((ingredient: string, index: number) => (
<span
key={index}
className="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm"
>
{ingredient}
</span>
))}
</div>
</div>
)}
{/* 过敏原 */}
{currentDish.allergens && parseJsonField(currentDish.allergens).length > 0 && (
<div>
<h4 className="font-bold text-gray-800 mb-2"></h4>
<div className="flex flex-wrap gap-2">
{parseJsonField(currentDish.allergens).map((allergen: string, index: number) => (
<span
key={index}
className="px-3 py-1 bg-red-50 text-red-600 rounded-full text-sm"
>
{allergen}
</span>
))}
</div>
</div>
)}
{/* 操作按钮 */}
{currentDish.status === 'pending' && (
<div className="flex gap-3 pt-4 border-t">
<button
onClick={() => {
setShowDetailModal(false)
handleReject(currentDish)
}}
className="flex-1 py-3 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-colors font-medium"
>
</button>
<button
onClick={() => {
setShowDetailModal(false)
handleApprove(currentDish)
}}
className="flex-1 py-3 bg-green-50 text-green-600 rounded-xl hover:bg-green-100 transition-colors font-medium"
>
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

@ -0,0 +1,400 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Users, Search, Edit, Lock, Shield
} from 'lucide-react'
import { getUsers, updateUser, resetUserPassword } from '../api/admin'
interface User {
id: number
username: string
name: string
phone: string
email: string
role: string
created_at: string
favorites_count: number
uploaded_dishes_count: number
}
export default function AdminUsersPage() {
const navigate = useNavigate()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [showEditModal, setShowEditModal] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [currentUser, setCurrentUser] = useState<User | null>(null)
const [editForm, setEditForm] = useState({
name: '',
phone: '',
email: '',
role: 'user'
})
const [newPassword, setNewPassword] = useState('')
const limit = 20
useEffect(() => {
// 检查管理员权限
if (localStorage.getItem('isAdmin') !== 'true') {
navigate('/admin/login')
return
}
loadUsers()
}, [page, keyword, navigate])
const loadUsers = async () => {
try {
setLoading(true)
const response = await getUsers({ page, limit, keyword })
// 兼容不同的数据格式响应
if (response.data && response.data.users) {
// 格式1: { success: boolean, data: { users: [], total: number } }
setUsers(Array.isArray(response.data.users) ? response.data.users : [])
setTotal(response.data.total || 0)
} else if (Array.isArray(response.data)) {
// 格式2: { data: [], total: number }
setUsers(response.data)
setTotal(response.total || 0)
} else {
// 默认处理
const usersData = response?.users || response?.data || []
setUsers(Array.isArray(usersData) ? usersData : [])
setTotal(response?.total || 0)
}
} catch (error) {
console.error('加载用户列表失败:', error)
alert('加载用户列表失败,请重试')
// 出错时确保users是一个空数组
setUsers([])
} finally {
setLoading(false)
}
}
const handleSearch = () => {
setPage(1)
loadUsers()
}
const handleEdit = (user: User) => {
setCurrentUser(user)
setEditForm({
name: user.name,
phone: user.phone,
email: user.email || '',
role: user.role
})
setShowEditModal(true)
}
const handleSaveEdit = async () => {
if (!currentUser) return
try {
const response = await updateUser(currentUser.id, editForm)
if (response.success) {
alert('更新成功!')
setShowEditModal(false)
loadUsers()
} else {
alert('更新失败:' + response.message)
}
} catch (error) {
console.error('更新失败:', error)
alert('更新失败,请重试')
}
}
const handleResetPassword = (user: User) => {
setCurrentUser(user)
setNewPassword('')
setShowPasswordModal(true)
}
const handleSavePassword = async () => {
if (!currentUser) return
if (!newPassword || newPassword.length < 6) {
alert('密码长度至少为6位')
return
}
try {
const response = await resetUserPassword(currentUser.id, newPassword)
if (response.success) {
alert('密码重置成功!')
setShowPasswordModal(false)
setNewPassword('')
} else {
alert('密码重置失败:' + response.message)
}
} catch (error) {
console.error('密码重置失败:', error)
alert('密码重置失败,请重试')
}
}
const totalPages = Math.ceil(total / limit)
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6 space-y-6">
{/* 页面标题 */}
<div className="flex items-center gap-3">
<Users className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
{/* 搜索栏 */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="搜索用户名、姓名或手机号"
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
</button>
</div>
</div>
{/* 统计信息 */}
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-800 mt-1">{total}</p>
</div>
<Users className="w-12 h-12 text-blue-500 opacity-20" />
</div>
</div>
{/* 用户列表 */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b bg-gray-50">
<h2 className="font-bold text-gray-800"></h2>
</div>
{loading ? (
<div className="text-center py-20">
<div className="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-gray-500">...</p>
</div>
) : users.length === 0 ? (
<div className="text-center py-20 text-gray-500">
{keyword ? '未找到匹配的用户' : '暂无用户'}
</div>
) : (
<div className="divide-y">
{users.map((user) => (
<div
key={user.id}
className="p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-bold text-gray-800">{user.name}</h3>
{user.role === 'admin' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-100 text-purple-600 text-xs rounded-full">
<Shield className="w-3 h-3" />
</span>
)}
</div>
<p className="text-sm text-gray-600 mb-1">@{user.username}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>📱 {user.phone}</span>
{user.email && <span>📧 {user.email}</span>}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
<span>: {user.favorites_count}</span>
<span>: {user.uploaded_dishes_count}</span>
<span>: {new Date(user.created_at).toLocaleDateString('zh-CN')}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 ml-4">
<button
onClick={() => handleEdit(user)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="编辑"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleResetPassword(user)}
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
title="重置密码"
>
<Lock className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="px-4 py-3 border-t bg-gray-50 flex items-center justify-between">
<p className="text-sm text-gray-600">
{page} / {totalPages} {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
</div>
</div>
{/* 编辑用户模态框 */}
{showEditModal && currentUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={editForm.role}
onChange={(e) => setEditForm({ ...editForm, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="user"></option>
<option value="admin"></option>
</select>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowEditModal(false)}
className="flex-1 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleSaveEdit}
className="flex-1 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
</button>
</div>
</div>
</div>
)}
{/* 重置密码模态框 */}
{showPasswordModal && currentUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">
<span className="font-bold text-gray-800">{currentUser.name}</span>
</p>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码至少6位"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowPasswordModal(false)}
className="flex-1 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={handleSavePassword}
className="flex-1 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}

@ -0,0 +1,200 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Heart, Trash2, ChevronLeft } from 'lucide-react'
import { getFavorites, removeFavorite } from '../api/favorites'
interface FavoriteDish {
favorite_id: number
favorited_at: string
id: number
name: string
price: number
image: string
rating: number
sales_count: number
canteen_name: string
calories: number
protein: number
fat: number
carbs: number
}
export default function FavoritesPage() {
const navigate = useNavigate()
const [favorites, setFavorites] = useState<FavoriteDish[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadFavorites()
}, [])
const loadFavorites = async () => {
try {
setLoading(true)
const response = await getFavorites()
if (response.success) {
setFavorites(response.data)
} else {
console.error('加载收藏失败:', response.message)
}
} catch (error) {
console.error('加载收藏失败:', error)
} finally {
setLoading(false)
}
}
const handleRemoveFavorite = async (dishId: number) => {
if (!confirm('确定要取消收藏吗?')) {
return
}
try {
const response = await removeFavorite(dishId)
if (response.success) {
// 从列表中移除
setFavorites(favorites.filter(item => item.id !== dishId))
} else {
alert('取消收藏失败:' + response.message)
}
} catch (error) {
console.error('取消收藏失败:', error)
alert('取消收藏失败,请重试')
}
}
const handleDishClick = (dishId: number) => {
navigate(`/meal/${dishId}`)
}
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* 顶部导航栏 */}
<div className="sticky top-0 z-10 bg-white border-b px-4 py-3 flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-bold flex items-center gap-2">
<Heart className="w-5 h-5 text-red-500 fill-red-500" />
</h1>
</div>
{/* 主内容区 */}
<div className="p-4">
{loading ? (
<div className="text-center py-20">
<div className="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-gray-500">...</p>
</div>
) : favorites.length === 0 ? (
<div className="text-center py-20">
<Heart className="w-16 h-16 mx-auto text-gray-300" />
<p className="mt-4 text-gray-500"></p>
<button
onClick={() => navigate('/home')}
className="mt-6 px-6 py-2 bg-primary text-white rounded-full hover:bg-primary/90 transition-colors"
>
</button>
</div>
) : (
<div className="space-y-3">
{favorites.map((dish) => (
<div
key={dish.favorite_id}
className="bg-white rounded-xl overflow-hidden shadow-sm"
>
<div className="flex gap-3 p-3">
{/* 菜品图片 */}
<div
onClick={() => handleDishClick(dish.id)}
className="flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden cursor-pointer"
>
<img
src={dish.image || 'https://picsum.photos/100'}
alt={dish.name}
className="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
/>
</div>
{/* 菜品信息 */}
<div className="flex-1 min-w-0">
<h3
onClick={() => handleDishClick(dish.id)}
className="font-bold text-base mb-1 cursor-pointer hover:text-primary truncate"
>
{dish.name}
</h3>
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<span> {dish.rating ? Number(dish.rating).toFixed(1) : '5.0'}</span>
<span></span>
<span> {dish.sales_count || 0}</span>
{dish.canteen_name && (
<>
<span></span>
<span className="truncate">{dish.canteen_name}</span>
</>
)}
</div>
{/* 营养信息 */}
{dish.calories && (
<div className="flex items-center gap-3 text-xs text-gray-600 mb-2">
<span>{dish.calories}</span>
{dish.protein && <span> {dish.protein}g</span>}
</div>
)}
{/* 价格和操作 */}
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-1">
<span className="text-primary font-bold text-lg">
¥{dish.price ? Number(dish.price).toFixed(2) : '0.00'}
</span>
</div>
<button
onClick={() => handleRemoveFavorite(dish.id)}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="取消收藏"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* 收藏时间 */}
<div className="px-3 pb-2 text-xs text-gray-400">
{new Date(dish.favorited_at).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
))}
</div>
)}
</div>
{/* 统计信息 */}
{!loading && favorites.length > 0 && (
<div className="fixed bottom-20 left-0 right-0 bg-white border-t px-4 py-3">
<div className="text-center text-sm text-gray-600">
<span className="font-bold text-primary">{favorites.length}</span>
</div>
</div>
)}
</div>
)
}

@ -0,0 +1,662 @@
import { useState, useEffect } from 'react'
import { Search, Bell, Sparkles, RefreshCw, Plus, Upload, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import MealCard from '../components/Common/MealCard'
import { getRecommendedDishes, getCurrentUser } from '../api'
import { uploadDish, UserDishData } from '../api/userDishes'
import { getCanteens } from '../api/canteens'
import { getTodayNutrition } from '../api/health'
const HomePage = () => {
const navigate = useNavigate()
const [refreshing, setRefreshing] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(true)
interface Meal {
id: string
name: string
image: string
price: number
location: string
rating: number
calories: number
protein: number
isFavorite?: boolean
matchRate?: number
}
const [meals, setMeals] = useState<Meal[]>([])
const [greeting, setGreeting] = useState<string>('')
const [userName, setUserName] = useState<string>('用户')
const [activeCategory, setActiveCategory] = useState<string>('推荐')
interface NutritionData {
caloriesConsumed: number
caloriesGoal: number
proteinConsumed: number
proteinGoal: number
}
const [nutritionData, setNutritionData] = useState<NutritionData>({
caloriesConsumed: 0,
caloriesGoal: 2000,
proteinConsumed: 0,
proteinGoal: 80
})
// 上传菜品相关状态
const [showUploadModal, setShowUploadModal] = useState<boolean>(false)
const [uploading, setUploading] = useState<boolean>(false)
interface Canteen {
id: string
name: string
}
const [canteens, setCanteens] = useState<Canteen[]>([])
const [uploadForm, setUploadForm] = useState({
name: '',
description: '',
category: '',
price: '',
canteen_id: '',
window_number: '',
image_url: '',
calories: '',
protein: '',
fat: '',
carbs: '',
ingredients: '',
spicy_level: '0'
})
useEffect(() => {
console.log('✅ Home 组件已挂载')
const hour = new Date().getHours()
if (hour < 11) setGreeting('早上好')
else if (hour < 14) setGreeting('中午好')
else if (hour < 18) setGreeting('下午好')
else setGreeting('晚上好')
// 加载用户信息和推荐菜品
loadData()
}, [])
const loadData = async () => {
try {
setLoading(true)
// 获取用户信息
const userResponse = await getCurrentUser()
if (userResponse.success && userResponse.data) {
setUserName(userResponse.data.name || userResponse.data.username || '用户')
}
// 获取推荐菜品
const dishesResponse = await getRecommendedDishes(10)
if (dishesResponse.success && dishesResponse.data) {
const formattedMeals = dishesResponse.data.map((dish: any) => ({
id: dish.id,
name: dish.name,
image: dish.image || 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=500',
price: dish.price,
location: dish.canteen_name || '食堂',
rating: dish.rating || 5.0,
calories: dish.calories || 0,
protein: dish.protein || 0,
matchRate: dish.match_rate || 85
}))
setMeals(formattedMeals)
}
// 获取食堂列表
const canteensResponse = await getCanteens()
if (canteensResponse.success && canteensResponse.data) {
setCanteens(canteensResponse.data)
}
// 获取今日营养摄入数据
const nutritionResponse = await getTodayNutrition()
if (nutritionResponse.success && nutritionResponse.data) {
setNutritionData({
caloriesConsumed: nutritionResponse.data.current.calories || 0,
caloriesGoal: nutritionResponse.data.target.calories || 2000,
proteinConsumed: nutritionResponse.data.current.protein || 0,
proteinGoal: nutritionResponse.data.target.protein || 80
})
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
setLoading(false)
}
}
const handleRefresh = async (categoryOverride?: string, useRandom: boolean = false) => {
console.log('🔄 handleRefresh 被调用', '分类参数:', categoryOverride || activeCategory, '随机:', useRandom)
setRefreshing(true)
try {
// 使用传入的分类参数,如果没有则使用当前激活的分类
const currentCategory = categoryOverride !== undefined ? categoryOverride : activeCategory
console.log('📡 开始请求推荐菜品...', '分类:', currentCategory, '随机:', useRandom)
// 将分类名称转换为后端category字段
const categoryMap: Record<string, string> = {
'推荐': '',
'减脂': '减脂',
'增肌': '增肌',
'清淡': '清淡',
'川菜': '川菜',
'西餐': '西餐'
}
const category = currentCategory === '推荐' ? undefined : categoryMap[currentCategory]
const response = await getRecommendedDishes(10, category, useRandom)
console.log('📡 推荐菜品响应:', response)
if (response.success && response.data) {
const formattedMeals = response.data.map((dish: any) => ({
id: dish.id,
name: dish.name,
image: dish.image || 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=500',
price: dish.price,
location: dish.canteen_name || '食堂',
rating: dish.rating || 5.0,
calories: dish.calories || 0,
protein: dish.protein || 0,
matchRate: dish.match_rate || 85
}))
setMeals(formattedMeals)
console.log('✅ 菜品列表已更新,数量:', formattedMeals.length)
}
} catch (error) {
console.error('❌ 刷新失败:', error)
} finally {
setRefreshing(false)
console.log('✅ 刷新完成')
}
}
const handleCategoryChange = (category: string) => {
console.log('🔍 点击分类:', category)
console.log('🔍 当前分类:', activeCategory)
// 先检查是否需要切换
if (category !== activeCategory) {
console.log('🔍 分类已切换,刷新菜品列表...')
// 更新选中状态
setActiveCategory(category)
// 立即用新分类刷新菜品列表(不等待状态更新)
handleRefresh(category)
} else {
console.log('⚠️ 点击的是当前已选中的分类,不刷新列表')
}
}
const handleUploadDish = async () => {
if (!uploadForm.name.trim()) {
alert('请输入菜品名称')
return
}
// 验证营养值范围
const protein = uploadForm.protein ? parseFloat(uploadForm.protein) : 0
const fat = uploadForm.fat ? parseFloat(uploadForm.fat) : 0
const carbs = uploadForm.carbs ? parseFloat(uploadForm.carbs) : 0
const calories = uploadForm.calories ? parseInt(uploadForm.calories) : 0
if (protein > 999.99) {
alert('蛋白质值不能超过 999.99g')
return
}
if (fat > 999.99) {
alert('脂肪值不能超过 999.99g')
return
}
if (carbs > 999.99) {
alert('碳水化合物值不能超过 999.99g')
return
}
if (calories > 999999) {
alert('热量值不能超过 999999 卡')
return
}
try {
setUploading(true)
const dishData: UserDishData = {
name: uploadForm.name,
description: uploadForm.description || undefined,
category: uploadForm.category || undefined,
price: uploadForm.price ? parseFloat(uploadForm.price) : undefined,
canteen_id: uploadForm.canteen_id ? parseInt(uploadForm.canteen_id) : undefined,
window_number: uploadForm.window_number || undefined,
image_url: uploadForm.image_url || undefined,
calories: calories > 0 ? calories : undefined,
protein: protein > 0 ? protein : undefined,
fat: fat > 0 ? fat : undefined,
carbs: carbs > 0 ? carbs : undefined,
ingredients: uploadForm.ingredients || undefined,
spicy_level: parseInt(uploadForm.spicy_level)
}
const response = await uploadDish(dishData)
if (response.success) {
alert('✅ 菜品添加成功!已自动发布到菜单中')
setShowUploadModal(false)
setUploadForm({
name: '',
description: '',
category: '',
price: '',
canteen_id: '',
window_number: '',
image_url: '',
calories: '',
protein: '',
fat: '',
carbs: '',
ingredients: '',
spicy_level: '0'
})
} else {
alert('提交失败:' + response.message)
}
} catch (error) {
console.error('上传菜品失败:', error)
alert('提交失败,请重试')
} finally {
setUploading(false)
}
}
const { caloriesConsumed, caloriesGoal, proteinConsumed, proteinGoal } = nutritionData
return (
<div className="bg-gray-50 min-h-full">
{/* 顶部栏 */}
<div className="bg-gradient-to-r from-orange-400 to-pink-500 text-white px-6 pt-6 pb-8 rounded-b-3xl">
<div className="flex justify-between items-center mb-6">
<div>
<p className="text-sm opacity-90">{greeting}{userName}</p>
<h1 className="text-2xl font-bold mt-1"></h1>
</div>
<div className="flex gap-3">
<button
onClick={() => navigate('/search')}
className="bg-white/20 backdrop-blur-sm p-2.5 rounded-full hover:bg-white/30 transition-colors"
>
<Search size={20} />
</button>
<button className="bg-white/20 backdrop-blur-sm p-2.5 rounded-full hover:bg-white/30 transition-colors relative">
<Bell size={20} />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
</div>
</div>
{/* 今日营养摘要 */}
<div className="bg-white/15 backdrop-blur-md rounded-2xl p-5">
<div className="flex items-center gap-2 mb-4">
<Sparkles size={18} />
<span className="font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 热量进度 */}
<div>
<div className="flex justify-between text-sm mb-2">
<span></span>
<span>{caloriesConsumed}/{caloriesGoal}</span>
</div>
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div
className="h-full bg-white rounded-full transition-all"
style={{ width: `${(caloriesConsumed / caloriesGoal) * 100}%` }}
></div>
</div>
<p className="text-xs mt-1 opacity-80"> {caloriesGoal - caloriesConsumed} </p>
</div>
{/* 蛋白质进度 */}
<div>
<div className="flex justify-between text-sm mb-2">
<span></span>
<span>{proteinConsumed}/{proteinGoal}g</span>
</div>
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div
className="h-full bg-white rounded-full transition-all"
style={{ width: `${(proteinConsumed / proteinGoal) * 100}%` }}
></div>
</div>
<p className="text-xs mt-1 opacity-80"> {proteinGoal - proteinConsumed}g</p>
</div>
</div>
</div>
</div>
{/* 智能推荐区域 */}
<div className="px-6 py-6">
{/* 调试信息 - 显示当前选中的分类 */}
<div className="mb-2 text-xs text-gray-500">
: <span className="font-bold text-primary">{activeCategory}</span>
</div>
{/* 快捷筛选标签 */}
<div className="flex gap-2 mb-4 overflow-x-auto pb-2">
{['推荐', '减脂', '增肌', '清淡', '川菜', '西餐'].map((tag) => {
const isActive = tag === activeCategory
console.log(`🎨 按钮 "${tag}": ${isActive ? '激活' : '未激活'}`)
return (
<button
key={tag}
onClick={() => handleCategoryChange(tag)}
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-all ${
isActive
? 'bg-gradient-to-r from-primary to-pink-500 text-white shadow-lg scale-105'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
{tag}
</button>
)
})}
</div>
{/* 标题和刷新按钮 */}
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<button
onClick={() => handleRefresh(undefined, true)}
disabled={refreshing}
className="flex items-center gap-2 text-primary text-sm font-medium bg-primary/10 px-4 py-2 rounded-full hover:bg-primary/20 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
</button>
</div>
{/* 推荐理由 */}
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded-lg mb-6">
<p className="text-sm text-blue-800">
<span className="font-bold">💡 </span>
</p>
</div>
{/* 餐品列表 */}
<div className="space-y-4">
{meals.map((meal) => (
<MealCard
key={meal.id}
id={meal.id}
name={meal.name}
image={meal.image}
price={meal.price}
location={meal.location}
rating={meal.rating}
calories={meal.calories}
protein={meal.protein}
matchRate={meal.matchRate}
/>
))}
</div>
{/* 食堂动态 */}
<div className="mt-8 bg-gradient-to-r from-purple-50 to-blue-50 rounded-2xl p-5 mb-20">
<h3 className="font-bold text-gray-800 mb-3 flex items-center gap-2">
<span className="text-lg">📢</span>
</h3>
<div className="space-y-3">
<div className="bg-white rounded-lg p-3 text-sm">
<span className="text-gray-500"> · 10</span>
<p className="text-gray-800 mt-1"> 50</p>
</div>
<div className="bg-white rounded-lg p-3 text-sm">
<span className="text-gray-500"> · 30</span>
<p className="text-gray-800 mt-1"> </p>
</div>
</div>
</div>
</div>
{/* 浮动上传按钮 */}
<button
onClick={() => setShowUploadModal(true)}
className="fixed bottom-20 right-6 bg-gradient-to-r from-primary to-pink-500 text-white p-4 rounded-full shadow-lg hover:shadow-xl transition-all hover:scale-110 z-40"
title="上传菜品"
>
<Upload size={24} />
</button>
{/* 上传菜品模态框 */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b p-4 flex items-center justify-between z-10">
<h2 className="text-xl font-bold flex items-center gap-2">
<Upload className="text-primary" size={24} />
</h2>
<button
onClick={() => setShowUploadModal(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 rounded text-sm text-blue-800">
<p className="font-bold mb-1">📝 </p>
<p></p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={uploadForm.name}
onChange={(e) => setUploadForm({ ...uploadForm, name: e.target.value })}
placeholder="例如:红烧肉套餐"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={uploadForm.description}
onChange={(e) => setUploadForm({ ...uploadForm, description: e.target.value })}
placeholder="介绍一下这道菜的特色..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={uploadForm.category}
onChange={(e) => setUploadForm({ ...uploadForm, category: e.target.value })}
placeholder="例如:中餐"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
step="0.01"
value={uploadForm.price}
onChange={(e) => setUploadForm({ ...uploadForm, price: e.target.value })}
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={uploadForm.canteen_id}
onChange={(e) => setUploadForm({ ...uploadForm, canteen_id: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value=""></option>
{canteens.map((canteen) => (
<option key={canteen.id} value={canteen.id}>
{canteen.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
value={uploadForm.window_number}
onChange={(e) => setUploadForm({ ...uploadForm, window_number: e.target.value })}
placeholder="例如A1"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="url"
value={uploadForm.image_url}
onChange={(e) => setUploadForm({ ...uploadForm, image_url: e.target.value })}
placeholder="https://example.com/image.jpg"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="grid grid-cols-2 gap-3">
<input
type="number"
min="0"
max="999999"
value={uploadForm.calories}
onChange={(e) => setUploadForm({ ...uploadForm, calories: e.target.value })}
placeholder="热量(卡)"
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<input
type="number"
step="0.1"
min="0"
max="999.99"
value={uploadForm.protein}
onChange={(e) => setUploadForm({ ...uploadForm, protein: e.target.value })}
placeholder="蛋白质g≤999.99"
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<input
type="number"
step="0.1"
min="0"
max="999.99"
value={uploadForm.fat}
onChange={(e) => setUploadForm({ ...uploadForm, fat: e.target.value })}
placeholder="脂肪g≤999.99"
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<input
type="number"
step="0.1"
min="0"
max="999.99"
value={uploadForm.carbs}
onChange={(e) => setUploadForm({ ...uploadForm, carbs: e.target.value })}
placeholder="碳水g≤999.99"
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
/
</label>
<input
type="text"
value={uploadForm.ingredients}
onChange={(e) => setUploadForm({ ...uploadForm, ingredients: e.target.value })}
placeholder="例如:猪肉、青椒、土豆"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={uploadForm.spicy_level}
onChange={(e) => setUploadForm({ ...uploadForm, spicy_level: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="4"></option>
</select>
</div>
<div className="flex gap-3 pt-4 border-t">
<button
onClick={() => setShowUploadModal(false)}
className="flex-1 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium"
disabled={uploading}
>
</button>
<button
onClick={handleUploadDish}
disabled={uploading}
className="flex-1 py-3 bg-gradient-to-r from-primary to-pink-500 text-white rounded-lg hover:shadow-lg transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? '提交中...' : '提交审核'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default HomePage

@ -0,0 +1,140 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Phone, Lock, UtensilsCrossed, Shield } from 'lucide-react'
import Input from '../components/Common/Input'
import Button from '../components/Common/Button'
import { login } from '../api'
import { setToken } from '../api/request'
interface LoginPageProps {
onLogin: () => void
}
const LoginPage = ({ onLogin }: LoginPageProps) => {
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const handleLogin = async () => {
if (!phone || !password) {
setError('请输入手机号和密码')
return
}
setLoading(true)
setError('')
try {
const response = await login(phone, password)
if (response.success && response.data?.token) {
// 保存token
setToken(response.data.token)
// 检查是否为管理员
if (response.data?.user?.role === 'admin') {
// 管理员:设置标记并跳转到管理后台
localStorage.setItem('isAdmin', 'true')
alert('欢迎管理员!正在跳转到管理后台...')
navigate('/admin/dashboard')
} else {
// 普通用户:跳转到首页
onLogin()
navigate('/home')
}
} else {
setError(response.message || '登录失败,请重试')
}
} catch (err: any) {
console.error('登录失败:', err)
setError(err.message || '登录失败,请检查网络连接')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex justify-center items-center bg-gradient-to-br from-purple-100 to-blue-100">
<div className="relative w-full max-w-[414px] h-[920px] bg-white shadow-2xl rounded-3xl overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto p-8 flex flex-col">
{/* Logo区域 */}
<div className="text-center mb-12 mt-12">
<div className="bg-gradient-to-br from-orange-400 to-pink-500 p-6 rounded-3xl inline-block mb-4">
<UtensilsCrossed size={48} className="text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-800 mb-2"></h1>
<p className="text-gray-500"></p>
</div>
{/* 表单区域 */}
<div className="space-y-5 flex-1">
<Input
label="手机号"
type="tel"
value={phone}
onChange={setPhone}
placeholder="请输入手机号"
icon={<Phone size={20} />}
/>
<Input
label="密码"
type="password"
value={password}
onChange={setPassword}
placeholder="请输入密码"
icon={<Lock size={20} />}
/>
<div className="flex justify-end">
<button className="text-sm text-primary hover:text-primary/80">
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<Button
onClick={handleLogin}
fullWidth
disabled={loading}
size="lg"
className="mt-8"
>
{loading ? '登录中...' : '登录'}
</Button>
<div className="text-center mt-6">
<span className="text-gray-600"></span>
<Link to="/register" className="text-primary font-medium ml-2 hover:text-primary/80">
</Link>
</div>
{/* 管理员登录入口 */}
<div className="mt-8 pt-6 border-t border-gray-200">
<div className="text-center">
<Link
to="/admin/login"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-primary transition-colors"
>
<Shield size={16} />
<span></span>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default LoginPage

@ -0,0 +1,623 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Heart, Share2, MapPin, Star, Flame, Users, Check } from 'lucide-react'
import Button from '../components/Common/Button'
import MealCard from '../components/Common/MealCard'
import { getDishDetail } from '../api'
import { recordMeal } from '../api/health'
import { createReview, getDishReviews } from '../api/reviews'
const MealDetailPage = () => {
const { id } = useParams()
const navigate = useNavigate()
interface Ingredient {
name: string
}
interface Review {
id: string
userName: string
rating: number
content: string
comment?: string // 添加comment属性以兼容代码
date: string
user?: string // 兼容代码中使用的user属性
}
interface MealData {
id: string
name: string
image: string
price: number
location: string
rating: number
calories: number
protein: number
fat: number
carbs: number
description: string
category: string
ingredients: string[] // 简化为字符串数组
healthTags?: string[] // 添加健康标签属性
cookingMethod: string
tasteNotes?: string // 设置为可选属性
suitableFor?: string[] // 简化为字符串数组
reviews?: Review[]
matchRate?: number // 添加matchRate属性
}
const [meal, setMeal] = useState<MealData | null>(null)
const [similarMeals, setSimilarMeals] = useState<MealData[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [isFavorite, setIsFavorite] = useState<boolean>(false)
const [currentImageIndex, setCurrentImageIndex] = useState<number>(0)
const [hasEatenToday, setHasEatenToday] = useState<boolean>(false)
const [recording, setRecording] = useState<boolean>(false)
// 评价相关状态
const [selectedRating, setSelectedRating] = useState<number>(0)
const [reviewContent, setReviewContent] = useState<string>('')
const [submitting, setSubmitting] = useState<boolean>(false)
const [showReviewForm, setShowReviewForm] = useState<boolean>(false)
const [userReviewed, setUserReviewed] = useState<boolean>(false)
useEffect(() => {
if (id) {
// 首先加载菜品详情
loadDishDetail();
// 然后加载评论数据
loadReviews();
// 并行加载方式已改为串行,确保数据加载的可靠性
/*
// 并行加载菜品详情和评论,确保评论数据不会丢失
const fetchData = async () => {
try {
// 并行请求以提高加载速度
const [dishResponse, reviewsResponse] = await Promise.all([
loadDishDetail(),
getDishReviews(Number(id))
]);
// 确保评论数据被正确保存无论meal是否已经设置
if (reviewsResponse.success && reviewsResponse.data?.reviews) {
// 如果meal已经存在更新它的评论
if (meal) {
setMeal(prev => ({
...prev,
reviews: reviewsResponse.data.reviews
}));
} else {
// 如果meal还未设置调用loadReviews函数来正确处理评论数据
loadReviews();
}
}
} catch (error) {
console.error('加载数据失败:', error);
}
};
fetchData();
*/
}
}, [id])
const loadDishDetail = async () => {
try {
setLoading(true)
const response = await getDishDetail(id!)
if (response.success && response.data) {
const dishData = response.data.dish
// 获取当前已有的评论数据(如果有)
let existingReviews = [];
if (meal && meal.reviews) {
existingReviews = meal.reviews;
}
// 格式化菜品数据,保留已有的评论
const formattedMeal = {
id: dishData.id,
name: dishData.name,
image: dishData.image || 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=500',
price: dishData.price,
location: dishData.canteen_name || '食堂',
rating: dishData.rating || 5.0,
calories: dishData.calories || 0,
protein: dishData.protein || 0,
fat: dishData.fat || 0,
carbs: dishData.carbs || 0,
description: dishData.description || '暂无描述',
category: dishData.category || '其他',
// 健康标签
healthTags: dishData.tags ? dishData.tags.split(',').filter((t: string) => t.trim()) : [],
// 使用默认食材信息
ingredients: ['主食材', '配料'],
cookingMethod: '采用传统烹饪工艺精心制作',
suitableFor: ['健身人群', '减脂人群', '学生群体'],
reviews: existingReviews, // 保留已有的评论
matchRate: 85
} as any
setMeal(formattedMeal as any)
// 格式化相似菜品
if (response.data.similarDishes) {
const formattedSimilar = response.data.similarDishes.map((dish: any) => ({
id: dish.id,
name: dish.name,
image: dish.image || 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=500',
price: dish.price,
location: dish.canteen_name || '食堂',
rating: dish.rating || 5.0,
calories: dish.calories || 0,
protein: dish.protein || 0
}))
setSimilarMeals(formattedSimilar)
}
}
return response; // 返回响应以便Promise.all使用
} catch (error) {
console.error('加载菜品详情失败:', error)
return null;
} finally {
setLoading(false)
}
}
const loadReviews = async () => {
try {
const response = await getDishReviews(Number(id))
if (response.success && response.data?.reviews) {
if (meal) {
setMeal({ ...meal, reviews: response.data.reviews })
} else {
// 如果meal还未加载先缓存评论数据
const cachedReviews = response.data.reviews;
// 创建一个基础的meal对象来保存评论
const baseMeal = {
id: id,
name: '',
image: '',
price: 0,
location: '',
rating: 0,
calories: 0,
protein: 0,
fat: 0,
carbs: 0,
description: '',
category: '',
healthTags: [],
ingredients: [],
cookingMethod: '',
suitableFor: [],
reviews: cachedReviews,
matchRate: 0
};
setMeal(baseMeal);
}
}
} catch (error) {
console.error('加载评价失败:', error)
}
}
const handleMarkAsEaten = async () => {
if (!meal || recording) return
try {
setRecording(true)
const response = await recordMeal({
dish_id: Number(meal.id),
quantity: 1,
meal_time: new Date().toISOString()
})
if (response.success) {
setHasEatenToday(true)
alert('✅ 已标记今日已吃!营养数据已记录')
} else {
alert('标记失败:' + response.message)
}
} catch (error) {
console.error('标记今日已吃失败:', error)
alert('标记失败,请重试')
} finally {
setRecording(false)
}
}
// 处理评分选择
const handleRatingChange = (rating: number) => {
setSelectedRating(rating)
}
// 处理评价提交
const handleReviewSubmit = async () => {
if (!meal || !selectedRating || !reviewContent.trim() || submitting) return
try {
setSubmitting(true)
const response = await createReview({
dish_id: Number(meal.id),
rating: selectedRating,
comment: reviewContent.trim()
})
if (response.success) {
alert('评价提交成功!')
setReviewContent('')
setSelectedRating(0)
setShowReviewForm(false)
setUserReviewed(true)
// 重新加载评价列表
await loadReviews()
} else {
alert('评价提交失败:' + (response.message || '未知错误'))
}
} catch (error) {
console.error('提交评价失败:', error)
alert('提交失败,请重试')
} finally {
setSubmitting(false)
}
}
// 渲染星级评分组件
const renderRatingStars = (value: number, onChange?: (rating: number) => void) => {
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={20}
className={`cursor-pointer transition-colors ${star <= value ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300 hover:text-yellow-400'}`}
onClick={() => onChange && onChange(star)}
/>
))}
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
)
}
if (!meal) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-6">
<p className="text-xl text-gray-600 mb-4"></p>
<Button onClick={() => navigate(-1)}></Button>
</div>
)
}
const images = [meal.image, meal.image, meal.image] // 模拟多张图片
const nutrients = [
{ label: '热量', value: meal.calories, unit: '千卡', color: 'bg-red-100 text-red-600' },
{ label: '蛋白质', value: meal.protein, unit: 'g', color: 'bg-blue-100 text-blue-600' },
{ label: '脂肪', value: meal.fat, unit: 'g', color: 'bg-yellow-100 text-yellow-600' },
{ label: '碳水', value: meal.carbs, unit: 'g', color: 'bg-green-100 text-green-600' },
]
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 图片轮播 */}
<div className="relative h-72 bg-gray-200">
<img src={images[currentImageIndex]} alt={meal.name} className="w-full h-full object-cover" />
{/* 顶部按钮 */}
<button
onClick={() => navigate(-1)}
className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm p-2 rounded-full"
>
<ArrowLeft size={24} />
</button>
<button
onClick={() => setIsFavorite(!isFavorite)}
className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm p-2 rounded-full"
>
<Heart
size={24}
className={isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-700'}
/>
</button>
{/* 图片指示器 */}
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2">
{images.map((_, index) => (
<button
key={index}
onClick={() => setCurrentImageIndex(index)}
className={`w-2 h-2 rounded-full transition-all ${
index === currentImageIndex ? 'bg-white w-6' : 'bg-white/50'
}`}
/>
))}
</div>
</div>
<div className="px-6 py-6 space-y-6">
{/* 基本信息 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-800 mb-2">{meal.name}</h1>
<div className="flex items-center gap-2 text-sm text-gray-600">
<MapPin size={16} />
<span>{meal.location}</span>
</div>
{/* 健康标签 */}
{meal.healthTags && meal.healthTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{meal.healthTags.map((tag, index) => (
<span
key={index}
className="bg-gradient-to-r from-primary/90 to-purple-500/90 text-white px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="text-right">
<p className="text-3xl font-bold text-primary">¥{meal.price}</p>
</div>
</div>
<div className="flex items-center gap-4 pt-3 border-t">
<div className="flex items-center gap-1">
<Star size={18} className="fill-yellow-400 text-yellow-400" />
<span className="font-bold">{meal.rating}</span>
<span className="text-sm text-gray-500"></span>
</div>
<div className="flex items-center gap-1">
<Users size={18} className="text-gray-600" />
<span className="text-sm text-gray-600"> 256+</span>
</div>
</div>
</div>
{/* 推荐理由 */}
{meal.matchRate && (
<div className="bg-gradient-to-r from-orange-50 to-pink-50 rounded-xl p-5 border-l-4 border-primary">
<div className="flex items-center gap-2 mb-2">
<div className="bg-gradient-to-r from-primary to-pink-500 text-white px-3 py-1 rounded-full text-sm font-bold">
{meal.matchRate}%
</div>
<Flame className="text-primary" size={20} />
</div>
<p className="text-gray-700">
<span className="font-bold"></span>
</p>
</div>
)}
{/* 营养成分 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="grid grid-cols-4 gap-3">
{nutrients.map((nutrient) => (
<div key={nutrient.label} className={`${nutrient.color} rounded-lg p-3 text-center`}>
<p className="text-2xl font-bold">{nutrient.value}</p>
<p className="text-xs mt-1">{nutrient.unit}</p>
<p className="text-xs opacity-80 mt-1">{nutrient.label}</p>
</div>
))}
</div>
{/* 标记今日已吃按钮 */}
<button
onClick={handleMarkAsEaten}
disabled={recording || hasEatenToday}
className={`w-full mt-4 py-3 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
hasEatenToday
? 'bg-green-100 text-green-700 cursor-not-allowed'
: 'bg-gradient-to-r from-primary to-pink-500 text-white hover:shadow-lg disabled:opacity-50'
}`}
>
<Check size={20} />
<span>{hasEatenToday ? '✅ 今日已吃' : recording ? '记录中...' : '标记今日已吃'}</span>
</button>
{hasEatenToday && (
<p className="text-sm text-gray-500 text-center mt-2">
</p>
)}
</div>
{/* 详细说明 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="space-y-4">
<div>
<h3 className="font-medium text-gray-700 mb-2"></h3>
<div className="flex flex-wrap gap-2">
{meal.ingredients.map((ingredient) => (
<span key={ingredient} className="bg-gray-100 px-3 py-1 rounded-full text-sm">
{ingredient}
</span>
))}
</div>
</div>
{meal.healthTags && meal.healthTags.length > 0 && (
<div>
<h3 className="font-medium text-gray-700 mb-2"></h3>
<div className="flex flex-wrap gap-2">
{meal.healthTags.map((tag, index) => (
<span
key={index}
className="bg-gradient-to-r from-primary/90 to-purple-500/90 text-white px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</div>
)}
<div>
<h3 className="font-medium text-gray-700 mb-2"></h3>
<p className="text-gray-600">{meal.cookingMethod}</p>
</div>
<div>
<h3 className="font-medium text-gray-700 mb-2"></h3>
<div className="flex flex-wrap gap-2">
{meal.suitableFor.map((group) => (
<span key={group} className="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-sm">
{group}
</span>
))}
</div>
</div>
<div>
<h3 className="font-medium text-gray-700 mb-2"></h3>
<p className="text-gray-600 leading-relaxed">{meal.description}</p>
</div>
</div>
</div>
{/* 过敏提醒 */}
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg">
<p className="text-sm text-yellow-800">
<span className="font-bold"> </span>
{meal.ingredients.slice(0, 2).join('、')}
</p>
</div>
{/* 用户评价 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"> ({meal.reviews?.length || 0})</h2>
{/* 添加评价按钮 */}
{!userReviewed && !showReviewForm && (
<button
onClick={() => setShowReviewForm(true)}
className="w-full py-3 bg-primary/10 text-primary font-medium rounded-lg hover:bg-primary/20 transition-colors mb-4"
>
</button>
)}
{/* 评价表单 */}
{showReviewForm && (
<div className="bg-gray-50 p-4 rounded-lg mb-4 space-y-4">
<div>
<p className="text-gray-700 mb-2 font-medium"></p>
{renderRatingStars(selectedRating, handleRatingChange)}
</div>
<div>
<p className="text-gray-700 mb-2 font-medium"></p>
<textarea
value={reviewContent}
onChange={(e) => setReviewContent(e.target.value)}
placeholder="请输入您的评价..."
className="w-full h-32 p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
maxLength={500}
/>
<p className="text-xs text-gray-500 text-right mt-1">{reviewContent.length}/500</p>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowReviewForm(false)
setSelectedRating(0)
setReviewContent('')
}}
className="flex-1 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-100"
>
</button>
<button
onClick={handleReviewSubmit}
disabled={!selectedRating || !reviewContent.trim() || submitting}
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
!selectedRating || !reviewContent.trim()
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-gradient-to-r from-primary to-pink-500 text-white hover:shadow-lg'
}`}
>
{submitting ? '提交中...' : '提交评价'}
</button>
</div>
</div>
)}
{userReviewed && !showReviewForm && (
<p className="text-center text-gray-500 py-2 text-sm">
</p>
)}
{meal.reviews && meal.reviews.length > 0 ? (
<div className="space-y-4">
{meal.reviews.map((review, index) => (
<div key={index} className="pb-4 border-b last:border-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-400 to-purple-400 rounded-full flex items-center justify-center text-white font-bold text-sm">
{(review.user || review.userName)?.[0] || 'U'}
</div>
<span className="font-medium">{review.user || review.userName}</span>
</div>
<div className="flex items-center gap-1">
<Star size={14} className="fill-yellow-400 text-yellow-400" />
<span className="text-sm font-medium">{review.rating}</span>
</div>
</div>
<p className="text-gray-700 mb-2">{review.comment || review.content}</p>
<p className="text-xs text-gray-400">{review.date}</p>
</div>
))}
</div>
) : (
<p className="text-center text-gray-400 py-4"></p>
)}
</div>
{/* 相似推荐 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="space-y-3">
{similarMeals.map((similarMeal) => (
<MealCard
key={similarMeal.id}
id={similarMeal.id}
name={similarMeal.name}
image={similarMeal.image}
price={similarMeal.price}
location={similarMeal.location}
rating={similarMeal.rating}
calories={similarMeal.calories}
protein={similarMeal.protein}
/>
))}
</div>
</div>
</div>
</div>
)
}
export default MealDetailPage

@ -0,0 +1,176 @@
import { useState, useEffect } from 'react'
import { Calendar, TrendingUp, DollarSign } from 'lucide-react'
import EmptyState from '../components/Common/EmptyState'
const MealRecordsPage = () => {
const [records, setRecords] = useState<any[]>([
// 暂时保留一些示例数据以维持UI显示
{
date: '2025-10-28',
meals: [
{
id: 'temp1',
name: '宫保鸡丁',
price: 15,
location: '第一食堂',
image: '/placeholder.jpg',
time: '12:30'
},
{
id: 'temp2',
name: '番茄鸡蛋面',
price: 12,
location: '第二食堂',
image: '/placeholder.jpg',
time: '18:45'
}
]
}
])
const [loading, setLoading] = useState(false)
useEffect(() => {
// TODO: 这里应该调用真实的用餐记录API
// const fetchMealRecords = async () => {
// setLoading(true)
// try {
// const data = await getMealRecords() // 假设的API函数
// setRecords(data)
// } catch (error) {
// console.error('获取用餐记录失败:', error)
// } finally {
// setLoading(false)
// }
// }
// fetchMealRecords()
}, [])
const totalMeals = records.reduce((sum, r) => sum + r.meals.length, 0)
const totalSpent = records.reduce((sum, r) =>
sum + r.meals.reduce((s, m) => s + m.price, 0), 0
)
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) return '今天'
if (date.toDateString() === yesterday.toDateString()) return '昨天'
return `${date.getMonth() + 1}${date.getDate()}`
}
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 顶部标题栏 */}
<div className="bg-gradient-to-r from-green-500 to-teal-600 text-white px-6 pt-6 pb-8 rounded-b-3xl">
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-sm opacity-90">30</p>
</div>
{records.length > 0 ? (
<div className="px-6 py-6">
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-5 text-white">
<div className="flex items-center gap-2 mb-2">
<TrendingUp size={20} />
<span className="text-sm opacity-90"></span>
</div>
<p className="text-3xl font-bold">{totalMeals}</p>
<p className="text-xs opacity-80 mt-1">30</p>
</div>
<div className="bg-gradient-to-br from-orange-500 to-pink-600 rounded-xl p-5 text-white">
<div className="flex items-center gap-2 mb-2">
<DollarSign size={20} />
<span className="text-sm opacity-90"></span>
</div>
<p className="text-3xl font-bold">¥{totalSpent.toFixed(0)}</p>
<p className="text-xs opacity-80 mt-1">30</p>
</div>
</div>
{/* 记录列表 */}
<div className="space-y-6">
{records.map((record) => (
<div key={record.date}>
<div className="flex items-center gap-2 mb-3">
<Calendar size={18} className="text-gray-600" />
<h3 className="font-bold text-gray-800">{formatDate(record.date)}</h3>
<span className="text-sm text-gray-500">
{record.meals.length}
</span>
</div>
<div className="space-y-3">
{record.meals.map((meal) => (
<div
key={meal.id + meal.time}
className="bg-white rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex gap-4">
<img
src={meal.image}
alt={meal.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<h4 className="font-bold text-gray-800">{meal.name}</h4>
<span className="text-primary font-bold">¥{meal.price}</span>
</div>
<p className="text-sm text-gray-500 mb-2">{meal.location}</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">{meal.time}</span>
<button className="text-xs text-primary font-medium">
</button>
</div>
</div>
</div>
{/* 营养信息 */}
<div className="mt-3 pt-3 border-t flex gap-4 text-xs text-gray-600">
<span>: {meal.calories}</span>
<span>: {meal.protein}g</span>
<span>: {meal.fat}g</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* 日历视图切换 */}
<div className="mt-8 text-center">
<button className="text-primary font-medium bg-primary/10 px-6 py-3 rounded-xl hover:bg-primary/20 transition-colors">
</button>
</div>
{/* 导出按钮 */}
<button className="w-full mt-4 py-3 border-2 border-primary text-primary font-medium rounded-xl hover:bg-primary hover:text-white transition-all">
</button>
</div>
) : (
<div className="px-6">
<EmptyState
icon={<Calendar size={80} />}
title="暂无用餐记录"
description="开始记录你的美食之旅吧"
actionText="去首页看看"
onAction={() => window.history.back()}
/>
</div>
)}
</div>
)
}
export default MealRecordsPage

@ -0,0 +1,225 @@
import { useState, useEffect } from 'react'
import { Search, SlidersHorizontal, MapPin, Clock } from 'lucide-react'
import MealCard from '../components/Common/MealCard'
import { getDishes } from '../api/dishes'
const MenuPage = () => {
const [selectedCanteen, setSelectedCanteen] = useState('全部')
const [showFilter, setShowFilter] = useState(false)
const [priceRange, setPriceRange] = useState<[number, number]>([0, 50])
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [sortBy, setSortBy] = useState('推荐')
const [meals, setMeals] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const canteens = ['全部', '第一食堂', '第二食堂', '第三食堂']
const categories = ['川菜', '粤菜', '西餐', '面食', '素食', '甜品']
const sortOptions = ['推荐', '价格', '评分', '距离']
useEffect(() => {
const fetchDishes = async () => {
setLoading(true)
try {
const params = {
category: selectedCategories.length > 0 ? selectedCategories[0] : undefined,
canteenId: selectedCanteen !== '全部' ? selectedCanteen : undefined,
priceMin: priceRange[0],
priceMax: priceRange[1],
sortBy: sortBy === '推荐' ? 'recommend' : sortBy === '价格' ? 'price' : sortBy === '评分' ? 'rating' : 'distance'
}
const data = await getDishes(params)
setMeals(data)
} catch (error) {
console.error('获取菜品失败:', error)
} finally {
setLoading(false)
}
}
fetchDishes()
}, [selectedCanteen, selectedCategories, priceRange, sortBy])
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 顶部搜索栏 */}
<div className="bg-white px-6 pt-6 pb-4 sticky top-0 z-10 shadow-sm">
<div className="flex gap-3 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="搜索菜品、食堂..."
className="w-full pl-11 pr-4 py-3 bg-gray-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<button
onClick={() => setShowFilter(!showFilter)}
className="bg-gray-100 p-3 rounded-xl hover:bg-gray-200 transition-colors"
>
<SlidersHorizontal size={20} className="text-gray-700" />
</button>
</div>
{/* 食堂标签 */}
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
{canteens.map((canteen) => (
<button
key={canteen}
onClick={() => setSelectedCanteen(canteen)}
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-all ${
selectedCanteen === canteen
? 'bg-gradient-to-r from-primary to-pink-500 text-white'
: 'bg-gray-100 text-gray-700'
}`}
>
{canteen}
</button>
))}
</div>
</div>
{/* 筛选面板 */}
{showFilter && (
<div className="bg-white mx-6 mt-4 p-5 rounded-xl shadow-md animate-fade-in-up">
{/* 排序 */}
<div className="mb-5">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="flex gap-2 flex-wrap">
{sortOptions.map((option) => (
<button
key={option}
onClick={() => setSortBy(option)}
className={`px-4 py-2 rounded-lg text-sm transition-all ${
sortBy === option
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-700'
}`}
>
{option}
</button>
))}
</div>
</div>
{/* 价格范围 */}
<div className="mb-5">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
value={priceRange[1]}
onChange={(e) => setPriceRange([0, parseInt(e.target.value)])}
className="flex-1"
/>
<span className="text-sm text-gray-600 whitespace-nowrap">
¥0 - ¥{priceRange[1]}
</span>
</div>
</div>
{/* 菜系分类 */}
<div className="mb-4">
<h3 className="font-bold text-gray-800 mb-3"></h3>
<div className="flex gap-2 flex-wrap">
{categories.map((category) => (
<button
key={category}
onClick={() => {
if (selectedCategories.includes(category)) {
setSelectedCategories(selectedCategories.filter((c) => c !== category))
} else {
setSelectedCategories([...selectedCategories, category])
}
}}
className={`px-4 py-2 rounded-lg text-sm transition-all ${
selectedCategories.includes(category)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-700'
}`}
>
{category}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-3 border-t">
<button
onClick={() => {
setPriceRange([0, 50])
setSelectedCategories([])
setSortBy('推荐')
}}
className="flex-1 py-2 text-gray-700 font-medium"
>
</button>
<button
onClick={() => setShowFilter(false)}
className="flex-1 py-2 bg-gradient-to-r from-primary to-pink-500 text-white rounded-lg font-medium"
>
</button>
</div>
</div>
)}
{/* 食堂信息卡片 */}
{selectedCanteen !== '全部' && (
<div className="mx-6 mt-4 bg-white rounded-xl p-4 shadow-sm">
<h3 className="font-bold text-lg mb-2">{selectedCanteen}</h3>
<div className="flex items-center gap-4 text-sm text-gray-600">
<div className="flex items-center gap-1">
<MapPin size={16} />
<span>200</span>
</div>
<div className="flex items-center gap-1">
<Clock size={16} />
<span> 06:30-20:00</span>
</div>
</div>
<div className="mt-3 pt-3 border-t flex items-center justify-between">
<span className="text-sm text-gray-600"> <span className="text-primary font-bold">8</span> </span>
<button className="text-primary text-sm font-medium"></button>
</div>
</div>
)}
{/* 菜品列表 */}
<div className="px-6 mt-4">
<div className="flex items-center justify-between mb-4">
<h2 className="font-bold text-gray-800">
{meals.length}
</h2>
<span className="text-sm text-gray-500">{sortBy}</span>
</div>
<div className="space-y-4">
{meals.map((meal) => (
<MealCard
key={meal.id}
id={meal.id}
name={meal.name}
image={meal.image}
price={meal.price}
location={meal.location}
rating={meal.rating}
calories={meal.calories}
protein={meal.protein}
/>
))}
</div>
{/* 加载更多 */}
<div className="text-center mt-6">
<p className="text-gray-400 text-sm"></p>
</div>
</div>
</div>
)
}
export default MenuPage

@ -0,0 +1,310 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ChevronLeft, Upload, Plus, Trash2, Eye,
CheckCircle, XCircle, Clock
} from 'lucide-react'
import { getUserDishes, deleteUserDish } from '../api/userDishes'
interface UserDish {
id: number
name: string
description: string
category: string
price: number
window_number: string
image_url: string
calories: number
protein: number
fat: number
carbs: number
ingredients: string
allergens: string
spicy_level: number
status: 'pending' | 'approved' | 'rejected'
reject_reason: string
created_at: string
updated_at: string
}
export default function MyDishesPage() {
const navigate = useNavigate()
const [dishes, setDishes] = useState<UserDish[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all')
useEffect(() => {
loadDishes()
}, [])
const loadDishes = async () => {
try {
setLoading(true)
const response = await getUserDishes()
if (response.success) {
setDishes(response.data)
} else {
alert('加载上传记录失败:' + response.message)
}
} catch (error) {
console.error('加载上传记录失败:', error)
alert('加载上传记录失败,请重试')
} finally {
setLoading(false)
}
}
const handleDelete = async (dish: UserDish) => {
if (dish.status !== 'pending') {
alert('只能删除待审核的菜品')
return
}
if (!confirm(`确定要删除菜品"${dish.name}"吗?`)) {
return
}
try {
const response = await deleteUserDish(dish.id)
if (response.success) {
alert('删除成功!')
loadDishes()
} else {
alert('删除失败:' + response.message)
}
} catch (error) {
console.error('删除失败:', error)
alert('删除失败,请重试')
}
}
const filteredDishes = filter === 'all'
? dishes
: dishes.filter(dish => dish.status === filter)
const statusConfig = {
pending: { text: '待审核', color: 'text-orange-600', bgColor: 'bg-orange-50', icon: Clock },
approved: { text: '已通过', color: 'text-green-600', bgColor: 'bg-green-50', icon: CheckCircle },
rejected: { text: '已拒绝', color: 'text-red-600', bgColor: 'bg-red-50', icon: XCircle }
}
const spicyLevelText = ['不辣', '微辣', '中辣', '重辣', '特辣']
const parseJsonField = (field: string) => {
try {
return JSON.parse(field)
} catch {
return []
}
}
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* 顶部导航栏 */}
<div className="sticky top-0 z-10 bg-white border-b px-4 py-3 flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-bold flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
</h1>
</div>
<div className="p-4 space-y-4">
{/* 上传按钮 */}
<button
onClick={() => navigate('/add-dish')}
className="w-full py-4 bg-primary text-white rounded-xl font-medium hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" />
</button>
{/* 筛选器 */}
<div className="bg-white rounded-xl p-3 shadow-sm">
<div className="flex gap-2 overflow-x-auto">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors ${
filter === 'all'
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
({dishes.length})
</button>
<button
onClick={() => setFilter('pending')}
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors ${
filter === 'pending'
? 'bg-orange-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
({dishes.filter(d => d.status === 'pending').length})
</button>
<button
onClick={() => setFilter('approved')}
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors ${
filter === 'approved'
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
({dishes.filter(d => d.status === 'approved').length})
</button>
<button
onClick={() => setFilter('rejected')}
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors ${
filter === 'rejected'
? 'bg-red-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
({dishes.filter(d => d.status === 'rejected').length})
</button>
</div>
</div>
{/* 菜品列表 */}
{loading ? (
<div className="text-center py-20">
<div className="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-gray-500">...</p>
</div>
) : filteredDishes.length === 0 ? (
<div className="text-center py-20 bg-white rounded-xl">
<Upload className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500 mb-4">
{filter === 'all' && '还没有上传任何菜品'}
{filter === 'pending' && '没有待审核的菜品'}
{filter === 'approved' && '没有已通过的菜品'}
{filter === 'rejected' && '没有被拒绝的菜品'}
</p>
{filter === 'all' && (
<button
onClick={() => navigate('/add-dish')}
className="px-6 py-2 bg-primary text-white rounded-full hover:bg-primary/90 transition-colors"
>
</button>
)}
</div>
) : (
<div className="space-y-3">
{filteredDishes.map((dish) => {
const status = statusConfig[dish.status]
const StatusIcon = status.icon
return (
<div
key={dish.id}
className="bg-white rounded-xl shadow-sm overflow-hidden"
>
<div className="p-4">
<div className="flex gap-3">
{/* 菜品图片 */}
<div className="flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden bg-gray-100">
<img
src={dish.image_url || 'https://picsum.photos/100'}
alt={dish.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = 'https://picsum.photos/100'
}}
/>
</div>
{/* 菜品信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<h3 className="font-bold text-base text-gray-800">{dish.name}</h3>
<span className={`ml-2 px-2 py-1 ${status.bgColor} ${status.color} text-xs rounded-full flex items-center gap-1 whitespace-nowrap`}>
<StatusIcon className="w-3 h-3" />
{status.text}
</span>
</div>
{dish.description && (
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{dish.description}
</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-gray-500 mb-2">
{dish.category && <span>🏷 {dish.category}</span>}
{dish.price && <span>💰 ¥{Number(dish.price).toFixed(2)}</span>}
{dish.window_number && <span>🪟 {dish.window_number}</span>}
{dish.spicy_level !== undefined && (
<span>🌶 {spicyLevelText[dish.spicy_level]}</span>
)}
</div>
{/* 营养信息 */}
{(dish.calories || dish.protein) && (
<div className="flex items-center gap-3 text-xs text-gray-400 mb-2">
{dish.calories && <span>🔥 {dish.calories}</span>}
{dish.protein && <span> {dish.protein}g</span>}
{dish.fat && <span> {dish.fat}g</span>}
{dish.carbs && <span> {dish.carbs}g</span>}
</div>
)}
{/* 拒绝原因 */}
{dish.status === 'rejected' && dish.reject_reason && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600">
<span className="font-medium"></span>
{dish.reject_reason}
</p>
</div>
)}
{/* 时间和操作 */}
<div className="flex items-center justify-between mt-2 pt-2 border-t">
<p className="text-xs text-gray-400">
{new Date(dish.created_at).toLocaleDateString('zh-CN')}
</p>
{dish.status === 'pending' && (
<button
onClick={() => handleDelete(dish)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
{/* 提示信息 */}
{dishes.length > 0 && (
<div className="bg-blue-50 rounded-xl p-4 text-sm text-blue-800">
<p className="font-medium mb-1">💡 </p>
<ul className="space-y-1 text-blue-600">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
)}
</div>
</div>
)
}

@ -0,0 +1,53 @@
import { useNavigate } from 'react-router-dom'
import { Home } from 'lucide-react'
import Button from '../components/Common/Button'
const NotFoundPage = () => {
const navigate = useNavigate()
return (
<div className="min-h-screen flex justify-center items-center bg-gradient-to-br from-purple-100 to-blue-100">
<div className="relative w-full max-w-[414px] h-[920px] bg-white shadow-2xl rounded-3xl overflow-hidden flex flex-col items-center justify-center p-8">
<div className="text-center animate-fade-in-up">
{/* 404 图标 */}
<div className="text-9xl font-bold text-gray-200 mb-4">404</div>
{/* 插图 */}
<div className="text-8xl mb-6">🔍</div>
{/* 文字说明 */}
<h1 className="text-2xl font-bold text-gray-800 mb-3">
</h1>
<p className="text-gray-500 mb-8">
访
</p>
{/* 按钮 */}
<div className="space-y-3">
<Button
onClick={() => navigate('/home')}
fullWidth
size="lg"
>
<Home size={20} className="inline mr-2" />
</Button>
<Button
onClick={() => navigate(-1)}
variant="outline"
fullWidth
size="lg"
>
</Button>
</div>
</div>
</div>
</div>
)
}
export default NotFoundPage

@ -0,0 +1,741 @@
import { useState, useEffect } from 'react'
import { User, Mail, Phone, Calendar, Target, TrendingUp, Heart, LogOut, Edit2, Save, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { removeToken } from '../api/request'
import { getCurrentUser, getUserProfile, getHealthProfile, updateUserProfile, updateHealthProfile } from '../api'
const PersonalFilePage = () => {
const [activeTab, setActiveTab] = useState<'basic' | 'health' | 'preferences'>('basic')
const [loading, setLoading] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const [saving, setSaving] = useState(false)
const navigate = useNavigate()
const [profile, setProfile] = useState<any>({
name: '',
studentId: '',
grade: '',
major: '',
avatar: '',
gender: '',
age: 0,
email: '',
phone: '',
joinDate: '',
college: '',
address: ''
})
const [healthData, setHealthData] = useState<any>({
height: 0,
weight: 0,
bmi: 0,
bloodType: '',
heartRate: 0,
bloodPressure: '',
healthGoal: '',
targetCalories: 2000,
targetProtein: 60,
targetFat: 60,
targetCarbs: 250,
allergies: [],
dietaryPreferences: [],
restrictions: []
})
useEffect(() => {
loadUserData()
}, [])
const loadUserData = async () => {
try {
setLoading(true)
// 获取当前用户基本信息
const userResponse = await getCurrentUser()
if (userResponse.success && userResponse.data) {
const user = userResponse.data
setProfile(prev => ({
...prev,
name: user.name || user.username || '', // 如果name为空使用username
email: user.email || '',
phone: user.phone || '',
avatar: user.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100',
studentId: user.username || '',
college: '信息学院',
joinDate: user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '',
address: '未设置'
}))
}
// 获取用户健康档案
const healthResponse = await getHealthProfile()
console.log('健康档案响应:', healthResponse)
if (healthResponse.success && healthResponse.data) {
const health = healthResponse.data
console.log('健康档案数据:', health)
const bmi = health.weight && health.height
? (health.weight / Math.pow(health.height / 100, 2)).toFixed(1)
: 0
// 解析JSON字段
const parseJsonField = (field: any) => {
if (!field) return []
if (typeof field === 'string') {
try {
return JSON.parse(field)
} catch (e) {
console.error('JSON解析失败:', e)
return []
}
}
return Array.isArray(field) ? field : []
}
// 更新个人资料中的年龄和性别(从健康档案获取)
setProfile(prev => ({
...prev,
age: health.age || 20,
gender: health.gender || '未设置'
}))
setHealthData({
height: health.height || 0,
weight: health.weight || 0,
bmi: health.bmi || ((health.weight || 0) / Math.pow((health.height || 0) / 100, 2)).toFixed(1),
bloodType: health.blood_type || '',
heartRate: 72,
bloodPressure: '120/80',
healthGoal: health.health_goal || '未设置',
targetCalories: health.target_calories || 2000,
targetProtein: health.target_protein || 60,
targetFat: health.target_fat || 60,
targetCarbs: health.target_carbs || 250,
allergies: parseJsonField(health.allergies),
dietaryPreferences: parseJsonField(health.dietary_preferences),
restrictions: parseJsonField(health.dietary_restrictions)
})
}
} catch (error) {
console.error('加载用户数据失败:', error)
} finally {
setLoading(false)
}
}
// 保存基本信息
const handleSaveBasicInfo = async () => {
try {
setSaving(true)
const response = await updateUserProfile({
nickname: profile.name,
phone: profile.phone,
email: profile.email,
})
if (response.success) {
alert('基本信息更新成功!')
setIsEditing(false)
await loadUserData() // 重新加载数据
} else {
alert('更新失败:' + (response.message || '未知错误'))
}
} catch (error) {
console.error('更新基本信息失败:', error)
alert('更新失败,请稍后重试')
} finally {
setSaving(false)
}
}
// 保存健康档案
const handleSaveHealthInfo = async () => {
try {
setSaving(true)
const response = await updateHealthProfile({
height: healthData.height,
weight: healthData.weight,
age: profile.age,
gender: profile.gender,
allergies: healthData.allergies,
dietary_preferences: healthData.dietaryPreferences,
target_calories: healthData.targetCalories,
target_protein: healthData.targetProtein,
target_fat: healthData.targetFat,
target_carbs: healthData.targetCarbs,
})
if (response.success) {
alert('健康档案更新成功!')
setIsEditing(false)
await loadUserData() // 重新加载数据
} else {
alert('更新失败:' + (response.message || '未知错误'))
}
} catch (error) {
console.error('更新健康档案失败:', error)
alert('更新失败,请稍后重试')
} finally {
setSaving(false)
}
}
// 保存饮食偏好
const handleSavePreferences = async () => {
try {
setSaving(true)
const response = await updateHealthProfile({
dietary_preferences: healthData.dietaryPreferences,
allergies: healthData.allergies,
})
if (response.success) {
alert('饮食偏好更新成功!')
setIsEditing(false)
await loadUserData() // 重新加载数据
} else {
alert('更新失败:' + (response.message || '未知错误'))
}
} catch (error) {
console.error('更新饮食偏好失败:', error)
alert('更新失败,请稍后重试')
} finally {
setSaving(false)
}
}
// 取消编辑
const handleCancelEdit = () => {
setIsEditing(false)
loadUserData() // 重新加载数据,恢复原始值
}
const handleLogout = () => {
if (confirm('确定要退出登录吗?')) {
removeToken()
// 清除可能存在的管理员状态
localStorage.removeItem('isAdmin')
// 使用window.location.href进行完全刷新和跳转确保直接返回登录界面
window.location.href = '/login'
}
}
return (
<div className="bg-gray-50 min-h-full">
{/* 顶部个人信息卡片 */}
<div className="bg-gradient-to-br from-blue-500 to-purple-600 text-white px-6 pt-8 pb-6">
<div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 rounded-full overflow-hidden border-4 border-white/30">
<img
src={profile.avatar}
alt={profile.name}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<p className="text-sm opacity-90 mt-1">{profile.studentId}</p>
</div>
</div>
</div>
{/* 标签页切换 */}
<div className="bg-white border-b border-gray-200 px-6">
<div className="flex justify-between items-center">
<div className="flex gap-8">
{[
{ key: 'basic', label: '基本信息' },
{ key: 'health', label: '健康档案' },
{ key: 'preferences', label: '饮食偏好' },
].map((tab) => (
<button
key={tab.key}
onClick={() => {
setActiveTab(tab.key as any)
setIsEditing(false) // 切换标签时退出编辑模式
}}
className={`py-4 px-2 font-medium transition-colors relative ${
activeTab === tab.key
? 'text-primary'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
{activeTab === tab.key && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-full" />
)}
</button>
))}
</div>
{/* 编辑/保存/取消按钮 */}
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={handleCancelEdit}
disabled={saving}
className="flex items-center gap-1 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
<X size={18} />
<span></span>
</button>
<button
onClick={() => {
if (activeTab === 'basic') handleSaveBasicInfo()
else if (activeTab === 'health') handleSaveHealthInfo()
else handleSavePreferences()
}}
disabled={saving}
className="flex items-center gap-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save size={18} />
<span>{saving ? '保存中...' : '保存'}</span>
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-1 px-4 py-2 text-primary hover:bg-primary/10 rounded-lg transition-colors"
>
<Edit2 size={18} />
<span></span>
</button>
)}
</div>
</div>
</div>
{/* 标签页内容 */}
<div className="px-6 py-6">
{activeTab === 'basic' && (
<div className="space-y-4">
{/* 基本信息卡片 */}
<div className="bg-white rounded-2xl p-5 shadow-sm">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<User size={20} className="text-primary" />
</h3>
<div className="space-y-4">
{isEditing ? (
<>
<div className="flex items-center gap-3">
<User size={18} className="text-gray-400" />
<div className="flex-1">
<label className="text-sm text-gray-600 block mb-1"></label>
<input
type="text"
value={profile.name}
onChange={(e) => setProfile({...profile, name: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<div className="flex items-center gap-3">
<User size={18} className="text-gray-400" />
<div className="flex-1">
<label className="text-sm text-gray-600 block mb-1"></label>
<select
value={profile.gender}
onChange={(e) => setProfile({...profile, gender: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="男"></option>
<option value="女"></option>
<option value="未设置"></option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<Calendar size={18} className="text-gray-400" />
<div className="flex-1">
<label className="text-sm text-gray-600 block mb-1"></label>
<input
type="number"
value={profile.age}
onChange={(e) => setProfile({...profile, age: parseInt(e.target.value) || 0})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Mail size={18} className="text-gray-400" />
<div className="flex-1">
<label className="text-sm text-gray-600 block mb-1"></label>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile({...profile, email: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Phone size={18} className="text-gray-400" />
<div className="flex-1">
<label className="text-sm text-gray-600 block mb-1"></label>
<input
type="tel"
value={profile.phone}
onChange={(e) => setProfile({...profile, phone: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</div>
</>
) : (
<>
<InfoItem icon={<User size={18} />} label="姓名" value={profile.name} />
<InfoItem icon={<User size={18} />} label="性别" value={profile.gender} />
<InfoItem icon={<Calendar size={18} />} label="年龄" value={`${profile.age}`} />
<InfoItem icon={<Mail size={18} />} label="邮箱" value={profile.email} />
<InfoItem icon={<Phone size={18} />} label="手机" value={profile.phone} />
</>
)}
</div>
</div>
</div>
)}
{activeTab === 'health' && (
<div className="space-y-4">
{/* 身体数据 */}
<div className="bg-white rounded-2xl p-5 shadow-sm">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<TrendingUp size={20} className="text-primary" />
</h3>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600 block mb-2"> (cm)</label>
<input
type="number"
value={healthData.height || ''}
onChange={(e) => setHealthData({...healthData, height: parseFloat(e.target.value) || 0})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="请输入身高"
/>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2"> (kg)</label>
<input
type="number"
step="0.1"
value={healthData.weight || ''}
onChange={(e) => setHealthData({...healthData, weight: parseFloat(e.target.value) || 0})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder="请输入体重"
/>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2"></label>
<select
value={healthData.healthGoal || '保持'}
onChange={(e) => setHealthData({...healthData, healthGoal: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="减重"></option>
<option value="增肌"></option>
<option value="保持"></option>
<option value="增重"></option>
</select>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2"></label>
<select
value={healthData.activityLevel || '轻度活动'}
onChange={(e) => setHealthData({...healthData, activityLevel: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="久坐"></option>
<option value="轻度活动"></option>
<option value="中度活动"></option>
<option value="高度活动"></option>
</select>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 rounded-xl p-4 text-center">
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-blue-600">{healthData.height || '--'}</p>
<p className="text-xs text-gray-500 mt-1">cm</p>
</div>
<div className="bg-green-50 rounded-xl p-4 text-center">
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-green-600">{healthData.weight || '--'}</p>
<p className="text-xs text-gray-500 mt-1">kg</p>
</div>
<div className="bg-purple-50 rounded-xl p-4 text-center">
<p className="text-sm text-gray-600 mb-1">BMI</p>
<p className="text-2xl font-bold text-purple-600">
{healthData.bmi || '--'}
</p>
<p className="text-xs text-gray-500 mt-1">{healthData.bmi > 0 ? (healthData.bmi < 18.5 ? '偏瘦' : healthData.bmi < 24 ? '标准' : healthData.bmi < 28 ? '偏胖' : '肥胖') : '--'}</p>
</div>
<div className="bg-orange-50 rounded-xl p-4 text-center">
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-orange-600">{profile.age}</p>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
</div>
)}
</div>
{/* 营养目标 */}
<div className="bg-white rounded-2xl p-5 shadow-sm">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Target size={20} className="text-primary" />
</h3>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600"></span>
<span className="font-medium text-primary">{healthData.healthGoal || '未设置'}</span>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600"></span>
<span className="font-medium">{healthData.targetCalories} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: '65%' }} />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600"></span>
<span className="font-medium">{healthData.targetProtein}g</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: '45%' }} />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600"></span>
<span className="font-medium">{healthData.targetFat}g</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-yellow-500 rounded-full" style={{ width: '55%' }} />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600"></span>
<span className="font-medium">{healthData.targetCarbs}g</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: '70%' }} />
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'preferences' && (
<div className="space-y-4">
{/* 饮食类型 */}
<div className="bg-white rounded-2xl p-5 shadow-sm">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Heart size={20} className="text-primary" />
</h3>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600 block mb-2"></label>
<div className="flex flex-wrap gap-2">
{['清淡', '重口味', '低脂', '高蛋白', '素食', '海鲜', '川菜', '粤菜', '西餐'].map((pref) => (
<button
key={pref}
onClick={() => {
const current = healthData.dietaryPreferences || [];
if (current.includes(pref)) {
setHealthData({
...healthData,
dietaryPreferences: current.filter((p: string) => p !== pref)
});
} else {
setHealthData({
...healthData,
dietaryPreferences: [...current, pref]
});
}
}}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
(healthData.dietaryPreferences || []).includes(pref)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{pref}
</button>
))}
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 py-2">
<div className="text-gray-400 mt-1">
<Heart size={18} />
</div>
<div className="flex-1">
<p className="text-sm text-gray-500 mb-2"></p>
<div className="flex flex-wrap gap-2">
{healthData.dietaryPreferences && healthData.dietaryPreferences.length > 0 ? (
healthData.dietaryPreferences.map((pref: string) => (
<span
key={pref}
className="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm"
>
{pref}
</span>
))
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
</div>
</div>
)}
</div>
{/* 饮食限制 */}
<div className="bg-white rounded-2xl p-5 shadow-sm">
<h3 className="font-semibold text-lg mb-4 text-red-600 flex items-center gap-2">
<Heart size={20} />
</h3>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600 block mb-2"></label>
<div className="flex flex-wrap gap-2">
{['花生', '海鲜', '鸡蛋', '牛奶', '大豆', '坚果', '小麦', '芝麻'].map((allergy) => (
<button
key={allergy}
onClick={() => {
const current = healthData.allergies || [];
if (current.includes(allergy)) {
setHealthData({
...healthData,
allergies: current.filter((a: string) => a !== allergy)
});
} else {
setHealthData({
...healthData,
allergies: [...current, allergy]
});
}
}}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
(healthData.allergies || []).includes(allergy)
? 'bg-red-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{allergy}
</button>
))}
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 mb-2"></p>
<div className="flex flex-wrap gap-2">
{healthData.allergies && healthData.allergies.length > 0 ? (
healthData.allergies.map((allergy: string) => (
<span
key={allergy}
className="px-3 py-1 bg-red-50 text-red-600 rounded-full text-sm"
>
{allergy}
</span>
))
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
<div>
<p className="text-sm text-gray-500 mb-2"></p>
<div className="flex flex-wrap gap-2">
{healthData.restrictions && healthData.restrictions.length > 0 ? (
healthData.restrictions.map((restriction: string) => (
<span
key={restriction}
className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm"
>
{restriction}
</span>
))
) : (
<span className="text-gray-400 text-sm"></span>
)}
</div>
</div>
</div>
)}
</div>
{/* 预算设置 */}
<div className="bg-white rounded-2xl p-5 shadow-sm">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<TrendingUp size={20} className="text-primary" />
</h3>
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-xl p-4">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-primary mt-1">
¥10 - ¥30
</p>
</div>
<div className="text-4xl">💰</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* 退出登录按钮 */}
<div className="px-6 pb-6">
<button
onClick={handleLogout}
className="w-full bg-red-50 hover:bg-red-100 text-red-600 font-medium py-4 px-6 rounded-2xl flex items-center justify-center gap-2 transition-colors"
>
<LogOut size={20} />
<span>退</span>
</button>
</div>
</div>
)
}
// 信息项组件
const InfoItem = ({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) => (
<div className="flex items-center gap-3 py-2">
<div className="text-gray-400">{icon}</div>
<div className="flex-1 flex justify-between">
<span className="text-sm text-gray-500">{label}</span>
<span className="font-medium text-gray-900">{value}</span>
</div>
</div>
)
export default PersonalFilePage

@ -0,0 +1,193 @@
import { useNavigate } from 'react-router-dom'
import {
Edit, Heart, FileText, MessageSquare, Settings,
HelpCircle, Shield, LogOut, ChevronRight, Award, TrendingUp, Upload
} from 'lucide-react'
interface ProfilePageProps {
onLogout: () => void
}
const ProfilePage = ({ onLogout }: ProfilePageProps) => {
const navigate = useNavigate()
const handleLogout = () => {
if (confirm('确定要退出登录吗?')) {
// 调用从父组件传入的onLogout函数
onLogout()
// 额外使用window.location.href进行完全刷新和跳转确保直接返回登录界面
window.location.href = '/login'
}
}
const userInfo = {
name: 'xxx',
phone: '138****8888',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
level: 5,
}
const bmiInfo = {
value: 22.5,
status: '正常',
height: 170,
weight: 65,
}
const stats = [
{ label: '用餐天数', value: 128, icon: TrendingUp, color: 'text-blue-500' },
{ label: '收藏菜品', value: 45, icon: Heart, color: 'text-red-500' },
{ label: '我的评价', value: 32, icon: MessageSquare, color: 'text-green-500' },
]
const profileItems = [
{ icon: Heart, label: '我的收藏', path: '/favorites', badge: 45 },
{ icon: FileText, label: '用餐记录', path: '/meal-records', badge: 128 },
{ icon: MessageSquare, label: '我的评价', path: '/reviews', badge: 32 },
{ icon: Upload, label: '我上传的菜品', path: '/my-dishes', badge: 'NEW' },
{ icon: Award, label: '我的成就', path: '/achievements', badge: 'NEW' },
]
const settingItems = [
{ icon: Settings, label: '账户设置', path: '/settings' },
{ icon: Shield, label: '隐私设置', path: '/privacy' },
{ icon: HelpCircle, label: '帮助中心', path: '/help' },
]
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 顶部背景 */}
<div className="bg-gradient-to-r from-purple-500 to-pink-500 h-32 rounded-b-3xl"></div>
{/* 用户信息卡片 */}
<div className="px-6 -mt-20">
<div className="bg-white rounded-2xl p-6 shadow-lg">
<div className="flex items-center gap-4 mb-4">
<img
src={userInfo.avatar}
alt="avatar"
className="w-20 h-20 rounded-full border-4 border-white shadow-md"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-gray-800">{userInfo.name}</h2>
<span className="bg-gradient-to-r from-yellow-400 to-orange-400 text-white text-xs px-2 py-1 rounded-full">
LV.{userInfo.level}
</span>
</div>
<p className="text-gray-500 text-sm mt-1">{userInfo.phone}</p>
</div>
<button
onClick={() => navigate('/profile/edit')}
className="p-2 bg-gray-100 rounded-full hover:bg-gray-200 transition-colors"
>
<Edit size={20} className="text-gray-600" />
</button>
</div>
{/* 数据统计 */}
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className={`${stat.color} mb-1`}>
<stat.icon size={24} className="mx-auto" />
</div>
<p className="text-2xl font-bold text-gray-800">{stat.value}</p>
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
</div>
))}
</div>
</div>
{/* BMI卡片 */}
<div className="mt-4 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-2xl p-6 text-white">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-bold mb-1">BMI </h3>
<p className="text-sm opacity-90"></p>
</div>
<div className="text-right">
<p className="text-4xl font-bold">{bmiInfo.value}</p>
<p className="text-sm opacity-90">{bmiInfo.status}</p>
</div>
</div>
<div className="flex justify-around pt-4 border-t border-white/20">
<div className="text-center">
<p className="text-2xl font-bold">{bmiInfo.height}</p>
<p className="text-xs opacity-80">(cm)</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{bmiInfo.weight}</p>
<p className="text-xs opacity-80">(kg)</p>
</div>
</div>
</div>
{/* 个人档案 */}
<div className="mt-4 bg-white rounded-2xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b">
<h3 className="font-bold text-gray-800"></h3>
</div>
{profileItems.map((item, index) => (
<button
key={item.label}
onClick={() => navigate(item.path)}
className={`w-full px-5 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors ${
index < profileItems.length - 1 ? 'border-b' : ''
}`}
>
<div className="flex items-center gap-3">
<item.icon size={20} className="text-gray-600" />
<span className="text-gray-800">{item.label}</span>
</div>
<div className="flex items-center gap-2">
{typeof item.badge === 'number' ? (
<span className="text-gray-400 text-sm">{item.badge}</span>
) : item.badge === 'NEW' ? (
<span className="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
NEW
</span>
) : null}
<ChevronRight size={18} className="text-gray-400" />
</div>
</button>
))}
</div>
{/* 设置与帮助 */}
<div className="mt-4 bg-white rounded-2xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b">
<h3 className="font-bold text-gray-800"></h3>
</div>
{settingItems.map((item, index) => (
<button
key={item.label}
onClick={() => navigate(item.path)}
className={`w-full px-5 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors ${
index < settingItems.length - 1 ? 'border-b' : ''
}`}
>
<div className="flex items-center gap-3">
<item.icon size={20} className="text-gray-600" />
<span className="text-gray-800">{item.label}</span>
</div>
<ChevronRight size={18} className="text-gray-400" />
</button>
))}
</div>
{/* 退出登录 */}
<button
onClick={handleLogout}
className="w-full mt-4 bg-white text-red-500 py-4 rounded-2xl font-medium hover:bg-red-50 transition-colors shadow-sm flex items-center justify-center gap-2"
>
<LogOut size={20} />
退
</button>
</div>
</div>
)
}
export default ProfilePage

@ -0,0 +1,301 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, Check, Save } from 'lucide-react'
import Input from '../components/Common/Input'
import Button from '../components/Common/Button'
const ProfileEditPage = () => {
const navigate = useNavigate()
const [saving, setSaving] = useState(false)
// 基本信息
const [gender, setGender] = useState('男')
const [birthday, setBirthday] = useState('2000-01-01')
const [height, setHeight] = useState('170')
const [weight, setWeight] = useState('65')
// 健康状态
const [healthStatus, setHealthStatus] = useState('健康')
// 饮食偏好
const [taste, setTaste] = useState<string[]>(['清淡'])
const [dislikedFoods, setDislikedFoods] = useState<string[]>([])
// 营养目标
const [goal, setGoal] = useState('保持健康')
const [activityLevel, setActivityLevel] = useState('中等')
// 过敏史
const [allergies, setAllergies] = useState<string[]>(['无'])
const calculateBMI = () => {
if (!height || !weight) return 0
const h = parseFloat(height) / 100
const w = parseFloat(weight)
return (w / (h * h)).toFixed(1)
}
const handleSave = () => {
setSaving(true)
setTimeout(() => {
alert('保存成功!')
navigate('/profile')
}, 500)
}
const tasteOptions = ['清淡', '微辣', '中辣', '重辣', '酸甜', '咸鲜']
const goalOptions = ['减脂', '增肌', '保持健康', '增重']
const activityOptions = ['久坐', '轻度', '中等', '高强度']
const allergyOptions = ['海鲜', '花生', '牛奶', '鸡蛋', '大豆', '小麦', '坚果', '无']
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 顶部导航 */}
<div className="bg-white px-6 py-4 flex items-center justify-between sticky top-0 z-10 shadow-sm">
<button onClick={() => navigate(-1)} className="p-2">
<ArrowLeft size={24} />
</button>
<h1 className="text-lg font-bold"></h1>
<div className="w-10"></div>
</div>
<div className="px-6 py-6 space-y-6">
{/* 基本信息 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="flex gap-4">
{['男', '女'].map((g) => (
<button
key={g}
onClick={() => setGender(g)}
className={`flex-1 py-3 rounded-lg border-2 font-medium transition-all ${
gender === g
? 'border-primary bg-primary/10 text-primary'
: 'border-gray-200 text-gray-600'
}`}
>
{g}
</button>
))}
</div>
</div>
<Input
label="出生日期"
type="date"
value={birthday}
onChange={setBirthday}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="身高 (cm)"
type="number"
value={height}
onChange={setHeight}
/>
<Input
label="体重 (kg)"
type="number"
value={weight}
onChange={setWeight}
/>
</div>
{/* BMI实时显示 */}
{height && weight && (
<div className="bg-gradient-to-r from-blue-50 to-cyan-50 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">BMI</p>
<p className="text-3xl font-bold text-primary">{calculateBMI()}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600"></p>
<p className="text-lg font-bold text-green-600"></p>
</div>
</div>
</div>
)}
</div>
</div>
{/* 健康状态 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="grid grid-cols-2 gap-3">
{['健康', '亚健康', '慢性病', '康复期'].map((status) => (
<button
key={status}
onClick={() => setHealthStatus(status)}
className={`py-3 rounded-lg border-2 font-medium transition-all ${
healthStatus === status
? 'border-primary bg-primary/10 text-primary'
: 'border-gray-200 text-gray-600'
}`}
>
{status}
</button>
))}
</div>
</div>
{/* 饮食偏好 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="flex flex-wrap gap-2">
{tasteOptions.map((t) => (
<button
key={t}
onClick={() => {
if (taste.includes(t)) {
setTaste(taste.filter((item) => item !== t))
} else {
setTaste([...taste, t])
}
}}
className={`px-4 py-2 rounded-full text-sm transition-all ${
taste.includes(t)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600'
}`}
>
{taste.includes(t) && <Check size={14} className="inline mr-1" />}
{t}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<Input
value={dislikedFoods.join(', ')}
onChange={(val) => setDislikedFoods(val.split(',').map(v => v.trim()))}
placeholder="例如:香菜、芹菜(用逗号分隔)"
/>
</div>
</div>
{/* 营养目标 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="grid grid-cols-2 gap-3">
{goalOptions.map((g) => (
<button
key={g}
onClick={() => setGoal(g)}
className={`py-3 rounded-lg border-2 font-medium transition-all ${
goal === g
? 'border-primary bg-primary/10 text-primary'
: 'border-gray-200 text-gray-600'
}`}
>
{g}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="grid grid-cols-2 gap-3">
{activityOptions.map((level) => (
<button
key={level}
onClick={() => setActivityLevel(level)}
className={`py-3 rounded-lg border-2 font-medium transition-all ${
activityLevel === level
? 'border-primary bg-primary/10 text-primary'
: 'border-gray-200 text-gray-600'
}`}
>
{level}
</button>
))}
</div>
</div>
</div>
{/* 过敏史 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h2 className="font-bold text-lg mb-4"></h2>
<div className="flex flex-wrap gap-2">
{allergyOptions.map((a) => (
<button
key={a}
onClick={() => {
if (a === '无') {
setAllergies(['无'])
} else {
if (allergies.includes(a)) {
setAllergies(allergies.filter((item) => item !== a))
} else {
setAllergies([...allergies.filter((item) => item !== '无'), a])
}
}
}}
className={`px-4 py-2 rounded-full text-sm transition-all ${
allergies.includes(a)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600'
}`}
>
{allergies.includes(a) && <Check size={14} className="inline mr-1" />}
{a}
</button>
))}
</div>
</div>
{/* 营养目标预览 */}
<div className="bg-gradient-to-r from-orange-50 to-pink-50 rounded-xl p-5 border-l-4 border-primary">
<h3 className="font-bold mb-3"></h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-white rounded-lg p-3">
<p className="text-gray-600"></p>
<p className="text-2xl font-bold text-primary">2000 <span className="text-sm"></span></p>
</div>
<div className="bg-white rounded-lg p-3">
<p className="text-gray-600"></p>
<p className="text-2xl font-bold text-blue-600">80 <span className="text-sm">g</span></p>
</div>
<div className="bg-white rounded-lg p-3">
<p className="text-gray-600"></p>
<p className="text-2xl font-bold text-yellow-600">65 <span className="text-sm">g</span></p>
</div>
<div className="bg-white rounded-lg p-3">
<p className="text-gray-600"></p>
<p className="text-2xl font-bold text-green-600">250 <span className="text-sm">g</span></p>
</div>
</div>
</div>
{/* 保存按钮 */}
<Button
onClick={handleSave}
fullWidth
size="lg"
disabled={saving}
className="sticky bottom-6"
>
<Save size={20} className="inline mr-2" />
{saving ? '保存中...' : '保存档案'}
</Button>
</div>
</div>
)
}
export default ProfileEditPage

@ -0,0 +1,352 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Phone, Lock, User, ArrowLeft, ArrowRight, Check } from 'lucide-react'
import Input from '../components/Common/Input'
import Button from '../components/Common/Button'
import { register } from '../api'
import { setToken } from '../api/request'
const RegisterPage = () => {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
// 第一步:基本信息
const [phone, setPhone] = useState('')
const [code, setCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [countdown, setCountdown] = useState(0)
// 第二步:健康档案
const [gender, setGender] = useState('男')
const [height, setHeight] = useState('')
const [weight, setWeight] = useState('')
const [taste, setTaste] = useState<string[]>([])
const [goal, setGoal] = useState('保持')
const [allergies, setAllergies] = useState<string[]>([])
const sendCode = () => {
if (!phone) {
alert('请先输入手机号')
return
}
setCountdown(60)
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
const handleNextStep = () => {
if (!phone || !code || !username || !password || !confirmPassword) {
alert('请填写完整信息')
return
}
if (password !== confirmPassword) {
alert('两次密码不一致')
return
}
setStep(2)
}
const handleRegister = async () => {
try {
setLoading(true)
// 计算年龄如果有身高体重估算年龄为20
const age = (height && weight) ? 20 : null
// 调用注册 API
const response = await register({
username,
password,
phone,
email: '', // 可以添加邮箱输入框
name: username, // 使用用户名作为姓名
// 健康档案信息
gender,
height: height ? parseFloat(height) : null,
weight: weight ? parseFloat(weight) : null,
age,
healthGoal: goal,
activityLevel: '轻度活动',
dietaryPreferences: taste,
allergies: allergies.filter(a => a !== '无')
})
if (response.success) {
// 保存 tokenAPI 已经自动保存了)
alert('注册成功!')
// 跳转到首页
navigate('/home')
} else {
alert(response.message || '注册失败,请重试')
}
} catch (error) {
console.error('注册失败:', error)
alert('注册失败,请检查网络连接或稍后重试')
} finally {
setLoading(false)
}
}
const calculateBMI = () => {
if (!height || !weight) return 0
const h = parseFloat(height) / 100
const w = parseFloat(weight)
return (w / (h * h)).toFixed(1)
}
const tasteOptions = ['清淡', '微辣', '中辣', '重辣', '酸甜', '咸鲜']
const goalOptions = ['减重', '增肌', '保持', '增重']
const allergyOptions = ['海鲜', '花生', '牛奶', '鸡蛋', '大豆', '小麦', '坚果', '无']
return (
<div className="min-h-screen flex justify-center items-center bg-gradient-to-br from-purple-100 to-blue-100">
<div className="relative w-full max-w-[414px] h-[920px] bg-white shadow-2xl rounded-3xl overflow-hidden flex flex-col">
{/* 顶部导航 */}
<div className="p-4 border-b flex items-center justify-between">
<button onClick={() => step === 1 ? navigate('/login') : setStep(1)} className="p-2">
<ArrowLeft size={24} />
</button>
<h1 className="text-lg font-bold"></h1>
<div className="w-10"></div>
</div>
{/* 进度条 */}
<div className="px-8 py-4 bg-gray-50">
<div className="flex items-center justify-between mb-2">
<span className={`text-sm ${step >= 1 ? 'text-primary font-medium' : 'text-gray-400'}`}></span>
<span className={`text-sm ${step >= 2 ? 'text-primary font-medium' : 'text-gray-400'}`}></span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-pink-500 transition-all duration-300"
style={{ width: `${(step / 2) * 100}%` }}
></div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{/* 第一步:基本信息 */}
{step === 1 && (
<div className="space-y-4 animate-fade-in-up">
<Input
label="手机号"
type="tel"
value={phone}
onChange={setPhone}
placeholder="请输入手机号"
icon={<Phone size={20} />}
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<span className="text-red-500 ml-1">*</span>
</label>
<div className="flex gap-2">
<Input
value={code}
onChange={setCode}
placeholder="请输入验证码"
/>
<Button
onClick={sendCode}
disabled={countdown > 0}
variant="outline"
className="whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</div>
</div>
<Input
label="用户名"
value={username}
onChange={setUsername}
placeholder="请输入用户名"
icon={<User size={20} />}
required
/>
<Input
label="密码"
type="password"
value={password}
onChange={setPassword}
placeholder="请输入密码6-20位"
icon={<Lock size={20} />}
required
/>
<Input
label="确认密码"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="请再次输入密码"
icon={<Lock size={20} />}
required
/>
<Button onClick={handleNextStep} fullWidth size="lg" className="mt-6">
<ArrowRight size={20} className="ml-2" />
</Button>
</div>
)}
{/* 第二步:健康档案 */}
{step === 2 && (
<div className="space-y-6 animate-fade-in-up">
{/* 性别 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="flex gap-4">
{['男', '女'].map((g) => (
<button
key={g}
onClick={() => setGender(g)}
className={`flex-1 py-3 rounded-lg border-2 font-medium transition-all ${
gender === g
? 'border-primary bg-primary/10 text-primary'
: 'border-gray-200 text-gray-600'
}`}
>
{g}
</button>
))}
</div>
</div>
{/* 身高体重 */}
<div className="grid grid-cols-2 gap-4">
<Input
label="身高 (cm)"
type="number"
value={height}
onChange={setHeight}
placeholder="170"
/>
<Input
label="体重 (kg)"
type="number"
value={weight}
onChange={setWeight}
placeholder="65"
/>
</div>
{/* BMI显示 */}
{height && weight && (
<div className="bg-blue-50 p-4 rounded-lg text-center">
<p className="text-sm text-gray-600 mb-1">BMI</p>
<p className="text-3xl font-bold text-primary">{calculateBMI()}</p>
</div>
)}
{/* 口味偏好 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="flex flex-wrap gap-2">
{tasteOptions.map((t) => (
<button
key={t}
onClick={() => {
if (taste.includes(t)) {
setTaste(taste.filter((item) => item !== t))
} else {
setTaste([...taste, t])
}
}}
className={`px-4 py-2 rounded-full text-sm transition-all ${
taste.includes(t)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600'
}`}
>
{taste.includes(t) && <Check size={14} className="inline mr-1" />}
{t}
</button>
))}
</div>
</div>
{/* 营养目标 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="grid grid-cols-2 gap-3">
{goalOptions.map((g) => (
<button
key={g}
onClick={() => setGoal(g)}
className={`py-3 rounded-lg border-2 font-medium transition-all ${
goal === g
? 'border-primary bg-primary/10 text-primary'
: 'border-gray-200 text-gray-600'
}`}
>
{g}
</button>
))}
</div>
</div>
{/* 过敏史 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3"></label>
<div className="flex flex-wrap gap-2">
{allergyOptions.map((a) => (
<button
key={a}
onClick={() => {
if (a === '无') {
setAllergies(['无'])
} else {
if (allergies.includes(a)) {
setAllergies(allergies.filter((item) => item !== a))
} else {
setAllergies([...allergies.filter((item) => item !== '无'), a])
}
}
}}
className={`px-4 py-2 rounded-full text-sm transition-all ${
allergies.includes(a)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600'
}`}
>
{allergies.includes(a) && <Check size={14} className="inline mr-1" />}
{a}
</button>
))}
</div>
</div>
<Button
onClick={handleRegister}
fullWidth
size="lg"
disabled={loading}
className="mt-6"
>
{loading ? '注册中...' : '完成注册'}
</Button>
</div>
)}
</div>
</div>
</div>
)
}
export default RegisterPage

@ -0,0 +1,294 @@
import { useState, useEffect } from 'react'
import { TrendingUp, Activity, Target, Award } from 'lucide-react'
import ReactECharts from 'echarts-for-react'
import { getPeriodReport } from '../api/health'
const ReportPage = () => {
const [timePeriod, setTimePeriod] = useState('本周')
const [reportData, setReportData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadReportData()
}, [timePeriod])
const loadReportData = async () => {
try {
setLoading(true)
const type = timePeriod === '本周' ? 'week' : 'month'
const response = await getPeriodReport(type)
if (response.success && response.data) {
setReportData(response.data)
}
} catch (error) {
console.error('加载报告数据失败:', error)
} finally {
setLoading(false)
}
}
// 生成图表标签最近7天或30天
const getDateLabels = () => {
if (!reportData || !reportData.dailyData) return []
return reportData.dailyData.map((d: any) => {
const date = new Date(d.date)
return `${date.getMonth() + 1}/${date.getDate()}`
})
}
// 获取趋势数据
const getTrendData = (key: string) => {
if (!reportData || !reportData.dailyData) return []
return reportData.dailyData.map((d: any) => d[key] || 0)
}
// 营养摄入趋势图表配置
const trendOption = {
tooltip: {
trigger: 'axis',
},
legend: {
data: ['热量', '蛋白质', '脂肪', '碳水'],
bottom: 0,
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: getDateLabels(),
},
yAxis: {
type: 'value',
},
series: [
{
name: '热量',
type: 'line',
data: getTrendData('calories'),
smooth: true,
itemStyle: { color: '#FF6B6B' },
},
{
name: '蛋白质',
type: 'line',
data: getTrendData('protein'),
smooth: true,
itemStyle: { color: '#4ECDC4' },
},
{
name: '脂肪',
type: 'line',
data: getTrendData('fat'),
smooth: true,
itemStyle: { color: '#FFE66D' },
},
{
name: '碳水',
type: 'line',
data: getTrendData('carbs'),
smooth: true,
itemStyle: { color: '#A8E6CF' },
},
],
}
// 营养素分布饼图
const pieOption = {
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold',
},
},
data: [
{ value: reportData?.averages?.protein || 0, name: '蛋白质', itemStyle: { color: '#4ECDC4' } },
{ value: reportData?.averages?.fat || 0, name: '脂肪', itemStyle: { color: '#FFE66D' } },
{ value: reportData?.averages?.carbs || 0, name: '碳水化合物', itemStyle: { color: '#A8E6CF' } },
],
},
],
}
const avgNutrients = reportData ? [
{ name: '热量', value: reportData.averages.calories, unit: '千卡', goal: reportData.target.target_calories || 2000, color: 'bg-red-500' },
{ name: '蛋白质', value: reportData.averages.protein, unit: 'g', goal: reportData.target.target_protein || 80, color: 'bg-blue-500' },
{ name: '脂肪', value: reportData.averages.fat, unit: 'g', goal: reportData.target.target_fat || 65, color: 'bg-yellow-500' },
{ name: '碳水', value: reportData.averages.carbs, unit: 'g', goal: reportData.target.target_carbs || 250, color: 'bg-green-500' },
] : []
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
)
}
if (!reportData) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
<p className="text-gray-600"></p>
<p className="text-sm text-gray-500 mt-2"></p>
</div>
</div>
)
}
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 顶部标题栏 */}
<div className="bg-gradient-to-r from-blue-500 to-purple-600 text-white px-6 pt-6 pb-8 rounded-b-3xl">
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-sm opacity-90"></p>
</div>
{/* 时间切换 */}
<div className="px-6 -mt-4 mb-4">
<div className="bg-white rounded-xl p-2 flex gap-2 shadow-md">
{['本周', '本月'].map((period) => (
<button
key={period}
onClick={() => setTimePeriod(period)}
className={`flex-1 py-2 rounded-lg font-medium transition-all ${
timePeriod === period
? 'bg-gradient-to-r from-primary to-pink-500 text-white'
: 'text-gray-600'
}`}
>
{period}
</button>
))}
</div>
</div>
<div className="px-6 space-y-4">
{/* 营养摄入趋势 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="text-primary" size={20} />
<h2 className="font-bold text-lg"></h2>
</div>
<ReactECharts option={trendOption} style={{ height: '250px' }} />
</div>
{/* 营养素分布 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<div className="flex items-center gap-2 mb-4">
<Activity className="text-primary" size={20} />
<h2 className="font-bold text-lg"></h2>
</div>
<ReactECharts option={pieOption} style={{ height: '250px' }} />
</div>
{/* 每日平均摄入 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<div className="flex items-center gap-2 mb-4">
<Target className="text-primary" size={20} />
<h2 className="font-bold text-lg"></h2>
</div>
<div className="space-y-4">
{avgNutrients.map((nutrient) => (
<div key={nutrient.name}>
<div className="flex justify-between mb-2">
<span className="text-gray-700 font-medium">{nutrient.name}</span>
<span className="text-gray-600">
{nutrient.value}/{nutrient.goal} {nutrient.unit}
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${nutrient.color} rounded-full transition-all`}
style={{ width: `${Math.min((nutrient.value / nutrient.goal) * 100, 100)}%` }}
></div>
</div>
<p className="text-xs text-gray-500 mt-1">
{Math.min(Math.round((nutrient.value / nutrient.goal) * 100), 999)}%
</p>
</div>
))}
</div>
</div>
{/* 饮食建议 */}
<div className="bg-gradient-to-r from-orange-50 to-pink-50 rounded-xl p-5 border-l-4 border-primary">
<div className="flex items-center gap-2 mb-3">
<Award className="text-primary" size={20} />
<h3 className="font-bold text-lg"></h3>
</div>
<div className="space-y-2 text-sm text-gray-700">
{reportData?.suggestions && reportData.suggestions.length > 0 ? (
reportData.suggestions.map((suggestion: string, index: number) => (
<p key={index}>{suggestion}</p>
))
) : (
<p className="text-gray-500"></p>
)}
</div>
</div>
{/* 成就卡片 */}
<div className="bg-white rounded-xl p-5 shadow-sm">
<h3 className="font-bold text-lg mb-4">{timePeriod}</h3>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-3xl mb-1">🏆</div>
<p className="text-xs text-gray-600"></p>
<p className="text-lg font-bold text-yellow-600">{reportData?.achievements?.consecutive_days || 0}</p>
</div>
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-3xl mb-1">💪</div>
<p className="text-xs text-gray-600"></p>
<p className="text-lg font-bold text-blue-600">{reportData?.achievements?.goal_achieved_days || 0}</p>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-3xl mb-1">🎯</div>
<p className="text-xs text-gray-600"></p>
<p className="text-lg font-bold text-green-600">{reportData?.achievements?.perfect_days || 0}</p>
</div>
</div>
</div>
{/* 数据导出 */}
<button className="w-full py-3 bg-white border-2 border-primary text-primary font-medium rounded-xl hover:bg-primary hover:text-white transition-all">
</button>
</div>
</div>
)
}
export default ReportPage

@ -0,0 +1,181 @@
import { useState } from 'react'
import { Star, ThumbsUp, MessageSquare, Edit2, Trash2 } from 'lucide-react'
import EmptyState from '../components/Common/EmptyState'
const ReviewsPage = () => {
const [reviews] = useState([
{
id: '1',
mealName: '宫保鸡丁套餐',
mealImage: 'https://images.unsplash.com/photo-1603073203482-55d7e2e66325?w=500',
rating: 5,
comment: '非常好吃,辣度适中,配菜也很新鲜!',
date: '2025-10-27',
likes: 12,
replies: 2,
images: [],
},
{
id: '2',
mealName: '番茄鸡蛋面',
mealImage: 'https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=500',
rating: 4.5,
comment: '味道不错,分量足,就是有点咸',
date: '2025-10-26',
likes: 8,
replies: 1,
images: [],
},
])
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
const renderStars = (rating: number) => {
const fullStars = Math.floor(rating)
const hasHalfStar = rating % 1 >= 0.5
return (
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={16}
className={
i < fullStars
? 'fill-yellow-400 text-yellow-400'
: i === fullStars && hasHalfStar
? 'fill-yellow-400 text-yellow-400 opacity-50'
: 'text-gray-300'
}
/>
))}
</div>
)
}
return (
<div className="bg-gray-50 min-h-full pb-6">
{/* 顶部标题栏 */}
<div className="bg-gradient-to-r from-purple-500 to-indigo-600 text-white px-6 pt-6 pb-8 rounded-b-3xl">
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-sm opacity-90"> {reviews.length} </p>
</div>
{reviews.length > 0 ? (
<div className="px-6 py-6">
{/* 评价统计 */}
<div className="bg-white rounded-xl p-5 shadow-sm mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold text-gray-800 mb-2"></h3>
<div className="flex items-center gap-3">
<span className="text-4xl font-bold text-primary">{avgRating.toFixed(1)}</span>
{renderStars(avgRating)}
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-500 mb-1"></p>
<p className="text-3xl font-bold text-gray-800">{reviews.length}</p>
</div>
</div>
</div>
{/* 评价列表 */}
<div className="space-y-4">
{reviews.map((review) => (
<div key={review.id} className="bg-white rounded-xl p-5 shadow-sm">
{/* 餐品信息 */}
<div className="flex gap-3 mb-4">
<img
src={review.mealImage}
alt={review.mealName}
className="w-16 h-16 rounded-lg object-cover"
/>
<div className="flex-1">
<h4 className="font-bold text-gray-800">{review.mealName}</h4>
<p className="text-xs text-gray-500 mt-1">{review.date}</p>
</div>
</div>
{/* 评分 */}
<div className="flex items-center gap-2 mb-3">
{renderStars(review.rating)}
<span className="text-sm text-gray-600">{review.rating}</span>
</div>
{/* 评价内容 */}
<p className="text-gray-700 mb-4 leading-relaxed">{review.comment}</p>
{/* 图片(如果有) */}
{review.images.length > 0 && (
<div className="flex gap-2 mb-4">
{review.images.map((img, index) => (
<img
key={index}
src={img}
alt=""
className="w-20 h-20 rounded-lg object-cover"
/>
))}
</div>
)}
{/* 互动数据 */}
<div className="flex items-center gap-6 text-sm text-gray-500 pb-4 border-b">
<div className="flex items-center gap-1">
<ThumbsUp size={16} />
<span>{review.likes} </span>
</div>
<div className="flex items-center gap-1">
<MessageSquare size={16} />
<span>{review.replies} </span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-3 mt-4">
<button className="flex-1 flex items-center justify-center gap-2 py-2 bg-gray-50 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
<Edit2 size={16} />
</button>
<button className="flex-1 flex items-center justify-center gap-2 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors">
<Trash2 size={16} />
</button>
</div>
{/* 商家回复(如果有) */}
{review.replies > 0 && (
<div className="mt-4 bg-blue-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white text-xs">
</div>
<span className="font-medium text-sm"></span>
</div>
<p className="text-sm text-gray-700">
</p>
</div>
)}
</div>
))}
</div>
</div>
) : (
<div className="px-6">
<EmptyState
icon={<MessageSquare size={80} />}
title="还没有评价"
description="去吃过的美食留下你的评价吧"
actionText="去用餐记录"
onAction={() => window.history.back()}
/>
</div>
)}
</div>
)
}
export default ReviewsPage

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save