|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
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,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,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,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,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,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,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
|
||||
|
||||